Skip to content

Route CSS removed on first client-side navigation and never restored (client manifest is pruned to SSR'd matches) #8224

Description

@problem

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

  1. A component <Dashboard /> with a CSS-module import, rendered by two routes:
    • / (index)
    • /pool (a layout route, parent of /pool/$id)
  2. Build for production and load / directly, so it is server-rendered.
  3. 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

  1. getStartManifest() returns assets for all routes.
  2. serverSsr.dehydrate() prunes to only the matched routes before sending to the client — router-core/dist/esm/ssr/ssr-server.js:301, via getPreparedMatchedManifestRoutesprepareMatchedManifestRoutes (:145-161, which iterates matches only).
  3. The client assigns that pruned object as the whole manifest — load-client.js:1075:
    router.ssr = { manifest: dehydratedRouter.manifest };
  4. 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.
  5. React unmounts the outgoing route's <link> elements — correctly, they're its own keyed children.
  6. 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 15px0px, display: flexblock.

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions