Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
14 changes: 11 additions & 3 deletions packages/router-core/src/ssr/ssr-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NonNullable<TsrSsrGlobal['router']>['manifest']> {
attachRouterServerSsrUtils({ router, manifest })
try {
await router.load()
await router.serverSsr!.dehydrate()

const script = router.serverSsr!.takeBufferedScripts()
expect(script?.children).toBeTruthy()

const context: Record<string, any> = {
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',
])
})
})
34 changes: 26 additions & 8 deletions packages/router-core/tests/ssr-server-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
})

Expand Down Expand Up @@ -315,23 +318,30 @@ 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: {
suppressHydrationWarning: true,
},
})
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',
])
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
])
})
})