Summary
On the first client-side navigation away from the server-rendered route, HeadContent removes that route's manifest CSS <link> elements. If the incoming route reuses the same chunk — a component rendered by both routes — nothing re-declares those stylesheets and nothing re-injects them, so the page keeps its class names and loses every rule.
The result looks like a stylesheet that failed to load, but DevTools shows every .css at 200. They loaded, and then their <link> elements were deleted.
Versions
@tanstack/react-start 1.168.49 (latest)
@tanstack/react-router 1.170.32 (latest)
@tanstack/router-core 1.171.27 (latest)
vite 7.3.6 · react 18.3.1 · SSR via Nitro
Reproduction
- A component
<Dashboard /> with a CSS-module import, rendered by two routes:
/ (index)
/pool (a layout route, parent of /pool/$id)
- Build for production and load
/ directly, so it is server-rendered.
- Client-side navigate to
/pool/$id.
Dashboard's stylesheet is removed from <head> and never restored.
It does not reproduce if you arrive at / by client-side navigation from some other route — then the CSS came from Vite's runtime injection, which the router doesn't own and therefore doesn't remove. That asymmetry is what makes it look intermittent in the wild.
What's happening
getStartManifest() returns assets for all routes.
serverSsr.dehydrate() prunes to only the matched routes before sending to the client — router-core/dist/esm/ssr/ssr-server.js:301, via getPreparedMatchedManifestRoutes → prepareMatchedManifestRoutes (:145-161, which iterates matches only).
- The client assigns that pruned object as the whole manifest —
load-client.js:1075:
router.ssr = { manifest: dehydratedRouter.manifest };
HeadContent looks up manifest.routes[match.routeId]?.css (react-router/dist/esm/headContentUtils.js:65). For any route not in the original SSR match set this is undefined, so no CSS tags are produced.
- React unmounts the outgoing route's
<link> elements — correctly, they're its own keyed children.
- Vite does not re-inject, because the chunk is already in its module cache.
Observed client-side after loading /:
routes in client manifest: 2 -> __root__, /
"/" css: TooltipButton, GalleryIcon, FocusReveal, BookmarkDashboard
"/pool" css: (NONE)
The server-side manifest has all 34 routes, and /pool there correctly lists all four stylesheets. Only the client's copy is missing them.
MutationObserver on <head> across the navigation, bracketed by the title swap:
12947ms add LINK stylesheet pool-PDzBNjqJ.css <- Vite, for the newly loaded chunk
13464ms REMOVE TITLE
13464ms REMOVE LINK modulepreload ...16 entries...
13464ms REMOVE LINK stylesheet TooltipButton-Dla7QkuC.css
13464ms REMOVE LINK stylesheet GalleryIcon-BwNEuhX0.css
13464ms REMOVE LINK stylesheet FocusReveal-CakzEOUS.css
13464ms REMOVE LINK stylesheet BookmarkDashboard-5xTt8aTr.css
13464ms add TITLE
All three matches are success at that moment (__root__, /pool, /pool/$idSlug), so _getAssetMatches truncation is not involved.
Elements keep their hashed class names (_searchContainer_15j3j_55); the rules are simply gone — padding: 20px 20px 15px → 0px, display: flex → block.
Expected
Either:
- the dehydrated manifest carries assets for all routes (or is extended as routes load), so
HeadContent can re-declare a stylesheet a newly matched route needs; or
HeadContent does not remove a stylesheet <link> it cannot guarantee re-adding.
The current combination — prune the client manifest to the SSR'd matches, then remove head assets by route on navigation — means any chunk shared between an SSR'd route and a later-visited route loses its CSS.
Workaround
build.cssCodeSplit: false. One stylesheet, which the manifest attaches to __root__ — always matched, never unmounted, so there is nothing to tear down. Costs ~23 KB gzipped on first load for us.
Note
Filed from a production incident. Happy to put together a minimal standalone reproduction if that would help — the shape above is the whole of it, and the internal trace is included so it can be verified by inspection in the meantime.
Summary
On the first client-side navigation away from the server-rendered route,
HeadContentremoves that route's manifest CSS<link>elements. If the incoming route reuses the same chunk — a component rendered by both routes — nothing re-declares those stylesheets and nothing re-injects them, so the page keeps its class names and loses every rule.The result looks like a stylesheet that failed to load, but DevTools shows every
.cssat 200. They loaded, and then their<link>elements were deleted.Versions
Reproduction
<Dashboard />with a CSS-module import, rendered by two routes:/(index)/pool(a layout route, parent of/pool/$id)/directly, so it is server-rendered./pool/$id.Dashboard's stylesheet is removed from<head>and never restored.It does not reproduce if you arrive at
/by client-side navigation from some other route — then the CSS came from Vite's runtime injection, which the router doesn't own and therefore doesn't remove. That asymmetry is what makes it look intermittent in the wild.What's happening
getStartManifest()returns assets for all routes.serverSsr.dehydrate()prunes to only the matched routes before sending to the client —router-core/dist/esm/ssr/ssr-server.js:301, viagetPreparedMatchedManifestRoutes→prepareMatchedManifestRoutes(:145-161, which iteratesmatchesonly).load-client.js:1075:HeadContentlooks upmanifest.routes[match.routeId]?.css(react-router/dist/esm/headContentUtils.js:65). For any route not in the original SSR match set this isundefined, so no CSS tags are produced.<link>elements — correctly, they're its own keyed children.Observed client-side after loading
/:The server-side manifest has all 34 routes, and
/poolthere correctly lists all four stylesheets. Only the client's copy is missing them.MutationObserveron<head>across the navigation, bracketed by the title swap:All three matches are
successat that moment (__root__,/pool,/pool/$idSlug), so_getAssetMatchestruncation is not involved.Elements keep their hashed class names (
_searchContainer_15j3j_55); the rules are simply gone —padding: 20px 20px 15px→0px,display: flex→block.Expected
Either:
HeadContentcan re-declare a stylesheet a newly matched route needs; orHeadContentdoes not remove a stylesheet<link>it cannot guarantee re-adding.The current combination — prune the client manifest to the SSR'd matches, then remove head assets by route on navigation — means any chunk shared between an SSR'd route and a later-visited route loses its CSS.
Workaround
build.cssCodeSplit: false. One stylesheet, which the manifest attaches to__root__— always matched, never unmounted, so there is nothing to tear down. Costs ~23 KB gzipped on first load for us.Note
Filed from a production incident. Happy to put together a minimal standalone reproduction if that would help — the shape above is the whole of it, and the internal trace is included so it can be verified by inspection in the meantime.