Skip to content

Commit 9d0ca52

Browse files
committed
fix hmr
1 parent 9c67e34 commit 9d0ca52

File tree

3 files changed

+97
-35
lines changed

3 files changed

+97
-35
lines changed

packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,26 @@ import { expect, test } from '@playwright/test'
22
import { useFixture } from './fixture'
33
import { expectNoReload, waitForHydration } from './helper'
44

5-
// Reproduces an HMR bug affecting server components whose modules live
6-
// exclusively in the `rsc` environment and are rendered through a nested
7-
// Flight stream (`renderToReadableStream` + `createFromReadableStream`),
8-
// the pattern used by frameworks like TanStack Start's `createServerFn` +
5+
// Verifies CSS HMR for server components whose modules live exclusively
6+
// in the `rsc` environment and are rendered through a nested Flight
7+
// stream (`renderToReadableStream` + `createFromReadableStream`) — the
8+
// pattern used by frameworks like TanStack Start's `createServerFn` +
99
// `renderServerComponent`.
1010
//
1111
// The fixture sets `cssLinkPrecedence: false` (matching TanStack Start's
12-
// config) so plugin-rsc's emitted `<link>` has no `precedence` attribute,
13-
// disabling React 19's resource-manager dedup/swap path that would
14-
// otherwise paper over the underlying bugs.
12+
// config) so plugin-rsc's emitted `<link>` has no `precedence` attribute
13+
// and React 19's resource-manager dedup/swap path is not in play. This
14+
// is the configuration where the underlying CSS-HMR issues surface;
15+
// under the default (`true`) path Vite's client CSS HMR + Float
16+
// dedup papers over them.
1517
//
16-
// Expected failures on current `main` (both tied to plugin-rsc's dev-mode
17-
// CSS pipeline):
18-
// 1. `normalizeViteImportAnalysisUrl` gates the `?t=<HMRTimestamp>`
19-
// cache-buster on `environment.config.consumer === 'client'`, so
20-
// CSS hrefs emitted into the Flight stream from the `rsc` env
21-
// (consumer: 'server') never get cache-busted.
22-
// 2. `hotUpdate` in plugin-rsc does not invalidate importers of a
23-
// changed CSS file in the `rsc` module graph, so the derived
24-
// `\0virtual:vite-rsc/css?type=rsc&id=…` virtual keeps emitting
25-
// the same stale href on re-render.
26-
//
27-
// The test edits the CSS file twice in the same dev session (change
28-
// color, then revert). This matters because the reporter's proposed
29-
// two-line patch fixes the **first** CSS edit after dev-server start
30-
// but not subsequent edits in the same session — the `?t=` fix lands
31-
// once, the virtual's `load` re-runs once (via some transitive Vite
32-
// invalidation), and then on the second CSS change `mod.importers` no
33-
// longer carries what's needed to re-invalidate the virtual. Asserting
34-
// after the revert catches that the fix is incomplete.
18+
// The test performs a round-trip edit (change color, then revert) in the
19+
// same dev session. The revert is load-bearing: a naive fix can make the
20+
// first edit land while leaving every subsequent edit silently stuck on
21+
// the previous value (Vite's client CSS HMR hangs its `Promise.all` when
22+
// it races React's reconciliation of the RSC-owned `<link>`, which
23+
// blocks every later WebSocket message including the next `rsc:update`).
24+
// Asserting after the revert catches that regression class.
3525

3626
test.describe('nested-rsc-css-hmr', () => {
3727
const f = useFixture({

packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,3 @@ import './inner.css'
33
export function TestNestedRscInner() {
44
return <div className="test-nested-rsc-inner">test-nested-rsc-inner</div>
55
}
6-
7-
// add no-op `import.meta.hot` to trigger `prune` event.
8-
// this is needed until we land https://github.com/vitejs/vite/pull/20768
9-
import.meta.hot

packages/plugin-rsc/src/plugin.ts

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)