Skip to content

Commit 85095b5

Browse files
dai-shihi-ogawaOpenCode
authored
fix(plugin-rsc): expose encodeFormAction option type and re-export encodeReply and createTemporaryReferenceSet from ssr (#1280)
Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: OpenCode <noreply@opencode.ai> Co-authored-by: Hiroshi Ogawa <hi.ogawa.zz@gmail.com>
1 parent 61637cf commit 85095b5

15 files changed

Lines changed: 425 additions & 7 deletions

File tree

packages/plugin-rsc/README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -496,17 +496,21 @@ function renderToReadableStream<T>(
496496

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

499-
This module re-exports RSC runtime API provided by `react-server-dom/client.edge`
499+
This module re-exports RSC runtime APIs provided by `react-server-dom/client.edge`:
500500

501-
- `createFromReadableStream`: RSC deserialization (RSC stream -> React VDOM)
501+
- `createFromReadableStream`: Deserializes an RSC stream into a React VDOM
502+
- `encodeReply`: Serializes server function arguments
503+
- `createTemporaryReferenceSet`: Creates a temporary reference set shared by deserialization and reply serialization
502504

503505
### `@vitejs/plugin-rsc/browser`
504506

505-
This module re-exports RSC runtime API provided by `react-server-dom/client.browser`
507+
This module re-exports RSC runtime APIs provided by `react-server-dom/client.browser`:
506508

507-
- `createFromReadableStream`: RSC deserialization (RSC stream -> React VDOM)
508-
- `createFromFetch`: a robust way of `createFromReadableStream((await fetch("...")).body)`
509-
- `encodeReply/setServerCallback`: server function related...
509+
- `createFromReadableStream`: Deserializes an RSC stream into a React VDOM
510+
- `createFromFetch`: Deserializes an RSC response from a fetch promise
511+
- `encodeReply`: Serializes server function arguments
512+
- `createTemporaryReferenceSet`: Creates a temporary reference set shared by deserialization and reply serialization
513+
- `setServerCallback`: Configures how server functions are called
510514

511515
## Tips
512516

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test } from '@playwright/test'
2+
import { type Fixture, useFixture } from './fixture'
3+
import { testNoJs } from './helper'
4+
5+
test.describe('dev', () => {
6+
const f = useFixture({
7+
root: 'examples/ssr-encode-form-action',
8+
mode: 'dev',
9+
})
10+
defineTests(f)
11+
})
12+
13+
test.describe('build', () => {
14+
const f = useFixture({
15+
root: 'examples/ssr-encode-form-action',
16+
mode: 'build',
17+
})
18+
defineTests(f)
19+
})
20+
21+
function defineTests(f: Fixture) {
22+
testNoJs('custom form action encoding', async ({ page }) => {
23+
await page.goto(f.url())
24+
25+
await expect(page.getByTestId('result')).toHaveText('initial')
26+
const form = page.getByTestId('server-action-form')
27+
await expect(form).toHaveAttribute('action', '/?custom-action=1')
28+
await expect(
29+
form.locator('input[name="$ACTION_REF_custom_prefix"]'),
30+
).toHaveCount(1)
31+
32+
await page.getByRole('button', { name: 'test-action' }).click()
33+
await expect(page).toHaveURL(/\?custom-action=1$/)
34+
await expect(page.getByTestId('result')).toHaveText('bound:form')
35+
})
36+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# SSR `encodeFormAction`
2+
3+
This example exercises the React Flight client APIs exposed by `@vitejs/plugin-rsc/ssr` for custom progressive-enhancement form encoding:
4+
5+
- `createFromReadableStream`
6+
- `encodeReply`
7+
8+
It renders one bound server-action form and submits it without JavaScript.
9+
10+
## Background
11+
12+
During SSR, `createFromReadableStream` deserializes an RSC stream and reconstructs its server references. React attaches a default `$$FORM_ACTION` implementation to those references so that a server function can be rendered as a native HTML form action.
13+
14+
For an unbound server reference, React's default encoder emits an `$ACTION_ID_*` field. For a bound reference, it serializes `{ id, bound }` with the Flight reply serializer and emits `$ACTION_REF_*` plus the associated `$ACTION_*` multipart fields. A server can later pass that form data to `decodeAction` to reconstruct and invoke the action.
15+
16+
Frameworks can replace this behavior through the `encodeFormAction` option accepted by the SSR Flight client. The callback may customize the form's action URL, method, encoding, field name, and additional form data.
17+
18+
The callback replaces React's default encoder rather than decorating its result. A framework that wants to change only the action URL while preserving compatibility with `decodeAction` must therefore recreate React's default multipart field layout. Calling `encodeReply({ id, bound })` from the same Flight client instance provides the matching serializer without importing the vendored runtime directly.
19+
20+
## Upstream Design
21+
22+
React introduced `encodeFormAction` in [React PR #27563, "[Flight] Allow custom encoding of the form action"](https://github.com/react/react/pull/27563). The PR describes server-action endpoints and their protocols as meta-framework responsibilities. React provides `callServer` for browser-initiated calls and summarizes the new hook as:
23+
24+
> `encodeFormAction` is to the SSR what `callServer` is to the Browser.
25+
26+
React's default current-page POST and `$ACTION_*` multipart protocol are therefore defaults rather than requirements. An SSR framework may replace the form action URL and encoded fields through `encodeFormAction`. The browser Flight client does not accept this option because native progressive-enhancement metadata is generated during SSR.
27+
28+
The upstream implementation wires the callback into server references as `$$FORM_ACTION`, which React DOM reads while rendering forms. React has lower-level tests for custom `$$FORM_ACTION` metadata, but it does not have an end-to-end test that passes `encodeFormAction` through Flight deserialization and then submits the resulting form. This example covers that integration path.
29+
30+
## Waku Motivation
31+
32+
[Waku PR #2191](https://github.com/wakujs/waku/pull/2191) directly motivates exposing these APIs from `@vitejs/plugin-rsc/ssr`. Both ordinary HTML forms and React server-action forms submitted without JavaScript can use `multipart/form-data`, but React's `$ACTION_*` discriminator is inside the single-read request body. Waku cannot inspect that body to classify the request and still pass an untouched request to an ordinary form handler.
33+
34+
Waku supplies `encodeFormAction` during SSR to add a marker to the form's action URL. It can classify the request from the URL before consuming the body, while `encodeReply({ id, bound })` lets it preserve React's default multipart field layout for `decodeAction`.
35+
36+
This requirement falls between React's two supported modes. React can provide its complete default protocol, or a framework can replace it with `encodeFormAction`, but the callback cannot obtain and modify the default `ReactCustomFormAction`. Waku wants to retain the default protocol and change only the action URL, so it must reproduce React's `{ id, bound }` serialization, `$ACTION_REF_*` naming, field prefixing, and suspension behavior.
37+
38+
The callback also receives no stable server-reference identity, which makes it difficult to distinguish the same action when it is used both unbound and bound on the client. Waku handles this with a promise-identity heuristic and documents the remaining ambiguity.
39+
40+
`useActionState` exposes another gap. When a permalink is provided, React applies it after `encodeFormAction` and replaces the encoder's action URL. Waku must currently require the permalink itself to contain the marker. A complete upstream solution would let a framework transform the final action URL after permalink resolution while retaining React's default encoding, or otherwise provide a context-bound default encoder that can be composed safely.
41+
42+
## Next.js Comparison
43+
44+
Next.js takes a different approach. It does not customize `encodeFormAction` or add an action URL marker for progressive-enhancement forms. Forms submitted without JavaScript use React's default `$ACTION_ID_*` or `$ACTION_REF_*` fields, and Next treats a multipart `POST` as a possible action request before calling `decodeAction`. This works because the App Router owns the request path and does not need to preserve the body for an ordinary form handler. Hydrated server-action calls use a separate `Next-Action` request header.
45+
46+
Next.js does use the Flight client's `encodeReply` and `createTemporaryReferenceSet` in a server runtime for `"use cache"` key serialization, but not for its Flight-to-HTML form-rendering path. `@vitejs/plugin-rsc` follows the same pattern through its `/rsc/client` entry point, which exposes `encodeReply` and `createClientTemporaryReferenceSet` for client-protocol work inside the RSC environment.
47+
48+
## Plugin Scope
49+
50+
The `/ssr` exports expose corresponding Flight client-protocol capabilities in the separate Flight-to-HTML environment. This is a low-level unblock rather than the ideal framework API: the plugin re-exports capabilities already provided by its vendored `react-server-dom/client.edge` runtime so frameworks can implement today's workaround without deep imports or protocol-version mismatches.
51+
52+
The plugin does not make React's experimental `$ACTION_*` protocol stable, nor does it solve the missing default-encoding composition or final-URL transformation hooks. Those ergonomic improvements belong in an upstream React feature request.
53+
54+
## Example Flow
55+
56+
The example performs this complete round trip:
57+
58+
```text
59+
RSC render
60+
-> SSR createFromReadableStream({ encodeFormAction })
61+
-> encodeReply({ id, bound })
62+
-> custom action URL and $ACTION_REF_* fields
63+
-> native multipart POST without JavaScript
64+
-> decodeAction
65+
-> bound server action execution
66+
```
67+
68+
The rendered form binds the value `"bound"` to its server action and submits an input containing `"form"`. After `decodeAction` reconstructs and invokes the action, the response displays `bound:form`.
69+
70+
## Structure
71+
72+
- `src/framework/entry.rsc.tsx` handles requests, calls `decodeAction` for form submissions, renders the RSC payload, and delegates HTML rendering to the SSR environment.
73+
- `src/framework/entry.ssr.tsx` recreates React's default bound-action encoding with a custom action URL, wires it into deserialization, and renders the payload with React DOM.
74+
- `src/action.ts` contains the server action.
75+
- `src/root.tsx` renders the form and action result.
76+
- `src/state.ts` stores the result across the POST and subsequent render.
77+
78+
## Scope
79+
80+
The encoder is intentionally limited to one bound action. It uses a fixed field prefix and caches serialization by the bound-arguments promise so React can suspend and retry SSR while `encodeReply` completes.
81+
82+
A production framework must generate unique field prefixes and handle unbound references, multiple forms, retries, errors, and any framework-specific request classification. The custom query parameter in this example only proves that React applies the returned action URL; the request handler does not depend on that marker for classification.
83+
84+
## Running
85+
86+
```bash
87+
pnpm dev
88+
pnpm build
89+
pnpm preview
90+
```
91+
92+
The corresponding Playwright coverage is in `../../e2e/ssr-encode-form-action.test.ts` and runs the no-JavaScript submission in both development and production-build modes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "@vitejs/plugin-rsc-examples-ssr-encode-form-action",
3+
"version": "0.0.0",
4+
"private": true,
5+
"license": "MIT",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"react": "^19.2.7",
14+
"react-dom": "^19.2.7"
15+
},
16+
"devDependencies": {
17+
"@types/react": "^19.2.17",
18+
"@types/react-dom": "^19.2.3",
19+
"@vitejs/plugin-rsc": "latest",
20+
"vite": "^8.1.4"
21+
}
22+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
'use server'
2+
3+
import { setServerState } from './state'
4+
5+
export async function testAction(boundValue: string, formData: FormData) {
6+
setServerState(`${boundValue}:${formData.get('value')}`)
7+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// This example only tests progressive enhancement without JavaScript.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { decodeAction, renderToReadableStream } from '@vitejs/plugin-rsc/rsc'
2+
import { Root } from '../root'
3+
4+
async function handler(request: Request): Promise<Response> {
5+
if (request.method === 'POST') {
6+
const action = await decodeAction(await request.formData())
7+
await action()
8+
}
9+
10+
const rscStream = renderToReadableStream({ root: <Root /> })
11+
const ssr = await import.meta.viteRsc.loadModule<
12+
typeof import('./entry.ssr')
13+
>('ssr', 'index')
14+
const htmlStream = await ssr.renderHtml(rscStream)
15+
return new Response(htmlStream, {
16+
headers: { 'content-type': 'text/html;charset=utf-8' },
17+
})
18+
}
19+
20+
export default { fetch: handler }
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {
2+
createFromReadableStream,
3+
encodeReply,
4+
type EncodeFormActionCallback,
5+
} from '@vitejs/plugin-rsc/ssr'
6+
import React from 'react'
7+
import { renderToReadableStream } from 'react-dom/server.edge'
8+
9+
type RscPayload = { root: React.ReactNode }
10+
11+
export async function renderHtml(
12+
rscStream: ReadableStream<Uint8Array>,
13+
): Promise<ReadableStream<Uint8Array>> {
14+
let payload: Promise<RscPayload> | undefined
15+
function SsrRoot() {
16+
payload ??= createFromReadableStream<RscPayload>(rscStream, {
17+
encodeFormAction: customEncodeFormAction,
18+
})
19+
return React.use(payload).root
20+
}
21+
22+
return renderToReadableStream(<SsrRoot />)
23+
}
24+
25+
// Preserve React's default form metadata, but post to a custom URL.
26+
const customEncodeFormAction: EncodeFormActionCallback = (id, bound) => {
27+
const defaultResult = defaultEncodeFormAction(id, bound)
28+
return {
29+
...defaultResult,
30+
action: '/?custom-action=1',
31+
}
32+
}
33+
34+
// Recreates React's default bound-action encoding:
35+
// https://github.com/react/react/blob/8d48183291870898ec42ac1f84482d9d26789424/packages/react-client/src/ReactFlightReplyClient.js#L462-L508
36+
37+
// React supplies this internally, but encodeFormAction does not receive it.
38+
const identifierPrefix = 'custom_prefix'
39+
40+
type FormDataThenable = Promise<FormData> & {
41+
status?: 'pending' | 'fulfilled' | 'rejected'
42+
value?: FormData
43+
reason?: unknown
44+
}
45+
46+
// React caches by its internal server-reference object. The callback only exposes
47+
// the bound promise, so this example uses that promise as the cache identity.
48+
const boundCache = new WeakMap<Promise<unknown[]>, FormDataThenable>()
49+
50+
const defaultEncodeFormAction: EncodeFormActionCallback = (id, bound) => {
51+
let data: null | FormData = null
52+
let name
53+
// React's custom callback always receives a promise, so it cannot distinguish
54+
// an unbound reference from one bound with no arguments. This example is bound.
55+
const boundPromise = bound
56+
if (boundPromise !== null) {
57+
const reference = { id, bound }
58+
let thenable = boundCache.get(boundPromise)
59+
if (!thenable) {
60+
thenable = encodeFormData(reference)
61+
boundCache.set(boundPromise, thenable)
62+
}
63+
if (thenable.status === 'rejected') {
64+
throw thenable.reason
65+
} else if (thenable.status !== 'fulfilled') {
66+
throw thenable
67+
}
68+
const encodedFormData = thenable.value!
69+
const prefixedData = new FormData()
70+
encodedFormData.forEach((value, key) => {
71+
prefixedData.append('$ACTION_' + identifierPrefix + ':' + key, value)
72+
})
73+
data = prefixedData
74+
name = '$ACTION_REF_' + identifierPrefix
75+
}
76+
77+
return {
78+
name: name,
79+
method: 'POST',
80+
encType: 'multipart/form-data',
81+
data: data,
82+
}
83+
}
84+
85+
function encodeFormData(reference: {
86+
id: string
87+
bound: Promise<unknown[]>
88+
}): FormDataThenable {
89+
// Unlike React's internal processReply, the public encodeReply returns a
90+
// regular promise, so instrument it with the status React's renderer expects.
91+
const thenable = encodeReply(reference as never).then(
92+
(body) => {
93+
if (typeof body === 'string') {
94+
const data = new FormData()
95+
data.append('0', body)
96+
body = data
97+
}
98+
thenable.status = 'fulfilled'
99+
thenable.value = body
100+
return body
101+
},
102+
(reason) => {
103+
thenable.status = 'rejected'
104+
thenable.reason = reason
105+
throw reason
106+
},
107+
) as FormDataThenable
108+
thenable.status = 'pending'
109+
return thenable
110+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { testAction } from './action'
2+
import { getServerState } from './state'
3+
4+
export function Root() {
5+
return (
6+
<html>
7+
<body>
8+
<form
9+
data-testid="server-action-form"
10+
action={testAction.bind(null, 'bound')}
11+
>
12+
<input name="value" defaultValue="form" />
13+
<button type="submit">test-action</button>
14+
</form>
15+
<output data-testid="result">{getServerState()}</output>
16+
</body>
17+
</html>
18+
)
19+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
let result = 'initial'
2+
3+
export function getServerState() {
4+
return result
5+
}
6+
7+
export function setServerState(value: string) {
8+
result = value
9+
}

0 commit comments

Comments
 (0)