Skip to content

fix(rsc): fix server css hmr with cssLinkPrecedence: false#1188

Merged
hi-ogawa merged 16 commits intovitejs:mainfrom
jantimon:failing-test/rsc-nested-css-hmr
Apr 27, 2026
Merged

fix(rsc): fix server css hmr with cssLinkPrecedence: false#1188
hi-ogawa merged 16 commits intovitejs:mainfrom
jantimon:failing-test/rsc-nested-css-hmr

Conversation

@jantimon
Copy link
Copy Markdown
Contributor

@jantimon jantimon commented Apr 15, 2026

I saw a bug in CSS HMR when trying out RSC in Tan Stack start https://tanstack.com/blog/react-server-components

Actually I wanted to see if RSC works well in TanStack and allows the css of next-yak static css-in-js plugin to be hot reloaded: DigitecGalaxus/next-yak#529

It seems that a server component whose module lives only in the rsc Vite environment (not in the client bundle) is rendered through a nested RSC Flight stream.

What the bug does

Editing a CSS file that is only imported from a Server Component rendered through a nested RSC Flight stream does not reliably update the page. The first edit in a dev session often appears to work (via a Vite side-effect), but any subsequent edit in the same session is silently ignored. So every HMR message after that sits behind a hung promise on the client. Users see the page stuck on edit‑1 CSS until a full page reload or dev-server restart.

Scope / who is affected

  • Frameworks that set cssLinkPrecedence: false e.g TanStack Start.
  • Frameworks that render RSC through nested Flight streams (createServerFn + renderServerComponent, etc.).
  • The default path (cssLinkPrecedence: true, React 19 Float-managed) is unaffected — the fix is narrowly gated so it does not change that path.

The three-part fix (in plugin-rsc/src/plugin.ts)

  1. Invalidate the derived CSS virtual in hotUpdate (rsc env). Walk the importer chain from the changed CSS and invalidate only the \0virtual:vite-rsc/css?type=rsc&… modules. JS importers (inner.tsx, server.tsx, root.tsx) are intentionally left untouched to avoid regressing the Float-managed default path
  2. Cache-bust the emitted <link href> in collectCss. Only when cssLinkPrecedence: false and the consumer isn't already client, append ?t=<lastHMRTimestamp>. The default Float path stays bare-href so Vite's in-place <link> swap keeps working
  3. Skip Vite client CSS HMR for RSC-only CSS in the client env's hotUpdate. When cssLinkPrecedence: false and the changed CSS has no client-side JS importer, return []. This is what prevents Vite's Promise.all from hanging on a React-owned <link> that gets unmounted mid-swap.. and that hang was the reason only the first edit ever appeared to work

@hi-ogawa
Copy link
Copy Markdown
Contributor

Hey thanks for digging.
Is this reproducible only with cssLinkPrecedence: false? That's added for tanstack for their need as experimental so we don't test on our side nor haven't properly reviewed how it works. cc @schiller-manuel

Also, if you have a fix, please feel free to push here to verify the fix.

@jantimon jantimon changed the title CSS HMR breaks for RSC-only server components rendered via a nested Flight stream fix(rsc): CSS HMR for nested RSC with cssLinkPrecedence: false Apr 17, 2026
@jantimon jantimon changed the title fix(rsc): CSS HMR for nested RSC with cssLinkPrecedence: false fix(rsc): hot module replacement of CSS for nested RSC with cssLinkPrecedence: false Apr 17, 2026
@jantimon
Copy link
Copy Markdown
Contributor Author

This pr fixes the bug in vite-plugin-react rsc for cssLinkPrecedence: false:

vite-hmr.mp4

However it introduces a new issue for the tanstack integration where we will need the help of @schiller-manuel to find the best approach. The problem is that it does not fix the removal of css props as old css styles now stay in DOM because of the cache busting ?t=...

stylesheet links stay in dom

@hi-ogawa hi-ogawa added the trigger: preview Trigger pkg.pr.new label Apr 24, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 24, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@vitejs/plugin-react@1188
npm i https://pkg.pr.new/@vitejs/plugin-rsc@1188
npm i https://pkg.pr.new/@vitejs/plugin-react-swc@1188

commit: 380f3cb

@hi-ogawa
Copy link
Copy Markdown
Contributor

hi-ogawa commented Apr 24, 2026

I should've triggered this sooner, but the preview release is available in #1188 (comment) Please test it out with tasntack router integration.

I get the idea of changes, but need more time to review. Also will push some changes to test some ideas.

for (const imp of mod.importers) {
walk(imp)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks similar to what we had before moving to addWatchFile in rsc:css-virtual #847.

// invalidate virtual module on js file changes to reflect added/deleted css import
for (const file of [mod.file, ...result.visitedFiles]) {
this.addWatchFile(file)
}

This logic can be naturally subsumed there by adding collected to visitedFiles. Currently missing but it seems like that was an oversight.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, relying on addWatchFile broke the CI ba6c1c2

Comment thread packages/plugin-rsc/src/plugin.ts Outdated
Comment on lines +2310 to +2316
// cssLinkPrecedence: false — without `precedence` React leaves the
// <link> as a regular DOM element (no auto-dedupe / resource handling)
// and Vite's client CSS HMR can't swap it by href-pathname match either
// (see hotUpdate above), so bake the HMR timestamp into the href and
// let the RSC-side Flight refetch drive the swap
// Default (true) leaves the href bare so Vite's client CSS HMR can swap
// the <link> in place — matches pre-fix behavior
Copy link
Copy Markdown
Contributor

@hi-ogawa hi-ogawa Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, we can drive css swap only for non float case, but the same technique won't be applicable for float case?

Based on my previous comment, it looks like float css would keep appending instead of swapping but not sure if I verified that properly and I might have confused with Vite side css behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like float css would keep appending instead of swapping

Okay, I think I verified locally just in case. I feel like this means that we've been relying on somewhat not-so-well-defined behavior of float css not re-inserting the original css that get removed by vite.

Probably had the same conclusion when I was writing that comment, but just iterating for the record. Will probably polish the comment accordinglly.

@hi-ogawa hi-ogawa changed the title fix(rsc): hot module replacement of CSS for nested RSC with cssLinkPrecedence: false fix(rsc): hot module replacement of CSS for nested RSC with cssLinkPrecedence: false Apr 24, 2026
@hi-ogawa hi-ogawa changed the title fix(rsc): hot module replacement of CSS for nested RSC with cssLinkPrecedence: false fix(rsc): fix server css hmr with cssLinkPrecedence: false Apr 24, 2026
Copy link
Copy Markdown
Contributor

@hi-ogawa hi-ogawa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for PR and analysis! Merging.

@hi-ogawa hi-ogawa merged commit f4647c4 into vitejs:main Apr 27, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

trigger: preview Trigger pkg.pr.new

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants