@@ -60,6 +60,7 @@ import {
6060 cleanUrl ,
6161 directRequestRE ,
6262 evalValue ,
63+ injectQuery ,
6364 normalizeViteImportAnalysisUrl ,
6465 prepareError ,
6566} from './plugins/vite-utils'
@@ -722,7 +723,66 @@ export default function vitePluginRsc(
722723 async hotUpdate ( ctx ) {
723724 if ( isCSSRequest ( ctx . file ) ) {
724725 if ( this . environment . name === 'client' ) {
725- return
726+ // With the default (`cssLinkPrecedence: true`) setup Vite's default
727+ // client CSS HMR handles the swap already
728+ if ( rscPluginOptions . cssLinkPrecedence !== false ) return
729+
730+ // Only relevant when this CSS is reachable from the RSC
731+ // module graph — otherwise Vite's default CSS HMR applies
732+ const rscMod =
733+ ctx . server . environments . rsc ?. moduleGraph . getModuleById ( ctx . file )
734+ if ( ! rscMod ) return
735+
736+ // If the CSS also has a client-side JS importer, Vite's
737+ // default client CSS HMR is still needed to update the
738+ // non-RSC usages — only skip it when the CSS is RSC-only
739+ const hasClientJsImporter = ctx . modules . some ( ( mod ) =>
740+ [ ...mod . importers ] . some ( ( imp ) => imp . id && ! isCSSRequest ( imp . id ) ) ,
741+ )
742+ if ( hasClientJsImporter ) return
743+
744+ // Skip Vite's default client CSS HMR for RSC-only CSS. The
745+ // RSC-side `rsc:update` event drives a Flight refetch that
746+ // brings a fresh `?t=<timestamp>` href, which is enough.
747+ //
748+ // Why skipping matters: with `cssLinkPrecedence: false` the
749+ // emitted `<link>` is React-owned (Float won't manage it
750+ // without precedence). Vite's client CSS HMR clones that
751+ // `<link>`, appends the clone, and awaits its `load`/`error`
752+ // event inside a `Promise.all`. React's RSC re-render
753+ // unmounts the original before the clone's event fires, the
754+ // Promise never resolves, and every subsequent WebSocket
755+ // message (including the next edit's `rsc:update`) queues
756+ // forever behind the hung promise. The first edit in a
757+ // session appears to work via Vite's `<style>` injection;
758+ // every later edit is silently ignored.
759+ return [ ]
760+ } else if ( this . environment . name === 'rsc' ) {
761+ // Walk up the importer chain from the changed CSS and
762+ // invalidate every derived `\0virtual:vite-rsc/css?type=rsc&...`
763+ // module we encounter. Those virtuals are what emit `<link
764+ // rel="stylesheet">` into the Flight stream via `collectCss` ->
765+ // `normalizeViteImportAnalysisUrl`. Without invalidating them,
766+ // the next render uses the cached transform with the pre-edit
767+ // href. We intentionally do NOT invalidate the JS importers
768+ // themselves (inner.tsx, server.tsx, ...) — those are fine to
769+ // keep; only the derived CSS virtual needs to be recomputed.
770+ const visited = new Set < string > ( )
771+ const walk = ( mod : EnvironmentModuleNode ) => {
772+ if ( ! mod . id || visited . has ( mod . id ) ) return
773+ visited . add ( mod . id )
774+ if ( mod . id . startsWith ( '\0virtual:vite-rsc/css?' ) ) {
775+ this . environment . moduleGraph . invalidateModule ( mod )
776+ }
777+ for ( const imp of mod . importers ) {
778+ walk ( imp )
779+ }
780+ }
781+ for ( const mod of ctx . modules ) {
782+ for ( const imp of mod . importers ) {
783+ walk ( imp )
784+ }
785+ }
726786 }
727787 }
728788
@@ -2217,10 +2277,26 @@ function vitePluginRscCss(
22172277
22182278 recurse ( entryId )
22192279
2220- // this doesn't include ?t= query so that RSC <link /> won't keep adding styles.
2221- const hrefs = [ ...cssIds ] . map ( ( id ) =>
2222- normalizeViteImportAnalysisUrl ( environment , id ) ,
2223- )
2280+ // When `cssLinkPrecedence: false`, React 19 Float won't manage the
2281+ // emitted `<link>` as a resource and Vite's client-side CSS HMR can't
2282+ // rely on href-pathname-match swapping (see hotUpdate comment above).
2283+ // To make the browser pick up updated CSS after an HMR edit we need to
2284+ // include the HMR timestamp in the emitted href. Under the default
2285+ // (`cssLinkPrecedence: true`) path we deliberately leave the href bare
2286+ // so Vite's client CSS HMR can find and swap the `<link>` in-place —
2287+ // that's what matches behavior before this fix and keeps the basic
2288+ // `css hmr server` path working with Float dedup.
2289+ const usePrecedence = rscCssOptions ?. cssLinkPrecedence !== false
2290+ const hrefs = [ ...cssIds ] . map ( ( id ) => {
2291+ let url = normalizeViteImportAnalysisUrl ( environment , id )
2292+ if ( ! usePrecedence && environment . config . consumer !== 'client' ) {
2293+ const mod = environment . moduleGraph . getModuleById ( id )
2294+ if ( mod && mod . lastHMRTimestamp > 0 ) {
2295+ url = injectQuery ( url , `t=${ mod . lastHMRTimestamp } ` )
2296+ }
2297+ }
2298+ return url
2299+ } )
22242300 return { ids : [ ...cssIds ] , hrefs, visitedFiles : [ ...visitedFiles ] }
22252301 }
22262302
0 commit comments