From 0c6f3acc555a091d574af88bbc9a8334d555a2a4 Mon Sep 17 00:00:00 2001 From: Sreetam Das Date: Fri, 12 Jun 2026 21:39:59 +0530 Subject: [PATCH 1/4] fix(rsc): keep client HMR for client modules co-located with rsc-graph code The client hotUpdate branch returns [] for any file present in the rsc module graph that is not inside a "use client" boundary, to avoid full reloads from server-only files pulled into the client graph as style deps (tailwind / addWatchFile). This also suppresses HMR for genuine client modules that happen to co-reside with rsc-graph code. A framework route file (e.g. TanStack Start) that co-locates a createServerFn with a client-rendered route component is in the rsc graph, yet the route component is a real client module (imported by the client route tree), so its Fast Refresh is wrongly suppressed and edits never reach the browser. Only suppress when the file has no non-CSS client importers, mirroring the existing rsc-branch check (importers.every(isCSSRequest)). --- packages/plugin-rsc/src/plugin.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 01ec7b083..5572a4edb 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -826,14 +826,27 @@ export default function vitePluginRsc( const env = ctx.server.environments.rsc! const mod = env.moduleGraph.getModuleById(ctx.file) if (mod) { - for (const clientMod of ctx.modules) { - for (const importer of clientMod.importers) { - if (importer.id && isCSSRequest(importer.id)) { - await this.environment.reloadModule(importer) + // Only treat this as a server-only file leaking into the client + // graph when its client modules are referenced solely as style + // dependencies (CSS importers). If a client module here has a + // non-CSS importer (e.g. a framework route file that co-locates a + // server function with a client-rendered route component), it is + // genuine client code and must keep its client HMR / Fast Refresh. + const hasNonCssImporter = ctx.modules.some((clientMod) => + [...clientMod.importers].some( + (importer) => importer.id && !isCSSRequest(importer.id), + ), + ) + if (!hasNonCssImporter) { + for (const clientMod of ctx.modules) { + for (const importer of clientMod.importers) { + if (importer.id && isCSSRequest(importer.id)) { + await this.environment.reloadModule(importer) + } } } + return [] } - return [] } } } From 46704f817ff1d984f2fa736a6c0066eefc868124 Mon Sep 17 00:00:00 2001 From: Sreetam Das Date: Fri, 12 Jun 2026 23:16:12 +0530 Subject: [PATCH 2/4] docs(rsc): tighten the hotUpdate guard comment --- packages/plugin-rsc/src/plugin.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 5572a4edb..ab4f77b9f 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -826,12 +826,10 @@ export default function vitePluginRsc( const env = ctx.server.environments.rsc! const mod = env.moduleGraph.getModuleById(ctx.file) if (mod) { - // Only treat this as a server-only file leaking into the client - // graph when its client modules are referenced solely as style - // dependencies (CSS importers). If a client module here has a - // non-CSS importer (e.g. a framework route file that co-locates a - // server function with a client-rendered route component), it is - // genuine client code and must keep its client HMR / Fast Refresh. + // A non-CSS importer means a client module here is real client + // code (e.g. a route component co-located with a server fn), not a + // server-only file pulled into the client graph as a style dep, so + // keep its HMR instead of returning []. const hasNonCssImporter = ctx.modules.some((clientMod) => [...clientMod.importers].some( (importer) => importer.id && !isCSSRequest(importer.id), From 4429bf4388722c6a889130563ba3997d3415e012 Mon Sep 17 00:00:00 2001 From: Sreetam Das Date: Sat, 13 Jun 2026 13:45:29 +0530 Subject: [PATCH 3/4] test(rsc): add e2e regression for client HMR of a co-located rsc-graph component --- .../e2e/co-located-client-hmr.test.ts | 36 ++++++ .../examples/co-located-client-hmr/.gitignore | 2 + .../co-located-client-hmr/package.json | 24 ++++ .../co-located-client-hmr/src/app.tsx | 9 ++ .../src/framework/entry.browser.tsx | 21 +++ .../src/framework/entry.rsc.tsx | 122 ++++++++++++++++++ .../src/framework/entry.ssr.tsx | 74 +++++++++++ .../src/framework/request.tsx | 58 +++++++++ .../co-located-client-hmr/src/root.tsx | 25 ++++ .../co-located-client-hmr/src/routes/page.tsx | 29 +++++ .../co-located-client-hmr/tsconfig.json | 17 +++ .../co-located-client-hmr/vite.config.ts | 72 +++++++++++ pnpm-lock.yaml | 28 ++++ 13 files changed, 517 insertions(+) create mode 100644 packages/plugin-rsc/e2e/co-located-client-hmr.test.ts create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/.gitignore create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/package.json create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/app.tsx create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.browser.tsx create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.rsc.tsx create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.ssr.tsx create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/framework/request.tsx create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/root.tsx create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/routes/page.tsx create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/tsconfig.json create mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/vite.config.ts diff --git a/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts b/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts new file mode 100644 index 000000000..72e8a9a37 --- /dev/null +++ b/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test' +import { useFixture } from './fixture' + +// Regression test for the client `hotUpdate` guard: a genuine client-rendered +// component that is also present in the `rsc` module graph (because its file +// co-locates server-graph code) must keep Fast Refresh. Before the fix the +// guard returned `[]` for such files and the edit was silently dropped on the +// client. See `examples/co-located-client-hmr` for how the import graph is set +// up (browser entry -> app -> page, with no `"use client"` boundary). +test.describe('co-located-client-hmr', () => { + const f = useFixture({ root: 'examples/co-located-client-hmr', mode: 'dev' }) + + test('route component co-located with rsc-graph code hot-updates', async ({ + page, + }) => { + await page.goto(f.url()) + + const marker = page.getByTestId('marker') + const count = page.getByTestId('count') + await expect(marker).toHaveText('marker-baseline') + + // seed client state to prove the edit is a Fast Refresh, not a reload + await count.click() + await count.click() + await expect(count).toHaveText('count: 2') + + const editor = f.createEditor('src/routes/page.tsx') + editor.edit((s) => s.replace('marker-baseline', 'marker-edited')) + await expect(marker).toHaveText('marker-edited') + await expect(count).toHaveText('count: 2') + + editor.reset() + await expect(marker).toHaveText('marker-baseline') + await expect(count).toHaveText('count: 2') + }) +}) diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/.gitignore b/packages/plugin-rsc/examples/co-located-client-hmr/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-hmr/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/package.json b/packages/plugin-rsc/examples/co-located-client-hmr/package.json new file mode 100644 index 000000000..4bd18995c --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-hmr/package.json @@ -0,0 +1,24 @@ +{ + "name": "@vitejs/plugin-rsc-examples-co-located-client-hmr", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^8.0.16" + } +} diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/src/app.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/app.tsx new file mode 100644 index 000000000..16fe762ab --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-hmr/src/app.tsx @@ -0,0 +1,9 @@ +// No `"use client"` here on purpose: this module is imported directly by the +// browser entry and statically imports the route component, so the import chain +// `entry.browser -> app -> page` contains no client reference. This is the +// pattern a client-side router uses when it imports route components. +import { Page } from './routes/page' + +export function App() { + return +} diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.browser.tsx new file mode 100644 index 000000000..388752e07 --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.browser.tsx @@ -0,0 +1,21 @@ +import { createRoot } from 'react-dom/client' +import { App } from '../app' + +// This example intentionally renders the client app as a CSR island instead of +// hydrating the RSC payload. The server renders a static shell with an empty +// `#client-root` (see `src/root.tsx`); here we statically import `App` (which +// imports the route component in `src/routes/page.tsx`) and mount it. +// +// The point is the import chain `entry.browser -> app -> page` has NO +// `"use client"` boundary, so `page.tsx` enters the client module graph as a +// non-client-reference. Because `page.tsx` is also in the `rsc` module graph +// (the server shell imports `ServerNote` from it), this is exactly the shape +// where the client `hotUpdate` guard used to suppress Fast Refresh. +function main() { + const el = document.getElementById('client-root') + if (el) { + createRoot(el).render() + } +} + +main() diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..c9cf5c4b3 --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-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/co-located-client-hmr/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..7fc5a9564 --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-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/co-located-client-hmr/src/framework/request.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/request.tsx new file mode 100644 index 000000000..4c7c666e8 --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-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/co-located-client-hmr/src/root.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/root.tsx new file mode 100644 index 000000000..a3faca679 --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-hmr/src/root.tsx @@ -0,0 +1,25 @@ +import { ServerNote } from './routes/page' + +// Server-rendered shell. Importing `ServerNote` from `./routes/page` puts that +// file into the `rsc` module graph, mirroring a route file that co-locates +// server-graph code with its client route component. The actual route +// component (`Page`) is NOT rendered here — it is mounted client-side into +// `#client-root` by the browser entry. +export function Root(props: { url: URL }) { + return ( + + + + + plugin-rsc co-located client HMR + + +
+ + + + + ) +} diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/src/routes/page.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/routes/page.tsx new file mode 100644 index 000000000..c692aa47b --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-hmr/src/routes/page.tsx @@ -0,0 +1,29 @@ +import React from 'react' + +// This file is imported by the server root (see `src/root.tsx`) via +// `ServerNote`, so it is present in the `rsc` module graph — mirroring a +// framework route file that co-locates server-graph code (e.g. a server +// function) with its route component. +export function ServerNote() { + return

server-note

+} + +// `Page` is a genuine client-rendered component. It is reached from the browser +// entry through `src/app.tsx` WITHOUT any `"use client"` boundary in the chain +// (like a client router statically importing route components), so on the +// `client` environment `isInsideClientBoundary` is false. Because the file is +// also in the `rsc` graph (above), the client `hotUpdate` guard used to return +// `[]` and suppress this component's Fast Refresh. Editing the marker should +// hot-update while preserving the counter state. +export function Page() { + const [count, setCount] = React.useState(0) + + return ( +
+

marker-baseline

+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/tsconfig.json b/packages/plugin-rsc/examples/co-located-client-hmr/tsconfig.json new file mode 100644 index 000000000..b212cd7a7 --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-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/co-located-client-hmr/vite.config.ts b/packages/plugin-rsc/examples/co-located-client-hmr/vite.config.ts new file mode 100644 index 000000000..9b9c9e014 --- /dev/null +++ b/packages/plugin-rsc/examples/co-located-client-hmr/vite.config.ts @@ -0,0 +1,72 @@ +import react from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + rsc({ + // `entries` option is only a shorthand for specifying each `rollupOptions.input` below + // > entries: { rsc, ssr, client }, + // + // by default, the plugin setup request handler based on `default export` of `rsc` environment `rollupOptions.input.index`. + // This can be disabled when setting up own server handler e.g. `@cloudflare/vite-plugin`. + // > serverHandler: 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 10772547e..f23cc7d21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -599,6 +599,34 @@ importers: specifier: ^2.0.0 version: 2.0.0(vite@8.0.16(@types/node@24.12.4)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2)) + packages/plugin-rsc/examples/co-located-client-hmr: + dependencies: + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@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.16 + version: 8.0.16(@types/node@24.12.4)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2) + packages/plugin-rsc/examples/e2e: devDependencies: '@rolldown/plugin-babel': From 0be2755aee60cec2cba6cef54ad509255f15ef70 Mon Sep 17 00:00:00 2001 From: Sreetam Das Date: Sat, 13 Jun 2026 14:40:45 +0530 Subject: [PATCH 4/4] test(rsc): set up co-located client HMR regression via setupInlineFixture --- .../e2e/co-located-client-hmr.test.ts | 140 ++++++++++++++---- .../examples/co-located-client-hmr/.gitignore | 2 - .../co-located-client-hmr/package.json | 24 --- .../co-located-client-hmr/src/app.tsx | 9 -- .../src/framework/entry.browser.tsx | 21 --- .../src/framework/entry.rsc.tsx | 122 --------------- .../src/framework/entry.ssr.tsx | 74 --------- .../src/framework/request.tsx | 58 -------- .../co-located-client-hmr/src/root.tsx | 25 ---- .../co-located-client-hmr/src/routes/page.tsx | 29 ---- .../co-located-client-hmr/tsconfig.json | 17 --- .../co-located-client-hmr/vite.config.ts | 72 --------- pnpm-lock.yaml | 28 ---- 13 files changed, 113 insertions(+), 508 deletions(-) delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/.gitignore delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/package.json delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/app.tsx delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.browser.tsx delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.rsc.tsx delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.ssr.tsx delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/framework/request.tsx delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/root.tsx delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/src/routes/page.tsx delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/tsconfig.json delete mode 100644 packages/plugin-rsc/examples/co-located-client-hmr/vite.config.ts diff --git a/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts b/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts index 72e8a9a37..92155f135 100644 --- a/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts +++ b/packages/plugin-rsc/e2e/co-located-client-hmr.test.ts @@ -1,36 +1,122 @@ import { expect, test } from '@playwright/test' -import { useFixture } from './fixture' +import { type Fixture, setupInlineFixture, useFixture } from './fixture' // Regression test for the client `hotUpdate` guard: a genuine client-rendered // component that is also present in the `rsc` module graph (because its file // co-locates server-graph code) must keep Fast Refresh. Before the fix the // guard returned `[]` for such files and the edit was silently dropped on the -// client. See `examples/co-located-client-hmr` for how the import graph is set -// up (browser entry -> app -> page, with no `"use client"` boundary). +// client. +// +// The fixture sets up the trigger without a framework: the browser entry mounts +// `App` as a CSR island, and the import chain `entry.browser -> app -> page` +// has no `"use client"` boundary, so `Page` enters the client graph as a +// non-client-reference. `page.tsx` is also in the `rsc` graph because the +// server `root.tsx` imports `ServerNote` from it. test.describe('co-located-client-hmr', () => { - const f = useFixture({ root: 'examples/co-located-client-hmr', mode: 'dev' }) - - test('route component co-located with rsc-graph code hot-updates', async ({ - page, - }) => { - await page.goto(f.url()) - - const marker = page.getByTestId('marker') - const count = page.getByTestId('count') - await expect(marker).toHaveText('marker-baseline') - - // seed client state to prove the edit is a Fast Refresh, not a reload - await count.click() - await count.click() - await expect(count).toHaveText('count: 2') - - const editor = f.createEditor('src/routes/page.tsx') - editor.edit((s) => s.replace('marker-baseline', 'marker-edited')) - await expect(marker).toHaveText('marker-edited') - await expect(count).toHaveText('count: 2') - - editor.reset() - await expect(marker).toHaveText('marker-baseline') - await expect(count).toHaveText('count: 2') + const root = 'examples/e2e/temp/co-located-client-hmr' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/routes/page.tsx': /* tsx */ ` + import React from 'react' + + // Imported by the server 'root.tsx' below, so this file is in the + // 'rsc' module graph -- like a route file co-locating server-graph + // code with its route component. + export function ServerNote() { + return

server-note

+ } + + // Client-rendered route component, reached from the browser entry via + // 'app.tsx' with no "use client" boundary in the chain. + export function Page() { + const [count, setCount] = React.useState(0) + return ( +
+

marker-baseline

+ +
+ ) + } + `, + 'src/app.tsx': /* tsx */ ` + // No "use client": this module is imported directly by the browser + // entry and statically imports the route component, so the chain + // 'entry.browser -> app -> page' contains no client reference. + import { Page } from './routes/page' + + export function App() { + return + } + `, + 'src/root.tsx': /* tsx */ ` + import { ServerNote } from './routes/page' + + // Server shell. Importing 'ServerNote' puts 'routes/page' into the + // 'rsc' module graph. 'Page' itself is mounted client-side into + // '#client-root' by the browser entry, not rendered here. + export function Root(_props: { url: URL }) { + return ( + + + + + +
+ + + + ) + } + `, + 'src/framework/entry.browser.tsx': /* tsx */ ` + import { createRoot } from 'react-dom/client' + import { App } from '../app' + + // Render the client app as a CSR island instead of hydrating the RSC + // payload, so 'Page' is a client-rendered, non-"use client" component. + const el = document.getElementById('client-root') + if (el) { + createRoot(el).render() + } + `, + }, + }) + }) + + function defineTest(f: Fixture) { + test('route component co-located with rsc-graph code hot-updates', async ({ + page, + }) => { + await page.goto(f.url()) + + const marker = page.getByTestId('marker') + const count = page.getByTestId('count') + await expect(marker).toHaveText('marker-baseline') + + // seed client state to prove the edit is a Fast Refresh, not a reload + await count.click() + await count.click() + await expect(count).toHaveText('count: 2') + + const editor = f.createEditor('src/routes/page.tsx') + editor.edit((s) => s.replace('marker-baseline', 'marker-edited')) + await expect(marker).toHaveText('marker-edited') + await expect(count).toHaveText('count: 2') + + editor.reset() + await expect(marker).toHaveText('marker-baseline') + await expect(count).toHaveText('count: 2') + }) + } + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineTest(f) }) }) diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/.gitignore b/packages/plugin-rsc/examples/co-located-client-hmr/.gitignore deleted file mode 100644 index f06235c46..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/package.json b/packages/plugin-rsc/examples/co-located-client-hmr/package.json deleted file mode 100644 index 4bd18995c..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@vitejs/plugin-rsc-examples-co-located-client-hmr", - "version": "0.0.0", - "private": true, - "license": "MIT", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "react": "^19.2.7", - "react-dom": "^19.2.7" - }, - "devDependencies": { - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "latest", - "@vitejs/plugin-rsc": "latest", - "rsc-html-stream": "^0.0.7", - "vite": "^8.0.16" - } -} diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/src/app.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/app.tsx deleted file mode 100644 index 16fe762ab..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/src/app.tsx +++ /dev/null @@ -1,9 +0,0 @@ -// No `"use client"` here on purpose: this module is imported directly by the -// browser entry and statically imports the route component, so the import chain -// `entry.browser -> app -> page` contains no client reference. This is the -// pattern a client-side router uses when it imports route components. -import { Page } from './routes/page' - -export function App() { - return -} diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.browser.tsx deleted file mode 100644 index 388752e07..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.browser.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createRoot } from 'react-dom/client' -import { App } from '../app' - -// This example intentionally renders the client app as a CSR island instead of -// hydrating the RSC payload. The server renders a static shell with an empty -// `#client-root` (see `src/root.tsx`); here we statically import `App` (which -// imports the route component in `src/routes/page.tsx`) and mount it. -// -// The point is the import chain `entry.browser -> app -> page` has NO -// `"use client"` boundary, so `page.tsx` enters the client module graph as a -// non-client-reference. Because `page.tsx` is also in the `rsc` module graph -// (the server shell imports `ServerNote` from it), this is exactly the shape -// where the client `hotUpdate` guard used to suppress Fast Refresh. -function main() { - const el = document.getElementById('client-root') - if (el) { - createRoot(el).render() - } -} - -main() diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.rsc.tsx deleted file mode 100644 index c9cf5c4b3..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.rsc.tsx +++ /dev/null @@ -1,122 +0,0 @@ -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/co-located-client-hmr/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.ssr.tsx deleted file mode 100644 index 7fc5a9564..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/entry.ssr.tsx +++ /dev/null @@ -1,74 +0,0 @@ -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/co-located-client-hmr/src/framework/request.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/request.tsx deleted file mode 100644 index 4c7c666e8..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/src/framework/request.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// 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/co-located-client-hmr/src/root.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/root.tsx deleted file mode 100644 index a3faca679..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/src/root.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ServerNote } from './routes/page' - -// Server-rendered shell. Importing `ServerNote` from `./routes/page` puts that -// file into the `rsc` module graph, mirroring a route file that co-locates -// server-graph code with its client route component. The actual route -// component (`Page`) is NOT rendered here — it is mounted client-side into -// `#client-root` by the browser entry. -export function Root(props: { url: URL }) { - return ( - - - - - plugin-rsc co-located client HMR - - -
- - - - - ) -} diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/src/routes/page.tsx b/packages/plugin-rsc/examples/co-located-client-hmr/src/routes/page.tsx deleted file mode 100644 index c692aa47b..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/src/routes/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react' - -// This file is imported by the server root (see `src/root.tsx`) via -// `ServerNote`, so it is present in the `rsc` module graph — mirroring a -// framework route file that co-locates server-graph code (e.g. a server -// function) with its route component. -export function ServerNote() { - return

