From 5a23f96a0db332b3e59ab04a48567d6ca4aab1d1 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Wed, 15 Apr 2026 20:57:47 +0200 Subject: [PATCH 01/14] test(rsc): failing e2e repro for CSS HMR via nested RSC Flight stream --- .../plugin-rsc/e2e/nested-rsc-css-hmr.test.ts | 63 ++++++++ .../examples/nested-rsc-css-hmr/.gitignore | 2 + .../examples/nested-rsc-css-hmr/README.md | 40 +++++ .../examples/nested-rsc-css-hmr/package.json | 24 +++ .../nested-rsc-css-hmr/public/vite.svg | 1 + .../nested-rsc-css-hmr/src/action.tsx | 11 ++ .../nested-rsc-css-hmr/src/assets/react.svg | 1 + .../nested-rsc-css-hmr/src/client.tsx | 13 ++ .../src/framework/entry.browser.tsx | 138 ++++++++++++++++++ .../src/framework/entry.rsc.tsx | 122 ++++++++++++++++ .../src/framework/entry.ssr.tsx | 74 ++++++++++ .../src/framework/error-boundary.tsx | 81 ++++++++++ .../src/framework/request.tsx | 58 ++++++++ .../examples/nested-rsc-css-hmr/src/index.css | 112 ++++++++++++++ .../src/nested-rsc/inner.css | 3 + .../src/nested-rsc/inner.tsx | 9 ++ .../src/nested-rsc/server.tsx | 15 ++ .../examples/nested-rsc-css-hmr/src/root.tsx | 75 ++++++++++ .../examples/nested-rsc-css-hmr/tsconfig.json | 17 +++ .../nested-rsc-css-hmr/vite.config.ts | 70 +++++++++ pnpm-lock.yaml | 28 ++++ 21 files changed, 957 insertions(+) create mode 100644 packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/.gitignore create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/README.md create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/package.json create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/public/vite.svg create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/action.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/assets/react.svg create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/client.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.browser.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.rsc.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.ssr.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/error-boundary.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/request.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/index.css create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.css create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/server.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/src/root.tsx create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/tsconfig.json create mode 100644 packages/plugin-rsc/examples/nested-rsc-css-hmr/vite.config.ts diff --git a/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts b/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts new file mode 100644 index 000000000..85cdfd90e --- /dev/null +++ b/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test' +import { useFixture } from './fixture' +import { expectNoReload, waitForHydration } from './helper' + +// Reproduces an HMR bug affecting server components whose modules live +// exclusively in the `rsc` environment and are rendered through a nested +// Flight stream (`renderToReadableStream` + `createFromReadableStream`), +// the pattern used by frameworks like TanStack Start's `createServerFn` + +// `renderServerComponent`. +// +// The fixture sets `cssLinkPrecedence: false` (matching TanStack Start's +// config) so plugin-rsc's emitted `` has no `precedence` attribute, +// disabling React 19's resource-manager dedup/swap path that would +// otherwise paper over the underlying bugs. +// +// Expected failures on current `main` (both tied to plugin-rsc's dev-mode +// CSS pipeline): +// 1. `normalizeViteImportAnalysisUrl` gates the `?t=` +// cache-buster on `environment.config.consumer === 'client'`, so +// CSS hrefs emitted into the Flight stream from the `rsc` env +// (consumer: 'server') never get cache-busted. +// 2. `hotUpdate` in plugin-rsc does not invalidate importers of a +// changed CSS file in the `rsc` module graph, so the derived +// `\0virtual:vite-rsc/css?type=rsc&id=…` virtual keeps emitting +// the same stale href on re-render. +// +// The test edits the CSS file twice in the same dev session (change +// color, then revert). This matters because the reporter's proposed +// two-line patch fixes the **first** CSS edit after dev-server start +// but not subsequent edits in the same session — the `?t=` fix lands +// once, the virtual's `load` re-runs once (via some transitive Vite +// invalidation), and then on the second CSS change `mod.importers` no +// longer carries what's needed to re-invalidate the virtual. Asserting +// after the revert catches that the fix is incomplete. + +test.describe('nested-rsc-css-hmr', () => { + const f = useFixture({ + root: 'examples/nested-rsc-css-hmr', + mode: 'dev', + }) + + test('css hmr through nested RSC Flight stream', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + + await using _ = await expectNoReload(page) + const editor = f.createEditor('src/nested-rsc/inner.css') + editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)')) + await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS( + 'color', + 'rgb(0, 165, 255)', + ) + editor.reset() + await expect(page.locator('.test-nested-rsc-inner')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + }) +}) diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/.gitignore b/packages/plugin-rsc/examples/nested-rsc-css-hmr/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/README.md b/packages/plugin-rsc/examples/nested-rsc-css-hmr/README.md new file mode 100644 index 000000000..cd1117106 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/README.md @@ -0,0 +1,40 @@ +# Vite + RSC + +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). + +[![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) + +```sh +# run dev server +npm run dev + +# build for production and preview +npm run build +npm run preview +``` + +## API usage + +See [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) for the documentation. + +- [`vite.config.ts`](./vite.config.ts) + - `@vitejs/plugin-rsc/plugin` +- [`./src/framework/entry.rsc.tsx`](./src/framework/entry.rsc.tsx) + - `@vitejs/plugin-rsc/rsc` + - `import.meta.viteRsc.loadModule` +- [`./src/framework/entry.ssr.tsx`](./src/framework/entry.ssr.tsx) + - `@vitejs/plugin-rsc/ssr` + - `import.meta.viteRsc.loadBootstrapScriptContent` + - `rsc-html-stream/server` +- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx) + - `@vitejs/plugin-rsc/browser` + - `rsc-html-stream/client` + +## Notes + +- [`./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. +- 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. + +## Deployment + +See [vite-plugin-rsc-deploy-example](https://github.com/hi-ogawa/vite-plugin-rsc-deploy-example) diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/package.json b/packages/plugin-rsc/examples/nested-rsc-css-hmr/package.json new file mode 100644 index 000000000..762b9f681 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/package.json @@ -0,0 +1,24 @@ +{ + "name": "@vitejs/plugin-rsc-examples-nested-rsc-css-hmr", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^8.0.8" + } +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/public/vite.svg b/packages/plugin-rsc/examples/nested-rsc-css-hmr/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/action.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/action.tsx new file mode 100644 index 000000000..4fc55d65b --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/action.tsx @@ -0,0 +1,11 @@ +'use server' + +let serverCounter = 0 + +export async function getServerCounter() { + return serverCounter +} + +export async function updateServerCounter(change: number) { + serverCounter += change +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/assets/react.svg b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/client.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/client.tsx new file mode 100644 index 000000000..29bb5d367 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import React from 'react' + +export function ClientCounter() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.browser.tsx new file mode 100644 index 000000000..5b48ebdfa --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.browser.tsx @@ -0,0 +1,138 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc' +import { GlobalErrorBoundary } from './error-boundary' +import { createRscRenderRequest } from './request' + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }) + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }) + setPayload(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data + }) + + // hydration + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + // implement server HMR by triggering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..c9cf5c4b3 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.rsc.tsx @@ -0,0 +1,122 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.tsx' + +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. +export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. + root: React.ReactNode + // server action return value of non-progressive enhancement case + returnValue?: { ok: boolean; data: unknown } + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState +} + +// the plugin by default assumes `rsc` entry having default export of request handler. +// however, how server entries are executed can be customized by registering own server handler. +export default { fetch: handler } + +async function handler(request: Request): Promise { + // differentiate RSC, SSR, action, etc. + const renderRequest = parseRenderRequest(request) + request = renderRequest.request + + // handle server function request + let returnValue: RscPayload['returnValue'] | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + let actionStatus: number | undefined + if (renderRequest.isAction === true) { + if (renderRequest.actionId) { + // action is called via `ReactClient.setServerCallback`. + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(renderRequest.actionId) + try { + const data = await action.apply(null, args) + returnValue = { ok: true, data } + } catch (e) { + returnValue = { ok: false, data: e } + actionStatus = 500 + } + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + // there's no single general obvious way to surface this error, + // so explicitly return classic 500 response. + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } + } + } + + // serialization from React VDOM tree to RSC stream. + // we render RSC stream after handling server function request + // so that new render reflects updated state from server function call + // to achieve single round trip to mutate and fetch from server. + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // Respond RSC stream without HTML rendering as decided by `RenderRequest` + if (renderRequest.isRsc) { + return new Response(rscStream, { + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + // Delegate to SSR environment for html rendering. + // The plugin provides `loadModule` helper to allow loading SSR environment entry module + // in RSC environment. however this can be customized by implementing own runtime communication + // e.g. `@cloudflare/vite-plugin`'s service binding. + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + // allow quick simulation of javascript disabled browser + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..7fc5a9564 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/entry.ssr.tsx @@ -0,0 +1,74 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + + // deserialize RSC stream back to React VDOM + let payload: Promise | undefined + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + // render html (traditional SSR) + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + // fallback to render an empty shell and run pure CSR on browser, + // which can replay server component error and trigger error boundary. + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/error-boundary.tsx new file mode 100644 index 000000000..39d916510 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/error-boundary.tsx @@ -0,0 +1,81 @@ +'use client' + +import React from 'react' + +// Minimal ErrorBoundary example to handle errors globally on browser +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode + errorComponent: React.FC<{ + error: Error + reset: () => void + }> +}> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const error = this.state.error + if (error) { + return + } + return this.props.children + } +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{' '}
+          {import.meta.env.DEV && 'message' in props.error
+            ? props.error.message
+            : '(Unknown)'}
+        
+ + + + ) +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/request.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/request.tsx new file mode 100644 index 000000000..4c7c666e8 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/framework/request.tsx @@ -0,0 +1,58 @@ +// Framework conventions (arbitrary choices for this demo): +// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests +// - Use `x-rsc-action` header to pass server action ID +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +// Parsed request information used to route between RSC/SSR rendering and action handling. +// Created by parseRenderRequest() from incoming HTTP requests. +type RenderRequest = { + isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean // true if this is a server action call (POST request) + actionId?: string // server action ID from x-rsc-action header + request: Request // normalized Request with _.rsc suffix removed from URL + url: URL // normalized URL with _.rsc suffix removed +} + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString) + url.pathname += URL_POSTFIX + const headers = new Headers() + if (action) { + headers.set(HEADER_ACTION_ID, action.id) + } + return new Request(url.toString(), { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined + if (request.method === 'POST' && !actionId) { + throw new Error('Missing action id header for RSC action request') + } + return { + isRsc: true, + isAction, + actionId, + request: new Request(url, request), + url, + } + } else { + return { + isRsc: false, + isAction, + request, + url, + } + } +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/index.css b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/index.css new file mode 100644 index 000000000..f4d2128c0 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/index.css @@ -0,0 +1,112 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1rem; +} + +.read-the-docs { + color: #888; + text-align: left; +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.css b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.css new file mode 100644 index 000000000..724c910c6 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.css @@ -0,0 +1,3 @@ +.test-nested-rsc-inner { + color: rgb(255, 165, 0); +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.tsx new file mode 100644 index 000000000..6f6badeef --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.tsx @@ -0,0 +1,9 @@ +import './inner.css' + +export function TestNestedRscInner() { + return
test-nested-rsc-inner
+} + +// add no-op `import.meta.hot` to trigger `prune` event. +// this is needed until we land https://github.com/vitejs/vite/pull/20768 +import.meta.hot diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/server.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/server.tsx new file mode 100644 index 000000000..6011c67f5 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/server.tsx @@ -0,0 +1,15 @@ +import { + createFromReadableStream, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc' +import { TestNestedRscInner } from './inner' + +// Reproduces the framework pattern (e.g. TanStack Start's +// `createServerFn` + `renderServerComponent`) where a server component +// whose module lives only in the `rsc` environment is rendered through +// a nested Flight stream and embedded back into the outer tree. +export function TestNestedRsc() { + const stream = renderToReadableStream() + const deserialized = createFromReadableStream(stream) + return
test-nested-rsc:{deserialized}
+} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/root.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/root.tsx new file mode 100644 index 000000000..6f473e644 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/root.tsx @@ -0,0 +1,75 @@ +import './index.css' // css import is automatically injected in exported server components +import viteLogo from '/vite.svg' +import { getServerCounter, updateServerCounter } from './action.tsx' +import reactLogo from './assets/react.svg' +import { ClientCounter } from './client.tsx' +import { TestNestedRsc } from './nested-rsc/server.tsx' + +export function Root(props: { url: URL }) { + return ( + + + + + + Vite + RSC + + + + + + ) +} + +function App(props: { url: URL }) { + return ( +
+ +

