Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9ae6435
feat(rsc): add onServerReference callback to decode APIs
james-elicx Oct 19, 2026
7c6cb4b
docs(rsc): clarify onServerReference coverage
james-elicx Oct 19, 2026
d2b3f8d
chore(rsc): validate progressive actions with onServerReference in ac…
hi-ogawa Oct 20, 2026
261780b
chore(rsc): validate nested server references in action-reachability …
hi-ogawa Oct 20, 2026
9e53ea9
docs(rsc): add figures to action-reachability example README
hi-ogawa Oct 20, 2026
1e9719f
refactor(rsc): declare onServerReference extension on decodeReply wra…
hi-ogawa Oct 20, 2026
c5dda5e
Merge remote-tracking branch 'origin/main' into feat/rsc-on-server-re…
hi-ogawa Oct 20, 2026
fe42512
test(rsc): document testProgressiveAction in action-reachability e2e
hi-ogawa Oct 20, 2026
abd2b76
test(rsc): clarify how testProgressiveAction simulates a replayed form
hi-ogawa Oct 20, 2026
06e8a80
test(rsc): name the progressive form variants in testProgressiveAction
hi-ogawa Oct 20, 2026
f8eca69
chore(rsc): explain where action-reachability checks each server refe…
hi-ogawa Oct 20, 2026
e9a146c
chore(rsc): explain why decodeFormState also receives onServerReference
hi-ogawa Oct 20, 2026
949cdf1
test(rsc): reject unreachable references found by decodeFormState in …
hi-ogawa Oct 20, 2026
6625ad1
test(rsc): drop crafted decodeFormState e2e case in action-reachabili…
hi-ogawa Oct 20, 2026
5ef31c9
test(rsc): note which reference each progressive form replay fails on
hi-ogawa Oct 20, 2026
be9cc7f
test(rsc): assert rejection text for retained action argument
hi-ogawa Oct 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,32 @@ function renderToReadableStream<T>(
): ReadableStream<Uint8Array>
```

#### 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.
`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
function decodeReply(
body: string | FormData,
// standard options (e.g. temporaryReferences)
options?: object,
// vite-specific options
extraOptions?: {
onServerReference?: (metadata: { id: string; name: string }) => void
},
): Promise<unknown[]>

// `decodeAction(body, extraOptions?)` and
// `decodeFormState(actionResult, body, extraOptions?)` accept the same `extraOptions`.
```

### `@vitejs/plugin-rsc/ssr`

This module provides Vite-integrated RSC runtime APIs based on
Expand Down
60 changes: 59 additions & 1 deletion packages/plugin-rsc/e2e/action-reachability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,44 @@ 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)
expect(await response.text()).toContain('Server action is not reachable')
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 }) => {
Expand All @@ -45,7 +83,27 @@ test.describe('build', () => {
},
)

async function testProgressiveAction(page: Page, name: 'Unbound' | 'Bound') {
testNoJs(
'server function bound to a progressive action is limited to its route',
async ({ page }) => {
await testProgressiveAction(page, 'Shared')
},
)

/**
* 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,
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'))
Expand Down
136 changes: 136 additions & 0 deletions packages/plugin-rsc/e2e/server-reference-callback.test.ts
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<head>
<meta charSet="UTF-8" />
</head>
<body>
<form action={describeArgument.bind(null, allowedReference)}>
<button>allowed</button>
</form>
<form action={describeArgument.bind(null, rejectedReference)}>
<button>rejected</button>
</form>
<p data-testid="calls">[{calls.join(',')}]</p>
</body>
</html>
)
}
`,
'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
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)
})
})
27 changes: 17 additions & 10 deletions packages/plugin-rsc/examples/action-reachability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -28,22 +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 `<id>#<name>` 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.

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.

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.
![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 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.
The example covers both cases:

The route manifest is build-time data, so this progressive validation is production-only. Development keeps the baseline `decodeAction()` behavior.
- 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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading