Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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',
attrs: nonce ? { nonce } : undefined,
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 type { AnyRoute, RouteMask } from './route'
import { escapeHtml } from './utils'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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