Vite + RSC

+
+ +
+
+ + + +
+
Request URL: {props.url?.href}
+
+ +
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • +
  • + Visit{' '} + + _.rsc + {' '} + to view RSC stream payload. +
  • +
  • + Visit{' '} + + ?__nojs + {' '} + to test server action without js enabled. +
  • +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/tsconfig.json b/packages/plugin-rsc/examples/nested-rsc-css-hmr/tsconfig.json new file mode 100644 index 000000000..b212cd7a7 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/vite.config.ts b/packages/plugin-rsc/examples/nested-rsc-css-hmr/vite.config.ts new file mode 100644 index 000000000..41d6dd9e3 --- /dev/null +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/vite.config.ts @@ -0,0 +1,70 @@ +import react from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + rsc({ + // Mirror TanStack Start's config so plugin-rsc's emitted has no + // `precedence` attribute — this disables React 19's resource-dedupe path + // that would otherwise paper over the missing `?t=` cache-buster. + cssLinkPrecedence: false, + }), + + // use any of react plugins https://github.com/vitejs/vite-plugin-react + // to enable client component HMR + react(), + + // use https://github.com/antfu-collective/vite-plugin-inspect + // to understand internal transforms required for RSC. + // import("vite-plugin-inspect").then(m => m.default()), + ], + + // specify entry point for each environment. + // (currently the plugin assumes `rollupOptions.input.index` for some features.) + environments: { + // `rsc` environment loads modules with `react-server` condition. + // this environment is responsible for: + // - RSC stream serialization (React VDOM -> RSC stream) + // - server functions handling + rsc: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.rsc.tsx', + }, + }, + }, + }, + + // `ssr` environment loads modules without `react-server` condition. + // this environment is responsible for: + // - RSC stream deserialization (RSC stream -> React VDOM) + // - traditional SSR (React VDOM -> HTML string/stream) + ssr: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.ssr.tsx', + }, + }, + }, + }, + + // client environment is used for hydration and client-side rendering + // this environment is responsible for: + // - RSC stream deserialization (RSC stream -> React VDOM) + // - traditional CSR (React VDOM -> Browser DOM tree mount/hydration) + // - refetch and re-render RSC + // - calling server functions + client: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.browser.tsx', + }, + }, + }, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e8dd3198..3ef224bac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -623,6 +623,34 @@ importers: specifier: ^3.0.2 version: 3.0.2 + packages/plugin-rsc/examples/nested-rsc-css-hmr: + dependencies: + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: latest + version: link:../../../plugin-react + '@vitejs/plugin-rsc': + specifier: latest + version: link:../.. + rsc-html-stream: + specifier: ^0.0.7 + version: 0.0.7 + vite: + specifier: ^8.0.8 + version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2) + packages/plugin-rsc/examples/no-ssr: dependencies: react: From 9c67e34aa6eb77c7322b136fb9220d44dd06dc4e Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Thu, 16 Apr 2026 23:01:13 +0200 Subject: [PATCH 02/14] fixes --- .../examples/nested-rsc-css-hmr/src/nested-rsc/server.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/server.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/server.tsx index 6011c67f5..3b60e2b01 100644 --- a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/server.tsx +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/server.tsx @@ -9,7 +9,8 @@ import { TestNestedRscInner } from './inner' // whose module lives only in the `rsc` environment is rendered through // a nested Flight stream and embedded back into the outer tree. export function TestNestedRsc() { - const stream = renderToReadableStream() - const deserialized = createFromReadableStream(stream) + const original = + const stream = renderToReadableStream(original) + const deserialized = createFromReadableStream(stream) return
test-nested-rsc:{deserialized}
} From 9d0ca5285afe6f331bfbc7595e65f8ee790fcd4e Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Fri, 17 Apr 2026 09:16:34 +0200 Subject: [PATCH 03/14] fix hmr --- .../plugin-rsc/e2e/nested-rsc-css-hmr.test.ts | 42 ++++----- .../src/nested-rsc/inner.tsx | 4 - packages/plugin-rsc/src/plugin.ts | 86 +++++++++++++++++-- 3 files changed, 97 insertions(+), 35 deletions(-) diff --git a/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts b/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts index 85cdfd90e..1bd34e0f3 100644 --- a/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts +++ b/packages/plugin-rsc/e2e/nested-rsc-css-hmr.test.ts @@ -2,36 +2,26 @@ import { expect, test } from '@playwright/test' import { useFixture } from './fixture' import { expectNoReload, waitForHydration } from './helper' -// Reproduces an HMR bug affecting server components whose modules live -// exclusively in the `rsc` environment and are rendered through a nested -// Flight stream (`renderToReadableStream` + `createFromReadableStream`), -// the pattern used by frameworks like TanStack Start's `createServerFn` + +// Verifies CSS HMR for server components whose modules live exclusively +// in the `rsc` environment and are rendered through a nested Flight +// stream (`renderToReadableStream` + `createFromReadableStream`) — the +// pattern used by frameworks like TanStack Start's `createServerFn` + // `renderServerComponent`. // // The fixture sets `cssLinkPrecedence: false` (matching TanStack Start's -// config) so plugin-rsc's emitted `` has no `precedence` attribute, -// disabling React 19's resource-manager dedup/swap path that would -// otherwise paper over the underlying bugs. +// config) so plugin-rsc's emitted `` has no `precedence` attribute +// and React 19's resource-manager dedup/swap path is not in play. This +// is the configuration where the underlying CSS-HMR issues surface; +// under the default (`true`) path Vite's client CSS HMR + Float +// dedup papers over them. // -// Expected failures on current `main` (both tied to plugin-rsc's dev-mode -// CSS pipeline): -// 1. `normalizeViteImportAnalysisUrl` gates the `?t=` -// cache-buster on `environment.config.consumer === 'client'`, so -// CSS hrefs emitted into the Flight stream from the `rsc` env -// (consumer: 'server') never get cache-busted. -// 2. `hotUpdate` in plugin-rsc does not invalidate importers of a -// changed CSS file in the `rsc` module graph, so the derived -// `\0virtual:vite-rsc/css?type=rsc&id=…` virtual keeps emitting -// the same stale href on re-render. -// -// The test edits the CSS file twice in the same dev session (change -// color, then revert). This matters because the reporter's proposed -// two-line patch fixes the **first** CSS edit after dev-server start -// but not subsequent edits in the same session — the `?t=` fix lands -// once, the virtual's `load` re-runs once (via some transitive Vite -// invalidation), and then on the second CSS change `mod.importers` no -// longer carries what's needed to re-invalidate the virtual. Asserting -// after the revert catches that the fix is incomplete. +// The test performs a round-trip edit (change color, then revert) in the +// same dev session. The revert is load-bearing: a naive fix can make the +// first edit land while leaving every subsequent edit silently stuck on +// the previous value (Vite's client CSS HMR hangs its `Promise.all` when +// it races React's reconciliation of the RSC-owned ``, which +// blocks every later WebSocket message including the next `rsc:update`). +// Asserting after the revert catches that regression class. test.describe('nested-rsc-css-hmr', () => { const f = useFixture({ diff --git a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.tsx b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.tsx index 6f6badeef..0e4c2e798 100644 --- a/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.tsx +++ b/packages/plugin-rsc/examples/nested-rsc-css-hmr/src/nested-rsc/inner.tsx @@ -3,7 +3,3 @@ import './inner.css' export function TestNestedRscInner() { return
test-nested-rsc-inner
} - -// add no-op `import.meta.hot` to trigger `prune` event. -// this is needed until we land https://github.com/vitejs/vite/pull/20768 -import.meta.hot diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 9141febba..68ebd0044 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -60,6 +60,7 @@ import { cleanUrl, directRequestRE, evalValue, + injectQuery, normalizeViteImportAnalysisUrl, prepareError, } from './plugins/vite-utils' @@ -722,7 +723,66 @@ export default function vitePluginRsc( async hotUpdate(ctx) { if (isCSSRequest(ctx.file)) { if (this.environment.name === 'client') { - return + // With the default (`cssLinkPrecedence: true`) setup Vite's default + // client CSS HMR handles the swap already + if (rscPluginOptions.cssLinkPrecedence !== false) return + + // Only relevant when this CSS is reachable from the RSC + // module graph — otherwise Vite's default CSS HMR applies + const rscMod = + ctx.server.environments.rsc?.moduleGraph.getModuleById(ctx.file) + if (!rscMod) return + + // If the CSS also has a client-side JS importer, Vite's + // default client CSS HMR is still needed to update the + // non-RSC usages — only skip it when the CSS is RSC-only + const hasClientJsImporter = ctx.modules.some((mod) => + [...mod.importers].some((imp) => imp.id && !isCSSRequest(imp.id)), + ) + if (hasClientJsImporter) return + + // Skip Vite's default client CSS HMR for RSC-only CSS. The + // RSC-side `rsc:update` event drives a Flight refetch that + // brings a fresh `?t=` href, which is enough. + // + // Why skipping matters: with `cssLinkPrecedence: false` the + // emitted `` is React-owned (Float won't manage it + // without precedence). Vite's client CSS HMR clones that + // ``, appends the clone, and awaits its `load`/`error` + // event inside a `Promise.all`. React's RSC re-render + // unmounts the original before the clone's event fires, the + // Promise never resolves, and every subsequent WebSocket + // message (including the next edit's `rsc:update`) queues + // forever behind the hung promise. The first edit in a + // session appears to work via Vite's `