server-note

-} - -// `Page` is a genuine client-rendered component. It is reached from the browser -// entry through `src/app.tsx` WITHOUT any `"use client"` boundary in the chain -// (like a client router statically importing route components), so on the -// `client` environment `isInsideClientBoundary` is false. Because the file is -// also in the `rsc` graph (above), the client `hotUpdate` guard used to return -// `[]` and suppress this component's Fast Refresh. Editing the marker should -// hot-update while preserving the counter state. -export function Page() { - const [count, setCount] = React.useState(0) - - return ( -
-

marker-baseline

- -
- ) -} diff --git a/packages/plugin-rsc/examples/co-located-client-hmr/tsconfig.json b/packages/plugin-rsc/examples/co-located-client-hmr/tsconfig.json deleted file mode 100644 index b212cd7a7..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "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/co-located-client-hmr/vite.config.ts b/packages/plugin-rsc/examples/co-located-client-hmr/vite.config.ts deleted file mode 100644 index 9b9c9e014..000000000 --- a/packages/plugin-rsc/examples/co-located-client-hmr/vite.config.ts +++ /dev/null @@ -1,72 +0,0 @@ -import react from '@vitejs/plugin-react' -import rsc from '@vitejs/plugin-rsc' -import { defineConfig } from 'vite' - -export default defineConfig({ - plugins: [ - rsc({ - // `entries` option is only a shorthand for specifying each `rollupOptions.input` below - // > entries: { rsc, ssr, client }, - // - // by default, the plugin setup request handler based on `default export` of `rsc` environment `rollupOptions.input.index`. - // This can be disabled when setting up own server handler e.g. `@cloudflare/vite-plugin`. - // > serverHandler: 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 f23cc7d21..10772547e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -599,34 +599,6 @@ importers: specifier: ^2.0.0 version: 2.0.0(vite@8.0.16(@types/node@24.12.4)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2)) - packages/plugin-rsc/examples/co-located-client-hmr: - dependencies: - react: - specifier: ^19.2.7 - version: 19.2.7 - react-dom: - specifier: ^19.2.7 - version: 19.2.7(react@19.2.7) - devDependencies: - '@types/react': - specifier: ^19.2.17 - version: 19.2.17 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) - '@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.16 - version: 8.0.16(@types/node@24.12.4)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2) - packages/plugin-rsc/examples/e2e: devDependencies: '@rolldown/plugin-babel':