Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calmly-fox-smiled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-router': patch
---

Injected `unmaskOnReload` pre-hydration script during SSR so hard reloads preserved the unmasked route.
5 changes: 5 additions & 0 deletions .changeset/swiftly-otter-jumped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Added helpers for building `unmaskOnReload` reload scripts and route mask match patterns. This enabled SSR hard reload recovery for masked routes.
25 changes: 25 additions & 0 deletions packages/react-router/src/headContentUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
deepEqual,
escapeHtml,
getAssetCrossOrigin,
getUnmaskOnReloadScriptFromRouteMasks,
resolveManifestAssetLink,
} from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
Expand Down Expand Up @@ -169,13 +170,16 @@ function buildTagsFromMatches(
children,
}))

const unmaskOnReloadScript = buildUnmaskOnReloadHeadScript(router, nonce)

return uniqBy(
[
...resultMeta,
...preloadLinks,
...constructedLinks,
...assetLinks,
...styles,
...(unmaskOnReloadScript ? [unmaskOnReloadScript] : []),
...headScripts,
] as Array<RouterManagedTag>,
(d) => JSON.stringify(d),
Expand Down Expand Up @@ -397,12 +401,19 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => {
deepEqual,
)

// eslint-disable-next-line react-hooks/rules-of-hooks -- condition is static
const unmaskOnReloadScript = React.useMemo(
() => buildUnmaskOnReloadHeadScript(router, nonce),
[router, nonce],
)

return uniqBy(
[
...meta,
...preloadLinks,
...links,
...styles,
...(unmaskOnReloadScript ? [unmaskOnReloadScript] : []),
...headScripts,
] as Array<RouterManagedTag>,
(d) => {
Expand All @@ -411,6 +422,20 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => {
)
}

function buildUnmaskOnReloadHeadScript(
router: ReturnType<typeof useRouter>,
nonce: string | undefined,
) {
const script = getUnmaskOnReloadScriptFromRouteMasks(router.options.routeMasks)
if (!script) return undefined

return {
tag: 'script',
...(nonce ? { attrs: { nonce } } : {}),
children: script,
} satisfies RouterManagedTag
}

export function uniqBy<T>(arr: Array<T>, fn: (item: T) => string) {
const seen = new Set<string>()
return arr.filter((item) => {
Expand Down
90 changes: 90 additions & 0 deletions packages/react-router/tests/Scripts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouteMask,
createRouter,
} from '../src'
import { Scripts } from '../src/Scripts'
Expand Down Expand Up @@ -371,6 +372,95 @@ describe('ssr HeadContent', () => {
)
})

test('injects a nonce-aware preload script for masks that unmask on reload', async () => {
const rootRoute = createRootRoute({
component: () => <HeadContent />,
})

const indexRoute = createRoute({
path: '/',
getParentRoute: () => rootRoute,
})

const modalRoute = createRoute({
path: '/modal',
getParentRoute: () => rootRoute,
})

const routeTree = rootRoute.addChildren([indexRoute, modalRoute])

const router = createRouter({
history: createMemoryHistory({
initialEntries: ['/'],
}),
isServer: true,
routeMasks: [
createRouteMask({
from: '/modal',
routeTree,
to: '/',
unmaskOnReload: true,
}),
],
routeTree,
ssr: {
nonce: 'test-nonce',
},
})

await router.load()

const html = ReactDOMServer.renderToString(
<RouterProvider router={router} />,
)

expect(html).toContain('<script nonce="test-nonce">')
expect(html).toContain('window.history.state?.__tempLocation')
expect(html).toContain('window.location.replace(')
expect(html).toContain('^/modal$')
})

test('does not inject an unmask-on-reload script for ordinary route masks', async () => {
const rootRoute = createRootRoute({
component: () => <HeadContent />,
})

const indexRoute = createRoute({
path: '/',
getParentRoute: () => rootRoute,
})

const modalRoute = createRoute({
path: '/modal',
getParentRoute: () => rootRoute,
})

const routeTree = rootRoute.addChildren([indexRoute, modalRoute])

const router = createRouter({
history: createMemoryHistory({
initialEntries: ['/'],
}),
isServer: true,
routeMasks: [
createRouteMask({
from: '/modal',
routeTree,
to: '/',
}),
],
routeTree,
})

await router.load()

const html = ReactDOMServer.renderToString(
<RouterProvider router={router} />,
)

expect(html).not.toContain('window.history.state?.__tempLocation')
})

test('keeps manifest stylesheet links mounted when history state changes', async () => {
const history = createTestBrowserHistory()

Expand Down
5 changes: 5 additions & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,15 @@ export {
exactPathTest,
resolvePath,
interpolatePath,
routePathToRegExpSource,
} from './path'
export { encode, decode } from './qss'
export { rootRouteId } from './root'
export type { RootRouteId } from './root'
export {
getUnmaskOnReloadScript,
getUnmaskOnReloadScriptFromRouteMasks,
} from './unmask-on-reload-script'

export { BaseRoute, BaseRouteApi, BaseRootRoute } from './route'
export type {
Expand Down
58 changes: 57 additions & 1 deletion packages/router-core/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,58 @@ export function resolvePath({
return result
}

/**
* Convert a route path template into a regular expression source that matches
* concrete pathnames for that route.
*/
export function routePathToRegExpSource(routePath: string) {
if (!routePath || routePath === '/') {
return '^/$'
}

let cursor = 0
let regExpSource = ''
let segment

while (cursor < routePath.length) {
const start = cursor
segment = parseSegment(routePath, start, segment)
const end = segment[5]
cursor = end + 1

if (start === end) continue

const kind = segment[0]

if (kind === SEGMENT_TYPE_PATHNAME) {
regExpSource += `/${escapeRegExp(routePath.substring(start, end))}`
continue
}

const prefix = routePath.substring(start, segment[1])
const suffix = routePath.substring(segment[4], end)

if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) {
regExpSource += `(?:/${escapeRegExp(prefix)}[^/]+${escapeRegExp(suffix)})?`
continue
}

if (kind === SEGMENT_TYPE_WILDCARD) {
if (!prefix && !suffix) {
regExpSource += '(?:/.*)?'
continue
}

regExpSource += `/${escapeRegExp(prefix)}.*${escapeRegExp(suffix)}`
continue
}

regExpSource += `/${escapeRegExp(prefix)}[^/]+${escapeRegExp(suffix)}`
}

return `^${regExpSource}$`
}

/**
* Create a pre-compiled decode config from allowed characters.
* This should be called once at router initialization.
Expand All @@ -210,7 +262,7 @@ export function compileDecodeCharMap(
)
// Escape special regex characters and join with |
const pattern = Array.from(charMap.keys())
.map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.map((key) => escapeRegExp(key))
.join('|')
const regex = new RegExp(pattern, 'g')
return (encoded: string) =>
Expand Down Expand Up @@ -434,3 +486,7 @@ function encodePathParam(
const encoded = encodeURIComponent(value)
return decoder?.(encoded) ?? encoded
}

function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
22 changes: 22 additions & 0 deletions packages/router-core/src/unmask-on-reload-inline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export default function (options: { routeMaskSources: Array<string> }) {
const maskedRoutePathPatterns = options.routeMaskSources.map(
(source) => new RegExp(source),
)
const tempLocation = window.history.state?.__tempLocation

if (!tempLocation?.pathname) return

if (
tempLocation.pathname === window.location.pathname &&
(tempLocation.search ?? '') === window.location.search &&
(tempLocation.hash ?? '') === window.location.hash
)
return

if (!maskedRoutePathPatterns.some((pattern) => pattern.test(tempLocation.pathname)))
return

window.location.replace(
tempLocation.pathname + (tempLocation.search ?? '') + (tempLocation.hash ?? ''),
)
}
32 changes: 32 additions & 0 deletions packages/router-core/src/unmask-on-reload-script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import minifiedUnmaskOnReloadScript from './unmask-on-reload-inline?script-string'
import { routePathToRegExpSource } from './path'
import { escapeHtml } from './utils'
import type { AnyRoute, RouteMask } from './route'

export function getUnmaskOnReloadScriptFromRouteMasks(
routeMasks?: ReadonlyArray<
Pick<RouteMask<AnyRoute>, 'from' | 'unmaskOnReload'>
>,
) {
return getUnmaskOnReloadScript(
(routeMasks ?? [])
.filter(
(
routeMask,
): routeMask is {
from: RouteMask<AnyRoute>['from']
unmaskOnReload: true
} =>
routeMask.unmaskOnReload === true && typeof routeMask.from === 'string',
)
.map((routeMask) => routePathToRegExpSource(routeMask.from)),
)
}

export function getUnmaskOnReloadScript(routeMaskSources: Array<string>) {
if (!routeMaskSources.length) return null

return `(${minifiedUnmaskOnReloadScript})(${escapeHtml(
JSON.stringify({ routeMaskSources }),
)})`
}
Loading