diff --git a/.changeset/calmly-fox-smiled.md b/.changeset/calmly-fox-smiled.md new file mode 100644 index 00000000000..d7fdc713b7b --- /dev/null +++ b/.changeset/calmly-fox-smiled.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-router': patch +--- + +Injected `unmaskOnReload` pre-hydration script during SSR so hard reloads preserved the unmasked route. diff --git a/.changeset/swiftly-otter-jumped.md b/.changeset/swiftly-otter-jumped.md new file mode 100644 index 00000000000..b6b94284768 --- /dev/null +++ b/.changeset/swiftly-otter-jumped.md @@ -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. diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index b180af2941d..8b5d7281f49 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -4,6 +4,7 @@ import { deepEqual, escapeHtml, getAssetCrossOrigin, + getUnmaskOnReloadScriptFromRouteMasks, resolveManifestAssetLink, } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' @@ -169,6 +170,8 @@ function buildTagsFromMatches( children, })) + const unmaskOnReloadScript = buildUnmaskOnReloadHeadScript(router, nonce) + return uniqBy( [ ...resultMeta, @@ -176,6 +179,7 @@ function buildTagsFromMatches( ...constructedLinks, ...assetLinks, ...styles, + ...(unmaskOnReloadScript ? [unmaskOnReloadScript] : []), ...headScripts, ] as Array, (d) => JSON.stringify(d), @@ -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, (d) => { @@ -411,6 +422,20 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { ) } +function buildUnmaskOnReloadHeadScript( + router: ReturnType, + 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(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('