From 9ae64359361e5cb69beadf264b2342930779c0e1 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 11:47:10 +0100 Subject: [PATCH 01/15] feat(rsc): add onServerReference callback to decode APIs --- packages/plugin-rsc/README.md | 34 +++++ .../e2e/server-reference-callback.test.ts | 136 ++++++++++++++++++ packages/plugin-rsc/src/core/rsc.ts | 22 ++- .../plugin-rsc/src/react/rsc/server.node.ts | 22 ++- packages/plugin-rsc/src/react/rsc/server.ts | 19 ++- packages/plugin-rsc/src/types/index.ts | 4 + 6 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 packages/plugin-rsc/e2e/server-reference-callback.test.ts diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index be7e8d607..dc36dee2b 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -518,6 +518,40 @@ function renderToReadableStream( ): ReadableStream ``` +#### Vite-specific extension: `decodeReply`, `decodeAction`, and `decodeFormState` (experimental) + +> [!NOTE] +> This is a Vite-specific extension to the standard React RSC API. The official `react-server-dom` does not provide this callback mechanism. + +`decodeReply`, `decodeAction`, and `decodeFormState` (and `decodeReplyFromBusboy` in `@vitejs/plugin-rsc/rsc/server.node`) are extended with an optional last parameter with `onServerReference` callback. +This is invoked whenever React resolves a server reference while decoding: the action itself in `decodeAction`, and any server function passed as an argument or bound to an action. +Throwing from the callback rejects the reference before its module is loaded, which allows a framework to limit decoding to the server functions it exposes for the request. + +```ts +type ServerManifestExtraOptions = { + onServerReference?: (metadata: { id: string; name: string }) => void +} + +function decodeReply( + body: string | FormData, + // standard options (e.g. temporaryReferences) + options?: object, + // vite-specific options + extraOptions?: ServerManifestExtraOptions, +): Promise + +function decodeAction( + body: FormData, + extraOptions?: ServerManifestExtraOptions, +): Promise<() => Promise> + +function decodeFormState( + actionResult: unknown, + body: FormData, + extraOptions?: ServerManifestExtraOptions, +): Promise +``` + ### `@vitejs/plugin-rsc/ssr` This module provides Vite-integrated RSC runtime APIs based on diff --git a/packages/plugin-rsc/e2e/server-reference-callback.test.ts b/packages/plugin-rsc/e2e/server-reference-callback.test.ts new file mode 100644 index 000000000..bedd9f257 --- /dev/null +++ b/packages/plugin-rsc/e2e/server-reference-callback.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from '@playwright/test' +import { setupInlineFixture, type Fixture, useFixture } from './fixture' +import { waitForHydration } from './helper' + +test.describe(() => { + const root = 'examples/e2e/temp/server-reference-callback' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter-extra', + dest: root, + files: { + 'src/root.tsx': /* tsx */ ` + import { + allowedReference, + describeArgument, + rejectedReference, + } from './server-reference.tsx' + import { calls } from './calls.tsx' + + export function Root() { + return ( + + + + + +
+ +
+
+ +
+

[{calls.join(',')}]

+ + + ) + } + `, + 'src/calls.tsx': /* tsx */ ` + export const calls: string[] = [] + `, + 'src/server-reference.tsx': /* tsx */ ` + 'use server' + import { calls } from './calls.tsx' + + export async function describeArgument(argument: unknown) { + calls.push(typeof argument) + } + + export async function allowedReference() {} + + export async function rejectedReference() {} + `, + 'src/framework/entry.rsc.tsx': { + edit: (s) => + s + .replace( + `decodeReply(body, { temporaryReferences })`, + `decodeReply(body, { temporaryReferences }, { onServerReference })`, + ) + .replace( + `decodeAction(formData)`, + `decodeAction(formData, { onServerReference })`, + ) + .replace( + `decodeFormState(result, formData)`, + `decodeFormState(result, formData, { onServerReference })`, + ) + + /* tsx */ ` +function onServerReference(metadata: { id: string; name: string }) { + if (metadata.name === 'rejectedReference') { + throw new Error('rejected server reference: ' + metadata.id + '#' + metadata.name) + } +} +`, + }, + }, + }) + }) + + function defineServerReferenceCallbackTest(f: Fixture) { + test('onServerReference rejects decoded server references', async ({ + browser, + page, + }) => { + // decodeReply + await page.goto(f.url()) + await waitForHydration(page) + const rejected = page.waitForResponse( + (response) => response.request().method() === 'POST', + ) + await page.getByRole('button', { name: 'rejected' }).click() + expect((await rejected).status()).toBe(500) + + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('calls')).toHaveText('[]') + await page.getByRole('button', { name: 'allowed' }).click() + await expect(page.getByTestId('calls')).toHaveText('[function]') + + // decodeAction and decodeFormState + const context = await browser.newContext({ javaScriptEnabled: false }) + try { + const nojsPage = await context.newPage() + await nojsPage.goto(f.url()) + const [rejectedForm] = await Promise.all([ + nojsPage.waitForResponse( + (response) => response.request().method() === 'POST', + ), + nojsPage.getByRole('button', { name: 'rejected' }).click(), + ]) + expect(rejectedForm.status()).toBe(500) + + await nojsPage.goto(f.url()) + await expect(nojsPage.getByTestId('calls')).toHaveText('[function]') + await nojsPage.getByRole('button', { name: 'allowed' }).click() + await expect(nojsPage.getByTestId('calls')).toHaveText( + '[function,function]', + ) + } finally { + await context.close() + } + }) + } + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineServerReferenceCallbackTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineServerReferenceCallbackTest(f) + }) +}) diff --git a/packages/plugin-rsc/src/core/rsc.ts b/packages/plugin-rsc/src/core/rsc.ts index a4573a39e..85020db75 100644 --- a/packages/plugin-rsc/src/core/rsc.ts +++ b/packages/plugin-rsc/src/core/rsc.ts @@ -97,9 +97,11 @@ export async function loadServerAction(id: string): Promise { return mod[name] } -export function createServerManifest(options?: { - preserveServerReferences?: boolean -}): BundlerConfig { +export function createServerManifest( + options?: CreateServerManifestOptions & { + preserveServerReferences?: boolean + }, +): BundlerConfig { const prefix = options?.preserveServerReferences ? SERVER_REFERENCE_PRESERVE_PREFIX : '' @@ -113,6 +115,7 @@ export function createServerManifest(options?: { let [id, name] = $$id.split('#') tinyassert(id) tinyassert(name) + options?.onServerReference?.({ id, name }) return { id: SERVER_REFERENCE_PREFIX + prefix + id + cacheTag, name, @@ -180,3 +183,16 @@ export interface ClientReferenceMetadata { export interface CreateClientManifestOptions { onClientReference?: (metadata: ClientReferenceMetadata) => void } + +export interface ServerReferenceMetadata { + id: string + name: string +} + +export interface CreateServerManifestOptions { + /** + * Called for each server reference React resolves while decoding. + * Throw to reject the reference before its module loads. + */ + onServerReference?: (metadata: ServerReferenceMetadata) => void +} diff --git a/packages/plugin-rsc/src/react/rsc/server.node.ts b/packages/plugin-rsc/src/react/rsc/server.node.ts index 98f046763..6932b1ec6 100644 --- a/packages/plugin-rsc/src/react/rsc/server.node.ts +++ b/packages/plugin-rsc/src/react/rsc/server.node.ts @@ -5,6 +5,7 @@ import { createClientManifest, createServerManifest, type CreateClientManifestOptions, + type CreateServerManifestOptions, } from '../../core/rsc' import type { DecodeReplyFromBusboyFunction, @@ -59,29 +60,38 @@ export const registerServerReference: ( name: string, ) => T = ReactServerNode.registerServerReference -export const decodeReply: DecodeReplyFunction = (body, options) => - ReactServerNode.decodeReply(body, createServerManifest(), options) +export const decodeReply: DecodeReplyFunction = (body, options, extraOptions) => + ReactServerNode.decodeReply(body, createServerManifest(extraOptions), options) export const decodeReplyFromBusboy: DecodeReplyFromBusboyFunction = ( body, options, + extraOptions, ) => - ReactServerNode.decodeReplyFromBusboy(body, createServerManifest(), options) + ReactServerNode.decodeReplyFromBusboy( + body, + createServerManifest(extraOptions), + options, + ) // TODO: Expose decodeReplyFromAsyncIterable with a shared Edge/Node type. -export function decodeAction(body: FormData): Promise<() => Promise> { - return ReactServerNode.decodeAction(body, createServerManifest()) +export function decodeAction( + body: FormData, + extraOptions?: CreateServerManifestOptions, +): Promise<() => Promise> { + return ReactServerNode.decodeAction(body, createServerManifest(extraOptions)) } export function decodeFormState( actionResult: unknown, body: FormData, + extraOptions?: CreateServerManifestOptions, ): Promise { return ReactServerNode.decodeFormState( actionResult, body, - createServerManifest(), + createServerManifest(extraOptions), ) } diff --git a/packages/plugin-rsc/src/react/rsc/server.ts b/packages/plugin-rsc/src/react/rsc/server.ts index 9136c0696..3ce6e71c2 100644 --- a/packages/plugin-rsc/src/react/rsc/server.ts +++ b/packages/plugin-rsc/src/react/rsc/server.ts @@ -5,6 +5,7 @@ import { createClientManifest, createServerManifest, type CreateClientManifestOptions, + type CreateServerManifestOptions, } from '../../core/rsc' import type { DecodeReplyFunction, @@ -42,18 +43,26 @@ export const registerServerReference: ( name: string, ) => T = ReactServer.registerServerReference -export const decodeReply: DecodeReplyFunction = (body, options) => - ReactServer.decodeReply(body, createServerManifest(), options) +export const decodeReply: DecodeReplyFunction = (body, options, extraOptions) => + ReactServer.decodeReply(body, createServerManifest(extraOptions), options) -export function decodeAction(body: FormData): Promise<() => Promise> { - return ReactServer.decodeAction(body, createServerManifest()) +export function decodeAction( + body: FormData, + extraOptions?: CreateServerManifestOptions, +): Promise<() => Promise> { + return ReactServer.decodeAction(body, createServerManifest(extraOptions)) } export function decodeFormState( actionResult: unknown, body: FormData, + extraOptions?: CreateServerManifestOptions, ): Promise { - return ReactServer.decodeFormState(actionResult, body, createServerManifest()) + return ReactServer.decodeFormState( + actionResult, + body, + createServerManifest(extraOptions), + ) } export const createTemporaryReferenceSet: () => ServerTemporaryReferenceSet = diff --git a/packages/plugin-rsc/src/types/index.ts b/packages/plugin-rsc/src/types/index.ts index 5e6a4fdfa..b4bd538b5 100644 --- a/packages/plugin-rsc/src/types/index.ts +++ b/packages/plugin-rsc/src/types/index.ts @@ -1,3 +1,5 @@ +import type { CreateServerManifestOptions } from '../core/rsc' + export interface ImportManifestEntry { id: string name: string @@ -139,12 +141,14 @@ export type EncodeReplyFunction = ( export type DecodeReplyFunction = ( body: string | FormData, options?: DecodeReplyOptions, + extraOptions?: CreateServerManifestOptions, ) => Promise // https://github.com/facebook/react/blob/6117d7cca4906492c51fe6a03381e35adfd86e7d/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js#L566-L573 export type DecodeReplyFromBusboyFunction = ( body: import('node:stream').Writable, // BusBoy instance options?: DecodeReplyOptions, + extraOptions?: CreateServerManifestOptions, ) => Promise // https://github.com/react/react/blob/6117d7cca4906492c51fe6a03381e35adfd86e7d/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js#L159-L162 From 7c6cb4b124ef3bf318bfa03c8cf57a4110bc069b Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 11:52:28 +0100 Subject: [PATCH 02/15] docs(rsc): clarify onServerReference coverage --- packages/plugin-rsc/README.md | 22 ++++++------------- .../e2e/server-reference-callback.test.ts | 2 +- packages/plugin-rsc/src/core/rsc.ts | 1 + 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index dc36dee2b..5f7cd5400 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -525,31 +525,23 @@ function renderToReadableStream( `decodeReply`, `decodeAction`, and `decodeFormState` (and `decodeReplyFromBusboy` in `@vitejs/plugin-rsc/rsc/server.node`) are extended with an optional last parameter with `onServerReference` callback. This is invoked whenever React resolves a server reference while decoding: the action itself in `decodeAction`, and any server function passed as an argument or bound to an action. +`id` and `name` match the `${id}#${name}` id that `loadServerAction` receives. Throwing from the callback rejects the reference before its module is loaded, which allows a framework to limit decoding to the server functions it exposes for the request. +`loadServerAction` doesn't invoke the callback, so a framework should check the action id it passes there separately. ```ts -type ServerManifestExtraOptions = { - onServerReference?: (metadata: { id: string; name: string }) => void -} - function decodeReply( body: string | FormData, // standard options (e.g. temporaryReferences) options?: object, // vite-specific options - extraOptions?: ServerManifestExtraOptions, + extraOptions?: { + onServerReference?: (metadata: { id: string; name: string }) => void + }, ): Promise -function decodeAction( - body: FormData, - extraOptions?: ServerManifestExtraOptions, -): Promise<() => Promise> - -function decodeFormState( - actionResult: unknown, - body: FormData, - extraOptions?: ServerManifestExtraOptions, -): Promise +// `decodeAction(body, extraOptions?)` and +// `decodeFormState(actionResult, body, extraOptions?)` accept the same `extraOptions`. ``` ### `@vitejs/plugin-rsc/ssr` diff --git a/packages/plugin-rsc/e2e/server-reference-callback.test.ts b/packages/plugin-rsc/e2e/server-reference-callback.test.ts index bedd9f257..3fbc9af34 100644 --- a/packages/plugin-rsc/e2e/server-reference-callback.test.ts +++ b/packages/plugin-rsc/e2e/server-reference-callback.test.ts @@ -99,7 +99,7 @@ function onServerReference(metadata: { id: string; name: string }) { await page.getByRole('button', { name: 'allowed' }).click() await expect(page.getByTestId('calls')).toHaveText('[function]') - // decodeAction and decodeFormState + // decodeAction const context = await browser.newContext({ javaScriptEnabled: false }) try { const nojsPage = await context.newPage() diff --git a/packages/plugin-rsc/src/core/rsc.ts b/packages/plugin-rsc/src/core/rsc.ts index 85020db75..dfc7405d8 100644 --- a/packages/plugin-rsc/src/core/rsc.ts +++ b/packages/plugin-rsc/src/core/rsc.ts @@ -193,6 +193,7 @@ export interface CreateServerManifestOptions { /** * Called for each server reference React resolves while decoding. * Throw to reject the reference before its module loads. + * @experimental */ onServerReference?: (metadata: ServerReferenceMetadata) => void } From d2b3f8d91c3f28a2e6daafc035c4f2e637bb3231 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:39:55 +0900 Subject: [PATCH 03/15] chore(rsc): validate progressive actions with onServerReference in action-reachability example The progressive form path rejects server references that the current route cannot reach through the `onServerReference` callback of `decodeAction` and `decodeFormState`, so React's own decoder does the checking instead of a pre-decode copy of its `$ACTION_*` field parsing. Co-Authored-By: Claude Opus 5.5 --- .../src/framework/action-routing.ts | 103 ++---------------- .../src/framework/entry.rsc.tsx | 29 +++-- 2 files changed, 31 insertions(+), 101 deletions(-) diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts index 77d9534c6..2d92b90e1 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts @@ -48,107 +48,22 @@ export function routeActionRequest( } } -const $ACTION_ = '$ACTION_' -const $ACTION_REF_ = '$ACTION_REF_' -const $ACTION_ID_ = '$ACTION_ID_' +export class UnreachableServerReferenceError extends Error {} /** - * This function mirrors logic inside React's decodeAction and should be kept in - * sync with that. It pre-parses the FormData to ensure that all action IDs are - * reachable from the current route. - * Adapted from Next.js's pre-decode validation: - * https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/packages/next/src/server/app-render/action-handler.ts#L1467-L1576 + * Rejects server references that the current route cannot reach while React + * decodes a progressive action, including references bound to the action. */ -export function areAllActionIdsValid( - mpaFormData: FormData, +export function createServerReferenceValidator( pathname: string, -): boolean { +): ((metadata: { id: string; name: string }) => void) | undefined { if (!routeActionManifest) { - return true + return } - const reachableActionIds = routeActionManifest[pathname] ?? [] - let seenActionRefs = 0 - let hasAtLeastOneAction = false - // Before we attempt to decode the payload for a possible MPA action, assert that all - // action IDs are valid IDs. If not we should disregard the payload - for (const key of mpaFormData.keys()) { - if (!key.startsWith($ACTION_)) { - // not a relevant field - continue + return ({ id, name }) => { + if (!reachableActionIds.includes(`${id}#${name}`)) { + throw new UnreachableServerReferenceError(`${id}#${name}`) } - - if (key.startsWith($ACTION_ID_)) { - // No Bound args case - if (isInvalidActionIdFieldName(key, reachableActionIds)) { - return false - } - - hasAtLeastOneAction = true - } else if (key.startsWith($ACTION_REF_)) { - if (++seenActionRefs > 2) { - // We only expect to see at most 2 $ACTION_REF_ fields in the form data: - // one from
- // and one from - return false - } - - // Bound args case - const actionDescriptorField = - $ACTION_ + key.slice($ACTION_REF_.length) + ':0' - const actionFields = mpaFormData.getAll(actionDescriptorField) - if (actionFields.length !== 1) { - return false - } - const actionField = actionFields[0] - if (typeof actionField !== 'string') { - return false - } - - if (isInvalidStringActionDescriptor(actionField, reachableActionIds)) { - return false - } - hasAtLeastOneAction = true - } - } - return hasAtLeastOneAction -} - -const ACTION_DESCRIPTOR_ID_PREFIX = '{"id":"' -function isInvalidStringActionDescriptor( - actionDescriptor: string, - reachableActionIds: readonly string[], -): unknown { - if (actionDescriptor.startsWith(ACTION_DESCRIPTOR_ID_PREFIX) === false) { - return true } - - const from = ACTION_DESCRIPTOR_ID_PREFIX.length - const to = actionDescriptor.indexOf('"', from) - if (to === -1) { - return true - } - - // We expect actionDescriptor to be '{"id":"",...}' - const actionId = actionDescriptor.slice(from, to) - if (!reachableActionIds.includes(actionId)) { - return true - } - - return false -} - -function isInvalidActionIdFieldName( - actionIdFieldName: string, - reachableActionIds: readonly string[], -): boolean { - // The field name must always start with $ACTION_ID_ but since the action id - // is extracted from the key of the field we have already validated this - // before entering this function. - const actionId = actionIdFieldName.slice($ACTION_ID_.length) - if (!reachableActionIds.includes(actionId)) { - return true - } - - return false } 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 7c580a8fb..97856feb1 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 @@ -8,7 +8,11 @@ import { } from '@vitejs/plugin-rsc/rsc' import type { ReactFormState } from 'react-dom/client' import { getRoute, RouteRoot } from '../app/routes.tsx' -import { areAllActionIdsValid, routeActionRequest } from './action-routing.ts' +import { + routeActionRequest, + UnreachableServerReferenceError, + createServerReferenceValidator, +} from './action-routing.ts' import { parseRenderRequest } from './request.tsx' export type RscPayload = { @@ -60,15 +64,26 @@ async function handleRequest( } } else { const formData = await request.formData() - if (!areAllActionIdsValid(formData, renderRequest.url.pathname)) { - return new Response('Server action is not reachable from this route', { - status: 404, - }) + const onServerReference = createServerReferenceValidator( + renderRequest.url.pathname, + ) + let decodedAction: () => Promise + try { + decodedAction = await decodeAction(formData, { onServerReference }) + } catch (e) { + if (e instanceof UnreachableServerReferenceError) { + return new Response( + 'Server action is not reachable from this route', + { status: 404 }, + ) + } + throw e } - const decodedAction = await decodeAction(formData) try { const result = await decodedAction() - formState = await decodeFormState(result, formData) + formState = await decodeFormState(result, formData, { + onServerReference, + }) } catch (e) { return new Response('Internal Server Error: server action failed', { status: 500, From 261780b9113e03c7e9b9d27436da1253ed1a453a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:42:30 +0900 Subject: [PATCH 04/15] chore(rsc): validate nested server references in action-reachability example `decodeReply` also receives the `onServerReference` validator, so a server function passed as an action argument has to be reachable from the route that runs the action, just like the progressive path. New e2e cases cover a retained action passed as an argument from another route, and a progressive form whose shared action is reachable from the replay target while the server function bound to it is not. Co-Authored-By: Claude Opus 5.5 --- .../e2e/action-reachability.test.ts | 51 ++++++++++++++++++- .../examples/action-reachability/README.md | 8 +-- .../action-reachability/src/app/b/action.tsx | 4 ++ .../action-reachability/src/app/b/client.tsx | 10 +++- .../action-reachability/src/app/b/page.tsx | 8 +++ .../action-reachability/src/app/c/page.tsx | 7 +++ .../src/app/shared/action.tsx | 8 +++ .../src/framework/action-routing.ts | 3 +- .../src/framework/entry.rsc.tsx | 31 ++++++++--- 9 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 packages/plugin-rsc/examples/action-reachability/src/app/shared/action.tsx diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 7cbf2d7a4..4b423e37f 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -31,6 +31,43 @@ test.describe('build', () => { ).toBeVisible() }) + test('rejects a retained action passed as an argument', async ({ page }) => { + // Action B is reachable from /b, but the retained action A in its + // arguments is not. + 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('button', { name: 'Run action B with saved action' }), + ).toBeEnabled() + + const responsePromise = page.waitForResponse( + (response) => response.request().method() === 'POST', + ) + await page + .getByRole('button', { name: 'Run action B with saved action' }) + .click() + const response = await responsePromise + expect(response.status()).toBe(404) + await expect(page.getByText('Result: none')).toBeVisible() + }) + + test('accepts a reachable action passed as an argument', async ({ page }) => { + await page.goto(f.url('/b')) + await waitForHydration(page) + await page.getByRole('button', { name: 'Save action B' }).click() + // Navigate away and back to re-render with the saved action. + await page.getByRole('link', { name: '/a' }).click() + await page.getByRole('link', { name: '/b' }).click() + await page + .getByRole('button', { name: 'Run action B with saved action' }) + .click() + await expect( + page.getByText('Result: ACTION_B_WITH:ACTION_B_OK:MIDDLEWARE_B'), + ).toBeVisible() + }) + testNoJs( 'unbound progressive action is limited to its route', async ({ page }) => { @@ -45,7 +82,19 @@ test.describe('build', () => { }, ) - async function testProgressiveAction(page: Page, name: 'Unbound' | 'Bound') { + // The shared action is reachable from /b, but the action bound to it on /c + // is not. + testNoJs( + 'server function bound to a progressive action is limited to its route', + async ({ page }) => { + await testProgressiveAction(page, 'Shared') + }, + ) + + async function testProgressiveAction( + page: Page, + name: 'Unbound' | 'Bound' | 'Shared', + ) { // Without JavaScript, React submits the action as a native full-page (MPA) // multipart form request to the route that rendered it. await page.goto(f.url('/c')) diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index e8d058931..01eb23f83 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -42,8 +42,10 @@ Development skips route-aware redispatch. The handler executes action A on `/b`, ## Protocol scope -Hydrated action calls carry an explicit action ID and can be redispatched to a route whose graph reaches the action. Progressive forms normally post back to the route that rendered them, so their multipart `$ACTION_*` fields are instead validated against the current route before `decodeAction()` can load the submitted references. A mismatched progressive action is rejected as stale or manipulated input rather than redispatched. +Hydrated action calls carry an explicit action ID and can be redispatched to a route whose graph reaches the action. Progressive forms normally post back to the route that rendered them, so a mismatched progressive action is rejected as stale or manipulated input rather than redispatched. -Route `/c` demonstrates this behavior with unbound and bound progressive forms. Their normal submissions run through `/c` middleware, while replaying the same multipart fields to another route is rejected. +Server functions can also arrive nested inside a request, as an argument to the action or bound to it. While `decodeReply()` or `decodeAction()` decodes the request, the `onServerReference` callback checks every server reference against the route that runs the action and rejects the request before an unreachable reference loads. For example, action A retained from `/a` and passed as an argument to an action on `/b` is rejected even though the action itself is reachable from `/b`. -The route manifest is build-time data, so this progressive validation is production-only. Development keeps the baseline `decodeAction()` behavior. +Route `/c` demonstrates the progressive case with unbound and bound forms, and with a shared action that `/b` also renders, bound to an action only `/c` reaches. Their normal submissions run through `/c` middleware, while replaying the same multipart fields to `/b` is rejected. + +The route manifest is build-time data, so this validation is production-only. Development decodes every server reference. diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx index 0142ae85e..4d565c71f 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx @@ -5,3 +5,7 @@ import { getRequestContext } from '../request-context.ts' export async function actionB() { return `ACTION_B_OK:${getRequestContext().middlewareTag}` } + +export async function actionBWithCallback(callback: () => Promise) { + return `ACTION_B_WITH:${await callback()}` +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx index c020191f0..3831d35a3 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx @@ -2,7 +2,7 @@ import React from 'react' import { getSavedAction, setSavedAction } from '../saved-action.ts' -import { actionB } from './action.tsx' +import { actionB, actionBWithCallback } from './action.tsx' export function ActionB() { const [result, setResult] = React.useState('none') @@ -17,6 +17,14 @@ export function ActionB() { > Run saved action +

