Route CSS removed on first client-side navigation and never restored (client manifest is pruned to SSR'd matches)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 15.1k
- Forks
- 1.9k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 143
Description
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, viagetPreparedMatchedManifestRoutes→prepareMatchedManifestRoutes(:145-161, which iteratesmatchesonly).- The client assigns that pruned object as the whole manifest —
load-client.js:1075:router.ssr = { manifest: dehydratedRouter.manifest }; 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.- 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
HeadContentcan re-declare a stylesheet a newly matched route needs; or HeadContentdoes 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by tracing manifest dehydration in router-core/dist/esm/ssr/ssr-server.js, client assignment in load-client.js, and CSS lookup/removal in react-router/dist/esm/headContentUtils.js. Reproduce a production build by loading the SSR route and navigating client-side to the shared-component route; done means shared CSS links remain available or are restored after navigation without disabling CSS splitting.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100