From 32cfdc1b4de333d9bcfeca2d02224801838f0738 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 7 Apr 2026 20:04:02 -0400 Subject: [PATCH 1/6] fix: preserve unmasked route on reload --- .../react-router/src/headContentUtils.tsx | 113 ++++++++++++++++++ packages/react-router/tests/Scripts.test.tsx | 90 ++++++++++++++ 2 files changed, 203 insertions(+) diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index b180af2941d..5c067fad271 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -169,6 +169,8 @@ function buildTagsFromMatches( children, })) + const unmaskOnReloadScript = buildUnmaskOnReloadHeadScript(router, nonce) + return uniqBy( [ ...resultMeta, @@ -176,6 +178,7 @@ function buildTagsFromMatches( ...constructedLinks, ...assetLinks, ...styles, + ...(unmaskOnReloadScript ? [unmaskOnReloadScript] : []), ...headScripts, ] as Array, (d) => JSON.stringify(d), @@ -397,12 +400,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, (d) => { @@ -411,6 +421,109 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { ) } +function buildUnmaskOnReloadHeadScript( + router: ReturnType, + 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 + + return { + tag: 'script', + attrs: nonce ? { nonce } : undefined, + children: ` +(() => { + const maskedRoutePathPatterns = ${JSON.stringify(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 ?? ''), + ) +})() +`, + } satisfies RouterManagedTag +} + +function routePathToRegExpSource(routePath: string) { + let regExpSource = '^' + + for (const segment of routePath.split('/').filter(Boolean)) { + const routeSegment = parseRoutePathSegment(segment) + + if (routeSegment.type === 'optional-param') { + regExpSource += `(?:/${routeSegment.source})?` + continue + } + + if (routeSegment.type === 'wildcard') { + regExpSource += `(?:/${routeSegment.source})?` + continue + } + + regExpSource += `/${routeSegment.source}` + } + + return `${regExpSource}$` +} + +function parseRoutePathSegment(segment: string) { + if (!segment.includes('$')) { + return { source: escapeRegExp(segment), type: 'pathname' as const } + } + + if (segment === '$') { + return { source: '.*', type: 'wildcard' as const } + } + + if (segment.startsWith('$')) { + return { source: '[^/]+', type: 'param' as const } + } + + const openBraceIndex = segment.indexOf('{') + if (openBraceIndex === -1) { + return { source: escapeRegExp(segment), type: 'pathname' as const } + } + + const closeBraceIndex = segment.indexOf('}', openBraceIndex) + if (closeBraceIndex === -1) { + return { source: escapeRegExp(segment), type: 'pathname' as const } + } + + const prefix = segment.slice(0, openBraceIndex) + const suffix = segment.slice(closeBraceIndex + 1) + const token = segment.slice(openBraceIndex + 1, closeBraceIndex) + const source = `${escapeRegExp(prefix)}${token === '$' ? '.*' : '[^/]+'}${escapeRegExp(suffix)}` + + if (token === '$') { + return { source, type: 'wildcard' as const } + } + + if (token.startsWith('-$') && token.length > 2) { + return { source, type: 'optional-param' as const } + } + + if (token.startsWith('$') && token.length > 1) { + return { source, type: 'param' as const } + } + + return { source: escapeRegExp(segment), type: 'pathname' as const } +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + export function uniqBy(arr: Array, fn: (item: T) => string) { const seen = new Set() return arr.filter((item) => { diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index 0ca10b794a5..d677724bf1a 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -19,6 +19,7 @@ import { createMemoryHistory, createRootRoute, createRoute, + createRouteMask, createRouter, } from '../src' import { Scripts } from '../src/Scripts' @@ -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: () => , + }) + + 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( + , + ) + + expect(html).toContain('