From d519286059572c95302cb2a049237e6dac1dd6de Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:47:32 +0900 Subject: [PATCH 01/33] feat(rsc): expose client action reachability Co-authored-by: OpenCode --- packages/plugin-rsc/README.md | 1 + .../e2e/action-reachability.test.ts | 39 ++++++++++++++ .../examples/action-reachability/README.md | 14 +++++ .../examples/action-reachability/package.json | 22 ++++++++ .../action-reachability/src/action.js | 5 ++ .../action-reachability/src/client-form.jsx | 13 +++++ .../action-reachability/src/commands.js | 3 ++ .../src/framework/entry.browser.jsx | 35 ++++++++++++ .../src/framework/entry.rsc.jsx | 44 +++++++++++++++ .../src/framework/entry.ssr.jsx | 19 +++++++ .../src/framework/request.js | 24 +++++++++ .../examples/action-reachability/src/root.jsx | 12 +++++ .../action-reachability/tsconfig.json | 14 +++++ .../action-reachability/vite.config.ts | 39 ++++++++++++++ packages/plugin-rsc/src/index.ts | 1 + packages/plugin-rsc/src/plugin.ts | 53 +++++++++++++++++++ pnpm-lock.yaml | 22 ++++++++ 17 files changed, 360 insertions(+) create mode 100644 packages/plugin-rsc/e2e/action-reachability.test.ts create mode 100644 packages/plugin-rsc/examples/action-reachability/README.md create mode 100644 packages/plugin-rsc/examples/action-reachability/package.json create mode 100644 packages/plugin-rsc/examples/action-reachability/src/action.js create mode 100644 packages/plugin-rsc/examples/action-reachability/src/client-form.jsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/commands.js create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.jsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.jsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.jsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/request.js create mode 100644 packages/plugin-rsc/examples/action-reachability/src/root.jsx create mode 100644 packages/plugin-rsc/examples/action-reachability/tsconfig.json create mode 100644 packages/plugin-rsc/examples/action-reachability/vite.config.ts diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index e2d11b867..999efeeed 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -32,6 +32,7 @@ npm create vite@latest -- --template rsc - [`./examples/ppr`](./examples/ppr) - Partial prerendering with a reusable static HTML shell and request-time RSC content. - [`./examples/no-ssr`](./examples/no-ssr) - RSC application without an SSR environment. - [`./examples/client-first`](./examples/client-first) - Experimental client-owned page that consumes RSC function results. +- [`./examples/action-reachability`](./examples/action-reachability) - Cross-environment module reachability for a server action wrapped in an ordinary client-side object. - [`./examples/browser-mode`](./examples/browser-mode) - Advanced setup that runs both RSC and React client environments in the browser with custom module loading. - [`./examples/performance-track`](./examples/performance-track) - Minimal React Server Components performance track probe. - [`./examples/react-router`](./examples/react-router) - React Router RSC integration diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts new file mode 100644 index 000000000..98dbd177b --- /dev/null +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test' +import { useFixture } from './fixture' +import { waitForHydration } from './helper' + +test.describe('build', () => { + const f = useFixture({ + root: 'examples/action-reachability', + mode: 'build', + }) + + test('tracks and executes an object-wrapped server action', async ({ + page, + }) => { + const reachability: Record< + string, + { importId: string; serverReferenceIds: string[] } + > = JSON.parse( + f.createEditor('dist/client/reference-reachability.json').read(), + ) + const clientReference = Object.values(reachability).find((value) => + value.importId.endsWith('/src/client-form.jsx'), + ) + expect(clientReference?.serverReferenceIds).toEqual([ + expect.stringMatching(/#objectWrappedAction$/), + ]) + + await page.goto(f.url()) + await waitForHydration(page) + const responsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + response.url().endsWith('_.rsc'), + ) + await page.getByTestId('object-wrapped-action').click() + const response = await responsePromise + expect(response.status()).toBe(200) + expect(await response.text()).toContain('OBJECT_WRAPPED_OK') + }) +}) diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md new file mode 100644 index 000000000..eb519a2d3 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -0,0 +1,14 @@ +# Cross-Environment Action Reachability + +This example isolates a server reference that crosses a Client Component boundary through an ordinary JavaScript object: + +```text +root.jsx + -> client-form.jsx ("use client") + -> commands.js + -> action.js ("use server") +``` + +The client can invoke `commands.objectWrappedAction`, even though import/export binding analysis cannot infer that the `commands` object carries the server reference. + +The build writes `dist/client/reference-reachability.json` from plugin-RSC's experimental `clientReferenceServerReferences` manager metadata. It demonstrates that final client module-graph traversal connects `client-form.jsx` to `objectWrappedAction` without parsing local JavaScript value flow. diff --git a/packages/plugin-rsc/examples/action-reachability/package.json b/packages/plugin-rsc/examples/action-reachability/package.json new file mode 100644 index 000000000..daeaf3f4a --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/package.json @@ -0,0 +1,22 @@ +{ + "name": "@vitejs/plugin-rsc-examples-action-reachability", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^8.1.5" + } +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/action.js b/packages/plugin-rsc/examples/action-reachability/src/action.js new file mode 100644 index 000000000..f5e36b050 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/action.js @@ -0,0 +1,5 @@ +'use server' + +export async function objectWrappedAction() { + return 'OBJECT_WRAPPED_OK' +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/client-form.jsx b/packages/plugin-rsc/examples/action-reachability/src/client-form.jsx new file mode 100644 index 000000000..d9017459f --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/client-form.jsx @@ -0,0 +1,13 @@ +'use client' + +import { commands } from './commands.js' + +export function ClientForm() { + return ( +
+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/commands.js b/packages/plugin-rsc/examples/action-reachability/src/commands.js new file mode 100644 index 000000000..78b2ded26 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/commands.js @@ -0,0 +1,3 @@ +import { objectWrappedAction } from './action.js' + +export const commands = { objectWrappedAction } diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.jsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.jsx new file mode 100644 index 000000000..ff6ed693d --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.jsx @@ -0,0 +1,35 @@ +import { + createFromFetch, + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from '@vitejs/plugin-rsc/browser' +import { startTransition, useState } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import { createActionRequest } from './request.js' + +const initialPayload = await createFromReadableStream(rscStream) +let updatePayload + +function BrowserRoot() { + const [payload, setPayload] = useState(initialPayload) + updatePayload = (nextPayload) => + startTransition(() => setPayload(nextPayload)) + return payload.root +} + +setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const request = createActionRequest( + window.location.href, + id, + await encodeReply(args, { temporaryReferences }), + ) + const payload = await createFromFetch(fetch(request), { temporaryReferences }) + updatePayload(payload) + return payload.returnValue +}) + +hydrateRoot(document, ) diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.jsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.jsx new file mode 100644 index 000000000..addeea10c --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.jsx @@ -0,0 +1,44 @@ +import { + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc' +import { Root } from '../root.jsx' +import { parseRequest } from './request.js' + +export default { fetch: handler } + +async function handler(inputRequest) { + const parsed = parseRequest(inputRequest) + let returnValue + let temporaryReferences + + if (parsed.isAction) { + if (!parsed.actionId) + return new Response('Missing action ID', { status: 400 }) + const contentType = parsed.request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await parsed.request.formData() + : await parsed.request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(parsed.actionId) + returnValue = await action(...args) + } + + const rscStream = renderToReadableStream( + { root: , returnValue }, + { temporaryReferences }, + ) + if (parsed.isRsc) { + return new Response(rscStream, { + headers: { 'content-type': 'text/x-component;charset=utf-8' }, + }) + } + + const ssr = await import.meta.viteRsc.loadModule('ssr', 'index') + return new Response(await ssr.renderHtml(rscStream), { + headers: { 'content-type': 'text/html;charset=utf-8' }, + }) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.jsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.jsx new file mode 100644 index 000000000..973cf9972 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.jsx @@ -0,0 +1,19 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import { use } from 'react' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' + +export async function renderHtml(rscStream) { + const [renderStream, payloadStream] = rscStream.tee() + const payload = createFromReadableStream(renderStream) + function SsrRoot() { + return use(payload).root + } + + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + const htmlStream = await renderToReadableStream(, { + bootstrapScriptContent, + }) + return htmlStream.pipeThrough(injectRSCPayload(payloadStream)) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/request.js b/packages/plugin-rsc/examples/action-reachability/src/framework/request.js new file mode 100644 index 000000000..051ad53b2 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/request.js @@ -0,0 +1,24 @@ +const ACTION_HEADER = 'x-rsc-action' +const RSC_SUFFIX = '_.rsc' + +export function createActionRequest(urlString, id, body) { + const url = new URL(urlString) + url.pathname += RSC_SUFFIX + return new Request(url, { + method: 'POST', + headers: { [ACTION_HEADER]: id }, + body, + }) +} + +export function parseRequest(request) { + const url = new URL(request.url) + const isRsc = url.pathname.endsWith(RSC_SUFFIX) + if (isRsc) url.pathname = url.pathname.slice(0, -RSC_SUFFIX.length) + return { + actionId: request.headers.get(ACTION_HEADER), + isAction: request.method === 'POST', + isRsc, + request: new Request(url, request), + } +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/root.jsx b/packages/plugin-rsc/examples/action-reachability/src/root.jsx new file mode 100644 index 000000000..d11ab2f86 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/root.jsx @@ -0,0 +1,12 @@ +import { ClientForm } from './client-form.jsx' + +export function Root() { + return ( + + +

Cross-environment action reachability

+ + + + ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/tsconfig.json b/packages/plugin-rsc/examples/action-reachability/tsconfig.json new file mode 100644 index 000000000..e89736f1e --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + }, + "include": ["src", "vite.config.ts"] +} diff --git a/packages/plugin-rsc/examples/action-reachability/vite.config.ts b/packages/plugin-rsc/examples/action-reachability/vite.config.ts new file mode 100644 index 000000000..01e676f64 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/vite.config.ts @@ -0,0 +1,39 @@ +import fs from 'node:fs' +import path from 'node:path' +import react from '@vitejs/plugin-react' +import rsc, { getPluginApi } from '@vitejs/plugin-rsc' +import { defineConfig, type Plugin } from 'vite' + +export default defineConfig({ + plugins: [ + rsc({ + entries: { + client: './src/framework/entry.browser.jsx', + rsc: './src/framework/entry.rsc.jsx', + ssr: './src/framework/entry.ssr.jsx', + }, + }), + react(), + writeReachabilityPicture(), + ], +}) + +function writeReachabilityPicture(): Plugin { + return { + name: 'write-reference-reachability-picture', + buildApp: { + order: 'post', + async handler(builder) { + const { manager } = getPluginApi(builder.config)! + const outputPath = path.join( + builder.config.root, + 'dist/client/reference-reachability.json', + ) + fs.writeFileSync( + outputPath, + JSON.stringify(manager.clientReferenceServerReferences, null, 2), + ) + }, + }, + } +} diff --git a/packages/plugin-rsc/src/index.ts b/packages/plugin-rsc/src/index.ts index f1f6f65d8..72881bc9a 100644 --- a/packages/plugin-rsc/src/index.ts +++ b/packages/plugin-rsc/src/index.ts @@ -1,5 +1,6 @@ export { default, + type ClientReferenceServerReferences, type RscPluginOptions, getPluginApi, type PluginApi, diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 22be254bc..c7159b7b1 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -98,6 +98,12 @@ type ClientReferenceMeta = { groupChunkId?: string } +export type ClientReferenceServerReferences = { + importId: string + referenceKey: string + serverReferenceIds: string[] +} + const PKG_NAME = '@vitejs/plugin-rsc' const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` @@ -126,6 +132,10 @@ class RscPluginManager { clientReferenceMetaMap: Record = {} clientReferenceGroups: Record = {} + clientReferenceServerReferences: Record< + string, + ClientReferenceServerReferences + > = {} serverReferences: ServerReferencesManager = new ServerReferencesManager(this) serverResourcesMetaMap: Record = {} environmentImportMetaMap: Record< @@ -142,6 +152,9 @@ class RscPluginManager { stabilize(): void { // sort for stable build this.clientReferenceMetaMap = sortObject(this.clientReferenceMetaMap) + this.clientReferenceServerReferences = sortObject( + this.clientReferenceServerReferences, + ) this.serverResourcesMetaMap = sortObject(this.serverResourcesMetaMap) } @@ -1687,6 +1700,46 @@ function vitePluginUseClient( } }, }, + generateBundle() { + if (manager.isScanBuild) return + if (this.environment.name !== browserEnvironmentName) return + + manager.clientReferenceServerReferences = {} + for (const clientReference of Object.values( + manager.clientReferenceMetaMap, + )) { + const serverReferenceIds = new Set() + const visited = new Set() + const queue = [clientReference.importId] + for (let index = 0; index < queue.length; index++) { + const id = queue[index]! + if (visited.has(id)) continue + visited.add(id) + + const serverReference = manager.serverReferences.metaMap.get(id) + if (serverReference) { + for (const exportName of serverReference.exportNames) { + serverReferenceIds.add( + `${serverReference.referenceKey}#${exportName}`, + ) + } + } + + const info = this.getModuleInfo(id) + if (!info) continue + queue.push(...info.importedIds, ...info.dynamicallyImportedIds) + } + + manager.clientReferenceServerReferences[clientReference.importId] = { + importId: clientReference.importId, + referenceKey: clientReference.referenceKey, + serverReferenceIds: [...serverReferenceIds].sort(), + } + } + manager.clientReferenceServerReferences = sortObject( + manager.clientReferenceServerReferences, + ) + }, }, { name: 'rsc:virtual-client-in-server-package', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10761ebea..93dab07c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -485,6 +485,28 @@ importers: specifier: ^0.22.14 version: 0.22.14(publint@0.3.21)(typescript@6.0.3) + packages/plugin-rsc/examples/action-reachability: + dependencies: + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@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.5 + version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + packages/plugin-rsc/examples/basic: dependencies: react: From 690b92817aba0bd34f905040f08b65bde0cbfd40 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:20:36 +0900 Subject: [PATCH 02/33] refactor(rsc): expose action reachability as query Co-authored-by: OpenCode --- .../examples/action-reachability/README.md | 2 +- .../action-reachability/vite.config.ts | 29 ++++---- packages/plugin-rsc/src/plugin.ts | 74 ++++++------------- .../client-reference-server-references.ts | 59 +++++++++++++++ 4 files changed, 95 insertions(+), 69 deletions(-) create mode 100644 packages/plugin-rsc/src/plugins/client-reference-server-references.ts diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index eb519a2d3..63bfe17a7 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -11,4 +11,4 @@ root.jsx The client can invoke `commands.objectWrappedAction`, even though import/export binding analysis cannot infer that the `commands` object carries the server reference. -The build writes `dist/client/reference-reachability.json` from plugin-RSC's experimental `clientReferenceServerReferences` manager metadata. It demonstrates that final client module-graph traversal connects `client-form.jsx` to `objectWrappedAction` without parsing local JavaScript value flow. +The framework plugin calls plugin-RSC's experimental `manager.getClientReferenceServerReferences(this)` method during its final client `generateBundle` hook and writes the result to `dist/client/reference-reachability.json`. It demonstrates that final client module-graph traversal connects `client-form.jsx` to `objectWrappedAction` without parsing local JavaScript value flow. diff --git a/packages/plugin-rsc/examples/action-reachability/vite.config.ts b/packages/plugin-rsc/examples/action-reachability/vite.config.ts index 01e676f64..a65c761f5 100644 --- a/packages/plugin-rsc/examples/action-reachability/vite.config.ts +++ b/packages/plugin-rsc/examples/action-reachability/vite.config.ts @@ -1,7 +1,5 @@ -import fs from 'node:fs' -import path from 'node:path' import react from '@vitejs/plugin-react' -import rsc, { getPluginApi } from '@vitejs/plugin-rsc' +import rsc, { getPluginApi, type PluginApi } from '@vitejs/plugin-rsc' import { defineConfig, type Plugin } from 'vite' export default defineConfig({ @@ -19,21 +17,20 @@ export default defineConfig({ }) function writeReachabilityPicture(): Plugin { + let manager: PluginApi['manager'] return { name: 'write-reference-reachability-picture', - buildApp: { - order: 'post', - async handler(builder) { - const { manager } = getPluginApi(builder.config)! - const outputPath = path.join( - builder.config.root, - 'dist/client/reference-reachability.json', - ) - fs.writeFileSync( - outputPath, - JSON.stringify(manager.clientReferenceServerReferences, null, 2), - ) - }, + configResolved(config) { + manager = getPluginApi(config)!.manager + }, + generateBundle() { + if (this.environment.name !== 'client') return + const reachability = manager.getClientReferenceServerReferences(this) + this.emitFile({ + type: 'asset', + fileName: 'reference-reachability.json', + source: JSON.stringify(reachability, null, 2), + }) }, } } diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index dc073562e..596775088 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -28,6 +28,10 @@ import { import { crawlFrameworkPkgs } from 'vitefu' import vitePluginRscCore from './core/plugin' import { cjsModuleRunnerPlugin } from './plugins/cjs' +import { + getClientReferenceServerReferences, + type ClientReferenceServerReferences, +} from './plugins/client-reference-server-references' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' import { ensureEnvironmentImportsEntryFallback, @@ -98,11 +102,7 @@ type ClientReferenceMeta = { groupChunkId?: string } -export type ClientReferenceServerReferences = { - importId: string - referenceKey: string - serverReferenceIds: string[] -} +export type { ClientReferenceServerReferences } const PKG_NAME = '@vitejs/plugin-rsc' const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` @@ -132,10 +132,23 @@ class RscPluginManager { clientReferenceMetaMap: Record = {} clientReferenceGroups: Record = {} - clientReferenceServerReferences: Record< - string, - ClientReferenceServerReferences - > = {} + + /** + * Returns server references reachable from Client Component references + * through the current client module graph. + * + * Call this from a final client build hook while Rollup's module graph is + * available. See {@link ClientReferenceServerReferences} for the reachability + * semantics. + * + * @experimental + */ + getClientReferenceServerReferences( + context: Rollup.PluginContext, + ): Record { + return getClientReferenceServerReferences(context, this) + } + serverReferences: ServerReferencesManager = new ServerReferencesManager(this) /** @deprecated Use `serverReferences.metaMap` instead. */ @@ -158,9 +171,6 @@ class RscPluginManager { stabilize(): void { // sort for stable build this.clientReferenceMetaMap = sortObject(this.clientReferenceMetaMap) - this.clientReferenceServerReferences = sortObject( - this.clientReferenceServerReferences, - ) this.serverResourcesMetaMap = sortObject(this.serverResourcesMetaMap) } @@ -1706,46 +1716,6 @@ function vitePluginUseClient( } }, }, - generateBundle() { - if (manager.isScanBuild) return - if (this.environment.name !== browserEnvironmentName) return - - manager.clientReferenceServerReferences = {} - for (const clientReference of Object.values( - manager.clientReferenceMetaMap, - )) { - const serverReferenceIds = new Set() - const visited = new Set() - const queue = [clientReference.importId] - for (let index = 0; index < queue.length; index++) { - const id = queue[index]! - if (visited.has(id)) continue - visited.add(id) - - const serverReference = manager.serverReferences.metaMap.get(id) - if (serverReference) { - for (const exportName of serverReference.exportNames) { - serverReferenceIds.add( - `${serverReference.referenceKey}#${exportName}`, - ) - } - } - - const info = this.getModuleInfo(id) - if (!info) continue - queue.push(...info.importedIds, ...info.dynamicallyImportedIds) - } - - manager.clientReferenceServerReferences[clientReference.importId] = { - importId: clientReference.importId, - referenceKey: clientReference.referenceKey, - serverReferenceIds: [...serverReferenceIds].sort(), - } - } - manager.clientReferenceServerReferences = sortObject( - manager.clientReferenceServerReferences, - ) - }, }, { name: 'rsc:virtual-client-in-server-package', diff --git a/packages/plugin-rsc/src/plugins/client-reference-server-references.ts b/packages/plugin-rsc/src/plugins/client-reference-server-references.ts new file mode 100644 index 000000000..f8034bcbd --- /dev/null +++ b/packages/plugin-rsc/src/plugins/client-reference-server-references.ts @@ -0,0 +1,59 @@ +import type { Rollup } from 'vite' +import type { RscPluginManager } from '../plugin' +import { sortObject } from './utils' + +/** + * Server references reachable from a Client Component reference through the + * final client module graph. + * + * Reachability includes static and statically resolved dynamic imports. It is + * conservative at module granularity, so all server references exported by a + * reachable server-reference module are included. + * + * @experimental + */ +export type ClientReferenceServerReferences = { + /** Resolved module ID used as the client graph traversal root. */ + importId: string + /** Reference key identifying the Client Component across environments. */ + referenceKey: string + /** Complete server-reference IDs in `referenceKey#exportName` form. */ + serverReferenceIds: string[] +} + +export function getClientReferenceServerReferences( + context: Rollup.PluginContext, + manager: RscPluginManager, +): Record { + const result: Record = {} + for (const clientReference of Object.values(manager.clientReferenceMetaMap)) { + const serverReferenceIds = new Set() + const visited = new Set() + const queue = [clientReference.importId] + for (let index = 0; index < queue.length; index++) { + const id = queue[index]! + if (visited.has(id)) continue + visited.add(id) + + const serverReference = manager.serverReferences.metaMap.get(id) + if (serverReference) { + for (const exportName of serverReference.exportNames) { + serverReferenceIds.add( + `${serverReference.referenceKey}#${exportName}`, + ) + } + } + + const info = context.getModuleInfo(id) + if (!info) continue + queue.push(...info.importedIds, ...info.dynamicallyImportedIds) + } + + result[clientReference.importId] = { + importId: clientReference.importId, + referenceKey: clientReference.referenceKey, + serverReferenceIds: [...serverReferenceIds].sort(), + } + } + return sortObject(result) +} From 19b3e4e6d815c151bb0c8beb69e9604171a7c812 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:23:06 +0900 Subject: [PATCH 03/33] refactor(rsc): rename reference reachability type Co-authored-by: OpenCode --- packages/plugin-rsc/src/index.ts | 2 +- packages/plugin-rsc/src/plugin.ts | 16 ++++++++-------- ...r-references.ts => reference-reachability.ts} | 7 ++++--- 3 files changed, 13 insertions(+), 12 deletions(-) rename packages/plugin-rsc/src/plugins/{client-reference-server-references.ts => reference-reachability.ts} (89%) diff --git a/packages/plugin-rsc/src/index.ts b/packages/plugin-rsc/src/index.ts index 72881bc9a..6dbe2ca6e 100644 --- a/packages/plugin-rsc/src/index.ts +++ b/packages/plugin-rsc/src/index.ts @@ -1,6 +1,6 @@ export { default, - type ClientReferenceServerReferences, + type ClientReferenceToServerReferenceReachability, type RscPluginOptions, getPluginApi, type PluginApi, diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 596775088..18681a959 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -28,10 +28,6 @@ import { import { crawlFrameworkPkgs } from 'vitefu' import vitePluginRscCore from './core/plugin' import { cjsModuleRunnerPlugin } from './plugins/cjs' -import { - getClientReferenceServerReferences, - type ClientReferenceServerReferences, -} from './plugins/client-reference-server-references' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' import { ensureEnvironmentImportsEntryFallback, @@ -39,6 +35,10 @@ import { writeEnvironmentImportsManifest, type EnvironmentImportMeta, } from './plugins/import-environment' +import { + getClientReferenceServerReferences, + type ClientReferenceToServerReferenceReachability, +} from './plugins/reference-reachability' import { vitePluginResolvedIdProxy, withResolvedIdProxy, @@ -102,7 +102,7 @@ type ClientReferenceMeta = { groupChunkId?: string } -export type { ClientReferenceServerReferences } +export type { ClientReferenceToServerReferenceReachability } const PKG_NAME = '@vitejs/plugin-rsc' const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` @@ -138,14 +138,14 @@ class RscPluginManager { * through the current client module graph. * * Call this from a final client build hook while Rollup's module graph is - * available. See {@link ClientReferenceServerReferences} for the reachability - * semantics. + * available. See {@link ClientReferenceToServerReferenceReachability} for + * the reachability semantics. * * @experimental */ getClientReferenceServerReferences( context: Rollup.PluginContext, - ): Record { + ): Record { return getClientReferenceServerReferences(context, this) } diff --git a/packages/plugin-rsc/src/plugins/client-reference-server-references.ts b/packages/plugin-rsc/src/plugins/reference-reachability.ts similarity index 89% rename from packages/plugin-rsc/src/plugins/client-reference-server-references.ts rename to packages/plugin-rsc/src/plugins/reference-reachability.ts index f8034bcbd..7b58ce888 100644 --- a/packages/plugin-rsc/src/plugins/client-reference-server-references.ts +++ b/packages/plugin-rsc/src/plugins/reference-reachability.ts @@ -12,7 +12,7 @@ import { sortObject } from './utils' * * @experimental */ -export type ClientReferenceServerReferences = { +export type ClientReferenceToServerReferenceReachability = { /** Resolved module ID used as the client graph traversal root. */ importId: string /** Reference key identifying the Client Component across environments. */ @@ -24,8 +24,9 @@ export type ClientReferenceServerReferences = { export function getClientReferenceServerReferences( context: Rollup.PluginContext, manager: RscPluginManager, -): Record { - const result: Record = {} +): Record { + const result: Record = + {} for (const clientReference of Object.values(manager.clientReferenceMetaMap)) { const serverReferenceIds = new Set() const visited = new Set() From 2ed27321b5819fa6142ebe1da9e3e22c6357509f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:27:12 +0900 Subject: [PATCH 04/33] refactor(rsc): return reachability entries Co-authored-by: OpenCode --- .../plugin-rsc/e2e/action-reachability.test.ts | 10 +++++----- .../examples/action-reachability/README.md | 2 +- .../examples/action-reachability/vite.config.ts | 2 +- packages/plugin-rsc/src/index.ts | 2 +- packages/plugin-rsc/src/plugin.ts | 16 ++++++++-------- .../src/plugins/reference-reachability.ts | 16 +++++++--------- 6 files changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 98dbd177b..eb8822643 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -11,13 +11,13 @@ test.describe('build', () => { test('tracks and executes an object-wrapped server action', async ({ page, }) => { - const reachability: Record< - string, - { importId: string; serverReferenceIds: string[] } - > = JSON.parse( + const reachability: { + importId: string + serverReferenceIds: string[] + }[] = JSON.parse( f.createEditor('dist/client/reference-reachability.json').read(), ) - const clientReference = Object.values(reachability).find((value) => + const clientReference = reachability.find((value) => value.importId.endsWith('/src/client-form.jsx'), ) expect(clientReference?.serverReferenceIds).toEqual([ diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index 63bfe17a7..52389c474 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -11,4 +11,4 @@ root.jsx The client can invoke `commands.objectWrappedAction`, even though import/export binding analysis cannot infer that the `commands` object carries the server reference. -The framework plugin calls plugin-RSC's experimental `manager.getClientReferenceServerReferences(this)` method during its final client `generateBundle` hook and writes the result to `dist/client/reference-reachability.json`. It demonstrates that final client module-graph traversal connects `client-form.jsx` to `objectWrappedAction` without parsing local JavaScript value flow. +The framework plugin calls plugin-RSC's experimental `manager.getClientToServerReferenceReachability(this)` method during its final client `generateBundle` hook and writes the result to `dist/client/reference-reachability.json`. It demonstrates that final client module-graph traversal connects `client-form.jsx` to `objectWrappedAction` without parsing local JavaScript value flow. diff --git a/packages/plugin-rsc/examples/action-reachability/vite.config.ts b/packages/plugin-rsc/examples/action-reachability/vite.config.ts index a65c761f5..e943aab96 100644 --- a/packages/plugin-rsc/examples/action-reachability/vite.config.ts +++ b/packages/plugin-rsc/examples/action-reachability/vite.config.ts @@ -25,7 +25,7 @@ function writeReachabilityPicture(): Plugin { }, generateBundle() { if (this.environment.name !== 'client') return - const reachability = manager.getClientReferenceServerReferences(this) + const reachability = manager.getClientToServerReferenceReachability(this) this.emitFile({ type: 'asset', fileName: 'reference-reachability.json', diff --git a/packages/plugin-rsc/src/index.ts b/packages/plugin-rsc/src/index.ts index 6dbe2ca6e..b7374f77b 100644 --- a/packages/plugin-rsc/src/index.ts +++ b/packages/plugin-rsc/src/index.ts @@ -1,6 +1,6 @@ export { default, - type ClientReferenceToServerReferenceReachability, + type ReferenceReachabilityEntry, type RscPluginOptions, getPluginApi, type PluginApi, diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 18681a959..1bd0e6c3f 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -36,8 +36,8 @@ import { type EnvironmentImportMeta, } from './plugins/import-environment' import { - getClientReferenceServerReferences, - type ClientReferenceToServerReferenceReachability, + getClientToServerReferenceReachability, + type ReferenceReachabilityEntry, } from './plugins/reference-reachability' import { vitePluginResolvedIdProxy, @@ -102,7 +102,7 @@ type ClientReferenceMeta = { groupChunkId?: string } -export type { ClientReferenceToServerReferenceReachability } +export type { ReferenceReachabilityEntry } const PKG_NAME = '@vitejs/plugin-rsc' const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` @@ -138,15 +138,15 @@ class RscPluginManager { * through the current client module graph. * * Call this from a final client build hook while Rollup's module graph is - * available. See {@link ClientReferenceToServerReferenceReachability} for - * the reachability semantics. + * available. See {@link ReferenceReachabilityEntry} for the reachability + * semantics. * * @experimental */ - getClientReferenceServerReferences( + getClientToServerReferenceReachability( context: Rollup.PluginContext, - ): Record { - return getClientReferenceServerReferences(context, this) + ): ReferenceReachabilityEntry[] { + return getClientToServerReferenceReachability(context, this) } serverReferences: ServerReferencesManager = new ServerReferencesManager(this) diff --git a/packages/plugin-rsc/src/plugins/reference-reachability.ts b/packages/plugin-rsc/src/plugins/reference-reachability.ts index 7b58ce888..4e83e3d27 100644 --- a/packages/plugin-rsc/src/plugins/reference-reachability.ts +++ b/packages/plugin-rsc/src/plugins/reference-reachability.ts @@ -1,6 +1,5 @@ import type { Rollup } from 'vite' import type { RscPluginManager } from '../plugin' -import { sortObject } from './utils' /** * Server references reachable from a Client Component reference through the @@ -12,7 +11,7 @@ import { sortObject } from './utils' * * @experimental */ -export type ClientReferenceToServerReferenceReachability = { +export type ReferenceReachabilityEntry = { /** Resolved module ID used as the client graph traversal root. */ importId: string /** Reference key identifying the Client Component across environments. */ @@ -21,12 +20,11 @@ export type ClientReferenceToServerReferenceReachability = { serverReferenceIds: string[] } -export function getClientReferenceServerReferences( +export function getClientToServerReferenceReachability( context: Rollup.PluginContext, manager: RscPluginManager, -): Record { - const result: Record = - {} +): ReferenceReachabilityEntry[] { + const result: ReferenceReachabilityEntry[] = [] for (const clientReference of Object.values(manager.clientReferenceMetaMap)) { const serverReferenceIds = new Set() const visited = new Set() @@ -50,11 +48,11 @@ export function getClientReferenceServerReferences( queue.push(...info.importedIds, ...info.dynamicallyImportedIds) } - result[clientReference.importId] = { + result.push({ importId: clientReference.importId, referenceKey: clientReference.referenceKey, serverReferenceIds: [...serverReferenceIds].sort(), - } + }) } - return sortObject(result) + return result.sort((a, b) => a.importId.localeCompare(b.importId)) } From 7479fc1d589a6a52ce338e8e460d655699075ab0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:34:59 +0900 Subject: [PATCH 05/33] test(rsc): align reachability example with starter Co-authored-by: OpenCode --- .../e2e/action-reachability.test.ts | 2 +- .../examples/action-reachability/README.md | 10 +- .../examples/action-reachability/package.json | 2 + .../src/{action.js => action.tsx} | 0 .../src/{client-form.jsx => client-form.tsx} | 8 +- .../action-reachability/src/commands.js | 3 - .../action-reachability/src/commands.tsx | 3 + .../src/framework/entry.browser.jsx | 35 ----- .../src/framework/entry.browser.tsx | 138 ++++++++++++++++++ .../src/framework/entry.rsc.jsx | 44 ------ .../src/framework/entry.rsc.tsx | 122 ++++++++++++++++ .../src/framework/entry.ssr.jsx | 19 --- .../src/framework/entry.ssr.tsx | 74 ++++++++++ .../src/framework/error-boundary.tsx | 81 ++++++++++ .../src/framework/request.js | 24 --- .../src/framework/request.tsx | 58 ++++++++ .../src/{root.jsx => root.tsx} | 5 +- .../action-reachability/tsconfig.json | 11 +- .../action-reachability/vite.config.ts | 39 +++-- pnpm-lock.yaml | 6 + 20 files changed, 535 insertions(+), 149 deletions(-) rename packages/plugin-rsc/examples/action-reachability/src/{action.js => action.tsx} (100%) rename packages/plugin-rsc/examples/action-reachability/src/{client-form.jsx => client-form.tsx} (55%) delete mode 100644 packages/plugin-rsc/examples/action-reachability/src/commands.js create mode 100644 packages/plugin-rsc/examples/action-reachability/src/commands.tsx delete mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.jsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx delete mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.jsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx delete mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.jsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx delete mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/request.js create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx rename packages/plugin-rsc/examples/action-reachability/src/{root.jsx => root.tsx} (53%) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index eb8822643..466c130ef 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -18,7 +18,7 @@ test.describe('build', () => { f.createEditor('dist/client/reference-reachability.json').read(), ) const clientReference = reachability.find((value) => - value.importId.endsWith('/src/client-form.jsx'), + value.importId.endsWith('/src/client-form.tsx'), ) expect(clientReference?.serverReferenceIds).toEqual([ expect.stringMatching(/#objectWrappedAction$/), diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index 52389c474..1d0d4f89a 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -3,12 +3,12 @@ This example isolates a server reference that crosses a Client Component boundary through an ordinary JavaScript object: ```text -root.jsx - -> client-form.jsx ("use client") - -> commands.js - -> action.js ("use server") +root.tsx + -> client-form.tsx ("use client") + -> commands.tsx + -> action.tsx ("use server") ``` The client can invoke `commands.objectWrappedAction`, even though import/export binding analysis cannot infer that the `commands` object carries the server reference. -The framework plugin calls plugin-RSC's experimental `manager.getClientToServerReferenceReachability(this)` method during its final client `generateBundle` hook and writes the result to `dist/client/reference-reachability.json`. It demonstrates that final client module-graph traversal connects `client-form.jsx` to `objectWrappedAction` without parsing local JavaScript value flow. +The framework plugin calls plugin-RSC's experimental `manager.getClientToServerReferenceReachability(this)` method during its final client `generateBundle` hook and writes the result to `dist/client/reference-reachability.json`. It demonstrates that final client module-graph traversal connects `client-form.tsx` to `objectWrappedAction` without parsing local JavaScript value flow. diff --git a/packages/plugin-rsc/examples/action-reachability/package.json b/packages/plugin-rsc/examples/action-reachability/package.json index daeaf3f4a..a7714fd6e 100644 --- a/packages/plugin-rsc/examples/action-reachability/package.json +++ b/packages/plugin-rsc/examples/action-reachability/package.json @@ -14,6 +14,8 @@ "react-dom": "^19.2.8" }, "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", diff --git a/packages/plugin-rsc/examples/action-reachability/src/action.js b/packages/plugin-rsc/examples/action-reachability/src/action.tsx similarity index 100% rename from packages/plugin-rsc/examples/action-reachability/src/action.js rename to packages/plugin-rsc/examples/action-reachability/src/action.tsx diff --git a/packages/plugin-rsc/examples/action-reachability/src/client-form.jsx b/packages/plugin-rsc/examples/action-reachability/src/client-form.tsx similarity index 55% rename from packages/plugin-rsc/examples/action-reachability/src/client-form.jsx rename to packages/plugin-rsc/examples/action-reachability/src/client-form.tsx index d9017459f..3a136487c 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/client-form.jsx +++ b/packages/plugin-rsc/examples/action-reachability/src/client-form.tsx @@ -1,10 +1,14 @@ 'use client' -import { commands } from './commands.js' +import { commands } from './commands.tsx' export function ClientForm() { return ( -
+ { + await commands.objectWrappedAction() + }} + > diff --git a/packages/plugin-rsc/examples/action-reachability/src/commands.js b/packages/plugin-rsc/examples/action-reachability/src/commands.js deleted file mode 100644 index 78b2ded26..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/commands.js +++ /dev/null @@ -1,3 +0,0 @@ -import { objectWrappedAction } from './action.js' - -export const commands = { objectWrappedAction } diff --git a/packages/plugin-rsc/examples/action-reachability/src/commands.tsx b/packages/plugin-rsc/examples/action-reachability/src/commands.tsx new file mode 100644 index 000000000..baf6687cd --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/commands.tsx @@ -0,0 +1,3 @@ +import { objectWrappedAction } from './action.tsx' + +export const commands = { objectWrappedAction } diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.jsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.jsx deleted file mode 100644 index ff6ed693d..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import { - createFromFetch, - createFromReadableStream, - createTemporaryReferenceSet, - encodeReply, - setServerCallback, -} from '@vitejs/plugin-rsc/browser' -import { startTransition, useState } from 'react' -import { hydrateRoot } from 'react-dom/client' -import { rscStream } from 'rsc-html-stream/client' -import { createActionRequest } from './request.js' - -const initialPayload = await createFromReadableStream(rscStream) -let updatePayload - -function BrowserRoot() { - const [payload, setPayload] = useState(initialPayload) - updatePayload = (nextPayload) => - startTransition(() => setPayload(nextPayload)) - return payload.root -} - -setServerCallback(async (id, args) => { - const temporaryReferences = createTemporaryReferenceSet() - const request = createActionRequest( - window.location.href, - id, - await encodeReply(args, { temporaryReferences }), - ) - const payload = await createFromFetch(fetch(request), { temporaryReferences }) - updatePayload(payload) - return payload.returnValue -}) - -hydrateRoot(document, ) diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx new file mode 100644 index 000000000..5b48ebdfa --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx @@ -0,0 +1,138 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc' +import { GlobalErrorBoundary } from './error-boundary' +import { createRscRenderRequest } from './request' + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const renderRequest = createRscRenderRequest(window.location.href) + const payload = await createFromFetch(fetch(renderRequest)) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }) + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }) + setPayload(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + return data + }) + + // hydration + const browserRoot = ( + + + + + + ) + if ('__NO_HYDRATE' in globalThis) { + createRoot(document).render(browserRoot) + } else { + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + } + + // implement server HMR by triggering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.jsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.jsx deleted file mode 100644 index addeea10c..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - createTemporaryReferenceSet, - decodeReply, - loadServerAction, - renderToReadableStream, -} from '@vitejs/plugin-rsc/rsc' -import { Root } from '../root.jsx' -import { parseRequest } from './request.js' - -export default { fetch: handler } - -async function handler(inputRequest) { - const parsed = parseRequest(inputRequest) - let returnValue - let temporaryReferences - - if (parsed.isAction) { - if (!parsed.actionId) - return new Response('Missing action ID', { status: 400 }) - const contentType = parsed.request.headers.get('content-type') - const body = contentType?.startsWith('multipart/form-data') - ? await parsed.request.formData() - : await parsed.request.text() - temporaryReferences = createTemporaryReferenceSet() - const args = await decodeReply(body, { temporaryReferences }) - const action = await loadServerAction(parsed.actionId) - returnValue = await action(...args) - } - - const rscStream = renderToReadableStream( - { root: , returnValue }, - { temporaryReferences }, - ) - if (parsed.isRsc) { - return new Response(rscStream, { - headers: { 'content-type': 'text/x-component;charset=utf-8' }, - }) - } - - const ssr = await import.meta.viteRsc.loadModule('ssr', 'index') - return new Response(await ssr.renderHtml(rscStream), { - headers: { 'content-type': 'text/html;charset=utf-8' }, - }) -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..786ce67f7 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/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/deserializes the 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 assumes by default that the `rsc` entry has a default export of a request handler. +// However, server entries can be executed differently by registering your 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/action-reachability/src/framework/entry.ssr.jsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.jsx deleted file mode 100644 index 973cf9972..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' -import { use } from 'react' -import { renderToReadableStream } from 'react-dom/server.edge' -import { injectRSCPayload } from 'rsc-html-stream/server' - -export async function renderHtml(rscStream) { - const [renderStream, payloadStream] = rscStream.tee() - const payload = createFromReadableStream(renderStream) - function SsrRoot() { - return use(payload).root - } - - const bootstrapScriptContent = - await import.meta.viteRsc.loadBootstrapScriptContent('index') - const htmlStream = await renderToReadableStream(, { - bootstrapScriptContent, - }) - return htmlStream.pipeThrough(injectRSCPayload(payloadStream)) -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..7fc5a9564 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/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/action-reachability/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx new file mode 100644 index 000000000..39d916510 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx @@ -0,0 +1,81 @@ +'use client' + +import React from 'react' + +// Minimal ErrorBoundary example to handle errors globally on browser +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode + errorComponent: React.FC<{ + error: Error + reset: () => void + }> +}> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const error = this.state.error + if (error) { + return + } + return this.props.children + } +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{' '}
+          {import.meta.env.DEV && 'message' in props.error
+            ? props.error.message
+            : '(Unknown)'}
+        
+ + + + ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/request.js b/packages/plugin-rsc/examples/action-reachability/src/framework/request.js deleted file mode 100644 index 051ad53b2..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/request.js +++ /dev/null @@ -1,24 +0,0 @@ -const ACTION_HEADER = 'x-rsc-action' -const RSC_SUFFIX = '_.rsc' - -export function createActionRequest(urlString, id, body) { - const url = new URL(urlString) - url.pathname += RSC_SUFFIX - return new Request(url, { - method: 'POST', - headers: { [ACTION_HEADER]: id }, - body, - }) -} - -export function parseRequest(request) { - const url = new URL(request.url) - const isRsc = url.pathname.endsWith(RSC_SUFFIX) - if (isRsc) url.pathname = url.pathname.slice(0, -RSC_SUFFIX.length) - return { - actionId: request.headers.get(ACTION_HEADER), - isAction: request.method === 'POST', - isRsc, - request: new Request(url, request), - } -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx new file mode 100644 index 000000000..4c7c666e8 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/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/action-reachability/src/root.jsx b/packages/plugin-rsc/examples/action-reachability/src/root.tsx similarity index 53% rename from packages/plugin-rsc/examples/action-reachability/src/root.jsx rename to packages/plugin-rsc/examples/action-reachability/src/root.tsx index d11ab2f86..968fa30d4 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/root.jsx +++ b/packages/plugin-rsc/examples/action-reachability/src/root.tsx @@ -1,11 +1,12 @@ -import { ClientForm } from './client-form.jsx' +import { ClientForm } from './client-form.tsx' -export function Root() { +export function Root(props: { url: URL }) { return (

Cross-environment action reachability

+

Request URL: {props.url.href}

) diff --git a/packages/plugin-rsc/examples/action-reachability/tsconfig.json b/packages/plugin-rsc/examples/action-reachability/tsconfig.json index e89736f1e..b212cd7a7 100644 --- a/packages/plugin-rsc/examples/action-reachability/tsconfig.json +++ b/packages/plugin-rsc/examples/action-reachability/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { - "allowJs": true, - "checkJs": false, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, "noEmit": true, "moduleResolution": "Bundler", "module": "ESNext", @@ -9,6 +13,5 @@ "lib": ["ESNext", "DOM"], "types": ["vite/client", "@vitejs/plugin-rsc/types"], "jsx": "react-jsx" - }, - "include": ["src", "vite.config.ts"] + } } diff --git a/packages/plugin-rsc/examples/action-reachability/vite.config.ts b/packages/plugin-rsc/examples/action-reachability/vite.config.ts index e943aab96..c32e735dd 100644 --- a/packages/plugin-rsc/examples/action-reachability/vite.config.ts +++ b/packages/plugin-rsc/examples/action-reachability/vite.config.ts @@ -3,17 +3,36 @@ import rsc, { getPluginApi, type PluginApi } from '@vitejs/plugin-rsc' import { defineConfig, type Plugin } from 'vite' export default defineConfig({ - plugins: [ - rsc({ - entries: { - client: './src/framework/entry.browser.jsx', - rsc: './src/framework/entry.rsc.jsx', - ssr: './src/framework/entry.ssr.jsx', + plugins: [rsc(), react(), writeReachabilityPicture()], + environments: { + rsc: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.rsc.tsx', + }, + }, }, - }), - react(), - writeReachabilityPicture(), - ], + }, + ssr: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.ssr.tsx', + }, + }, + }, + }, + client: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.browser.tsx', + }, + }, + }, + }, + }, }) function writeReachabilityPicture(): Plugin { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93dab07c1..6f470a5eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -494,6 +494,12 @@ importers: specifier: ^19.2.8 version: 19.2.8(react@19.2.8) 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 From fe90836f7b1e5bad593778b7b5ca3933bb8bf138 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:34:22 +0900 Subject: [PATCH 06/33] test(rsc): demonstrate route-aware action dispatch Co-authored-by: OpenCode --- .../e2e/action-reachability.test.ts | 28 +++-- .../examples/action-reachability/README.md | 19 ++- .../route-action-manifest-plugin.ts | 113 ++++++++++++++++++ .../action-reachability/src/action.tsx | 5 - .../action-reachability/src/client-form.tsx | 17 --- .../src/framework/action-context.ts | 21 ++++ .../src/framework/entry.rsc.tsx | 54 ++++++++- .../src/framework/middleware.ts | 4 + .../src/framework/routes.tsx | 13 ++ .../src/framework/virtual.d.ts | 4 + .../examples/action-reachability/src/root.tsx | 7 +- .../src/routes/home/action.tsx | 7 ++ .../src/routes/home/client.tsx | 17 +++ .../src/{ => routes/home}/commands.tsx | 0 .../src/routes/home/middleware.ts | 5 + .../src/routes/home/page.tsx | 11 ++ .../src/routes/other/action.tsx | 7 ++ .../src/routes/other/client.tsx | 16 +++ .../src/routes/other/middleware.ts | 5 + .../src/routes/other/page.tsx | 11 ++ .../action-reachability/vite.config.ts | 26 +--- 21 files changed, 323 insertions(+), 67 deletions(-) create mode 100644 packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts delete mode 100644 packages/plugin-rsc/examples/action-reachability/src/action.tsx delete mode 100644 packages/plugin-rsc/examples/action-reachability/src/client-form.tsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/action-context.ts create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/middleware.ts create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/routes.tsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/home/action.tsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/home/client.tsx rename packages/plugin-rsc/examples/action-reachability/src/{ => routes/home}/commands.tsx (100%) create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/home/middleware.ts create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/home/page.tsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/other/action.tsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/other/client.tsx create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/other/middleware.ts create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/other/page.tsx diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 466c130ef..17329f7b6 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -8,21 +8,16 @@ test.describe('build', () => { mode: 'build', }) - test('tracks and executes an object-wrapped server action', async ({ + test('dispatches a delayed action to its reachable route', async ({ page, }) => { - const reachability: { - importId: string - serverReferenceIds: string[] - }[] = JSON.parse( - f.createEditor('dist/client/reference-reachability.json').read(), + const manifest: Record = JSON.parse( + f.createEditor('dist/client/route-action-manifest.json').read(), ) - const clientReference = reachability.find((value) => - value.importId.endsWith('/src/client-form.tsx'), - ) - expect(clientReference?.serverReferenceIds).toEqual([ + expect(manifest['/']).toEqual([ expect.stringMatching(/#objectWrappedAction$/), ]) + expect(manifest['/other']).toEqual([expect.stringMatching(/#otherAction$/)]) await page.goto(f.url()) await waitForHydration(page) @@ -31,9 +26,18 @@ test.describe('build', () => { response.request().method() === 'POST' && response.url().endsWith('_.rsc'), ) - await page.getByTestId('object-wrapped-action').click() + await page.getByTestId('home-action').click() + await page.getByRole('link', { name: 'Other route' }).click() + await expect( + page.getByRole('heading', { name: 'Other route' }), + ).toBeVisible() const response = await responsePromise expect(response.status()).toBe(200) - expect(await response.text()).toContain('OBJECT_WRAPPED_OK') + expect(response.headers()['x-action-route']).toBe('/') + expect(response.headers()['x-action-forwarded']).toBe('true') + expect(await response.text()).toContain('HOME_ACTION_OK:/') + await expect( + page.getByRole('heading', { name: 'Other route' }), + ).toBeVisible() }) }) diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index 1d0d4f89a..ee2fe2181 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -1,14 +1,21 @@ # Cross-Environment Action Reachability -This example isolates a server reference that crosses a Client Component boundary through an ordinary JavaScript object: +This example implements a small framework convention with two route roots: ```text -root.tsx - -> client-form.tsx ("use client") - -> commands.tsx +/ -> routes/home/page.tsx +/other -> routes/other/page.tsx +``` + +The home route's server reference crosses a Client Component boundary through an ordinary JavaScript object: + +```text +routes/home/page.tsx + -> client.tsx ("use client") + -> commands.tsx exports { objectWrappedAction } -> action.tsx ("use server") ``` -The client can invoke `commands.objectWrappedAction`, even though import/export binding analysis cannot infer that the `commands` object carries the server reference. +During the final RSC build, the framework plugin traverses from each page root and records reachable Client Component references. During the final client build, it calls plugin-RSC's experimental `manager.getClientToServerReferenceReachability(this)` method and joins the two relations into `dist/client/route-action-manifest.json`. -The framework plugin calls plugin-RSC's experimental `manager.getClientToServerReferenceReachability(this)` method during its final client `generateBundle` hook and writes the result to `dist/client/reference-reachability.json`. It demonstrates that final client module-graph traversal connects `client-form.tsx` to `objectWrappedAction` without parsing local JavaScript value flow. +The hydrated home action waits before invoking its server reference. Navigating to `/other` during that wait causes the action request to arrive on the other route. The RSC handler consults the generated manifest and redispatches the request through `/` within the same server output. Route-local `middleware.ts` establishes an action context with `AsyncLocalStorage`, so the action can verify that it executes under the home route's middleware while the response continues rendering the visible `/other` route. This example intentionally covers the explicit-ID hydrated transport and does not parse React's progressive multipart action protocol. diff --git a/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts new file mode 100644 index 000000000..e4c8034d6 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs' +import path from 'node:path' +import { getPluginApi, type RscPluginManager } from '@vitejs/plugin-rsc' +import { normalizePath, type Plugin, type ResolvedConfig } from 'vite' + +const routes = { + '/': './src/routes/home/page.tsx', + '/other': './src/routes/other/page.tsx', +} + +const virtualRouteActionManifest = 'virtual:route-action-manifest' +const resolvedVirtualRouteActionManifest = `\0${virtualRouteActionManifest}` + +export function routeActionManifestPlugin(): Plugin { + let manager: RscPluginManager + let config!: ResolvedConfig + const routeClientReferenceKeys = new Map>() + const routeDirectServerReferenceIds = new Map>() + let routeActionManifest: Record = {} + + return { + name: 'route-action-manifest', + configResolved(resolvedConfig) { + config = resolvedConfig + manager = getPluginApi(resolvedConfig)!.manager + }, + resolveId(source) { + if (source !== virtualRouteActionManifest) return + if (this.environment.mode === 'build') { + return { id: './route-action-manifest.js', external: true } + } + return resolvedVirtualRouteActionManifest + }, + load(id) { + if (id === resolvedVirtualRouteActionManifest) { + return 'export default {}' + } + }, + generateBundle() { + if (this.environment.name === 'rsc') { + for (const [route, source] of Object.entries(routes)) { + const clientReferenceKeys = new Set() + const directServerReferenceIds = new Set() + const visited = new Set() + const queue = [normalizePath(path.resolve(source))] + for (let index = 0; index < queue.length; index++) { + const id = queue[index]! + if (visited.has(id)) continue + visited.add(id) + + const clientReference = manager.clientReferenceMetaMap[id] + if (clientReference) { + clientReferenceKeys.add(clientReference.referenceKey) + } + + const serverReference = manager.serverReferences.metaMap.get(id) + if (serverReference) { + for (const exportName of serverReference.exportNames) { + directServerReferenceIds.add( + `${serverReference.referenceKey}#${exportName}`, + ) + } + } + + const info = this.getModuleInfo(id) + if (info) { + queue.push(...info.importedIds, ...info.dynamicallyImportedIds) + } + } + routeClientReferenceKeys.set(route, clientReferenceKeys) + routeDirectServerReferenceIds.set(route, directServerReferenceIds) + } + return + } + + if (this.environment.name !== 'client') return + const reachabilityByReferenceKey = new Map( + manager + .getClientToServerReferenceReachability(this) + .map((entry) => [entry.referenceKey, entry.serverReferenceIds]), + ) + routeActionManifest = Object.fromEntries( + Object.keys(routes).map((route) => { + const actionIds = new Set(routeDirectServerReferenceIds.get(route)) + for (const referenceKey of routeClientReferenceKeys.get(route) ?? + []) { + for (const actionId of reachabilityByReferenceKey.get( + referenceKey, + ) ?? []) { + actionIds.add(actionId) + } + } + return [route, [...actionIds].sort()] + }), + ) + this.emitFile({ + type: 'asset', + fileName: 'route-action-manifest.json', + source: JSON.stringify(routeActionManifest, null, 2), + }) + }, + buildApp: { + order: 'post', + async handler() { + const outDir = config.environments.rsc!.build.outDir + fs.writeFileSync( + path.join(outDir, 'route-action-manifest.js'), + `export default ${JSON.stringify(routeActionManifest, null, 2)}\n`, + ) + }, + }, + } +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/action.tsx deleted file mode 100644 index f5e36b050..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/action.tsx +++ /dev/null @@ -1,5 +0,0 @@ -'use server' - -export async function objectWrappedAction() { - return 'OBJECT_WRAPPED_OK' -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/client-form.tsx b/packages/plugin-rsc/examples/action-reachability/src/client-form.tsx deleted file mode 100644 index 3a136487c..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/client-form.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client' - -import { commands } from './commands.tsx' - -export function ClientForm() { - return ( - { - await commands.objectWrappedAction() - }} - > - - - ) -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/action-context.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/action-context.ts new file mode 100644 index 000000000..62f673b5d --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/action-context.ts @@ -0,0 +1,21 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +type ActionContext = { + request: Request + route: string +} + +const actionContextStorage = new AsyncLocalStorage() + +export function runWithActionContext( + context: ActionContext, + callback: () => T, +): T { + return actionContextStorage.run(context, callback) +} + +export function getActionContext(): ActionContext { + const context = actionContextStorage.getStore() + if (!context) throw new Error('Action context is not available') + return context +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx index 786ce67f7..700668e15 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx @@ -7,8 +7,10 @@ import { decodeFormState, } from '@vitejs/plugin-rsc/rsc' import type { ReactFormState } from 'react-dom/client' +import routeActionManifest from 'virtual:route-action-manifest' import { Root } from '../root.tsx' import { parseRenderRequest } from './request.tsx' +import { getRoute } from './routes.tsx' // The schema of payload which is serialized into RSC stream on rsc environment // and deserialized on ssr/client environments. @@ -31,15 +33,53 @@ async function handler(request: Request): Promise { // differentiate RSC, SSR, action, etc. const renderRequest = parseRenderRequest(request) request = renderRequest.request + const { middleware } = getRoute(renderRequest.url.pathname) + return middleware(request, () => handleRequest(renderRequest)) +} +async function handleRequest( + renderRequest: ReturnType, +): Promise { + const request = renderRequest.request // handle server function request let returnValue: RscPayload['returnValue'] | undefined let formState: ReactFormState | undefined let temporaryReferences: unknown | undefined let actionStatus: number | undefined + let actionRoute: string | undefined if (renderRequest.isAction === true) { if (renderRequest.actionId) { // action is called via `ReactClient.setServerCallback`. + actionRoute = Object.entries(routeActionManifest).find(([, actionIds]) => + actionIds.includes(renderRequest.actionId!), + )?.[0] + if (!actionRoute) { + return new Response('Server action is not reachable from any route', { + status: 404, + }) + } + if (actionRoute !== renderRequest.url.pathname) { + if (request.headers.has('x-action-forwarded')) { + return new Response( + 'Forwarded server action reached the wrong route', + { + status: 404, + }, + ) + } + const targetUrl = new URL(request.url) + targetUrl.pathname = actionRoute + '_.rsc' + const headers = new Headers(request.headers) + headers.set('x-action-forwarded', '1') + headers.set('x-rsc-render-url', renderRequest.url.href) + return handler( + new Request(targetUrl, { + method: request.method, + headers, + body: await request.arrayBuffer(), + }), + ) + } const contentType = request.headers.get('content-type') const body = contentType?.startsWith('multipart/form-data') ? await request.formData() @@ -78,7 +118,13 @@ async function handler(request: Request): Promise { // 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: , + root: ( + + ), formState, returnValue, } @@ -91,6 +137,12 @@ async function handler(request: Request): Promise { status: actionStatus, headers: { 'content-type': 'text/x-component;charset=utf-8', + ...(actionRoute && { + 'x-action-route': actionRoute, + 'x-action-forwarded': String( + request.headers.has('x-action-forwarded'), + ), + }), }, }) } diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/middleware.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/middleware.ts new file mode 100644 index 000000000..26105304a --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/middleware.ts @@ -0,0 +1,4 @@ +export type RouteMiddleware = ( + request: Request, + next: () => Promise, +) => Promise diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/routes.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/routes.tsx new file mode 100644 index 000000000..51ead0c95 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/routes.tsx @@ -0,0 +1,13 @@ +import { middleware as homeMiddleware } from '../routes/home/middleware.ts' +import { Page as HomePage } from '../routes/home/page.tsx' +import { middleware as otherMiddleware } from '../routes/other/middleware.ts' +import { Page as OtherPage } from '../routes/other/page.tsx' + +export const routes = { + '/': { Page: HomePage, middleware: homeMiddleware }, + '/other': { Page: OtherPage, middleware: otherMiddleware }, +} + +export function getRoute(pathname: string) { + return routes[pathname as keyof typeof routes] ?? routes['/'] +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts new file mode 100644 index 000000000..8e427aed1 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts @@ -0,0 +1,4 @@ +declare module 'virtual:route-action-manifest' { + const manifest: Record + export default manifest +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/root.tsx b/packages/plugin-rsc/examples/action-reachability/src/root.tsx index 968fa30d4..bc9b4270d 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/root.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/root.tsx @@ -1,12 +1,11 @@ -import { ClientForm } from './client-form.tsx' +import { getRoute } from './framework/routes.tsx' export function Root(props: { url: URL }) { + const { Page } = getRoute(props.url.pathname) return ( -

Cross-environment action reachability

- -

Request URL: {props.url.href}

+ ) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/home/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/home/action.tsx new file mode 100644 index 000000000..e74911cde --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/home/action.tsx @@ -0,0 +1,7 @@ +'use server' + +import { getActionContext } from '../../framework/action-context.ts' + +export async function objectWrappedAction() { + return `HOME_ACTION_OK:${getActionContext().route}` +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/home/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/home/client.tsx new file mode 100644 index 000000000..49d5daa1a --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/home/client.tsx @@ -0,0 +1,17 @@ +'use client' + +import { commands } from './commands.tsx' + +export function HomeAction() { + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/commands.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/home/commands.tsx similarity index 100% rename from packages/plugin-rsc/examples/action-reachability/src/commands.tsx rename to packages/plugin-rsc/examples/action-reachability/src/routes/home/commands.tsx diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/home/middleware.ts b/packages/plugin-rsc/examples/action-reachability/src/routes/home/middleware.ts new file mode 100644 index 000000000..815f7517e --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/home/middleware.ts @@ -0,0 +1,5 @@ +import { runWithActionContext } from '../../framework/action-context.ts' +import type { RouteMiddleware } from '../../framework/middleware.ts' + +export const middleware: RouteMiddleware = (request, next) => + runWithActionContext({ request, route: '/' }, next) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/home/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/home/page.tsx new file mode 100644 index 000000000..45ffa9b9a --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/home/page.tsx @@ -0,0 +1,11 @@ +import { HomeAction } from './client.tsx' + +export function Page() { + return ( +
+

Home route

+ + Other route +
+ ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/other/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/other/action.tsx new file mode 100644 index 000000000..059cdb95f --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/other/action.tsx @@ -0,0 +1,7 @@ +'use server' + +import { getActionContext } from '../../framework/action-context.ts' + +export async function otherAction() { + return `OTHER_ACTION_OK:${getActionContext().route}` +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/other/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/other/client.tsx new file mode 100644 index 000000000..e42b2db19 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/other/client.tsx @@ -0,0 +1,16 @@ +'use client' + +import { otherAction } from './action.tsx' + +export function OtherAction() { + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/other/middleware.ts b/packages/plugin-rsc/examples/action-reachability/src/routes/other/middleware.ts new file mode 100644 index 000000000..4a4234e18 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/other/middleware.ts @@ -0,0 +1,5 @@ +import { runWithActionContext } from '../../framework/action-context.ts' +import type { RouteMiddleware } from '../../framework/middleware.ts' + +export const middleware: RouteMiddleware = (request, next) => + runWithActionContext({ request, route: '/other' }, next) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/other/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/other/page.tsx new file mode 100644 index 000000000..496d12f83 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/other/page.tsx @@ -0,0 +1,11 @@ +import { OtherAction } from './client.tsx' + +export function Page() { + return ( +
+

Other route

+ + Home route +
+ ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/vite.config.ts b/packages/plugin-rsc/examples/action-reachability/vite.config.ts index c32e735dd..3c59ffd16 100644 --- a/packages/plugin-rsc/examples/action-reachability/vite.config.ts +++ b/packages/plugin-rsc/examples/action-reachability/vite.config.ts @@ -1,9 +1,10 @@ import react from '@vitejs/plugin-react' -import rsc, { getPluginApi, type PluginApi } from '@vitejs/plugin-rsc' -import { defineConfig, type Plugin } from 'vite' +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' +import { routeActionManifestPlugin } from './route-action-manifest-plugin.ts' export default defineConfig({ - plugins: [rsc(), react(), writeReachabilityPicture()], + plugins: [rsc(), react(), routeActionManifestPlugin()], environments: { rsc: { build: { @@ -34,22 +35,3 @@ export default defineConfig({ }, }, }) - -function writeReachabilityPicture(): Plugin { - let manager: PluginApi['manager'] - return { - name: 'write-reference-reachability-picture', - configResolved(config) { - manager = getPluginApi(config)!.manager - }, - generateBundle() { - if (this.environment.name !== 'client') return - const reachability = manager.getClientToServerReferenceReachability(this) - this.emitFile({ - type: 'asset', - fileName: 'reference-reachability.json', - source: JSON.stringify(reachability, null, 2), - }) - }, - } -} From 3e275bfa836c91124867df20f8e4da0c455b7d3a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:36:03 +0900 Subject: [PATCH 07/33] docs(rsc): note progressive action follow-up Co-authored-by: OpenCode --- .../action-reachability/src/framework/entry.rsc.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx index 700668e15..115192132 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx @@ -95,9 +95,9 @@ async function handleRequest( actionStatus = 500 } } else { - // otherwise server function is called via `
` - // before hydration (e.g. when javascript is disabled). - // aka progressive enhancement. + // TODO: extract the action ID from React's multipart + // fields and apply the same route-manifest redispatch before decoding. + // https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/packages/next/src/server/app-render/action-handler.ts#L1467-L1576 const formData = await request.formData() const decodedAction = await decodeAction(formData) try { From 0c6ff40ef4984f266aad8ac1919641a4ff8abc39 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:22:42 +0900 Subject: [PATCH 08/33] docs(rsc): focus reachability example comments Co-authored-by: OpenCode --- .../route-action-manifest-plugin.ts | 4 +++ .../src/framework/entry.browser.tsx | 24 ++++----------- .../src/framework/entry.rsc.tsx | 29 +++---------------- .../src/framework/entry.ssr.tsx | 11 ------- .../src/framework/error-boundary.tsx | 5 ---- .../src/framework/request.tsx | 15 ++++------ .../src/routes/home/client.tsx | 1 + 7 files changed, 19 insertions(+), 70 deletions(-) diff --git a/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts index e4c8034d6..9d6cf2635 100644 --- a/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts +++ b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts @@ -27,6 +27,7 @@ export function routeActionManifestPlugin(): Plugin { resolveId(source) { if (source !== virtualRouteActionManifest) return if (this.environment.mode === 'build') { + // The RSC output imports a sidecar written after the later client build. return { id: './route-action-manifest.js', external: true } } return resolvedVirtualRouteActionManifest @@ -38,6 +39,7 @@ export function routeActionManifestPlugin(): Plugin { }, generateBundle() { if (this.environment.name === 'rsc') { + // Collect each route's direct actions and reachable Client Components. for (const [route, source] of Object.entries(routes)) { const clientReferenceKeys = new Set() const directServerReferenceIds = new Set() @@ -74,6 +76,7 @@ export function routeActionManifestPlugin(): Plugin { } if (this.environment.name !== 'client') return + // Join RSC route reachability with the final client graph relation. const reachabilityByReferenceKey = new Map( manager .getClientToServerReferenceReachability(this) @@ -102,6 +105,7 @@ export function routeActionManifestPlugin(): Plugin { buildApp: { order: 'post', async handler() { + // The client graph is available only after the RSC output was emitted. const outDir = config.environments.rsc!.build.outDir fs.writeFileSync( path.join(outDir, 'route-action-manifest.js'), diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx index 5b48ebdfa..00dc9beef 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.browser.tsx @@ -13,17 +13,10 @@ import { GlobalErrorBoundary } from './error-boundary' import { createRscRenderRequest } from './request' async function main() { - // stash `setPayload` function to trigger re-rendering - // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) let setPayload: (v: RscPayload) => void - // deserialize RSC stream back to React VDOM for CSR - const initialPayload = await createFromReadableStream( - // initial RSC stream is injected in SSR stream as - rscStream, - ) + const initialPayload = await createFromReadableStream(rscStream) - // browser root component to (re-)render RSC payload as state function BrowserRoot() { const [payload, setPayload_] = React.useState(initialPayload) @@ -31,7 +24,6 @@ async function main() { setPayload = (v) => React.startTransition(() => setPayload_(v)) }, [setPayload_]) - // re-fetch/render on client side navigation React.useEffect(() => { return listenNavigation(() => fetchRscPayload()) }, []) @@ -39,15 +31,12 @@ async function main() { return payload.root } - // re-fetch RSC and trigger re-rendering async function fetchRscPayload() { const renderRequest = createRscRenderRequest(window.location.href) const payload = await createFromFetch(fetch(renderRequest)) setPayload(payload) } - // register a handler which will be internally called by React - // on server function request after hydration. setServerCallback(async (id, args) => { const temporaryReferences = createTemporaryReferenceSet() const renderRequest = createRscRenderRequest(window.location.href, { @@ -63,7 +52,6 @@ async function main() { return data }) - // hydration const browserRoot = ( @@ -79,7 +67,6 @@ async function main() { }) } - // implement server HMR by triggering re-fetch/render of RSC upon server code change if (import.meta.hot) { import.meta.hot.on('rsc:update', () => { fetchRscPayload() @@ -87,7 +74,6 @@ async function main() { } } -// a little helper to setup events interception for client side navigation function listenNavigation(onNavigation: () => void) { window.addEventListener('popstate', onNavigation) @@ -114,10 +100,10 @@ function listenNavigation(onNavigation: () => void) { (!link.target || link.target === '_self') && link.origin === location.origin && !link.hasAttribute('download') && - e.button === 0 && // left clicks only - !e.metaKey && // open in new tab (mac) - !e.ctrlKey && // open in new tab (windows) - !e.altKey && // download + e.button === 0 && + !e.metaKey && + !e.ctrlKey && + !e.altKey && !e.shiftKey && !e.defaultPrevented ) { diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx index 115192132..453cde441 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx @@ -12,28 +12,19 @@ import { Root } from '../root.tsx' import { parseRenderRequest } from './request.tsx' import { getRoute } from './routes.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/deserializes the 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 assumes by default that the `rsc` entry has a default export of a request handler. -// However, server entries can be executed differently by registering your 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 const { middleware } = getRoute(renderRequest.url.pathname) + // Forwarded requests re-enter here so the action route replaces the request context. return middleware(request, () => handleRequest(renderRequest)) } @@ -41,7 +32,6 @@ async function handleRequest( renderRequest: ReturnType, ): Promise { const request = renderRequest.request - // handle server function request let returnValue: RscPayload['returnValue'] | undefined let formState: ReactFormState | undefined let temporaryReferences: unknown | undefined @@ -49,7 +39,7 @@ async function handleRequest( let actionRoute: string | undefined if (renderRequest.isAction === true) { if (renderRequest.actionId) { - // action is called via `ReactClient.setServerCallback`. + // Select a route whose application graph reaches the requested action. actionRoute = Object.entries(routeActionManifest).find(([, actionIds]) => actionIds.includes(renderRequest.actionId!), )?.[0] @@ -59,6 +49,7 @@ async function handleRequest( }) } if (actionRoute !== renderRequest.url.pathname) { + // Redispatch through the action route so its middleware establishes context. if (request.headers.has('x-action-forwarded')) { return new Response( 'Forwarded server action reached the wrong route', @@ -71,6 +62,7 @@ async function handleRequest( targetUrl.pathname = actionRoute + '_.rsc' const headers = new Headers(request.headers) headers.set('x-action-forwarded', '1') + // Render the visible route after the action runs through another route. headers.set('x-rsc-render-url', renderRequest.url.href) return handler( new Request(targetUrl, { @@ -104,8 +96,6 @@ async function handleRequest( 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, }) @@ -113,10 +103,6 @@ async function handleRequest( } } - // 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: ( (rscPayload, rscOptions) - // Respond RSC stream without HTML rendering as decided by `RenderRequest` if (renderRequest.isRsc) { return new Response(rscStream, { status: actionStatus, @@ -147,20 +132,14 @@ async function handleRequest( }) } - // 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: { diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx index 7fc5a9564..6eb6b3650 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.ssr.tsx @@ -13,21 +13,14 @@ export async function renderHTML( 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 @@ -41,8 +34,6 @@ export async function renderHTML( 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( @@ -61,8 +52,6 @@ export async function renderHTML( 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, diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx index 39d916510..1c7e047c1 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/error-boundary.tsx @@ -2,7 +2,6 @@ import React from 'react' -// Minimal ErrorBoundary example to handle errors globally on browser export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { return ( @@ -11,8 +10,6 @@ export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { ) } -// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx -// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary class ErrorBoundary extends React.Component<{ children?: React.ReactNode errorComponent: React.FC<{ @@ -39,8 +36,6 @@ class ErrorBoundary extends React.Component<{ } } -// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 -// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { return ( diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx index 4c7c666e8..4cf961973 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/request.tsx @@ -1,17 +1,12 @@ -// 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 + isRsc: boolean + isAction: boolean + actionId?: string + request: Request + url: URL } export function createRscRenderRequest( diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/home/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/home/client.tsx index 49d5daa1a..4e3cc2f55 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/home/client.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/home/client.tsx @@ -7,6 +7,7 @@ export function HomeAction() { ) } diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/a/commands.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/a/commands.tsx new file mode 100644 index 000000000..09bd7e726 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/a/commands.tsx @@ -0,0 +1,3 @@ +import { actionA } from './action.tsx' + +export const commands = { actionA } diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/home/middleware.ts b/packages/plugin-rsc/examples/action-reachability/src/routes/a/middleware.ts similarity index 78% rename from packages/plugin-rsc/examples/action-reachability/src/routes/home/middleware.ts rename to packages/plugin-rsc/examples/action-reachability/src/routes/a/middleware.ts index 815f7517e..e84ba672e 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/home/middleware.ts +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/a/middleware.ts @@ -2,4 +2,4 @@ import { runWithActionContext } from '../../framework/action-context.ts' import type { RouteMiddleware } from '../../framework/middleware.ts' export const middleware: RouteMiddleware = (request, next) => - runWithActionContext({ request, route: '/' }, next) + runWithActionContext({ request, route: '/a' }, next) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/a/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/a/page.tsx new file mode 100644 index 000000000..ce607b6f6 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/a/page.tsx @@ -0,0 +1,10 @@ +import { ActionA } from './client.tsx' + +export function Page() { + return ( +
+

/a

+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/b/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/b/action.tsx new file mode 100644 index 000000000..d79550374 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/b/action.tsx @@ -0,0 +1,7 @@ +'use server' + +import { getActionContext } from '../../framework/action-context.ts' + +export async function actionB() { + return `ACTION_B_OK:${getActionContext().route}` +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx new file mode 100644 index 000000000..e73f28225 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx @@ -0,0 +1,16 @@ +'use client' + +import { actionB } from './action.tsx' + +export function ActionB() { + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/other/middleware.ts b/packages/plugin-rsc/examples/action-reachability/src/routes/b/middleware.ts similarity index 77% rename from packages/plugin-rsc/examples/action-reachability/src/routes/other/middleware.ts rename to packages/plugin-rsc/examples/action-reachability/src/routes/b/middleware.ts index 4a4234e18..909dce720 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/other/middleware.ts +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/b/middleware.ts @@ -2,4 +2,4 @@ import { runWithActionContext } from '../../framework/action-context.ts' import type { RouteMiddleware } from '../../framework/middleware.ts' export const middleware: RouteMiddleware = (request, next) => - runWithActionContext({ request, route: '/other' }, next) + runWithActionContext({ request, route: '/b' }, next) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/b/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/b/page.tsx new file mode 100644 index 000000000..1688ef788 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/b/page.tsx @@ -0,0 +1,10 @@ +import { ActionB } from './client.tsx' + +export function Page() { + return ( +
+

/b

+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/home/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/home/action.tsx deleted file mode 100644 index e74911cde..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/home/action.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use server' - -import { getActionContext } from '../../framework/action-context.ts' - -export async function objectWrappedAction() { - return `HOME_ACTION_OK:${getActionContext().route}` -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/home/commands.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/home/commands.tsx deleted file mode 100644 index baf6687cd..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/home/commands.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { objectWrappedAction } from './action.tsx' - -export const commands = { objectWrappedAction } diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/home/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/home/page.tsx deleted file mode 100644 index 45ffa9b9a..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/home/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { HomeAction } from './client.tsx' - -export function Page() { - return ( -
-

Home route

- - Other route -
- ) -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/other/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/other/action.tsx deleted file mode 100644 index 059cdb95f..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/other/action.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use server' - -import { getActionContext } from '../../framework/action-context.ts' - -export async function otherAction() { - return `OTHER_ACTION_OK:${getActionContext().route}` -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/other/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/other/client.tsx deleted file mode 100644 index e42b2db19..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/other/client.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client' - -import { otherAction } from './action.tsx' - -export function OtherAction() { - return ( - - ) -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/other/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/other/page.tsx deleted file mode 100644 index 496d12f83..000000000 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/other/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { OtherAction } from './client.tsx' - -export function Page() { - return ( -
-

Other route

- - Home route -
- ) -} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx new file mode 100644 index 000000000..ead54b70a --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx @@ -0,0 +1,12 @@ +export function Root(props: { children?: React.ReactNode }) { + return ( + + + + {props.children} + + + ) +} From a137cca249caaef6861cdace136588ef04edb624 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:44:40 +0900 Subject: [PATCH 11/33] docs(rsc): explain action reachability demo Co-authored-by: OpenCode --- .../examples/action-reachability/src/routes/root.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx index ead54b70a..f36df5613 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx @@ -2,6 +2,10 @@ export function Root(props: { children?: React.ReactNode }) { return ( +

+ Run delayed action A, then navigate to /b. It runs through /a + middleware. +

From 2601874ffb999a1eb571f236d1dfa9d1990483a1 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:45:38 +0900 Subject: [PATCH 12/33] docs(rsc): clarify reachability demo pages Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/action-reachability.test.ts | 8 ++++++-- .../examples/action-reachability/src/routes/a/page.tsx | 2 +- .../examples/action-reachability/src/routes/b/page.tsx | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 7b9b67719..239f6771c 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -26,12 +26,16 @@ test.describe('build', () => { ) await page.getByTestId('action-a').click() await page.getByRole('link', { name: '/b' }).click() - await expect(page.getByRole('heading', { name: '/b' })).toBeVisible() + await expect( + page.getByRole('heading', { name: 'This is page "b"' }), + ).toBeVisible() const response = await responsePromise expect(response.status()).toBe(200) expect(response.headers()['x-action-route']).toBe('/a') expect(response.headers()['x-action-forwarded']).toBe('true') expect(await response.text()).toContain('ACTION_A_OK:/a') - await expect(page.getByRole('heading', { name: '/b' })).toBeVisible() + await expect( + page.getByRole('heading', { name: 'This is page "b"' }), + ).toBeVisible() }) }) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/a/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/a/page.tsx index ce607b6f6..10aa68a4c 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/a/page.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/a/page.tsx @@ -3,7 +3,7 @@ import { ActionA } from './client.tsx' export function Page() { return (
-

/a

+

This is page "a"

) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/b/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/b/page.tsx index 1688ef788..5469d352a 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/b/page.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/b/page.tsx @@ -3,7 +3,7 @@ import { ActionB } from './client.tsx' export function Page() { return (
-

/b

+

This is page "b"

) From 245f67727871bce3b106eee0e21f88b758e0afb8 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:56:30 +0900 Subject: [PATCH 13/33] test(rsc): retain action across navigation Co-authored-by: OpenCode --- .../e2e/action-reachability.test.ts | 16 ++++++---- .../examples/action-reachability/README.md | 2 +- .../src/routes/a/client.tsx | 30 ++++++++++++------- .../src/routes/b/client.tsx | 26 +++++++++++----- .../action-reachability/src/routes/root.tsx | 5 +--- .../src/routes/saved-action.ts | 11 +++++++ 6 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/saved-action.ts diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 239f6771c..650fb0a5f 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -8,7 +8,7 @@ test.describe('build', () => { mode: 'build', }) - test('dispatches a delayed action to its reachable route', async ({ + test('dispatches a retained action to its reachable route', async ({ page, }) => { const manifest: Record = JSON.parse( @@ -19,21 +19,25 @@ test.describe('build', () => { await page.goto(f.url('/a')) await waitForHydration(page) + await page.getByRole('button', { name: 'Save action A' }).click() + await page.getByRole('link', { name: '/b' }).click() + await expect( + page.getByRole('heading', { name: 'This is page "b"' }), + ).toBeVisible() + await expect(page.getByText('Saved action: A')).toBeVisible() + const responsePromise = page.waitForResponse( (response) => response.request().method() === 'POST' && response.url().endsWith('_.rsc'), ) - await page.getByTestId('action-a').click() - await page.getByRole('link', { name: '/b' }).click() - await expect( - page.getByRole('heading', { name: 'This is page "b"' }), - ).toBeVisible() + await page.getByRole('button', { name: 'Run saved action' }).click() const response = await responsePromise expect(response.status()).toBe(200) expect(response.headers()['x-action-route']).toBe('/a') expect(response.headers()['x-action-forwarded']).toBe('true') expect(await response.text()).toContain('ACTION_A_OK:/a') + await expect(page.getByText('Result: ACTION_A_OK:/a')).toBeVisible() await expect( page.getByRole('heading', { name: 'This is page "b"' }), ).toBeVisible() diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index 7823f2ee6..3239826e4 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -19,4 +19,4 @@ routes/a/page.tsx During the final RSC build, the framework plugin traverses from each page root and records reachable Client Component references. During the final client build, it calls plugin-RSC's experimental `manager.getClientToServerReferenceReachability(this)` method and joins the two relations into `dist/client/route-action-manifest.json`. -The hydrated action A waits before invoking its server reference. Navigating to `/b` during that wait causes the action request to arrive on route B. The RSC handler consults the generated manifest and redispatches the request through `/a` within the same server output. Route-local `middleware.ts` establishes an action context with `AsyncLocalStorage`, so the action can verify that it executes under route A's middleware while the response continues rendering the visible `/b` route. This example intentionally covers the explicit-ID hydrated transport and does not parse React's progressive multipart action protocol. +A shared browser module stores a server reference without statically importing either route's action. Saving action A on `/a`, navigating to `/b`, and invoking the saved value sends the request to route B. The RSC handler consults the generated manifest and redispatches the request through `/a` within the same server output. Route-local `middleware.ts` establishes an action context with `AsyncLocalStorage`, so the action can verify that it executes under route A's middleware while the response continues rendering the visible `/b` route. This example intentionally covers the explicit-ID hydrated transport and does not parse React's progressive multipart action protocol. diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx index e0e03f24d..977218ef7 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx @@ -1,18 +1,28 @@ 'use client' +import React from 'react' +import { getSavedAction, setSavedAction } from '../saved-action.ts' import { commands } from './commands.tsx' export function ActionA() { + const [result, setResult] = React.useState('none') + const savedAction = getSavedAction() return ( - +
+ + + +

Saved action: {savedAction?.name ?? 'none'}

+

Result: {result}

+
) } diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx index e73f28225..2e72a1fd4 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx @@ -1,16 +1,26 @@ 'use client' +import React from 'react' +import { getSavedAction, setSavedAction } from '../saved-action.ts' import { actionB } from './action.tsx' export function ActionB() { + const [result, setResult] = React.useState('none') + const savedAction = getSavedAction() return ( - +
+ + + +

Saved action: {savedAction?.name ?? 'none'}

+

Result: {result}

+
) } diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx index f36df5613..ae1ea5568 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/root.tsx @@ -2,10 +2,7 @@ export function Root(props: { children?: React.ReactNode }) { return ( -

- Run delayed action A, then navigate to /b. It runs through /a - middleware. -

+

Save action A on /a, navigate to /b, then run the saved action.

diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/saved-action.ts b/packages/plugin-rsc/examples/action-reachability/src/routes/saved-action.ts new file mode 100644 index 000000000..8d5cbbf6d --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/saved-action.ts @@ -0,0 +1,11 @@ +export type Action = () => Promise + +let savedAction: { action: Action; name: string } | undefined + +export function setSavedAction(name: string, action: Action) { + savedAction = { action, name } +} + +export function getSavedAction() { + return savedAction +} From d6049acc2e25c1efca4b057b409fd3362c0490c2 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:57:28 +0900 Subject: [PATCH 14/33] refactor(rsc): simplify retained action state Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/action-reachability.test.ts | 4 +++- .../examples/action-reachability/src/routes/a/client.tsx | 5 ++--- .../examples/action-reachability/src/routes/b/client.tsx | 7 ++----- .../action-reachability/src/routes/saved-action.ts | 6 +++--- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 650fb0a5f..926a35dad 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -24,7 +24,9 @@ test.describe('build', () => { await expect( page.getByRole('heading', { name: 'This is page "b"' }), ).toBeVisible() - await expect(page.getByText('Saved action: A')).toBeVisible() + await expect( + page.getByRole('button', { name: 'Run saved action' }), + ).toBeEnabled() const responsePromise = page.waitForResponse( (response) => diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx index 977218ef7..83216647c 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx @@ -12,16 +12,15 @@ export function ActionA() { - -

Saved action: {savedAction?.name ?? 'none'}

Result: {result}

) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx index 2e72a1fd4..c020191f0 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/b/client.tsx @@ -10,16 +10,13 @@ export function ActionB() { return (
- + -

Saved action: {savedAction?.name ?? 'none'}

Result: {result}

) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/saved-action.ts b/packages/plugin-rsc/examples/action-reachability/src/routes/saved-action.ts index 8d5cbbf6d..efec25af5 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/saved-action.ts +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/saved-action.ts @@ -1,9 +1,9 @@ export type Action = () => Promise -let savedAction: { action: Action; name: string } | undefined +let savedAction: Action | undefined -export function setSavedAction(name: string, action: Action) { - savedAction = { action, name } +export function setSavedAction(action: Action) { + savedAction = action } export function getSavedAction() { From 3ae3bfd8df6f90cb831ce5dfcaabb6995a99e2c6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:00:12 +0900 Subject: [PATCH 15/33] docs(rsc): note action wrapper explanation Co-authored-by: OpenCode --- .../examples/action-reachability/src/routes/a/commands.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/a/commands.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/a/commands.tsx index 09bd7e726..de8599217 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/a/commands.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/a/commands.tsx @@ -1,3 +1,4 @@ import { actionA } from './action.tsx' +// TODO: comment on why the demo export const commands = { actionA } From 9bd979501a9bee3253cfd670b8c5098305f57250 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:03:35 +0900 Subject: [PATCH 16/33] docs(rsc): annotate action reachability scenario Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/action-reachability.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 926a35dad..3335b3c1e 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -17,6 +17,7 @@ test.describe('build', () => { expect(manifest['/a']).toEqual([expect.stringMatching(/#actionA$/)]) expect(manifest['/b']).toEqual([expect.stringMatching(/#actionB$/)]) + // /a -> save action A -> navigate to /b -> run action A through /a await page.goto(f.url('/a')) await waitForHydration(page) await page.getByRole('button', { name: 'Save action A' }).click() From 7f1d803141e8bc22d69d951e849671dcb3b5c346 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:05:42 +0900 Subject: [PATCH 17/33] test(rsc): assert action dispatch through UI Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/action-reachability.test.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 3335b3c1e..ceb03579d 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -29,18 +29,9 @@ test.describe('build', () => { page.getByRole('button', { name: 'Run saved action' }), ).toBeEnabled() - const responsePromise = page.waitForResponse( - (response) => - response.request().method() === 'POST' && - response.url().endsWith('_.rsc'), - ) await page.getByRole('button', { name: 'Run saved action' }).click() - const response = await responsePromise - expect(response.status()).toBe(200) - expect(response.headers()['x-action-route']).toBe('/a') - expect(response.headers()['x-action-forwarded']).toBe('true') - expect(await response.text()).toContain('ACTION_A_OK:/a') await expect(page.getByText('Result: ACTION_A_OK:/a')).toBeVisible() + await expect(page).toHaveURL(f.url('/b')) await expect( page.getByRole('heading', { name: 'This is page "b"' }), ).toBeVisible() From de5dad77db17851a8a64e7c030c4d09f41ab7feb Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:10:05 +0900 Subject: [PATCH 18/33] fix(rsc): bypass route manifest in dev Co-authored-by: OpenCode --- .../e2e/action-reachability.test.ts | 18 ++++++ .../examples/action-reachability/README.md | 2 + .../route-action-manifest-plugin.ts | 6 +- .../src/framework/entry.rsc.tsx | 60 ++++++++++--------- .../src/framework/virtual.d.ts | 2 +- 5 files changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index ceb03579d..20f952583 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -37,3 +37,21 @@ test.describe('build', () => { ).toBeVisible() }) }) + +test.describe('dev', () => { + const f = useFixture({ + root: 'examples/action-reachability', + mode: 'dev', + }) + + test('executes a retained action on the current route', async ({ page }) => { + await page.goto(f.url('/a')) + await waitForHydration(page) + await page.getByRole('button', { name: 'Save action A' }).click() + await page.getByRole('link', { name: '/b' }).click() + await page.getByRole('button', { name: 'Run saved action' }).click() + + await expect(page.getByText('Result: ACTION_A_OK:/b')).toBeVisible() + await expect(page).toHaveURL(f.url('/b')) + }) +}) diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index 3239826e4..21752a9ce 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -20,3 +20,5 @@ routes/a/page.tsx During the final RSC build, the framework plugin traverses from each page root and records reachable Client Component references. During the final client build, it calls plugin-RSC's experimental `manager.getClientToServerReferenceReachability(this)` method and joins the two relations into `dist/client/route-action-manifest.json`. A shared browser module stores a server reference without statically importing either route's action. Saving action A on `/a`, navigating to `/b`, and invoking the saved value sends the request to route B. The RSC handler consults the generated manifest and redispatches the request through `/a` within the same server output. Route-local `middleware.ts` establishes an action context with `AsyncLocalStorage`, so the action can verify that it executes under route A's middleware while the response continues rendering the visible `/b` route. This example intentionally covers the explicit-ID hydrated transport and does not parse React's progressive multipart action protocol. + +The route manifest is a production-build feature. In development, no manifest is installed and the retained action executes under the current `/b` route context. diff --git a/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts index 33b2cc089..47b63993b 100644 --- a/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts +++ b/packages/plugin-rsc/examples/action-reachability/route-action-manifest-plugin.ts @@ -31,17 +31,17 @@ export function routeActionManifestPlugin(): Plugin { }, load(id) { if (id === '\0' + ROUTE_ACTION_MANIFEST_ID) { - return 'export default {}' + return 'export default null' } }, generateBundle() { if (this.environment.name === 'rsc') { // Collect each route's direct actions and reachable Client Components. - for (const [route, sources] of Object.entries(routes)) { + for (const [route, roots] of Object.entries(routes)) { const clientReferenceKeys = new Set() const directServerReferenceIds = new Set() const visited = new Set() - const queue = sources.map((source) => + const queue = roots.map((source) => normalizePath(path.resolve(source)), ) for (let index = 0; index < queue.length; index++) { diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx index 12657ae66..c156ef0a1 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx @@ -39,37 +39,39 @@ async function handleRequest( if (renderRequest.isAction === true) { if (renderRequest.actionId) { // Select a route whose application graph reaches the requested action. - actionRoute = Object.entries(routeActionManifest).find(([, actionIds]) => - actionIds.includes(renderRequest.actionId!), - )?.[0] - if (!actionRoute) { - return new Response('Server action is not reachable from any route', { - status: 404, - }) - } - if (actionRoute !== renderRequest.url.pathname) { - // Redispatch through the action route so its middleware establishes context. - if (request.headers.has('x-action-forwarded')) { - return new Response( - 'Forwarded server action reached the wrong route', - { - status: 404, - }, + if (routeActionManifest) { + actionRoute = Object.entries(routeActionManifest).find( + ([, actionIds]) => actionIds.includes(renderRequest.actionId!), + )?.[0] + if (!actionRoute) { + return new Response('Server action is not reachable from any route', { + status: 404, + }) + } + if (actionRoute !== renderRequest.url.pathname) { + // Redispatch through the action route so its middleware establishes context. + if (request.headers.has('x-action-forwarded')) { + return new Response( + 'Forwarded server action reached the wrong route', + { + status: 404, + }, + ) + } + const targetUrl = new URL(request.url) + targetUrl.pathname = actionRoute + '_.rsc' + const headers = new Headers(request.headers) + headers.set('x-action-forwarded', '1') + // Render the visible route after the action runs through another route. + headers.set('x-rsc-render-url', renderRequest.url.href) + return handler( + new Request(targetUrl, { + method: request.method, + headers, + body: await request.arrayBuffer(), + }), ) } - const targetUrl = new URL(request.url) - targetUrl.pathname = actionRoute + '_.rsc' - const headers = new Headers(request.headers) - headers.set('x-action-forwarded', '1') - // Render the visible route after the action runs through another route. - headers.set('x-rsc-render-url', renderRequest.url.href) - return handler( - new Request(targetUrl, { - method: request.method, - headers, - body: await request.arrayBuffer(), - }), - ) } const contentType = request.headers.get('content-type') const body = contentType?.startsWith('multipart/form-data') diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts index 8e427aed1..e4a8ce208 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/virtual.d.ts @@ -1,4 +1,4 @@ declare module 'virtual:route-action-manifest' { - const manifest: Record + const manifest: Record | null export default manifest } From e7f4ff3956d98a280ddbd2577f60b21370916a44 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:12:16 +0900 Subject: [PATCH 19/33] test(rsc): keep reachability coverage behavioral Co-authored-by: OpenCode --- packages/plugin-rsc/e2e/action-reachability.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 20f952583..1ad655aa9 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -11,12 +11,6 @@ test.describe('build', () => { test('dispatches a retained action to its reachable route', async ({ page, }) => { - const manifest: Record = JSON.parse( - f.createEditor('dist/client/route-action-manifest.json').read(), - ) - expect(manifest['/a']).toEqual([expect.stringMatching(/#actionA$/)]) - expect(manifest['/b']).toEqual([expect.stringMatching(/#actionB$/)]) - // /a -> save action A -> navigate to /b -> run action A through /a await page.goto(f.url('/a')) await waitForHydration(page) From 91d1cbb35185e738863081371df2454a84fe1fd9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:26:47 +0900 Subject: [PATCH 20/33] test(rsc): align reachability scenarios Co-authored-by: OpenCode --- .../e2e/action-reachability.test.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 1ad655aa9..f59fd92f8 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -8,10 +8,8 @@ test.describe('build', () => { mode: 'build', }) - test('dispatches a retained action to its reachable route', async ({ - page, - }) => { - // /a -> save action A -> navigate to /b -> run action A through /a + test('executes a retained action through /a', async ({ page }) => { + // The production manifest redispatches the /b request through /a. await page.goto(f.url('/a')) await waitForHydration(page) await page.getByRole('button', { name: 'Save action A' }).click() @@ -38,14 +36,24 @@ test.describe('dev', () => { mode: 'dev', }) - test('executes a retained action on the current route', async ({ page }) => { + test('executes a retained action through /b', async ({ page }) => { + // Development has no route manifest, so the same request stays on /b. await page.goto(f.url('/a')) await waitForHydration(page) await page.getByRole('button', { name: 'Save action A' }).click() await page.getByRole('link', { name: '/b' }).click() - await page.getByRole('button', { name: 'Run saved action' }).click() + await expect( + page.getByRole('heading', { name: 'This is page "b"' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Run saved action' }), + ).toBeEnabled() + await page.getByRole('button', { name: 'Run saved action' }).click() await expect(page.getByText('Result: ACTION_A_OK:/b')).toBeVisible() await expect(page).toHaveURL(f.url('/b')) + await expect( + page.getByRole('heading', { name: 'This is page "b"' }), + ).toBeVisible() }) }) From 739d4e775c44449206a59f9ef6efec93a3b5c304 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:36:46 +0900 Subject: [PATCH 21/33] refactor(rsc): clarify indirect action value flow Co-authored-by: OpenCode --- .../plugin-rsc/examples/action-reachability/README.md | 2 +- .../src/routes/a/action-indirect.ts | 7 +++++++ .../action-reachability/src/routes/a/client.tsx | 11 ++++------- .../action-reachability/src/routes/a/commands.tsx | 4 ---- 4 files changed, 12 insertions(+), 12 deletions(-) create mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/a/action-indirect.ts delete mode 100644 packages/plugin-rsc/examples/action-reachability/src/routes/a/commands.tsx diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index 21752a9ce..da201a63a 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -13,7 +13,7 @@ Route A's server reference crosses a Client Component boundary through an ordina ```text routes/a/page.tsx -> client.tsx ("use client") - -> commands.tsx exports { actionA } + -> action-indirect.ts returns actionA through ordinary runtime value flow -> action.tsx ("use server") ``` diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/a/action-indirect.ts b/packages/plugin-rsc/examples/action-reachability/src/routes/a/action-indirect.ts new file mode 100644 index 000000000..0d2598cc2 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/a/action-indirect.ts @@ -0,0 +1,7 @@ +import { actionA } from './action.tsx' + +// Return the server reference through ordinary runtime value flow, which +// import/export binding reconstruction cannot follow. +export function getActionA() { + return actionA +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx index 83216647c..92be151c1 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/routes/a/client.tsx @@ -2,19 +2,16 @@ import React from 'react' import { getSavedAction, setSavedAction } from '../saved-action.ts' -import { commands } from './commands.tsx' +import { getActionA } from './action-indirect.ts' export function ActionA() { const [result, setResult] = React.useState('none') const savedAction = getSavedAction() + const actionA = getActionA() return (
- - + +