From 7e4be3d36971c5708d9b72f8574b7f8be4679da5 Mon Sep 17 00:00:00 2001 From: breken Date: Wed, 2 Sep 2026 23:04:27 -0700 Subject: [PATCH 1/3] fix(router-core): dehydrate all manifest routes, not just SSR matches (#8224) --- packages/router-core/src/ssr/ssr-server.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 30d3bc48906..741815b1d5f 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -505,8 +505,14 @@ export function attachRouterServerSsrUtils({ const matches = matchesToDehydrate.map(dehydrateMatch) let manifestToDehydrate: Manifest | undefined = undefined - // Only currently matched routes are dehydrated. Other route assets are - // loaded through dynamic imports when those routes become active. + // All routes are dehydrated, not just the currently matched ones. + // HeadContent removes an outgoing route's stylesheet links on + // navigation and looks the incoming route's css up in this manifest. + // If the incoming route is missing here (e.g. it shares a chunk with + // an SSR'd route), nothing re-declares the stylesheet and Vite will + // not re-inject it (its module is already cached), so the page loses + // every CSS rule. Matched routes keep their stripped entries when + // inlineCss is enabled; unmatched routes dehydrate as-is. if (manifest) { const cacheKey = getMatchedRoutesCacheKey(matchesToDehydrate) const preparedManifest = getPreparedMatchedManifestRoutes( @@ -522,7 +528,9 @@ export function attachRouterServerSsrUtils({ ...(preparedManifest.inlineCssHrefs ? { inlineStyle: createInlineCssPlaceholderAsset() } : {}), - routes: preparedManifest.routes, + routes: preparedManifest.hasStrippedRoutes + ? { ...manifest.routes, ...preparedManifest.routes } + : manifest.routes, } // Merge request-scoped assets into root route (without mutating cached manifest) From 203ee9b1a5ebee055cfaced241f0f86afdfb1240 Mon Sep 17 00:00:00 2001 From: breken Date: Wed, 2 Sep 2026 23:04:27 -0700 Subject: [PATCH 2/3] test(router-core): update manifest dehydration tests for all-routes contract (#8224) --- .../tests/ssr-server-manifest.test.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/router-core/tests/ssr-server-manifest.test.ts b/packages/router-core/tests/ssr-server-manifest.test.ts index 6cdc98a87b7..6d42b7543f0 100644 --- a/packages/router-core/tests/ssr-server-manifest.test.ts +++ b/packages/router-core/tests/ssr-server-manifest.test.ts @@ -104,10 +104,13 @@ function parseSerializedRouter(serialized: string): DehydratedRouter { } describe('attachRouterServerSsrUtils manifest dehydration', () => { - test('omits unmatched route assets by default', async () => { + test('dehydrates assets for all routes, not just the SSR matches', async () => { const manifest = await dehydrateManifest() - expect(manifest.routes['/posts']).toBeUndefined() + // Routes outside the SSR match set must be present so client-side + // navigations can look up and restore their assets (#8224). + expect(manifest.routes['/posts']?.css).toEqual(['/assets/shared.css']) + expect(manifest.routes['/posts']?.preloads).toEqual(['/assets/posts.js']) expect(manifest.routes['/']?.preloads).toEqual(['/assets/index.js']) }) @@ -315,9 +318,10 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { const dehydratedRouter = parseSerializedRouter(script!.children!) const dehydratedManifest = dehydratedRouter.manifest! const rootInlineCss = dehydratedManifest.inlineStyle - const allLinks = Object.values(dehydratedManifest.routes).flatMap( - (route) => route.css ?? [], - ) + const matchedLinks = [ + dehydratedManifest.routes.__root__, + dehydratedManifest.routes['/'], + ].flatMap((route) => route?.css ?? []) expect(rootInlineCss).toEqual({ attrs: { @@ -325,13 +329,19 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { }, }) expect('inlineCss' in dehydratedManifest).toBe(false) + // Matched routes strip stylesheet links that were inlined into the page. expect( - allLinks.some((asset) => + matchedLinks.some((asset) => typeof asset === 'string' ? asset === '/assets/shared.css' : asset.href === '/assets/shared.css', ), ).toBe(false) + // Unmatched routes keep their stylesheet links so client-side + // navigations can re-declare them (#8224). + expect(dehydratedManifest.routes['/posts']?.css).toEqual([ + '/assets/shared.css', + ]) expect(dehydratedManifest.routes['/']?.preloads).toEqual([ '/assets/index.js', ]) @@ -402,7 +412,7 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { ]) }) - test('omits descendant assets past a terminal parent boundary', async () => { + test('dehydrates descendant assets past a terminal parent boundary', async () => { const rootRoute = new BaseRootRoute({}) const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -451,6 +461,14 @@ describe('attachRouterServerSsrUtils manifest dehydration', () => { script!.children!, ).manifest! - expect(dehydratedManifest.routes[childRoute.id]).toBeUndefined() + // The child never rendered (its parent loader threw), but its assets + // are still dehydrated so a client-side navigation can restore + // them (#8224). + expect(dehydratedManifest.routes[childRoute.id]?.css).toEqual([ + '/assets/child.css', + ]) + expect(dehydratedManifest.routes[childRoute.id]?.preloads).toEqual([ + '/assets/child.js', + ]) }) }) From c67fd5d7c841ef38838e6925007320913a5c00bf Mon Sep 17 00:00:00 2001 From: breken Date: Wed, 2 Sep 2026 23:04:28 -0700 Subject: [PATCH 3/3] test(router-core): regression test for dehydrated manifest route pruning (#8224) --- ...224-dehydrated-manifest-all-routes.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packages/router-core/tests/issue-8224-dehydrated-manifest-all-routes.test.ts diff --git a/packages/router-core/tests/issue-8224-dehydrated-manifest-all-routes.test.ts b/packages/router-core/tests/issue-8224-dehydrated-manifest-all-routes.test.ts new file mode 100644 index 00000000000..1196ce771cb --- /dev/null +++ b/packages/router-core/tests/issue-8224-dehydrated-manifest-all-routes.test.ts @@ -0,0 +1,111 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' +import { createTestRouter } from './routerTestUtils' +import type { AnyRouter } from '../src' +import type { ServerManifest } from '../src/manifest' +import type { TsrSsrGlobal } from '../src/ssr/types' + +async function dehydrateToManifest( + router: AnyRouter, + manifest: ServerManifest, +): Promise['manifest']> { + attachRouterServerSsrUtils({ router, manifest }) + try { + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + expect(script?.children).toBeTruthy() + + const context: Record = { + document: { + currentScript: { + remove() {}, + }, + }, + } + context.self = context + runInNewContext(script!.children!, context) + + return context.$_TSR!.router!.manifest + } finally { + router.serverSsr?.cleanup() + } +} + +function createServerRouter() { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => 'Index', + }) + const poolRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/pool', + component: () => 'Pool', + }) + return { + router: createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, poolRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + }), + rootRoute, + } +} + +describe('issue-8224: dehydrated manifest keeps css for unmatched routes', () => { + it('includes routes outside the SSR match set so client-side navigation can re-declare shared chunk css', async () => { + const { router, rootRoute } = createServerRouter() + const manifest: ServerManifest = { + routes: { + [rootRoute.id]: { css: ['/assets/root.css'] }, + '/': { css: ['/assets/dashboard.css'] }, + // '/pool' is never part of the SSR match set for a direct load of '/', + // but shares the dashboard chunk's stylesheet with '/' + '/pool': { css: ['/assets/dashboard.css'] }, + }, + } + + const dehydratedManifest = await dehydrateToManifest(router, manifest) + + expect(dehydratedManifest).toBeDefined() + expect(Object.keys(dehydratedManifest!.routes).sort()).toEqual( + ['/', '/pool', rootRoute.id].sort(), + ) + expect(dehydratedManifest!.routes['/pool']!.css).toEqual([ + '/assets/dashboard.css', + ]) + }) + + it('keeps stripped entries for matched routes when inlineCss is enabled while still including unmatched routes', async () => { + const { router, rootRoute } = createServerRouter() + const manifest: ServerManifest = { + inlineCss: { + styles: { + '/assets/root.css': 'body { margin: 0 }', + }, + }, + routes: { + [rootRoute.id]: { css: ['/assets/root.css'] }, + '/': { css: ['/assets/dashboard.css'] }, + '/pool': { css: ['/assets/dashboard.css'] }, + }, + } + + const dehydratedManifest = await dehydrateToManifest(router, manifest) + + // the matched root route's inlined stylesheet is stripped from its entry + expect(dehydratedManifest!.routes[rootRoute.id]!.css).toBeUndefined() + // and the placeholder for the inlined styles is present + expect(dehydratedManifest!.inlineStyle).toBeDefined() + // the unmatched route still dehydrates with its stylesheet + expect(dehydratedManifest!.routes['/pool']!.css).toEqual([ + '/assets/dashboard.css', + ]) + }) +})