Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
32 changes: 32 additions & 0 deletions packages/react-router/src/headContentUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
deepEqual,
escapeHtml,
getAssetCrossOrigin,
getUnmaskOnReloadScript,
resolveManifestAssetLink,
routePathToRegExpSource,
} from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
Expand Down Expand Up @@ -169,13 +171,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 +402,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 +423,26 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => {
)
}

function buildUnmaskOnReloadHeadScript(
router: ReturnType<typeof useRouter>,
nonce: string | undefined,
) {
const routeMaskSources = (router.options.routeMasks ?? [])
.filter((routeMask) => routeMask.unmaskOnReload && typeof routeMask.from === 'string')
.map((routeMask) => routePathToRegExpSource(routeMask.from))

if (!routeMaskSources.length) return undefined

const script = getUnmaskOnReloadScript(routeMaskSources)
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
2 changes: 2 additions & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,12 @@ export {
exactPathTest,
resolvePath,
interpolatePath,
routePathToRegExpSource,
} from './path'
export { encode, decode } from './qss'
export { rootRouteId } from './root'
export type { RootRouteId } from './root'
export { getUnmaskOnReloadScript } 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 ?? ''),
)
}
16 changes: 16 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,16 @@
import minifiedUnmaskOnReloadScript from './unmask-on-reload-inline?script-string'
import { escapeHtml } from './utils'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

type InlineUnmaskOnReloadScriptOptions = {
routeMaskSources: Array<string>
}

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

return `(${minifiedUnmaskOnReloadScript})(${escapeHtml(
JSON.stringify({
routeMaskSources,
} satisfies InlineUnmaskOnReloadScriptOptions),
)})`
}
57 changes: 57 additions & 0 deletions packages/router-core/tests/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
exactPathTest,
interpolatePath,
removeTrailingSlash,
routePathToRegExpSource,
resolvePath,
trimPathLeft,
} from '../src/path'
Expand Down Expand Up @@ -242,6 +243,62 @@ describe('resolvePath', () => {
)
})

describe('routePathToRegExpSource', () => {
it('matches static paths', () => {
const pattern = new RegExp(routePathToRegExpSource('/modal'))

expect(pattern.test('/modal')).toBe(true)
expect(pattern.test('/billing')).toBe(false)
})

it('matches params', () => {
const pattern = new RegExp(
routePathToRegExpSource('/photos/$photoId/modal'),
)

expect(pattern.test('/photos/123/modal')).toBe(true)
expect(pattern.test('/photos/123')).toBe(false)
})

it('matches optional params with and without the segment', () => {
const pattern = new RegExp(
routePathToRegExpSource('/docs/{-$lang}/install'),
)

expect(pattern.test('/docs/install')).toBe(true)
expect(pattern.test('/docs/en/install')).toBe(true)
expect(pattern.test('/docs/en')).toBe(false)
})

it('matches wildcard segments with and without a splat', () => {
const pattern = new RegExp(routePathToRegExpSource('/files/$'))

expect(pattern.test('/files')).toBe(true)
expect(pattern.test('/files/readme.md')).toBe(true)
expect(pattern.test('/files/nested/path')).toBe(true)
expect(pattern.test('/file')).toBe(false)
})

it('keeps wildcard prefix and suffix segments required', () => {
const pattern = new RegExp(
routePathToRegExpSource('/docs/prefix{$}suffix'),
)

expect(pattern.test('/docs/prefixsuffix')).toBe(true)
expect(pattern.test('/docs/prefixnested/pathsuffix')).toBe(true)
expect(pattern.test('/docs')).toBe(false)
})

it('matches param segments with prefixes and suffixes', () => {
const pattern = new RegExp(
routePathToRegExpSource('/blog/prefix{$slug}suffix'),
)

expect(pattern.test('/blog/prefixpostsuffix')).toBe(true)
expect(pattern.test('/blog/prefixsuffix')).toBe(false)
})
})

describe.each([{ server: true }, { server: false }])(
'interpolatePath (server: $server)',
({ server }) => {
Expand Down
16 changes: 16 additions & 0 deletions packages/router-core/tests/unmask-on-reload-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from 'vitest'
import { getUnmaskOnReloadScript } from '../src'

describe('getUnmaskOnReloadScript', () => {
test('returns null when no route mask sources are provided', () => {
expect(getUnmaskOnReloadScript([])).toBeNull()
})

test('serializes the inline unmask-on-reload script', () => {
const script = getUnmaskOnReloadScript(['^/modal$'])

expect(script).toContain('window.history.state?.__tempLocation')
expect(script).toContain('window.location.replace')
expect(script).toContain('"routeMaskSources":["^/modal$"]')
})
})