Result: {result}

) diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx index 5469d352a..f8490d8b0 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx @@ -1,3 +1,5 @@ +import { sharedProgressiveAction } from '../shared/action.tsx' +import { actionB } from './action.tsx' import { ActionB } from './client.tsx' export function Page() { @@ -5,6 +7,12 @@ export function Page() {

This is page "b"

+ + +
) } diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/c/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/c/page.tsx index 67759c06a..e8760d1b4 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/app/c/page.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/app/c/page.tsx @@ -1,3 +1,4 @@ +import { sharedProgressiveAction } from '../shared/action.tsx' import { boundProgressiveActionC, progressiveActionC } from './action.tsx' export function Page() { @@ -13,6 +14,12 @@ export function Page() { > +
+ +
) } diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/shared/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/shared/action.tsx new file mode 100644 index 000000000..b0fe11a03 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/shared/action.tsx @@ -0,0 +1,8 @@ +'use server' + +export async function sharedProgressiveAction( + callback: (formData: FormData) => Promise, + formData: FormData, +) { + await callback(formData) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts index 2d92b90e1..949eee41a 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts @@ -52,7 +52,8 @@ export class UnreachableServerReferenceError extends Error {} /** * Rejects server references that the current route cannot reach while React - * decodes a progressive action, including references bound to the action. + * decodes an action request, including server functions passed as arguments + * or bound to the action. */ export function createServerReferenceValidator( pathname: string, 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 97856feb1..9be76ab59 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,6 +39,9 @@ async function handleRequest( let temporaryReferences: unknown | undefined let actionStatus: number | undefined if (renderRequest.isAction === true) { + const onServerReference = createServerReferenceValidator( + renderRequest.url.pathname, + ) if (renderRequest.actionId) { // Run the action through middleware for a page whose graph reaches it. const routing = routeActionRequest(renderRequest) @@ -53,7 +56,19 @@ async function handleRequest( ? await request.formData() : await request.text() temporaryReferences = createTemporaryReferenceSet() - const args = await decodeReply(body, { temporaryReferences }) + let args: unknown[] + try { + args = await decodeReply( + body, + { temporaryReferences }, + { onServerReference }, + ) + } catch (e) { + if (e instanceof UnreachableServerReferenceError) { + return unreachableServerReferenceResponse() + } + throw e + } const action = await loadServerAction(renderRequest.actionId) try { const data = await action.apply(null, args) @@ -64,18 +79,12 @@ async function handleRequest( } } else { const formData = await request.formData() - const onServerReference = createServerReferenceValidator( - renderRequest.url.pathname, - ) let decodedAction: () => Promise try { decodedAction = await decodeAction(formData, { onServerReference }) } catch (e) { if (e instanceof UnreachableServerReferenceError) { - return new Response( - 'Server action is not reachable from this route', - { status: 404 }, - ) + return unreachableServerReferenceResponse() } throw e } @@ -125,6 +134,12 @@ async function handleRequest( }) } +function unreachableServerReferenceResponse() { + return new Response('Server action is not reachable from this route', { + status: 404, + }) +} + if (import.meta.hot) { import.meta.hot.accept() } From 9e53ea953f6199accfa9572fd3c142f93be5276e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:10:30 +0900 Subject: [PATCH 05/15] docs(rsc): add figures to action-reachability example README The README walks through manifest generation, request redispatch, and the decode-time check for nested server references and progressive forms, each with a figure. It also links the comparable Next.js behavior for progressive form validation and for nested server references after navigation. Co-Authored-By: Claude Opus 5.5 --- .../examples/action-reachability/README.md | 27 ++++++----- .../action-reachability/images/manifest.svg | 48 +++++++++++++++++++ .../images/nested-check.svg | 45 +++++++++++++++++ .../action-reachability/images/redispatch.svg | 42 ++++++++++++++++ 4 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 packages/plugin-rsc/examples/action-reachability/images/manifest.svg create mode 100644 packages/plugin-rsc/examples/action-reachability/images/nested-check.svg create mode 100644 packages/plugin-rsc/examples/action-reachability/images/redispatch.svg diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index 01eb23f83..1a2a3c08c 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -15,9 +15,9 @@ The example follows this sequence: | Production | `/a` middleware | `ACTION_A_OK:MIDDLEWARE_A` | `/b` | | Development | `/b` middleware | `ACTION_A_OK:MIDDLEWARE_B` | `/b` | -In production, a generated route-action manifest lets the RSC handler redispatch the action request through middleware for a page whose graph reaches the action. This example enables manifest routing only in production, so development stays on the current route. +In production, a generated route-action manifest tells the RSC handler which server references each route reaches. The handler uses it to redispatch an action request through middleware for a page whose graph reaches the action, and to reject server references that the route running the action cannot reach. The manifest is build-time data, so development stays on the current route and decodes every server reference. -## Application graphs +## Manifest generation For simplicity, the app routes and their graph roots are declared manually. Route `/a` reaches action A through a Client Component and an ordinary runtime return value: @@ -28,24 +28,29 @@ src/app/a/page.tsx -> action.tsx ("use server") ``` -## Manifest generation +The final RSC graph ends at the Client Component proxy, so the edge to action A exists only in the final client graph. During the RSC build, the manifest plugin traverses each route graph and records directly reachable server reference IDs and reachable Client Component import IDs. During the client build, it traverses the client graph from those import IDs and records reachable server reference IDs. For each route, it unions the two sets, and after all environment builds finish, it writes the mapping into the RSC output for runtime use. -During the RSC build, the manifest plugin traverses each route graph and records directly reachable server reference IDs and reachable Client Component import IDs. During the client build, it traverses the final client graph from those import IDs and records reachable server reference IDs. For each route, it unions the directly reachable IDs with the IDs reachable through its Client Components. +![The manifest joins the RSC graph up to Client Components with the client graph from there to server references](images/manifest.svg) -After all environment builds finish, the plugin installs the mapping in the RSC output for runtime routing. +Each entry has the same `#` form that the RSC runtime uses for server references, so the runtime checks compare strings directly. ## Request redispatch -For the production scenario above, the RSC handler finds action A under `/a` in the manifest and creates a new action request for `/a`. That request re-enters `/a` middleware, so the action observes `MIDDLEWARE_A`. It also preserves `/b` as the render URL, so the response continues rendering page B. +A hydrated action call carries its action ID in a request header, so the handler can choose a route before decoding the body. For the production scenario above, the handler finds action A under `/a` in the manifest and creates a new action request for `/a`. That request re-enters `/a` middleware inside the original `/b` request, so the action observes `MIDDLEWARE_A`. It also preserves `/b` as the render URL, so the response continues rendering page B. + +![The /b handler redispatches action A to /a, whose middleware runs the action, and the response renders page B](images/redispatch.svg) Development skips route-aware redispatch. The handler executes action A on `/b`, so the action observes `MIDDLEWARE_B`. -## Protocol scope +## Nested server references and progressive forms + +Routing sees only the outer action ID, but server functions can also arrive nested inside a request, as an argument to the action or bound to it. React finds these only while `decodeReply()` or `decodeAction()` decodes the body, so the handler passes an `onServerReference` callback that checks every server reference against the route running the action. The callback throws before an unreachable reference's module loads, and the handler responds with 404. -Hydrated action calls carry an explicit action ID and can be redispatched to a route whose graph reaches the action. Progressive forms normally post back to the route that rendered them, so a mismatched progressive action is rejected as stale or manipulated input rather than redispatched. +Progressive forms have no action ID header and normally post back to the route that rendered them. A mismatched progressive action is therefore rejected as stale or manipulated input rather than redispatched, and this decode-time check is the only one it passes through. Next.js similarly validates progressive form fields against the current page's server module map, using a [pre-decode parser](https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/packages/next/src/server/app-render/action-handler.ts#L1467-L1576) of React's multipart fields. -Server functions can also arrive nested inside a request, as an argument to the action or bound to it. While `decodeReply()` or `decodeAction()` decodes the request, the `onServerReference` callback checks every server reference against the route that runs the action and rejects the request before an unreachable reference loads. For example, action A retained from `/a` and passed as an argument to an action on `/b` is rejected even though the action itself is reachable from `/b`. +![With JS, routing accepts the outer ID but decoding rejects the nested action A. Without JS, decoding accepts the shared action but rejects the bound /c action](images/nested-check.svg) -Route `/c` demonstrates the progressive case with unbound and bound forms, and with a shared action that `/b` also renders, bound to an action only `/c` reaches. Their normal submissions run through `/c` middleware, while replaying the same multipart fields to `/b` is rejected. +The example covers both cases: -The route manifest is build-time data, so this validation is production-only. Development decodes every server reference. +- On `/b`, passing a retained action A as an argument to an action on `/b` is rejected even though the outer action is reachable from `/b`. Passing a retained action B succeeds. Next.js also routes this request by the outer action ID only, and decoding then fails to find action A ([vercel/next.js#96331](https://github.com/vercel/next.js/issues/96331)). +- Route `/c` renders unbound and bound progressive forms, and a shared action that `/b` also renders, bound to an action only `/c` reaches. Their normal submissions run through `/c` middleware, while replaying the same multipart fields to `/b` is rejected. diff --git a/packages/plugin-rsc/examples/action-reachability/images/manifest.svg b/packages/plugin-rsc/examples/action-reachability/images/manifest.svg new file mode 100644 index 000000000..77baf2782 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/images/manifest.svg @@ -0,0 +1,48 @@ + + + + + + + + + + 1. RSC build graph from route /a roots + + root.tsx, a/page.tsx + + + a/client.tsx + client reference proxy, graph stops here + collects: server reference IDs imported directly + (none for /a) and client reference import IDs + + + + 2. Client build graph from those imports + + a/client.tsx ("use client") + + + a/action-indirect.ts + + + a/action.tsx ("use server") + server reference proxy: actionA + the action edge exists only in this graph + + + client reference + import ID + + + + union per route, written after both builds + + __route_action_manifest.js (build output) + + /a 598f…#actionA + /b 55d4…#actionB 55d4…#actionBWithCallback b3ee…#sharedProgressiveAction + /c 9560…#progressiveActionC 9560…#boundProgressiveActionC b3ee…#sharedProgressiveAction + + diff --git a/packages/plugin-rsc/examples/action-reachability/images/nested-check.svg b/packages/plugin-rsc/examples/action-reachability/images/nested-check.svg new file mode 100644 index 000000000..eef8a1635 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/images/nested-check.svg @@ -0,0 +1,45 @@ + + + + + + Every reference React resolves while decoding is checked against the route that runs the action + + + + With JS: decodeReply + + POST /b_.rsc + x-rsc-action: actionBWithCallback + body: [ref actionA] + + + + routing: outer ID in /b + ✓ continue on /b + + + decode: nested actionA in /b? + ✗ throw → 404 + Routing sees only the outer ID. The nested reference to actionA is found during decoding, before a/action.tsx loads. + + + + Without JS: decodeAction + + POST /b (fields from /c) + sharedProgressiveAction + .bind(progressiveActionC) + + + + decode: shared action in /b + ✓ /b renders it too + + + decode: bound C action in /b? + ✗ throw → 404 + No action ID header, so there's no routing step. Progressive forms are rejected on mismatch, never redispatched. + + The check is onServerReference from createServerReferenceValidator(pathname). In dev there's no manifest, so it is not installed. + diff --git a/packages/plugin-rsc/examples/action-reachability/images/redispatch.svg b/packages/plugin-rsc/examples/action-reachability/images/redispatch.svg new file mode 100644 index 000000000..7fa2d3c55 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/images/redispatch.svg @@ -0,0 +1,42 @@ + + + + + + + + + Browser on /b + saved actionA from /a + clicks "Run saved action" + + POST /b_.rsc + x-rsc-action: actionA + + + + + + handler: /b middleware (MIDDLEWARE_B) + routeActionRequest: manifest lists actionA under /a, not /b + → redispatch, a new request built from the same body + + POST /a_.rsc x-action-forwarded: 1 x-rsc-render-url: /b + + + + + handler: /a middleware (MIDDLEWARE_A) + routeActionRequest: actionA is under /a → continue + decodeReply → loadServerAction → actionA() + returns ACTION_A_OK:MIDDLEWARE_A + renders RouteRoot for render URL /b + so the response keeps showing page B + a second redispatch is refused: forwarded + wrong route → 404 + + + routeActionRequest outcomes + continue: the current route reaches the ID, or no manifest (dev) + redispatch: another route reaches the ID + reject 404: no route reaches the ID, or the request was already forwarded + From 1e9719f3d84d1f136661ad537b97af61c706d2cc Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:15:40 +0900 Subject: [PATCH 06/15] refactor(rsc): declare onServerReference extension on decodeReply wrappers `src/types` mirrors upstream React types, so the Vite-specific `extraOptions` parameter is declared on the `decodeReply` and `decodeReplyFromBusboy` wrappers, like `renderToReadableStream`'s, instead of on `DecodeReplyFunction` and `DecodeReplyFromBusboyFunction`. Co-Authored-By: Claude Opus 5.5 --- .../plugin-rsc/src/react/rsc/server.node.ts | 29 ++++++++++++------- packages/plugin-rsc/src/react/rsc/server.ts | 15 ++++++++-- packages/plugin-rsc/src/types/index.ts | 4 --- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/packages/plugin-rsc/src/react/rsc/server.node.ts b/packages/plugin-rsc/src/react/rsc/server.node.ts index 6932b1ec6..9c44f727b 100644 --- a/packages/plugin-rsc/src/react/rsc/server.node.ts +++ b/packages/plugin-rsc/src/react/rsc/server.node.ts @@ -8,8 +8,7 @@ import { type CreateServerManifestOptions, } from '../../core/rsc' import type { - DecodeReplyFromBusboyFunction, - DecodeReplyFunction, + DecodeReplyOptions, PipeableStream, RenderToPipeableStreamOptions, RenderToReadableStreamOptions, @@ -60,19 +59,29 @@ export const registerServerReference: ( name: string, ) => T = ReactServerNode.registerServerReference -export const decodeReply: DecodeReplyFunction = (body, options, extraOptions) => - ReactServerNode.decodeReply(body, createServerManifest(extraOptions), options) +export function decodeReply( + body: string | FormData, + options?: DecodeReplyOptions, + extraOptions?: CreateServerManifestOptions, +): Promise { + return ReactServerNode.decodeReply( + body, + createServerManifest(extraOptions), + options, + ) +} -export const decodeReplyFromBusboy: DecodeReplyFromBusboyFunction = ( - body, - options, - extraOptions, -) => - ReactServerNode.decodeReplyFromBusboy( +export function decodeReplyFromBusboy( + body: import('node:stream').Writable, // BusBoy instance + options?: DecodeReplyOptions, + extraOptions?: CreateServerManifestOptions, +): Promise { + return ReactServerNode.decodeReplyFromBusboy( body, createServerManifest(extraOptions), options, ) +} // TODO: Expose decodeReplyFromAsyncIterable with a shared Edge/Node type. diff --git a/packages/plugin-rsc/src/react/rsc/server.ts b/packages/plugin-rsc/src/react/rsc/server.ts index 3ce6e71c2..6a3117de6 100644 --- a/packages/plugin-rsc/src/react/rsc/server.ts +++ b/packages/plugin-rsc/src/react/rsc/server.ts @@ -8,7 +8,7 @@ import { type CreateServerManifestOptions, } from '../../core/rsc' import type { - DecodeReplyFunction, + DecodeReplyOptions, RenderToReadableStreamOptions, ServerTemporaryReferenceSet, } from '../../types' @@ -43,8 +43,17 @@ export const registerServerReference: ( name: string, ) => T = ReactServer.registerServerReference -export const decodeReply: DecodeReplyFunction = (body, options, extraOptions) => - ReactServer.decodeReply(body, createServerManifest(extraOptions), options) +export function decodeReply( + body: string | FormData, + options?: DecodeReplyOptions, + extraOptions?: CreateServerManifestOptions, +): Promise { + return ReactServer.decodeReply( + body, + createServerManifest(extraOptions), + options, + ) +} export function decodeAction( body: FormData, diff --git a/packages/plugin-rsc/src/types/index.ts b/packages/plugin-rsc/src/types/index.ts index b4bd538b5..5e6a4fdfa 100644 --- a/packages/plugin-rsc/src/types/index.ts +++ b/packages/plugin-rsc/src/types/index.ts @@ -1,5 +1,3 @@ -import type { CreateServerManifestOptions } from '../core/rsc' - export interface ImportManifestEntry { id: string name: string @@ -141,14 +139,12 @@ export type EncodeReplyFunction = ( export type DecodeReplyFunction = ( body: string | FormData, options?: DecodeReplyOptions, - extraOptions?: CreateServerManifestOptions, ) => Promise // https://github.com/facebook/react/blob/6117d7cca4906492c51fe6a03381e35adfd86e7d/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js#L566-L573 export type DecodeReplyFromBusboyFunction = ( body: import('node:stream').Writable, // BusBoy instance options?: DecodeReplyOptions, - extraOptions?: CreateServerManifestOptions, ) => Promise // https://github.com/react/react/blob/6117d7cca4906492c51fe6a03381e35adfd86e7d/packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js#L159-L162 From fe425125676fb53100bdb3098d7d4f33ed5f9296 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:28:16 +0900 Subject: [PATCH 07/15] test(rsc): document testProgressiveAction in action-reachability e2e Co-Authored-By: Claude Opus 5.5 --- packages/plugin-rsc/e2e/action-reachability.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 4b423e37f..2287aa05c 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -91,6 +91,10 @@ test.describe('build', () => { }, ) + /** + * Submits the named `/c` form without JavaScript, which must run on `/c`, + * then replays its fields to `/b`, which must reject them with 404. + */ async function testProgressiveAction( page: Page, name: 'Unbound' | 'Bound' | 'Shared', From abd2b7644c4e155b30f74b9e0e5892d37ccddbe6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:30:37 +0900 Subject: [PATCH 08/15] test(rsc): clarify how testProgressiveAction simulates a replayed form Co-Authored-By: Claude Opus 5.5 --- packages/plugin-rsc/e2e/action-reachability.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 2287aa05c..04d9c8594 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -93,7 +93,8 @@ test.describe('build', () => { /** * Submits the named `/c` form without JavaScript, which must run on `/c`, - * then replays its fields to `/b`, which must reject them with 404. + * then retargets the same form to `/b` to simulate a stale or manipulated + * submission, which must be rejected with 404. */ async function testProgressiveAction( page: Page, From 06e8a80d41c537b1f8953f7bf55585bdb45e94ad Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:31:58 +0900 Subject: [PATCH 09/15] test(rsc): name the progressive form variants in testProgressiveAction Co-Authored-By: Claude Opus 5.5 --- packages/plugin-rsc/e2e/action-reachability.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 04d9c8594..3eba626b3 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -92,9 +92,10 @@ test.describe('build', () => { ) /** - * Submits the named `/c` form without JavaScript, which must run on `/c`, - * then retargets the same form to `/b` to simulate a stale or manipulated - * submission, which must be rejected with 404. + * Submits one of the progressive forms on `/c` (unbound, bound, or shared) + * without JavaScript, which must run on `/c`, then retargets the same form to + * `/b` to simulate a stale or manipulated submission, which must be rejected + * with 404. */ async function testProgressiveAction( page: Page, From f8eca697d2761145459ff64b32a9e57e902dbd53 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:36:41 +0900 Subject: [PATCH 10/15] chore(rsc): explain where action-reachability checks each server reference Co-Authored-By: Claude Opus 5.5 --- .../action-reachability/src/framework/entry.rsc.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 9be76ab59..fa64defc8 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 @@ -56,6 +56,8 @@ async function handleRequest( ? await request.formData() : await request.text() temporaryReferences = createTemporaryReferenceSet() + // Routing checked only the outer action ID. Server functions nested in + // the arguments are checked while React decodes them. let args: unknown[] try { args = await decodeReply( @@ -69,6 +71,8 @@ async function handleRequest( } throw e } + // `loadServerAction` doesn't call `onServerReference`, but routing has + // already accepted this ID for the current route. const action = await loadServerAction(renderRequest.actionId) try { const data = await action.apply(null, args) @@ -78,6 +82,10 @@ async function handleRequest( actionStatus = 500 } } else { + // Progressive forms carry no action ID header, so decoding is their only + // check. React resolves the action from a `$ACTION_ID_*` field, or for a + // bound action from `$ACTION_:0` metadata, whose bound values can be + // further server references. const formData = await request.formData() let decodedAction: () => Promise try { @@ -90,6 +98,8 @@ async function handleRequest( } try { const result = await decodedAction() + // React decodes here only for `useActionState` forms (`$ACTION_KEY`), + // and `decodeAction` has already checked the same references. formState = await decodeFormState(result, formData, { onServerReference, }) From e9a146cf5b8e27c48fa3b52eab6c51afba69b132 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:38:20 +0900 Subject: [PATCH 11/15] chore(rsc): explain why decodeFormState also receives onServerReference Co-Authored-By: Claude Opus 5.5 --- .../action-reachability/src/framework/entry.rsc.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 fa64defc8..a06be3c00 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 @@ -98,8 +98,10 @@ async function handleRequest( } try { const result = await decodedAction() - // React decodes here only for `useActionState` forms (`$ACTION_KEY`), - // and `decodeAction` has already checked the same references. + // React decodes here only for `useActionState` forms (`$ACTION_KEY`). + // It reads the last `$ACTION_REF_*` field, while `decodeAction` may + // have used a later `$ACTION_ID_*` one, so a crafted body can reach + // references that `decodeAction` never checked. formState = await decodeFormState(result, formData, { onServerReference, }) From 949cdf12b111cca88a27dbb60090580b50cac82e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:39:56 +0900 Subject: [PATCH 12/15] test(rsc): reject unreachable references found by decodeFormState in action-reachability example `decodeFormState` decodes the last `$ACTION_REF_*` field, while `decodeAction` may use a later `$ACTION_ID_*` field, so a crafted progressive body can reach a server reference that `decodeAction` never checked. The example responds with 404 when that check fails, and a new e2e case posts such a body built from the server-rendered `/b` and `/c` forms. Co-Authored-By: Claude Opus 5.5 --- .../e2e/action-reachability.test.ts | 26 +++++++++++++++++++ .../src/framework/entry.rsc.tsx | 15 +++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 3eba626b3..453393ab9 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -91,6 +91,32 @@ test.describe('build', () => { }, ) + // `decodeAction` uses the last `$ACTION_REF_*` or `$ACTION_ID_*` field, but + // `decodeFormState` decodes the last `$ACTION_REF_*` field. A body with both + // runs a reachable action, then reaches action B through the other field. + testNoJs('form state decoding is limited to its route', async ({ page }) => { + const readFormFields = (name: string) => + page + .getByRole('form', { name, exact: true }) + .evaluate((form: HTMLFormElement) => + [...new FormData(form)].map(([key, value]) => [key, String(value)]), + ) + await page.goto(f.url('/b')) + const boundToActionB = await readFormFields('Shared progressive action') + await page.goto(f.url('/c')) + const unboundActionC = await readFormFields('Unbound progressive action') + + const response = await page.request.post(f.url('/c'), { + multipart: Object.fromEntries([ + ...boundToActionB, + ...unboundActionC, + ['$ACTION_KEY', 'crafted'], + ]), + }) + expect(response.status()).toBe(404) + expect(await response.text()).toContain('Server action is not reachable') + }) + /** * Submits one of the progressive forms on `/c` (unbound, bound, or shared) * without JavaScript, which must run on `/c`, then retargets the same form to 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 a06be3c00..ece9ea860 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 @@ -96,16 +96,27 @@ async function handleRequest( } throw e } + let result: unknown + try { + result = await decodedAction() + } catch (e) { + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } try { - const result = await decodedAction() // React decodes here only for `useActionState` forms (`$ACTION_KEY`). // It reads the last `$ACTION_REF_*` field, while `decodeAction` may // have used a later `$ACTION_ID_*` one, so a crafted body can reach - // references that `decodeAction` never checked. + // references that `decodeAction` never checked. The checked action + // has already run by then, so this only refuses the unchecked ones. formState = await decodeFormState(result, formData, { onServerReference, }) } catch (e) { + if (e instanceof UnreachableServerReferenceError) { + return unreachableServerReferenceResponse() + } return new Response('Internal Server Error: server action failed', { status: 500, }) From 6625ad1f026bbb10f2ad834f0eed5fb05f4862fe Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:49:46 +0900 Subject: [PATCH 13/15] test(rsc): drop crafted decodeFormState e2e case in action-reachability example `onServerReference` runs for every server reference React resolves through the manifest, so `decodeFormState` is held to the same route by construction. Co-Authored-By: Claude Opus 5.5 --- .../e2e/action-reachability.test.ts | 26 ------------------- .../src/framework/entry.rsc.tsx | 6 ++--- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 453393ab9..3eba626b3 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -91,32 +91,6 @@ test.describe('build', () => { }, ) - // `decodeAction` uses the last `$ACTION_REF_*` or `$ACTION_ID_*` field, but - // `decodeFormState` decodes the last `$ACTION_REF_*` field. A body with both - // runs a reachable action, then reaches action B through the other field. - testNoJs('form state decoding is limited to its route', async ({ page }) => { - const readFormFields = (name: string) => - page - .getByRole('form', { name, exact: true }) - .evaluate((form: HTMLFormElement) => - [...new FormData(form)].map(([key, value]) => [key, String(value)]), - ) - await page.goto(f.url('/b')) - const boundToActionB = await readFormFields('Shared progressive action') - await page.goto(f.url('/c')) - const unboundActionC = await readFormFields('Unbound progressive action') - - const response = await page.request.post(f.url('/c'), { - multipart: Object.fromEntries([ - ...boundToActionB, - ...unboundActionC, - ['$ACTION_KEY', 'crafted'], - ]), - }) - expect(response.status()).toBe(404) - expect(await response.text()).toContain('Server action is not reachable') - }) - /** * Submits one of the progressive forms on `/c` (unbound, bound, or shared) * without JavaScript, which must run on `/c`, then retargets the same form to 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 ece9ea860..3190aa43c 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 @@ -106,10 +106,8 @@ async function handleRequest( } try { // React decodes here only for `useActionState` forms (`$ACTION_KEY`). - // It reads the last `$ACTION_REF_*` field, while `decodeAction` may - // have used a later `$ACTION_ID_*` one, so a crafted body can reach - // references that `decodeAction` never checked. The checked action - // has already run by then, so this only refuses the unchecked ones. + // Every server reference React resolves goes through the manifest, so + // the callback holds this decode to the same route as `decodeAction`. formState = await decodeFormState(result, formData, { onServerReference, }) From 5ef31c9dfe58d6d89526ac43847d3c34795251c6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:18:36 +0900 Subject: [PATCH 14/15] test(rsc): note which reference each progressive form replay fails on Co-Authored-By: Claude Opus 5.5 --- .../plugin-rsc/e2e/action-reachability.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 3eba626b3..fa3de4f1a 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -82,8 +82,6 @@ test.describe('build', () => { }, ) - // The shared action is reachable from /b, but the action bound to it on /c - // is not. testNoJs( 'server function bound to a progressive action is limited to its route', async ({ page }) => { @@ -92,10 +90,14 @@ test.describe('build', () => { ) /** - * Submits one of the progressive forms on `/c` (unbound, bound, or shared) - * without JavaScript, which must run on `/c`, then retargets the same form to - * `/b` to simulate a stale or manipulated submission, which must be rejected - * with 404. + * Submits one of the progressive forms on `/c` without JavaScript, which must + * run on `/c`, then retargets the same form to `/b` to simulate a stale or + * manipulated submission, which must be rejected with 404. + * + * `decodeAction` rejects all three on `/b`, at a different reference: + * - Unbound: the action from the `$ACTION_ID_*` field. + * - Bound: the action from the `$ACTION_REF_*` metadata. + * - Shared: the action bound to it, since `/b` also reaches the shared action. */ async function testProgressiveAction( page: Page, From be9cc7f595deee45884cb62a1dfaf4f9a85e728e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:41:19 +0900 Subject: [PATCH 15/15] test(rsc): assert rejection text for retained action argument Co-Authored-By: Claude Opus 5.5 --- 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 fa3de4f1a..216ceeb3d 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -50,6 +50,7 @@ test.describe('build', () => { .click() const response = await responsePromise expect(response.status()).toBe(404) + expect(await response.text()).toContain('Server action is not reachable') await expect(page.getByText('Result: none')).toBeVisible() })