Skip to content

Commit e88f2a6

Browse files
committed
add shared-graph e2e tests
1 parent cbf53d8 commit e88f2a6

File tree

6 files changed

+159
-96
lines changed

6 files changed

+159
-96
lines changed

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

Lines changed: 90 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,11 @@ import { expect, test } from '@playwright/test'
22
import { useFixture } from './fixture'
33
import { 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
2610
test.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
})

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { getServerCounter, updateServerCounter } from './action.tsx'
44
import reactLogo from './assets/react.svg'
55
import { ClientCounter } from './client.tsx'
66
import { TestNestedRsc } from './nested-rsc/server.tsx'
7+
import { SharedGraphCard } from './shared-graph/card.tsx'
8+
import { SharedGraphClientTracker } from './shared-graph/client-tracker.tsx'
79

810
export function Root(props: { url: URL }) {
911
return (
@@ -48,6 +50,10 @@ function App(props: { url: URL }) {
4850
<div className="card">
4951
<TestNestedRsc />
5052
</div>
53+
<div className="card">
54+
<SharedGraphCard />
55+
<SharedGraphClientTracker />
56+
</div>
5157
<ul className="read-the-docs">
5258
<li>
5359
Edit <code>src/client.tsx</code> to test client HMR.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.card {
2+
padding: 12px;
3+
color: rgb(128, 0, 128);
4+
text-transform: uppercase;
5+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import styles from './card.module.css'
2+
3+
export function SharedGraphCard() {
4+
return (
5+
<div className={styles.card} data-testid="shared-graph-card">
6+
shared-graph-card
7+
</div>
8+
)
9+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use client'
2+
3+
// Side-effect import of a CSS module that's also consumed by an RSC
4+
// component (`./card.tsx`). The import has no runtime consumer on the
5+
// client — its only job is to put `card.module.css` into the client
6+
// environment's module graph so Vite's `hasClientJsImporter` flag
7+
// flips to true for that file. This mirrors the pattern a route file
8+
// uses in frameworks like TanStack Start, where an RSC-owned
9+
// stylesheet is re-declared on the client so Vite's HMR runtime
10+
// tracks it during dev.
11+
import './card.module.css'
12+
13+
export function SharedGraphClientTracker() {
14+
return null
15+
}

packages/plugin-rsc/src/plugin.ts

Lines changed: 34 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -723,50 +723,41 @@ export default function vitePluginRsc(
723723
async hotUpdate(ctx) {
724724
if (isCSSRequest(ctx.file)) {
725725
if (this.environment.name === 'client') {
726-
// With the default (`cssLinkPrecedence: true`) setup Vite's default
727-
// client CSS HMR handles the swap already
726+
// Default (cssLinkPrecedence: true) — Vite's client CSS HMR
727+
// already handles the swap
728728
if (rscPluginOptions.cssLinkPrecedence !== false) return
729729

730-
// Only relevant when this CSS is reachable from the RSC
731-
// module graph — otherwise Vite's default CSS HMR applies
730+
// Only relevant when CSS is reachable from the RSC graph —
731+
// otherwise Vite's default client CSS HMR applies
732732
const rscMod =
733733
ctx.server.environments.rsc?.moduleGraph.getModuleById(ctx.file)
734734
if (!rscMod) return
735735

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.
736+
// Drop the CSS-typed module (e.g. `?direct`) — it drives Vite's
737+
// default <link> swap (find matching <link> by path, cloneNode,
738+
// rewrite href with `?t=<timestamp>`) which picks up the
739+
// RSC-rendered <link data-rsc-css-href> as its target
740+
// RSC does its own swap (rsc:update -> Flight refetch, reconciled
741+
// in place by the `css:<href>` key), so letting both run against
742+
// the same <link> causes mid-render attribute mutations and
743+
// later HMR edits silently drop
747744
//
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 []
745+
// Keep the JS-typed wrapper (the .css file served as JS when
746+
// imported from JS) so Vite still emits its js-update, which
747+
// re-runs updateStyle(id, content) against <style data-vite-dev-id>
748+
// Without it, removing a rule (e.g. text-transform: uppercase)
749+
// leaves the stale rule live on <style> and the cascade still
750+
// wins over the refreshed <link>
751+
return ctx.modules.filter((mod) => mod.type !== 'css')
760752
} 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.
753+
// Walk importers of the changed CSS and invalidate every derived
754+
// `\0virtual:vite-rsc/css?type=rsc&...` module — those emit
755+
// <link rel="stylesheet"> into the Flight stream via collectCss +
756+
// normalizeViteImportAnalysisUrl
757+
// Without invalidation the next render reuses the cached transform
758+
// with the pre-edit href
759+
// JS importers (inner.tsx, server.tsx, ...) are left alone — only
760+
// the derived CSS virtual needs recomputing
770761
const visited = new Set<string>()
771762
const walk = (mod: EnvironmentModuleNode) => {
772763
if (!mod.id || visited.has(mod.id)) return
@@ -2277,15 +2268,13 @@ function vitePluginRscCss(
22772268

22782269
recurse(entryId)
22792270

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.
2271+
// cssLinkPrecedence: false — without `precedence` React leaves the
2272+
// <link> as a regular DOM element (no auto-dedupe / resource handling)
2273+
// and Vite's client CSS HMR can't swap it by href-pathname match either
2274+
// (see hotUpdate above), so bake the HMR timestamp into the href and
2275+
// let the RSC-side Flight refetch drive the swap
2276+
// Default (true) leaves the href bare so Vite's client CSS HMR can swap
2277+
// the <link> in place — matches pre-fix behavior
22892278
const usePrecedence = rscCssOptions?.cssLinkPrecedence !== false
22902279
const hrefs = [...cssIds].map((id) => {
22912280
let url = normalizeViteImportAnalysisUrl(environment, id)

0 commit comments

Comments
 (0)