Skip to content

Commit 5a23f96

Browse files
committed
test(rsc): failing e2e repro for CSS HMR via nested RSC Flight stream
1 parent 0389922 commit 5a23f96

File tree

21 files changed

+957
-0
lines changed

21 files changed

+957
-0
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { expect, test } from '@playwright/test'
2+
import { useFixture } from './fixture'
3+
import { expectNoReload, waitForHydration } from './helper'
4+
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` +
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+
// disabling React 19's resource-manager dedup/swap path that would
14+
// otherwise paper over the underlying bugs.
15+
//
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.
35+
36+
test.describe('nested-rsc-css-hmr', () => {
37+
const f = useFixture({
38+
root: 'examples/nested-rsc-css-hmr',
39+
mode: 'dev',
40+
})
41+
42+
test('css hmr through nested RSC Flight stream', async ({ page }) => {
43+
await page.goto(f.url())
44+
await waitForHydration(page)
45+
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
46+
'color',
47+
'rgb(255, 165, 0)',
48+
)
49+
50+
await using _ = await expectNoReload(page)
51+
const editor = f.createEditor('src/nested-rsc/inner.css')
52+
editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)'))
53+
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
54+
'color',
55+
'rgb(0, 165, 255)',
56+
)
57+
editor.reset()
58+
await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS(
59+
'color',
60+
'rgb(255, 165, 0)',
61+
)
62+
})
63+
})
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
dist
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Vite + RSC
2+
3+
This example shows how to set up a React application with [Server Component](https://react.dev/reference/rsc/server-components) features on Vite using [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc).
4+
5+
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter)
6+
7+
```sh
8+
# run dev server
9+
npm run dev
10+
11+
# build for production and preview
12+
npm run build
13+
npm run preview
14+
```
15+
16+
## API usage
17+
18+
See [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) for the documentation.
19+
20+
- [`vite.config.ts`](./vite.config.ts)
21+
- `@vitejs/plugin-rsc/plugin`
22+
- [`./src/framework/entry.rsc.tsx`](./src/framework/entry.rsc.tsx)
23+
- `@vitejs/plugin-rsc/rsc`
24+
- `import.meta.viteRsc.loadModule`
25+
- [`./src/framework/entry.ssr.tsx`](./src/framework/entry.ssr.tsx)
26+
- `@vitejs/plugin-rsc/ssr`
27+
- `import.meta.viteRsc.loadBootstrapScriptContent`
28+
- `rsc-html-stream/server`
29+
- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx)
30+
- `@vitejs/plugin-rsc/browser`
31+
- `rsc-html-stream/client`
32+
33+
## Notes
34+
35+
- [`./src/framework/entry.{browser,rsc,ssr}.tsx`](./src/framework) (with inline comments) provides an overview of how low level RSC (React flight) API can be used to build RSC framework.
36+
- You can use [`vite-plugin-inspect`](https://github.com/antfu-collective/vite-plugin-inspect) to understand how `"use client"` and `"use server"` directives are transformed internally.
37+
38+
## Deployment
39+
40+
See [vite-plugin-rsc-deploy-example](https://github.com/hi-ogawa/vite-plugin-rsc-deploy-example)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@vitejs/plugin-rsc-examples-nested-rsc-css-hmr",
3+
"version": "0.0.0",
4+
"private": true,
5+
"license": "MIT",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"react": "^19.2.5",
14+
"react-dom": "^19.2.5"
15+
},
16+
"devDependencies": {
17+
"@types/react": "^19.2.14",
18+
"@types/react-dom": "^19.2.3",
19+
"@vitejs/plugin-react": "latest",
20+
"@vitejs/plugin-rsc": "latest",
21+
"rsc-html-stream": "^0.0.7",
22+
"vite": "^8.0.8"
23+
}
24+
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use server'
2+
3+
let serverCounter = 0
4+
5+
export async function getServerCounter() {
6+
return serverCounter
7+
}
8+
9+
export async function updateServerCounter(change: number) {
10+
serverCounter += change
11+
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client'
2+
3+
import React from 'react'
4+
5+
export function ClientCounter() {
6+
const [count, setCount] = React.useState(0)
7+
8+
return (
9+
<button onClick={() => setCount((count) => count + 1)}>
10+
Client Counter: {count}
11+
</button>
12+
)
13+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import {
2+
createFromReadableStream,
3+
createFromFetch,
4+
setServerCallback,
5+
createTemporaryReferenceSet,
6+
encodeReply,
7+
} from '@vitejs/plugin-rsc/browser'
8+
import React from 'react'
9+
import { createRoot, hydrateRoot } from 'react-dom/client'
10+
import { rscStream } from 'rsc-html-stream/client'
11+
import type { RscPayload } from './entry.rsc'
12+
import { GlobalErrorBoundary } from './error-boundary'
13+
import { createRscRenderRequest } from './request'
14+
15+
async function main() {
16+
// stash `setPayload` function to trigger re-rendering
17+
// from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr)
18+
let setPayload: (v: RscPayload) => void
19+
20+
// deserialize RSC stream back to React VDOM for CSR
21+
const initialPayload = await createFromReadableStream<RscPayload>(
22+
// initial RSC stream is injected in SSR stream as <script>...FLIGHT_DATA...</script>
23+
rscStream,
24+
)
25+
26+
// browser root component to (re-)render RSC payload as state
27+
function BrowserRoot() {
28+
const [payload, setPayload_] = React.useState(initialPayload)
29+
30+
React.useEffect(() => {
31+
setPayload = (v) => React.startTransition(() => setPayload_(v))
32+
}, [setPayload_])
33+
34+
// re-fetch/render on client side navigation
35+
React.useEffect(() => {
36+
return listenNavigation(() => fetchRscPayload())
37+
}, [])
38+
39+
return payload.root
40+
}
41+
42+
// re-fetch RSC and trigger re-rendering
43+
async function fetchRscPayload() {
44+
const renderRequest = createRscRenderRequest(window.location.href)
45+
const payload = await createFromFetch<RscPayload>(fetch(renderRequest))
46+
setPayload(payload)
47+
}
48+
49+
// register a handler which will be internally called by React
50+
// on server function request after hydration.
51+
setServerCallback(async (id, args) => {
52+
const temporaryReferences = createTemporaryReferenceSet()
53+
const renderRequest = createRscRenderRequest(window.location.href, {
54+
id,
55+
body: await encodeReply(args, { temporaryReferences }),
56+
})
57+
const payload = await createFromFetch<RscPayload>(fetch(renderRequest), {
58+
temporaryReferences,
59+
})
60+
setPayload(payload)
61+
const { ok, data } = payload.returnValue!
62+
if (!ok) throw data
63+
return data
64+
})
65+
66+
// hydration
67+
const browserRoot = (
68+
<React.StrictMode>
69+
<GlobalErrorBoundary>
70+
<BrowserRoot />
71+
</GlobalErrorBoundary>
72+
</React.StrictMode>
73+
)
74+
if ('__NO_HYDRATE' in globalThis) {
75+
createRoot(document).render(browserRoot)
76+
} else {
77+
hydrateRoot(document, browserRoot, {
78+
formState: initialPayload.formState,
79+
})
80+
}
81+
82+
// implement server HMR by triggering re-fetch/render of RSC upon server code change
83+
if (import.meta.hot) {
84+
import.meta.hot.on('rsc:update', () => {
85+
fetchRscPayload()
86+
})
87+
}
88+
}
89+
90+
// a little helper to setup events interception for client side navigation
91+
function listenNavigation(onNavigation: () => void) {
92+
window.addEventListener('popstate', onNavigation)
93+
94+
const oldPushState = window.history.pushState
95+
window.history.pushState = function (...args) {
96+
const res = oldPushState.apply(this, args)
97+
onNavigation()
98+
return res
99+
}
100+
101+
const oldReplaceState = window.history.replaceState
102+
window.history.replaceState = function (...args) {
103+
const res = oldReplaceState.apply(this, args)
104+
onNavigation()
105+
return res
106+
}
107+
108+
function onClick(e: MouseEvent) {
109+
let link = (e.target as Element).closest('a')
110+
if (
111+
link &&
112+
link instanceof HTMLAnchorElement &&
113+
link.href &&
114+
(!link.target || link.target === '_self') &&
115+
link.origin === location.origin &&
116+
!link.hasAttribute('download') &&
117+
e.button === 0 && // left clicks only
118+
!e.metaKey && // open in new tab (mac)
119+
!e.ctrlKey && // open in new tab (windows)
120+
!e.altKey && // download
121+
!e.shiftKey &&
122+
!e.defaultPrevented
123+
) {
124+
e.preventDefault()
125+
history.pushState(null, '', link.href)
126+
}
127+
}
128+
document.addEventListener('click', onClick)
129+
130+
return () => {
131+
document.removeEventListener('click', onClick)
132+
window.removeEventListener('popstate', onNavigation)
133+
window.history.pushState = oldPushState
134+
window.history.replaceState = oldReplaceState
135+
}
136+
}
137+
138+
main()

0 commit comments

Comments
 (0)