From a976adee5f1eac22efb99bfe5bc2ef2069f2f487 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:41:03 +0900 Subject: [PATCH 01/16] fix(plugin-rsc): preserve server references during SSR Co-authored-by: OpenCode --- packages/plugin-rsc/src/core/rsc.ts | 35 +++++++++++++++++++-- packages/plugin-rsc/src/core/shared.ts | 2 ++ packages/plugin-rsc/src/react/rsc/client.ts | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/core/rsc.ts b/packages/plugin-rsc/src/core/rsc.ts index ecd46ea47..2398c3155 100644 --- a/packages/plugin-rsc/src/core/rsc.ts +++ b/packages/plugin-rsc/src/core/rsc.ts @@ -2,6 +2,7 @@ import { memoize, tinyassert } from '@hiogawa/utils' import type { BundlerConfig, ImportManifestEntry, ModuleMap } from '../types' import { SERVER_DECODE_CLIENT_PREFIX, + SERVER_REFERENCE_PROXY_PREFIX, SERVER_REFERENCE_PREFIX, createReferenceCacheTag, removeReferenceCacheTag, @@ -27,6 +28,31 @@ export function setRequireModule(options: { // need memoize to return stable promise from __webpack_require__ ;(globalThis as any).__vite_rsc_server_require__ = memoize( async (id: string) => { + if (id.startsWith(SERVER_REFERENCE_PROXY_PREFIX)) { + id = id.slice(SERVER_REFERENCE_PROXY_PREFIX.length) + id = removeReferenceCacheTag(id) + const target = {} as any + const getOrCreateServerReference = (name: string) => { + return (target[name] ??= ReactServer.registerServerReference( + () => { + throw new Error( + `Unexpectedly decoded server reference '${id}#${name}' is called on server`, + ) + }, + id, + name, + )) + } + return new Proxy(target, { + getOwnPropertyDescriptor(_target, name) { + if (typeof name !== 'string' || name === 'then') { + return Reflect.getOwnPropertyDescriptor(target, name) + } + getOrCreateServerReference(name) + return Reflect.getOwnPropertyDescriptor(target, name) + }, + }) + } if (id.startsWith(SERVER_DECODE_CLIENT_PREFIX)) { // decode client reference on the server id = id.slice(SERVER_DECODE_CLIENT_PREFIX.length) @@ -71,7 +97,12 @@ export async function loadServerAction(id: string): Promise { return mod[name] } -export function createServerManifest(): BundlerConfig { +export function createServerManifest(options?: { + preserveServerReferences?: boolean +}): BundlerConfig { + const prefix = options?.preserveServerReferences + ? SERVER_REFERENCE_PROXY_PREFIX + : '' const cacheTag = import.meta.env.DEV ? createReferenceCacheTag() : '' return new Proxy( @@ -83,7 +114,7 @@ export function createServerManifest(): BundlerConfig { tinyassert(id) tinyassert(name) return { - id: SERVER_REFERENCE_PREFIX + id + cacheTag, + id: SERVER_REFERENCE_PREFIX + prefix + id + cacheTag, name, chunks: [], async: true, diff --git a/packages/plugin-rsc/src/core/shared.ts b/packages/plugin-rsc/src/core/shared.ts index bd3d18af3..ec098336e 100644 --- a/packages/plugin-rsc/src/core/shared.ts +++ b/packages/plugin-rsc/src/core/shared.ts @@ -3,6 +3,8 @@ export const SERVER_REFERENCE_PREFIX = '$$server:' export const SERVER_DECODE_CLIENT_PREFIX = '$$decode-client:' +export const SERVER_REFERENCE_PROXY_PREFIX = '$$proxy:' + // cache bust memoized require promise during dev export function createReferenceCacheTag(): string { const cache = Math.random().toString(36).slice(2) diff --git a/packages/plugin-rsc/src/react/rsc/client.ts b/packages/plugin-rsc/src/react/rsc/client.ts index 3745d1ef9..17794e101 100644 --- a/packages/plugin-rsc/src/react/rsc/client.ts +++ b/packages/plugin-rsc/src/react/rsc/client.ts @@ -18,7 +18,7 @@ export function createFromReadableStream( serverConsumerManifest: { // https://github.com/facebook/react/pull/31300 // https://github.com/vercel/next.js/pull/71527 - serverModuleMap: createServerManifest(), + serverModuleMap: createServerManifest({ preserveServerReferences: true }), moduleMap: createServerDecodeClientManifest(), }, ...options, From 7fdad306bf4c031da9a25a586b120f0a9e8add39 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:07:35 +0900 Subject: [PATCH 02/16] fix(plugin-rsc): make server reference preservation opt-in Co-authored-by: OpenCode --- packages/plugin-rsc/src/react/rsc/client.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/src/react/rsc/client.ts b/packages/plugin-rsc/src/react/rsc/client.ts index 17794e101..7ad3865e7 100644 --- a/packages/plugin-rsc/src/react/rsc/client.ts +++ b/packages/plugin-rsc/src/react/rsc/client.ts @@ -13,12 +13,20 @@ import type { export function createFromReadableStream( stream: ReadableStream, options: CreateFromReadableStreamEdgeOptions = {}, + extraOptions?: { + /** + * @experimental + */ + preserveServerReferences?: boolean + }, ): Promise { return ReactClient.createFromReadableStream(stream, { serverConsumerManifest: { // https://github.com/facebook/react/pull/31300 // https://github.com/vercel/next.js/pull/71527 - serverModuleMap: createServerManifest({ preserveServerReferences: true }), + serverModuleMap: createServerManifest({ + preserveServerReferences: extraOptions?.preserveServerReferences, + }), moduleMap: createServerDecodeClientManifest(), }, ...options, From bde0b30309439b89af9b75020f3135e49ba4eff5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:57:22 +0900 Subject: [PATCH 03/16] fix(plugin-rsc): support replayed references in dev Add a persisted Flight example covering development and production server restarts. Co-authored-by: OpenCode --- .../rsc-server-reference-replay/.gitignore | 2 + .../rsc-server-reference-replay/README.md | 46 ++++++++++++ .../rsc-server-reference-replay/package.json | 22 ++++++ .../src/action-import-state.ts | 9 +++ .../rsc-server-reference-replay/src/action.ts | 7 ++ .../src/cached-content.tsx | 9 +++ .../src/framework/entry.browser.ts | 1 + .../src/framework/entry.rsc.tsx | 71 +++++++++++++++++++ .../src/framework/entry.ssr.tsx | 17 +++++ .../rsc-server-reference-replay/src/root.tsx | 19 +++++ .../rsc-server-reference-replay/tsconfig.json | 17 +++++ .../vite.config.ts | 29 ++++++++ packages/plugin-rsc/src/plugin.ts | 14 +++- pnpm-lock.yaml | 22 ++++++ 14 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/.gitignore create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/README.md create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/package.json create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.ts create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.tsx create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/tsconfig.json create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/vite.config.ts diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/.gitignore b/packages/plugin-rsc/examples/rsc-server-reference-replay/.gitignore new file mode 100644 index 000000000..08605c6fb --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/.gitignore @@ -0,0 +1,2 @@ +.flight-cache +dist diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/README.md b/packages/plugin-rsc/examples/rsc-server-reference-replay/README.md new file mode 100644 index 000000000..4143d688b --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/README.md @@ -0,0 +1,46 @@ +# Persisted Flight server reference replay + +This example persists a Flight payload containing a server reference, restarts the server, and replays the payload without importing the server action in the RSC environment. The action is imported only when the replayed form invokes it. + +The example intentionally uses a native form without JavaScript so the final step exercises `decodeAction`. + +## Development manual test + +Start the first process: + +```bash +pnpm dev +``` + +1. Visit `http://localhost:5173/cache` and confirm the page displays `true`. +2. Stop the development server without changing the source graph. +3. Run `pnpm dev` again. +4. Visit `http://localhost:5173/` and confirm the page displays `false`. +5. Select **Invoke replayed action** and confirm the response displays `true`. + +The source graph must remain unchanged across the restart so its development server-reference IDs remain stable. + +## Production manual test + +Build the example once: + +```bash +pnpm build +``` + +Start the first process: + +```bash +pnpm preview +``` + +1. Visit `http://localhost:4173/cache`. +2. Confirm the page displays `Action imported in the RSC environment: true`. +3. Stop the preview server without rebuilding. +4. Run `pnpm preview` again. +5. Visit `http://localhost:4173/` and confirm the page displays `false`. +6. Select **Invoke replayed action** and confirm the response displays `true`. + +The same production build must be used for both processes because the persisted Flight payload contains build-specific server-reference IDs. + +Delete `.flight-cache` to reset the example. diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/package.json b/packages/plugin-rsc/examples/rsc-server-reference-replay/package.json new file mode 100644 index 000000000..4a0ea1f86 --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/package.json @@ -0,0 +1,22 @@ +{ + "name": "@vitejs/plugin-rsc-examples-rsc-server-reference-replay", + "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-rsc": "latest", + "vite": "^8.1.4" + } +} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts new file mode 100644 index 000000000..cedd8381d --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts @@ -0,0 +1,9 @@ +let actionImported = false + +export function markActionImported() { + actionImported = true +} + +export function wasActionImported() { + return actionImported +} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts new file mode 100644 index 000000000..85149d8a8 --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts @@ -0,0 +1,7 @@ +'use server' + +import { markActionImported } from './action-import-state' + +markActionImported() + +export async function replayedAction() {} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx new file mode 100644 index 000000000..2c2ee919d --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx @@ -0,0 +1,9 @@ +import { replayedAction } from './action' + +export function CachedContent() { + return ( +
+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.ts new file mode 100644 index 000000000..d798a03e1 --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.ts @@ -0,0 +1 @@ +// This example only tests progressive enhancement without JavaScript. diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..8e1a7c752 --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.tsx @@ -0,0 +1,71 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { + createFromReadableStream, + decodeAction, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc' +import { wasActionImported } from '../action-import-state' +import { Root } from '../root' + +const cacheFile = resolve('.flight-cache') + +async function handler(request: Request): Promise { + const url = new URL(request.url) + + if (request.method === 'POST') { + const action = await decodeAction(await request.formData()) + await action() + } + + let bytes: Uint8Array + if (url.pathname === '/cache') { + const { CachedContent } = await import('../cached-content') + const stream = renderToReadableStream() + bytes = new Uint8Array(await new Response(stream).arrayBuffer()) + await writeFile(cacheFile, bytes) + } else { + try { + bytes = await readFile(cacheFile) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return new Response('Visit /cache before replaying the saved Flight.', { + status: 404, + }) + } + throw error + } + } + + const cachedContent = await createFromReadableStream( + toReadableStream(bytes), + {}, + { preserveServerReferences: true }, + ) + const rscStream = renderToReadableStream({ + root: ( + + ), + }) + const ssr = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr') + >('ssr', 'index') + const htmlStream = await ssr.renderHtml(rscStream) + return new Response(htmlStream, { + headers: { 'content-type': 'text/html;charset=utf-8' }, + }) +} + +function toReadableStream(bytes: Uint8Array): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }) +} + +export default { fetch: handler } diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..a26c97477 --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx @@ -0,0 +1,17 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import { renderToReadableStream } from 'react-dom/server.edge' + +type RscPayload = { root: React.ReactNode } + +export async function renderHtml( + rscStream: ReadableStream, +): Promise> { + let payload: Promise | undefined + function SsrRoot() { + payload ??= createFromReadableStream(rscStream) + return React.use(payload).root + } + + return renderToReadableStream() +} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx new file mode 100644 index 000000000..7663cec6a --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx @@ -0,0 +1,19 @@ +export function Root(props: { + actionImported: boolean + cachedContent: React.ReactNode +}) { + return ( + + +

Persisted Flight server reference

+

+ Action imported in the RSC environment:{' '} + + {String(props.actionImported)} + +

+ {props.cachedContent} + + + ) +} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/tsconfig.json b/packages/plugin-rsc/examples/rsc-server-reference-replay/tsconfig.json new file mode 100644 index 000000000..b212cd7a7 --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/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/rsc-server-reference-replay/vite.config.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/vite.config.ts new file mode 100644 index 000000000..2252a2583 --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/vite.config.ts @@ -0,0 +1,29 @@ +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [rsc()], + environments: { + rsc: { + build: { + rollupOptions: { + input: { index: './src/framework/entry.rsc.tsx' }, + }, + }, + }, + ssr: { + build: { + rollupOptions: { + input: { index: './src/framework/entry.ssr.tsx' }, + }, + }, + }, + client: { + build: { + rollupOptions: { + input: { index: './src/framework/entry.browser.ts' }, + }, + }, + }, + }, +}) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 05f576357..89dbceee5 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -346,7 +346,7 @@ export function vitePluginRscMinimal( apply: 'serve', load: { filter: { id: prefixRegex('\0virtual:vite-rsc/reference-validation?') }, - handler(id, _options) { + async handler(id, _options) { if (id.startsWith('\0virtual:vite-rsc/reference-validation?')) { const parsed = parseReferenceValidationVirtual(id) assert(parsed) @@ -359,9 +359,19 @@ export function vitePluginRscMinimal( } } if (parsed.type === 'server') { - const meta = Object.values(manager.serverReferenceMetaMap).find( + let meta = Object.values(manager.serverReferenceMetaMap).find( (meta) => meta.referenceKey === parsed.id, ) + if (!meta) { + try { + await (this.environment as DevEnvironment).transformRequest( + parsed.id, + ) + } catch {} + meta = Object.values(manager.serverReferenceMetaMap).find( + (meta) => meta.referenceKey === parsed.id, + ) + } if (meta) { return `export {}` } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3d494845..bd1d145dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -694,6 +694,28 @@ importers: specifier: ^4.110.0 version: 4.110.0 + packages/plugin-rsc/examples/rsc-server-reference-replay: + 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-rsc': + specifier: latest + version: link:../.. + vite: + specifier: ^8.1.4 + version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.8.2) + packages/plugin-rsc/examples/ssg: dependencies: react: From 1c85e2ac427c2035fa1b18ef09a6c913530d8bba Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:08:23 +0900 Subject: [PATCH 04/16] refactor(plugin-rsc): align replay example with starter Keep persisted Flight handling in the application root while reusing the starter framework flow. Co-authored-by: OpenCode --- .../rsc-server-reference-replay/README.md | 2 +- .../rsc-server-reference-replay/package.json | 2 + .../src/framework/entry.browser.ts | 1 - .../src/framework/entry.browser.tsx | 124 ++++++++++++++++++ .../src/framework/entry.rsc.tsx | 123 ++++++++++------- .../src/framework/entry.ssr.tsx | 58 +++++++- .../src/framework/error-boundary.tsx | 65 +++++++++ .../src/framework/request.tsx | 52 ++++++++ .../rsc-server-reference-replay/src/root.tsx | 33 ++++- .../vite.config.ts | 5 +- pnpm-lock.yaml | 6 + 11 files changed, 405 insertions(+), 66 deletions(-) delete mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.ts create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.tsx create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/error-boundary.tsx create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/request.tsx diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/README.md b/packages/plugin-rsc/examples/rsc-server-reference-replay/README.md index 4143d688b..c8ebd19bb 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/README.md +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/README.md @@ -2,7 +2,7 @@ This example persists a Flight payload containing a server reference, restarts the server, and replays the payload without importing the server action in the RSC environment. The action is imported only when the replayed form invokes it. -The example intentionally uses a native form without JavaScript so the final step exercises `decodeAction`. +The framework files follow the starter example. The application routes own persistence and replay, while the framework only performs its normal request parsing, action handling, and RSC serialization. ## Development manual test diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/package.json b/packages/plugin-rsc/examples/rsc-server-reference-replay/package.json index 4a0ea1f86..ac7465060 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/package.json +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/package.json @@ -16,7 +16,9 @@ "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.1.4" } } diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.ts deleted file mode 100644 index d798a03e1..000000000 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.ts +++ /dev/null @@ -1 +0,0 @@ -// This example only tests progressive enhancement without JavaScript. diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.tsx new file mode 100644 index 000000000..8ac1393be --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.tsx @@ -0,0 +1,124 @@ +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() { + let setPayload: (value: RscPayload) => void + + const initialPayload = await createFromReadableStream(rscStream) + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (value) => React.startTransition(() => setPayload_(value)) + }, [setPayload_]) + + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } + + 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 + }) + + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const result = oldPushState.apply(this, args) + onNavigation() + return result + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const result = oldReplaceState.apply(this, args) + onNavigation() + return result + } + + function onClick(event: MouseEvent) { + const link = (event.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + !event.defaultPrevented + ) { + event.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/rsc-server-reference-replay/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.tsx index 8e1a7c752..c5d7b1a5c 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.tsx @@ -1,71 +1,94 @@ -import { readFile, writeFile } from 'node:fs/promises' -import { resolve } from 'node:path' import { - createFromReadableStream, - decodeAction, renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, } from '@vitejs/plugin-rsc/rsc' -import { wasActionImported } from '../action-import-state' +import type { ReactFormState } from 'react-dom/client' import { Root } from '../root' +import { parseRenderRequest } from './request' -const cacheFile = resolve('.flight-cache') +export type RscPayload = { + root: React.ReactNode + returnValue?: { ok: boolean; data: unknown } + formState?: ReactFormState +} -async function handler(request: Request): Promise { - const url = new URL(request.url) +export default { fetch: handler } - if (request.method === 'POST') { - const action = await decodeAction(await request.formData()) - await action() - } +async function handler(request: Request): Promise { + const renderRequest = parseRenderRequest(request) + request = renderRequest.request - let bytes: Uint8Array - if (url.pathname === '/cache') { - const { CachedContent } = await import('../cached-content') - const stream = renderToReadableStream() - bytes = new Uint8Array(await new Response(stream).arrayBuffer()) - await writeFile(cacheFile, bytes) - } else { - try { - bytes = await readFile(cacheFile) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return new Response('Visit /cache before replaying the saved Flight.', { - status: 404, + let returnValue: RscPayload['returnValue'] | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + let actionStatus: number | undefined + if (renderRequest.isAction === true) { + if (renderRequest.actionId) { + 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 (error) { + returnValue = { ok: false, data: error } + actionStatus = 500 + } + } else { + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch { + return new Response('Internal Server Error: server action failed', { + status: 500, }) } - throw error } } - const cachedContent = await createFromReadableStream( - toReadableStream(bytes), - {}, - { preserveServerReferences: true }, - ) - const rscStream = renderToReadableStream({ - root: ( - - ), - }) - const ssr = await import.meta.viteRsc.loadModule< + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + if (renderRequest.isRsc) { + return new Response(rscStream, { + status: actionStatus, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + const ssrEntryModule = await import.meta.viteRsc.loadModule< typeof import('./entry.ssr') >('ssr', 'index') - const htmlStream = await ssr.renderHtml(rscStream) - return new Response(htmlStream, { - headers: { 'content-type': 'text/html;charset=utf-8' }, + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + debugNojs: renderRequest.url.searchParams.has('__nojs'), }) -} -function toReadableStream(bytes: Uint8Array): ReadableStream { - return new ReadableStream({ - start(controller) { - controller.enqueue(bytes) - controller.close() + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', }, }) } -export default { fetch: handler } +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx index a26c97477..d28ef5e5a 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx @@ -1,17 +1,63 @@ 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' -type RscPayload = { root: React.ReactNode } - -export async function renderHtml( +export async function renderHTML( rscStream: ReadableStream, -): Promise> { + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + const [rscStream1, rscStream2] = rscStream.tee() + let payload: Promise | undefined function SsrRoot() { - payload ??= createFromReadableStream(rscStream) + payload ??= createFromReadableStream(rscStream1) return React.use(payload).root } - return renderToReadableStream() + 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 { + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options.debugNojs ? '' : bootstrapScriptContent), + nonce: options.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options.debugNojs) { + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options.nonce, + }), + ) + } + + return { stream: responseStream, status } } diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/error-boundary.tsx new file mode 100644 index 000000000..62e31b065 --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/error-boundary.tsx @@ -0,0 +1,65 @@ +'use client' + +import React from 'react' + +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +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 + } +} + +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/rsc-server-reference-replay/src/framework/request.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/request.tsx new file mode 100644 index 000000000..cbde5e286 --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/request.tsx @@ -0,0 +1,52 @@ +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +type RenderRequest = { + isRsc: boolean + isAction: boolean + actionId?: string + request: Request + url: URL +} + +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, + } + } + return { + isRsc: false, + isAction, + request, + url, + } +} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx index 7663cec6a..f5264286a 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx @@ -1,7 +1,28 @@ -export function Root(props: { - actionImported: boolean - cachedContent: React.ReactNode -}) { +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { + createFromReadableStream, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc' +import { wasActionImported } from './action-import-state' + +export async function Root(props: { url: URL }) { + let cachedContent: React.ReactNode + if (props.url.pathname === '/cache') { + const { CachedContent } = await import('./cached-content') + cachedContent = + const stream = renderToReadableStream(cachedContent) + const bytes = new Uint8Array(await new Response(stream).arrayBuffer()) + await writeFile(resolve('.flight-cache'), bytes) + } else { + const bytes = await readFile(resolve('.flight-cache')) + cachedContent = await createFromReadableStream( + new Blob([bytes]).stream(), + {}, + { preserveServerReferences: true }, + ) + } + return ( @@ -9,10 +30,10 @@ export function Root(props: {

Action imported in the RSC environment:{' '} - {String(props.actionImported)} + {String(wasActionImported())}

- {props.cachedContent} + {cachedContent} ) diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/vite.config.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/vite.config.ts index 2252a2583..0cace0701 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/vite.config.ts +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/vite.config.ts @@ -1,8 +1,9 @@ +import react from '@vitejs/plugin-react' import rsc from '@vitejs/plugin-rsc' import { defineConfig } from 'vite' export default defineConfig({ - plugins: [rsc()], + plugins: [rsc(), react()], environments: { rsc: { build: { @@ -21,7 +22,7 @@ export default defineConfig({ client: { build: { rollupOptions: { - input: { index: './src/framework/entry.browser.ts' }, + input: { index: './src/framework/entry.browser.tsx' }, }, }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd1d145dd..e3216a404 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -709,9 +709,15 @@ importers: '@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.1.4 version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.8.2) From ee838511981e5a0afa306ba158b9f6644c02a22d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:54:37 +0900 Subject: [PATCH 05/16] refactor(plugin-rsc): simplify replay example Co-authored-by: OpenCode --- .../src/action-import-state.ts | 10 +--------- .../examples/rsc-server-reference-replay/src/action.ts | 6 +++--- .../rsc-server-reference-replay/src/cached-content.tsx | 4 ++-- .../examples/rsc-server-reference-replay/src/root.tsx | 4 ++-- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts index cedd8381d..a9fc85dc8 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts @@ -1,9 +1 @@ -let actionImported = false - -export function markActionImported() { - actionImported = true -} - -export function wasActionImported() { - return actionImported -} +export const actionImportState = { imported: false } diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts index 85149d8a8..d71e22614 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts @@ -1,7 +1,7 @@ 'use server' -import { markActionImported } from './action-import-state' +import { actionImportState } from './action-import-state' -markActionImported() +actionImportState.imported = true -export async function replayedAction() {} +export async function testAction() {} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx index 2c2ee919d..a88647984 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx @@ -1,8 +1,8 @@ -import { replayedAction } from './action' +import { testAction } from './action' export function CachedContent() { return ( -
+
) diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx index f5264286a..c80b83a03 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx @@ -4,7 +4,7 @@ import { createFromReadableStream, renderToReadableStream, } from '@vitejs/plugin-rsc/rsc' -import { wasActionImported } from './action-import-state' +import { actionImportState } from './action-import-state' export async function Root(props: { url: URL }) { let cachedContent: React.ReactNode @@ -30,7 +30,7 @@ export async function Root(props: { url: URL }) {

Action imported in the RSC environment:{' '} - {String(wasActionImported())} + {String(actionImportState.imported)}

{cachedContent} From 9509b52c23d52123a3d2a2d8b299378ab5585ba4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:57:47 +0900 Subject: [PATCH 06/16] refactor(plugin-rsc): track replay action state Co-authored-by: OpenCode --- .../src/action-import-state.ts | 1 - .../rsc-server-reference-replay/src/action-state.ts | 1 + .../examples/rsc-server-reference-replay/src/action.ts | 8 +++++--- .../examples/rsc-server-reference-replay/src/root.tsx | 10 ++++++++-- 4 files changed, 14 insertions(+), 6 deletions(-) delete mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts create mode 100644 packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-state.ts diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts deleted file mode 100644 index a9fc85dc8..000000000 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts +++ /dev/null @@ -1 +0,0 @@ -export const actionImportState = { imported: false } diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-state.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-state.ts new file mode 100644 index 000000000..3a57dc84c --- /dev/null +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-state.ts @@ -0,0 +1 @@ +export const actionState = { imported: false, invoked: false } diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts index d71e22614..bd008b825 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts @@ -1,7 +1,9 @@ 'use server' -import { actionImportState } from './action-import-state' +import { actionState } from './action-state' -actionImportState.imported = true +actionState.imported = true -export async function testAction() {} +export async function testAction() { + actionState.invoked = true +} diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx index c80b83a03..67da444d8 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx @@ -4,7 +4,7 @@ import { createFromReadableStream, renderToReadableStream, } from '@vitejs/plugin-rsc/rsc' -import { actionImportState } from './action-import-state' +import { actionState } from './action-state' export async function Root(props: { url: URL }) { let cachedContent: React.ReactNode @@ -30,7 +30,13 @@ export async function Root(props: { url: URL }) {

Action imported in the RSC environment:{' '} - {String(actionImportState.imported)} + {String(actionState.imported)} + +

+

+ Action invoked:{' '} + + {String(actionState.invoked)}

{cachedContent} From 1be7726afee6113956b0b679b94aae9fc21951ff Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:05:14 +0900 Subject: [PATCH 07/16] refactor(plugin-rsc): clarify replay example flow Co-authored-by: OpenCode --- .../src/cached-content.tsx | 9 +++++--- .../rsc-server-reference-replay/src/root.tsx | 23 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx index a88647984..ebeacb279 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx @@ -2,8 +2,11 @@ import { testAction } from './action' export function CachedContent() { return ( -
- -
+
+

Cached content

+
+ +
+
) } diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx index 67da444d8..9cd151348 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx +++ b/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx @@ -1,4 +1,5 @@ -import { readFile, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { readFile, rm, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { createFromReadableStream, @@ -7,26 +8,34 @@ import { import { actionState } from './action-state' export async function Root(props: { url: URL }) { + const cacheFile = resolve('.flight-cache') let cachedContent: React.ReactNode if (props.url.pathname === '/cache') { const { CachedContent } = await import('./cached-content') cachedContent = const stream = renderToReadableStream(cachedContent) const bytes = new Uint8Array(await new Response(stream).arrayBuffer()) - await writeFile(resolve('.flight-cache'), bytes) - } else { - const bytes = await readFile(resolve('.flight-cache')) + await writeFile(cacheFile, bytes) + } else if (props.url.pathname === '/read-cache') { + const bytes = await readFile(cacheFile) cachedContent = await createFromReadableStream( new Blob([bytes]).stream(), {}, { preserveServerReferences: true }, ) + } else if (props.url.pathname === '/delete-cache') { + await rm(cacheFile, { force: true }) } return (

Persisted Flight server reference

+

Action imported in the RSC environment:{' '} @@ -39,6 +48,12 @@ export async function Root(props: { url: URL }) { {String(actionState.invoked)}

+

+ Cache file exists:{' '} + + {String(existsSync(cacheFile))} + +

{cachedContent} From b082cd4e70b0c54e8cb56bf37f2298ae655f5ddf Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:15:53 +0900 Subject: [PATCH 08/16] test(plugin-rsc): cover cache replay across restarts Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/cache-replay.test.ts | 48 +++++++++++++++ packages/plugin-rsc/e2e/fixture.ts | 60 +++++++++---------- .../.gitignore | 0 .../README.md | 0 .../package.json | 2 +- .../src/action-state.ts | 0 .../src/action.ts | 0 .../src/cached-content.tsx | 0 .../src/framework/entry.browser.tsx | 0 .../src/framework/entry.rsc.tsx | 0 .../src/framework/entry.ssr.tsx | 0 .../src/framework/error-boundary.tsx | 0 .../src/framework/request.tsx | 0 .../src/root.tsx | 0 .../tsconfig.json | 0 .../vite.config.ts | 0 pnpm-lock.yaml | 56 ++++++++--------- 17 files changed, 105 insertions(+), 61 deletions(-) create mode 100644 packages/plugin-rsc/e2e/cache-replay.test.ts rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/.gitignore (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/README.md (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/package.json (87%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/src/action-state.ts (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/src/action.ts (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/src/cached-content.tsx (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/src/framework/entry.browser.tsx (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/src/framework/entry.rsc.tsx (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/src/framework/entry.ssr.tsx (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/src/framework/error-boundary.tsx (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/src/framework/request.tsx (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/src/root.tsx (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/tsconfig.json (100%) rename packages/plugin-rsc/examples/{rsc-server-reference-replay => cache-replay}/vite.config.ts (100%) diff --git a/packages/plugin-rsc/e2e/cache-replay.test.ts b/packages/plugin-rsc/e2e/cache-replay.test.ts new file mode 100644 index 000000000..f14b3b38a --- /dev/null +++ b/packages/plugin-rsc/e2e/cache-replay.test.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs' +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { type Fixture, useFixture } from './fixture' +import { waitForHydration } from './helper' + +for (const mode of ['dev', 'build'] as const) { + test.describe(mode, () => { + const f = useFixture({ + root: 'examples/cache-replay', + mode, + }) + + defineTests(f) + }) +} + +function defineTests(f: Fixture) { + const cacheFile = path.join(f.root, '.flight-cache') + + test.beforeEach(() => fs.rmSync(cacheFile, { force: true })) + test.afterEach(() => fs.rmSync(cacheFile, { force: true })) + + test('replays a server reference without loading its module', async ({ + page, + }) => { + await page.goto(f.url('/cache')) + await waitForHydration(page) + await expect(page.getByTestId('cache-exists')).toHaveText('true') + await expect(page.getByTestId('action-imported')).toHaveText('true') + await expect(page.getByTestId('action-invoked')).toHaveText('false') + + await f.restart() + + await page.goto(f.url('/read-cache')) + await waitForHydration(page) + await expect( + page.getByRole('heading', { name: 'Cached content' }), + ).toBeVisible() + await expect(page.getByTestId('cache-exists')).toHaveText('true') + await expect(page.getByTestId('action-imported')).toHaveText('false') + await expect(page.getByTestId('action-invoked')).toHaveText('false') + + await page.getByRole('button', { name: 'Invoke action' }).click() + await expect(page.getByTestId('action-imported')).toHaveText('true') + await expect(page.getByTestId('action-invoked')).toHaveText('true') + }) +} diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts index 76bcbe97c..17ead0219 100644 --- a/packages/plugin-rsc/e2e/fixture.ts +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -71,58 +71,50 @@ export function useFixture(options: { buildCommand?: string cliOptions?: SpawnOptions }) { - let cleanup: (() => Promise) | undefined let baseURL!: string const cwd = path.resolve(options.root) let proc!: ReturnType + async function startServer() { + const command = + options.command ?? + (options.mode === 'build' ? `pnpm preview` : `pnpm dev`) + proc = runCli({ + command, + label: `${options.root}:${options.mode}`, + cwd, + ...options.cliOptions, + }) + const port = await proc.findPort() + baseURL = `http://localhost:${port}` + } + + async function stopServer() { + proc.kill() + await proc.done + } + // TODO: `beforeAll` is called again on any test failure. // https://playwright.dev/docs/test-retries test.beforeAll(async () => { - if (options.mode === 'dev') { - proc = runCli({ - command: options.command ?? `pnpm dev`, - label: `${options.root}:dev`, - cwd, - ...options.cliOptions, - }) - const port = await proc.findPort() - // TODO: use `test.extend` to set `baseURL`? - baseURL = `http://localhost:${port}` - cleanup = async () => { - proc.kill() - await proc.done - } - } if (options.mode === 'build') { if (!process.env.TEST_SKIP_BUILD) { - const proc = runCli({ + const buildProc = runCli({ command: options.buildCommand ?? `pnpm build`, label: `${options.root}:build`, cwd, ...options.cliOptions, }) - await proc.done - assert(proc.proc.exitCode === 0) - } - proc = runCli({ - command: options.command ?? `pnpm preview`, - label: `${options.root}:preview`, - cwd, - ...options.cliOptions, - }) - const port = await proc.findPort() - baseURL = `http://localhost:${port}` - cleanup = async () => { - proc.kill() - await proc.done + await buildProc.done + assert(buildProc.proc.exitCode === 0) } } + if (options.mode) await startServer() }) test.afterAll(async () => { - await cleanup?.() + if (options.mode) await stopServer() }) const createEditor = useCreateEditor(cwd) @@ -133,6 +125,10 @@ export function useFixture(options: { url: (url: string = './') => new URL(url, baseURL).href, createEditor, proc: () => proc, + restart: async () => { + await stopServer() + await startServer() + }, } } diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/.gitignore b/packages/plugin-rsc/examples/cache-replay/.gitignore similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/.gitignore rename to packages/plugin-rsc/examples/cache-replay/.gitignore diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/README.md b/packages/plugin-rsc/examples/cache-replay/README.md similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/README.md rename to packages/plugin-rsc/examples/cache-replay/README.md diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/package.json b/packages/plugin-rsc/examples/cache-replay/package.json similarity index 87% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/package.json rename to packages/plugin-rsc/examples/cache-replay/package.json index ac7465060..81ebd320b 100644 --- a/packages/plugin-rsc/examples/rsc-server-reference-replay/package.json +++ b/packages/plugin-rsc/examples/cache-replay/package.json @@ -1,5 +1,5 @@ { - "name": "@vitejs/plugin-rsc-examples-rsc-server-reference-replay", + "name": "@vitejs/plugin-rsc-examples-cache-replay", "version": "0.0.0", "private": true, "license": "MIT", diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-state.ts b/packages/plugin-rsc/examples/cache-replay/src/action-state.ts similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-state.ts rename to packages/plugin-rsc/examples/cache-replay/src/action-state.ts diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts b/packages/plugin-rsc/examples/cache-replay/src/action.ts similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts rename to packages/plugin-rsc/examples/cache-replay/src/action.ts diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx b/packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/src/cached-content.tsx rename to packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/entry.browser.tsx similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.browser.tsx rename to packages/plugin-rsc/examples/cache-replay/src/framework/entry.browser.tsx diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/entry.rsc.tsx similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.tsx rename to packages/plugin-rsc/examples/cache-replay/src/framework/entry.rsc.tsx diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/entry.ssr.tsx similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.tsx rename to packages/plugin-rsc/examples/cache-replay/src/framework/entry.ssr.tsx diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/error-boundary.tsx similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/error-boundary.tsx rename to packages/plugin-rsc/examples/cache-replay/src/framework/error-boundary.tsx diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/request.tsx b/packages/plugin-rsc/examples/cache-replay/src/framework/request.tsx similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/request.tsx rename to packages/plugin-rsc/examples/cache-replay/src/framework/request.tsx diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx b/packages/plugin-rsc/examples/cache-replay/src/root.tsx similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/src/root.tsx rename to packages/plugin-rsc/examples/cache-replay/src/root.tsx diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/tsconfig.json b/packages/plugin-rsc/examples/cache-replay/tsconfig.json similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/tsconfig.json rename to packages/plugin-rsc/examples/cache-replay/tsconfig.json diff --git a/packages/plugin-rsc/examples/rsc-server-reference-replay/vite.config.ts b/packages/plugin-rsc/examples/cache-replay/vite.config.ts similarity index 100% rename from packages/plugin-rsc/examples/rsc-server-reference-replay/vite.config.ts rename to packages/plugin-rsc/examples/cache-replay/vite.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3216a404..860df5dcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,6 +602,34 @@ importers: specifier: ^2.0.0 version: 2.0.0(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.8.2)) + packages/plugin-rsc/examples/cache-replay: + 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.1.4 + version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.8.2) + packages/plugin-rsc/examples/e2e: devDependencies: '@rolldown/plugin-babel': @@ -694,34 +722,6 @@ importers: specifier: ^4.110.0 version: 4.110.0 - packages/plugin-rsc/examples/rsc-server-reference-replay: - 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.1.4 - version: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.8.2) - packages/plugin-rsc/examples/ssg: dependencies: react: From 533c4677e60ca71e6e15234137a2c065e999dea9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:25:50 +0900 Subject: [PATCH 09/16] test(plugin-rsc): compare cache replay modes Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/cache-replay.test.ts | 38 ++++++++++++++----- .../examples/cache-replay/README.md | 16 +++++--- .../examples/cache-replay/src/root.tsx | 8 +++- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/packages/plugin-rsc/e2e/cache-replay.test.ts b/packages/plugin-rsc/e2e/cache-replay.test.ts index f14b3b38a..f3ded66b2 100644 --- a/packages/plugin-rsc/e2e/cache-replay.test.ts +++ b/packages/plugin-rsc/e2e/cache-replay.test.ts @@ -4,16 +4,21 @@ import { expect, test } from '@playwright/test' import { type Fixture, useFixture } from './fixture' import { waitForHydration } from './helper' -for (const mode of ['dev', 'build'] as const) { - test.describe(mode, () => { - const f = useFixture({ - root: 'examples/cache-replay', - mode, - }) - - defineTests(f) +test.describe('dev', () => { + const f = useFixture({ + root: 'examples/cache-replay', + mode: 'dev', }) -} + defineTests(f) +}) + +test.describe('build', () => { + const f = useFixture({ + root: 'examples/cache-replay', + mode: 'build', + }) + defineTests(f) +}) function defineTests(f: Fixture) { const cacheFile = path.join(f.root, '.flight-cache') @@ -24,6 +29,10 @@ function defineTests(f: Fixture) { test('replays a server reference without loading its module', async ({ page, }) => { + await page.goto(f.url('/')) + await expect(page.getByTestId('cache-exists')).toHaveText('false') + + // Serialize content after importing its action. await page.goto(f.url('/cache')) await waitForHydration(page) await expect(page.getByTestId('cache-exists')).toHaveText('true') @@ -32,8 +41,18 @@ function defineTests(f: Fixture) { await f.restart() + // Default replay imports the referenced action. await page.goto(f.url('/read-cache')) await waitForHydration(page) + await expect(page.getByTestId('cache-exists')).toHaveText('true') + await expect(page.getByTestId('action-imported')).toHaveText('true') + await expect(page.getByTestId('action-invoked')).toHaveText('false') + + await f.restart() + + // Preserved replay leaves the referenced action unloaded. + await page.goto(f.url('/read-cache-preserve')) + await waitForHydration(page) await expect( page.getByRole('heading', { name: 'Cached content' }), ).toBeVisible() @@ -41,6 +60,7 @@ function defineTests(f: Fixture) { await expect(page.getByTestId('action-imported')).toHaveText('false') await expect(page.getByTestId('action-invoked')).toHaveText('false') + // Invoking the preserved reference imports and runs the action. await page.getByRole('button', { name: 'Invoke action' }).click() await expect(page.getByTestId('action-imported')).toHaveText('true') await expect(page.getByTestId('action-invoked')).toHaveText('true') diff --git a/packages/plugin-rsc/examples/cache-replay/README.md b/packages/plugin-rsc/examples/cache-replay/README.md index c8ebd19bb..0595688af 100644 --- a/packages/plugin-rsc/examples/cache-replay/README.md +++ b/packages/plugin-rsc/examples/cache-replay/README.md @@ -1,6 +1,6 @@ # Persisted Flight server reference replay -This example persists a Flight payload containing a server reference, restarts the server, and replays the payload without importing the server action in the RSC environment. The action is imported only when the replayed form invokes it. +This example persists a Flight payload containing a server reference and compares the default replay, which imports the server action in the RSC environment, with a replay that preserves the reference. With preservation enabled, the action is imported only when the replayed form invokes it. The framework files follow the starter example. The application routes own persistence and replay, while the framework only performs its normal request parsing, action handling, and RSC serialization. @@ -12,11 +12,13 @@ Start the first process: pnpm dev ``` -1. Visit `http://localhost:5173/cache` and confirm the page displays `true`. +1. Visit `http://localhost:5173/cache` and confirm the action is imported. 2. Stop the development server without changing the source graph. 3. Run `pnpm dev` again. -4. Visit `http://localhost:5173/` and confirm the page displays `false`. -5. Select **Invoke replayed action** and confirm the response displays `true`. +4. Visit `http://localhost:5173/read-cache` and confirm the action is imported. +5. Restart the development server again. +6. Visit `http://localhost:5173/read-cache-preserve` and confirm the action is not imported. +7. Select **Invoke action** and confirm the action is imported and invoked. The source graph must remain unchanged across the restart so its development server-reference IDs remain stable. @@ -38,8 +40,10 @@ pnpm preview 2. Confirm the page displays `Action imported in the RSC environment: true`. 3. Stop the preview server without rebuilding. 4. Run `pnpm preview` again. -5. Visit `http://localhost:4173/` and confirm the page displays `false`. -6. Select **Invoke replayed action** and confirm the response displays `true`. +5. Visit `http://localhost:4173/read-cache` and confirm the action is imported. +6. Restart the preview server again without rebuilding. +7. Visit `http://localhost:4173/read-cache-preserve` and confirm the action is not imported. +8. Select **Invoke action** and confirm the action is imported and invoked. The same production build must be used for both processes because the persisted Flight payload contains build-specific server-reference IDs. diff --git a/packages/plugin-rsc/examples/cache-replay/src/root.tsx b/packages/plugin-rsc/examples/cache-replay/src/root.tsx index 9cd151348..680fa5c29 100644 --- a/packages/plugin-rsc/examples/cache-replay/src/root.tsx +++ b/packages/plugin-rsc/examples/cache-replay/src/root.tsx @@ -17,6 +17,11 @@ export async function Root(props: { url: URL }) { const bytes = new Uint8Array(await new Response(stream).arrayBuffer()) await writeFile(cacheFile, bytes) } else if (props.url.pathname === '/read-cache') { + const bytes = await readFile(cacheFile) + cachedContent = await createFromReadableStream( + new Blob([bytes]).stream(), + ) + } else if (props.url.pathname === '/read-cache-preserve') { const bytes = await readFile(cacheFile) cachedContent = await createFromReadableStream( new Blob([bytes]).stream(), @@ -34,7 +39,8 @@ export async function Root(props: { url: URL }) {

Action imported in the RSC environment:{' '} From af13f9ea6c3352b6ef7f008c0caede2940cf3c8d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:31:37 +0900 Subject: [PATCH 10/16] test(plugin-rsc): stabilize cache replay assertions Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/cache-replay.test.ts | 13 +++++++++---- .../plugin-rsc/examples/cache-replay/README.md | 14 ++++++++------ .../examples/cache-replay/src/cached-content.tsx | 6 ++++-- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/plugin-rsc/e2e/cache-replay.test.ts b/packages/plugin-rsc/e2e/cache-replay.test.ts index f3ded66b2..ae0b64c21 100644 --- a/packages/plugin-rsc/e2e/cache-replay.test.ts +++ b/packages/plugin-rsc/e2e/cache-replay.test.ts @@ -39,29 +39,34 @@ function defineTests(f: Fixture) { await expect(page.getByTestId('action-imported')).toHaveText('true') await expect(page.getByTestId('action-invoked')).toHaveText('false') + await page.goto('about:blank') await f.restart() // Default replay imports the referenced action. await page.goto(f.url('/read-cache')) await waitForHydration(page) + await expect(page.getByTestId('cached-content')).toBeVisible() + await page.goto(f.url('/')) await expect(page.getByTestId('cache-exists')).toHaveText('true') await expect(page.getByTestId('action-imported')).toHaveText('true') await expect(page.getByTestId('action-invoked')).toHaveText('false') + await page.goto('about:blank') await f.restart() // Preserved replay leaves the referenced action unloaded. await page.goto(f.url('/read-cache-preserve')) await waitForHydration(page) - await expect( - page.getByRole('heading', { name: 'Cached content' }), - ).toBeVisible() + await expect(page.getByTestId('cached-content')).toBeVisible() + await page.goto(f.url('/')) await expect(page.getByTestId('cache-exists')).toHaveText('true') await expect(page.getByTestId('action-imported')).toHaveText('false') await expect(page.getByTestId('action-invoked')).toHaveText('false') // Invoking the preserved reference imports and runs the action. - await page.getByRole('button', { name: 'Invoke action' }).click() + await page.goto(f.url('/read-cache-preserve')) + await waitForHydration(page) + await page.getByTestId('invoke-action').click() await expect(page.getByTestId('action-imported')).toHaveText('true') await expect(page.getByTestId('action-invoked')).toHaveText('true') }) diff --git a/packages/plugin-rsc/examples/cache-replay/README.md b/packages/plugin-rsc/examples/cache-replay/README.md index 0595688af..5cf397c4d 100644 --- a/packages/plugin-rsc/examples/cache-replay/README.md +++ b/packages/plugin-rsc/examples/cache-replay/README.md @@ -15,10 +15,11 @@ pnpm dev 1. Visit `http://localhost:5173/cache` and confirm the action is imported. 2. Stop the development server without changing the source graph. 3. Run `pnpm dev` again. -4. Visit `http://localhost:5173/read-cache` and confirm the action is imported. +4. Visit `http://localhost:5173/read-cache`, then visit the home page and confirm the action is imported. 5. Restart the development server again. -6. Visit `http://localhost:5173/read-cache-preserve` and confirm the action is not imported. -7. Select **Invoke action** and confirm the action is imported and invoked. +6. Visit `http://localhost:5173/read-cache-preserve`, then visit the home page and confirm the action is not imported. +7. Return to `http://localhost:5173/read-cache-preserve`. +8. Select **Invoke action** and confirm the action is imported and invoked. The source graph must remain unchanged across the restart so its development server-reference IDs remain stable. @@ -40,10 +41,11 @@ pnpm preview 2. Confirm the page displays `Action imported in the RSC environment: true`. 3. Stop the preview server without rebuilding. 4. Run `pnpm preview` again. -5. Visit `http://localhost:4173/read-cache` and confirm the action is imported. +5. Visit `http://localhost:4173/read-cache`, then visit the home page and confirm the action is imported. 6. Restart the preview server again without rebuilding. -7. Visit `http://localhost:4173/read-cache-preserve` and confirm the action is not imported. -8. Select **Invoke action** and confirm the action is imported and invoked. +7. Visit `http://localhost:4173/read-cache-preserve`, then visit the home page and confirm the action is not imported. +8. Return to `http://localhost:4173/read-cache-preserve`. +9. Select **Invoke action** and confirm the action is imported and invoked. The same production build must be used for both processes because the persisted Flight payload contains build-specific server-reference IDs. diff --git a/packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx b/packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx index ebeacb279..bf694b03e 100644 --- a/packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx +++ b/packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx @@ -2,10 +2,12 @@ import { testAction } from './action' export function CachedContent() { return ( -

+

Cached content

- +
) From 117fe44f856cf561c95dbe1a6f4e5c1707c0a230 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:34:56 +0900 Subject: [PATCH 11/16] docs(plugin-rsc): unify cache replay instructions Co-authored-by: OpenCode --- .../examples/cache-replay/README.md | 44 +++++-------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/packages/plugin-rsc/examples/cache-replay/README.md b/packages/plugin-rsc/examples/cache-replay/README.md index 5cf397c4d..278a3fad8 100644 --- a/packages/plugin-rsc/examples/cache-replay/README.md +++ b/packages/plugin-rsc/examples/cache-replay/README.md @@ -4,49 +4,29 @@ This example persists a Flight payload containing a server reference and compare The framework files follow the starter example. The application routes own persistence and replay, while the framework only performs its normal request parsing, action handling, and RSC serialization. -## Development manual test +## Manual test -Start the first process: +Start the development server: ```bash pnpm dev ``` -1. Visit `http://localhost:5173/cache` and confirm the action is imported. -2. Stop the development server without changing the source graph. -3. Run `pnpm dev` again. -4. Visit `http://localhost:5173/read-cache`, then visit the home page and confirm the action is imported. -5. Restart the development server again. -6. Visit `http://localhost:5173/read-cache-preserve`, then visit the home page and confirm the action is not imported. -7. Return to `http://localhost:5173/read-cache-preserve`. -8. Select **Invoke action** and confirm the action is imported and invoked. - -The source graph must remain unchanged across the restart so its development server-reference IDs remain stable. - -## Production manual test - -Build the example once: +Alternatively, build once and start the production server: ```bash pnpm build -``` - -Start the first process: - -```bash pnpm preview ``` -1. Visit `http://localhost:4173/cache`. -2. Confirm the page displays `Action imported in the RSC environment: true`. -3. Stop the preview server without rebuilding. -4. Run `pnpm preview` again. -5. Visit `http://localhost:4173/read-cache`, then visit the home page and confirm the action is imported. -6. Restart the preview server again without rebuilding. -7. Visit `http://localhost:4173/read-cache-preserve`, then visit the home page and confirm the action is not imported. -8. Return to `http://localhost:4173/read-cache-preserve`. -9. Select **Invoke action** and confirm the action is imported and invoked. - -The same production build must be used for both processes because the persisted Flight payload contains build-specific server-reference IDs. +1. Visit `/cache` and confirm the action is imported. +2. Stop and restart the server with `pnpm dev` or `pnpm preview`. +3. Visit `/read-cache`, then visit `/` and confirm the action is imported. +4. Restart the server again. +5. Visit `/read-cache-preserve`, then visit `/` and confirm the action is not imported. +6. Return to `/read-cache-preserve`. +7. Select **Invoke action** and confirm the action is imported and invoked. + +Do not change the source graph between development server restarts or rebuild between production server restarts. The persisted Flight payload contains server-reference IDs that must remain stable. Delete `.flight-cache` to reset the example. From 07a82ea0dbb806562e5b5af4804999fe8ce25933 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:37:29 +0900 Subject: [PATCH 12/16] docs(plugin-rsc): explain preserved server references Co-authored-by: OpenCode --- packages/plugin-rsc/src/react/rsc/client.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/plugin-rsc/src/react/rsc/client.ts b/packages/plugin-rsc/src/react/rsc/client.ts index 7ad3865e7..ece563011 100644 --- a/packages/plugin-rsc/src/react/rsc/client.ts +++ b/packages/plugin-rsc/src/react/rsc/client.ts @@ -15,7 +15,14 @@ export function createFromReadableStream( options: CreateFromReadableStreamEdgeOptions = {}, extraOptions?: { /** + * Preserve server references for re-serialization without loading their modules. + * Preserved references cannot be invoked in the current RSC environment. + * + * Disabled by default because this API also decodes bound server action + * arguments, which must revive references as callable implementations. + * * @experimental + * @default false */ preserveServerReferences?: boolean }, From 3e2b53346f3e75d937774198c475e2c58f4632ba Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:39:09 +0900 Subject: [PATCH 13/16] test(plugin-rsc): explain restart navigation Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/cache-replay.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/plugin-rsc/e2e/cache-replay.test.ts b/packages/plugin-rsc/e2e/cache-replay.test.ts index ae0b64c21..0d45e72a5 100644 --- a/packages/plugin-rsc/e2e/cache-replay.test.ts +++ b/packages/plugin-rsc/e2e/cache-replay.test.ts @@ -39,6 +39,7 @@ function defineTests(f: Fixture) { await expect(page.getByTestId('action-imported')).toHaveText('true') await expect(page.getByTestId('action-invoked')).toHaveText('false') + // Avoid a dev client reload racing navigation after restart. await page.goto('about:blank') await f.restart() @@ -51,6 +52,7 @@ function defineTests(f: Fixture) { await expect(page.getByTestId('action-imported')).toHaveText('true') await expect(page.getByTestId('action-invoked')).toHaveText('false') + // Avoid a dev client reload racing navigation after restart. await page.goto('about:blank') await f.restart() From cdcdfe3436767399c951ec0246bfa2ba90e69ef5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:40:50 +0900 Subject: [PATCH 14/16] refactor(plugin-rsc): rename preserved reference tag Co-authored-by: OpenCode --- packages/plugin-rsc/src/core/rsc.ts | 8 ++++---- packages/plugin-rsc/src/core/shared.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/plugin-rsc/src/core/rsc.ts b/packages/plugin-rsc/src/core/rsc.ts index 2398c3155..c4c65f4a2 100644 --- a/packages/plugin-rsc/src/core/rsc.ts +++ b/packages/plugin-rsc/src/core/rsc.ts @@ -2,7 +2,7 @@ import { memoize, tinyassert } from '@hiogawa/utils' import type { BundlerConfig, ImportManifestEntry, ModuleMap } from '../types' import { SERVER_DECODE_CLIENT_PREFIX, - SERVER_REFERENCE_PROXY_PREFIX, + SERVER_REFERENCE_PRESERVE_PREFIX, SERVER_REFERENCE_PREFIX, createReferenceCacheTag, removeReferenceCacheTag, @@ -28,8 +28,8 @@ export function setRequireModule(options: { // need memoize to return stable promise from __webpack_require__ ;(globalThis as any).__vite_rsc_server_require__ = memoize( async (id: string) => { - if (id.startsWith(SERVER_REFERENCE_PROXY_PREFIX)) { - id = id.slice(SERVER_REFERENCE_PROXY_PREFIX.length) + if (id.startsWith(SERVER_REFERENCE_PRESERVE_PREFIX)) { + id = id.slice(SERVER_REFERENCE_PRESERVE_PREFIX.length) id = removeReferenceCacheTag(id) const target = {} as any const getOrCreateServerReference = (name: string) => { @@ -101,7 +101,7 @@ export function createServerManifest(options?: { preserveServerReferences?: boolean }): BundlerConfig { const prefix = options?.preserveServerReferences - ? SERVER_REFERENCE_PROXY_PREFIX + ? SERVER_REFERENCE_PRESERVE_PREFIX : '' const cacheTag = import.meta.env.DEV ? createReferenceCacheTag() : '' diff --git a/packages/plugin-rsc/src/core/shared.ts b/packages/plugin-rsc/src/core/shared.ts index ec098336e..adfd1a9fb 100644 --- a/packages/plugin-rsc/src/core/shared.ts +++ b/packages/plugin-rsc/src/core/shared.ts @@ -3,7 +3,7 @@ export const SERVER_REFERENCE_PREFIX = '$$server:' export const SERVER_DECODE_CLIENT_PREFIX = '$$decode-client:' -export const SERVER_REFERENCE_PROXY_PREFIX = '$$proxy:' +export const SERVER_REFERENCE_PRESERVE_PREFIX = '$$preserve:' // cache bust memoized require promise during dev export function createReferenceCacheTag(): string { From cb1c306fd9be788c0931f3a2d4caadbcdf8856d8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:10:28 +0900 Subject: [PATCH 15/16] fix(plugin-rsc): respect fs restrictions during reference validation Co-authored-by: OpenCode --- packages/plugin-rsc/src/plugin.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 89dbceee5..aa30c6d38 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -21,6 +21,7 @@ import { type ViteDevServer, defaultServerConditions, isCSSRequest, + isFileLoadingAllowed, normalizePath, parseAstAsync, } from 'vite' @@ -62,6 +63,7 @@ import { evalValue, normalizeViteImportAnalysisUrl, prepareError, + slash, } from './plugins/vite-utils' import { type TransformWrapExportFilter, @@ -350,6 +352,7 @@ export function vitePluginRscMinimal( if (id.startsWith('\0virtual:vite-rsc/reference-validation?')) { const parsed = parseReferenceValidationVirtual(id) assert(parsed) + assert(this.environment.mode === 'dev') if (parsed.type === 'client') { const meta = Object.values(manager.clientReferenceMetaMap).find( (meta) => meta.referenceKey === parsed.id, @@ -364,9 +367,15 @@ export function vitePluginRscMinimal( ) if (!meta) { try { - await (this.environment as DevEnvironment).transformRequest( - parsed.id, - ) + // https://github.com/vitejs/vite/blob/a477454442eff649b430f9e3c6caf2500fcb7183/packages/vite/src/node/server/transformRequest.ts#L170-L175 + const resolved = await this.resolve(parsed.id) + const id = resolved?.id ?? parsed.id + // https://github.com/vitejs/vite/blob/a477454442eff649b430f9e3c6caf2500fcb7183/packages/vite/src/node/server/transformRequest.ts#L271-L282 + if ( + isFileLoadingAllowed(manager.config, slash(cleanUrl(id))) + ) { + await this.environment.transformRequest(parsed.id) + } } catch {} meta = Object.values(manager.serverReferenceMetaMap).find( (meta) => meta.referenceKey === parsed.id, From 69a4b60e32b586ac0b6565feac737592dcd0ff54 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:11:15 +0900 Subject: [PATCH 16/16] nit --- packages/plugin-rsc/src/plugin.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index aa30c6d38..a17fbc79c 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -371,9 +371,11 @@ export function vitePluginRscMinimal( const resolved = await this.resolve(parsed.id) const id = resolved?.id ?? parsed.id // https://github.com/vitejs/vite/blob/a477454442eff649b430f9e3c6caf2500fcb7183/packages/vite/src/node/server/transformRequest.ts#L271-L282 - if ( - isFileLoadingAllowed(manager.config, slash(cleanUrl(id))) - ) { + const allowed = isFileLoadingAllowed( + manager.config, + slash(cleanUrl(id)), + ) + if (allowed) { await this.environment.transformRequest(parsed.id) } } catch {}