@@ -2,27 +2,11 @@ import { expect, test } from '@playwright/test'
22import { useFixture } from './fixture'
33import { expectNoReload , waitForHydration } from './helper'
44
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` +
9- // `renderServerComponent`.
10- //
11- // The fixture sets `cssLinkPrecedence: false` (matching TanStack Start's
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.
17- //
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.
25-
5+ // Covers CSS HMR for server-only components rendered via nested Flight stream
6+ // (renderToReadableStream + createFromReadableStream — TanStack Start's
7+ // createServerFn / renderServerComponent shape)
8+ // Fixture uses cssLinkPrecedence: false so React 19 Float dedup/swap is off
9+ // and raw HMR path is exercised
2610test . describe ( 'nested-rsc-css-hmr' , ( ) => {
2711 const f = useFixture ( {
2812 root : 'examples/nested-rsc-css-hmr' ,
@@ -44,38 +28,17 @@ test.describe('nested-rsc-css-hmr', () => {
4428 'color' ,
4529 'rgb(0, 165, 255)' ,
4630 )
31+ // Revert is load-bearing: naive fix lands edit 1 but wedges Vite's
32+ // Promise.all racing React's <link> reconcile, blocking later rsc:update
4733 editor . reset ( )
4834 await expect ( page . locator ( '.test-nested-rsc-inner' ) ) . toHaveCSS (
4935 'color' ,
5036 'rgb(255, 165, 0)' ,
5137 )
5238 } )
5339
54- // Verifies that *removing* a CSS rule (not just changing its value) takes
55- // effect across HMR edits. Because plugin-rsc's HMR fix appends a
56- // `?t=<ts>` cache-buster to the emitted `<link href>` on every RSC
57- // re-render, each edit produces a `<link>` with a new href. This test
58- // asserts two things that a value-only edit can't catch:
59- //
60- // 1. The previous `<link>` is actually unmounted by React on each edit
61- // (no DOM accumulation). If old `<link>` nodes lingered — as happens
62- // when React 19 Float manages them by precedence and dedupes by href
63- // rather than by intent — a deleted property would still cascade from
64- // the stale stylesheet and the change would be silently lost.
65- // 2. Commenting out the `color` rule actually falls back to the UA
66- // default (`rgb(0, 0, 0)`), which exercises the unmount path end to
67- // end (browser drops the old sheet, no rule applies anymore).
68- //
69- // The link-count assertion uses `[data-rsc-css-href]` so it only counts
70- // RSC-emitted stylesheets for `inner.css` and ignores other links Vite
71- // or React may inject (HMR client style tags, preload hints, etc).
72- //
73- // Note: in this fixture two `<link>`s for `inner.css` exist on initial
74- // load — one from the outer Root tree's `collectCss`, one from the
75- // nested Flight stream's own `collectCss`. The accumulation bug we want
76- // to catch is "count grows per edit" (yak-style), so we capture the
77- // initial count and assert it stays equal across edits — not that it
78- // equals 1.
40+ // Rule removal (not just value change) checks the unmount path: old <link>
41+ // must drop, else the cascade keeps the stale rule
7942 test ( 'round-trip with property removal does not leave stale link' , async ( {
8043 page,
8144 } ) => {
@@ -86,6 +49,10 @@ test.describe('nested-rsc-css-hmr', () => {
8649 'rgb(255, 165, 0)' ,
8750 )
8851
52+ // [data-rsc-css-href] scopes to RSC-emitted links, ignores Vite/React
53+ // injections
54+ // Two on load: outer Root collectCss + nested Flight collectCss
55+ // Bug shape is "grows per edit", so assert equal-to-initial, not equal-to-1
8956 const innerLinks = page . locator (
9057 'link[rel="stylesheet"][data-rsc-css-href*="inner.css"]' ,
9158 )
@@ -103,10 +70,8 @@ test.describe('nested-rsc-css-hmr', () => {
10370 )
10471 await expect ( innerLinks ) . toHaveCount ( initialLinkCount )
10572
106- // Edit 2: remove the rule — color must fall back to the inherited
107- // value (`:root { color: #213547 }` from `index.css`, light scheme =
108- // rgb(33, 53, 71)). If any old `<link>` for `inner.css` were still
109- // attached, the cascade would keep the blue.
73+ // Edit 2: remove rule — falls back to :root color from index.css
74+ // (rgb(33, 53, 71)) — stale <link> would keep the blue
11075 editor . edit ( ( s ) =>
11176 s . replaceAll (
11277 'color: rgb(0, 165, 255);' ,
@@ -119,12 +84,86 @@ test.describe('nested-rsc-css-hmr', () => {
11984 )
12085 await expect ( innerLinks ) . toHaveCount ( initialLinkCount )
12186
122- // Edit 3: revert — back to original orange.
87+ // Edit 3: revert — back to original orange
12388 editor . reset ( )
12489 await expect ( page . locator ( '.test-nested-rsc-inner' ) ) . toHaveCSS (
12590 'color' ,
12691 'rgb(255, 165, 0)' ,
12792 )
12893 await expect ( innerLinks ) . toHaveCount ( initialLinkCount )
12994 } )
95+
96+ // card.module.css reached from RSC graph (card.tsx server component) and
97+ // client graph (client-tracker.tsx 'use client' side-effect import) — shape
98+ // TanStack Start hits when a route re-declares an RSC-owned stylesheet on
99+ // the client
100+ // Exercises the hasClientJsImporter branch of hotUpdate: fix filters the
101+ // CSS-typed module out of the HMR payload (so Vite's default client HMR
102+ // doesn't cloneNode+mutate the RSC <link>) while keeping the JS-typed
103+ // wrapper so updateStyle() keeps refreshing <style data-vite-dev-id>
104+ // .module.css on purpose — plain .css has no JS wrapper to keep
105+ // Tests guard the config shape; they don't repro the (timing-sensitive)
106+ // hydration race
107+ test ( 'css module reached by both RSC and client graphs hot-updates across edits' , async ( {
108+ page,
109+ } ) => {
110+ await page . goto ( f . url ( ) )
111+ await waitForHydration ( page )
112+ await expect ( page . locator ( '[data-testid="shared-graph-card"]' ) ) . toHaveCSS (
113+ 'color' ,
114+ 'rgb(128, 0, 128)' ,
115+ )
116+
117+ await using _ = await expectNoReload ( page )
118+ const editor = f . createEditor ( 'src/shared-graph/card.module.css' )
119+
120+ editor . edit ( ( s ) => s . replaceAll ( 'rgb(128, 0, 128)' , 'rgb(255, 0, 0)' ) )
121+ await expect ( page . locator ( '[data-testid="shared-graph-card"]' ) ) . toHaveCSS (
122+ 'color' ,
123+ 'rgb(255, 0, 0)' ,
124+ )
125+
126+ editor . edit ( ( s ) => s . replaceAll ( 'rgb(255, 0, 0)' , 'rgb(0, 0, 255)' ) )
127+ await expect ( page . locator ( '[data-testid="shared-graph-card"]' ) ) . toHaveCSS (
128+ 'color' ,
129+ 'rgb(0, 0, 255)' ,
130+ )
131+
132+ editor . reset ( )
133+ await expect ( page . locator ( '[data-testid="shared-graph-card"]' ) ) . toHaveCSS (
134+ 'color' ,
135+ 'rgb(128, 0, 128)' ,
136+ )
137+ } )
138+
139+ // Covers the "keep JS wrapper in the HMR payload" half: without wrapper
140+ // self-accept, removed rules stay live on <style data-vite-dev-id>
141+ test ( 'removing a rule from the shared css module falls through the cascade' , async ( {
142+ page,
143+ } ) => {
144+ await page . goto ( f . url ( ) )
145+ await waitForHydration ( page )
146+ await expect ( page . locator ( '[data-testid="shared-graph-card"]' ) ) . toHaveCSS (
147+ 'text-transform' ,
148+ 'uppercase' ,
149+ )
150+
151+ await using _ = await expectNoReload ( page )
152+ const editor = f . createEditor ( 'src/shared-graph/card.module.css' )
153+ editor . edit ( ( s ) =>
154+ s . replaceAll (
155+ 'text-transform: uppercase;' ,
156+ '/* text-transform: removed */' ,
157+ ) ,
158+ )
159+ await expect ( page . locator ( '[data-testid="shared-graph-card"]' ) ) . toHaveCSS (
160+ 'text-transform' ,
161+ 'none' ,
162+ )
163+ editor . reset ( )
164+ await expect ( page . locator ( '[data-testid="shared-graph-card"]' ) ) . toHaveCSS (
165+ 'text-transform' ,
166+ 'uppercase' ,
167+ )
168+ } )
130169} )
0 commit comments