Skip to content

Commit aef7ce5

Browse files
authored
[Flight] Progressively Enhanced Server Actions (#26774)
This automatically exposes `$$FORM_ACTIONS` on Server References coming from Flight. So that when they're used in a form action, we can encode the ID for the server reference as a hidden field or as part of the name of a button. If the Server Action is a bound function it can have complex data associated with it. In this case this additional data is encoded as additional form fields. To process a POST on the server there's now a `decodeAction` helper that can take one of these progressive posts from FormData and give you a function that is prebound with the correct closure and FormData so that you can just invoke it. I updated the fixture which now has a "Server State" that gets automatically refreshed. This also lets us visualize form fields. There's no "Action State" here for showing error messages that are not thrown, that's still up to user space.
1 parent c10010a commit aef7ce5

18 files changed

Lines changed: 589 additions & 96 deletions

File tree

fixtures/flight/server/global.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ app.all('/', async function (req, res, next) {
9595
if (req.get('rsc-action')) {
9696
proxiedHeaders['Content-type'] = req.get('Content-type');
9797
proxiedHeaders['rsc-action'] = req.get('rsc-action');
98+
} else if (req.get('Content-type')) {
99+
proxiedHeaders['Content-type'] = req.get('Content-type');
98100
}
99101

100102
const promiseForData = request(

fixtures/flight/server/region.js

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const bodyParser = require('body-parser');
3636
const busboy = require('busboy');
3737
const app = express();
3838
const compress = require('compression');
39+
const {Readable} = require('node:stream');
3940

4041
app.use(compress());
4142

@@ -45,7 +46,7 @@ const {readFile} = require('fs').promises;
4546

4647
const React = require('react');
4748

48-
app.get('/', async function (req, res) {
49+
async function renderApp(res, returnValue) {
4950
const {renderToPipeableStream} = await import(
5051
'react-server-dom-webpack/server'
5152
);
@@ -91,37 +92,74 @@ app.get('/', async function (req, res) {
9192
),
9293
React.createElement(App),
9394
];
94-
const {pipe} = renderToPipeableStream(root, moduleMap);
95+
// For client-invoked server actions we refresh the tree and return a return value.
96+
const payload = returnValue ? {returnValue, root} : root;
97+
const {pipe} = renderToPipeableStream(payload, moduleMap);
9598
pipe(res);
99+
}
100+
101+
app.get('/', async function (req, res) {
102+
await renderApp(res, null);
96103
});
97104

98105
app.post('/', bodyParser.text(), async function (req, res) {
99-
const {renderToPipeableStream, decodeReply, decodeReplyFromBusboy} =
100-
await import('react-server-dom-webpack/server');
106+
const {
107+
renderToPipeableStream,
108+
decodeReply,
109+
decodeReplyFromBusboy,
110+
decodeAction,
111+
} = await import('react-server-dom-webpack/server');
101112
const serverReference = req.get('rsc-action');
102-
const [filepath, name] = serverReference.split('#');
103-
const action = (await import(filepath))[name];
104-
// Validate that this is actually a function we intended to expose and
105-
// not the client trying to invoke arbitrary functions. In a real app,
106-
// you'd have a manifest verifying this before even importing it.
107-
if (action.$$typeof !== Symbol.for('react.server.reference')) {
108-
throw new Error('Invalid action');
109-
}
110-
111-
let args;
112-
if (req.is('multipart/form-data')) {
113-
// Use busboy to streamingly parse the reply from form-data.
114-
const bb = busboy({headers: req.headers});
115-
const reply = decodeReplyFromBusboy(bb);
116-
req.pipe(bb);
117-
args = await reply;
113+
if (serverReference) {
114+
// This is the client-side case
115+
const [filepath, name] = serverReference.split('#');
116+
const action = (await import(filepath))[name];
117+
// Validate that this is actually a function we intended to expose and
118+
// not the client trying to invoke arbitrary functions. In a real app,
119+
// you'd have a manifest verifying this before even importing it.
120+
if (action.$$typeof !== Symbol.for('react.server.reference')) {
121+
throw new Error('Invalid action');
122+
}
123+
124+
let args;
125+
if (req.is('multipart/form-data')) {
126+
// Use busboy to streamingly parse the reply from form-data.
127+
const bb = busboy({headers: req.headers});
128+
const reply = decodeReplyFromBusboy(bb);
129+
req.pipe(bb);
130+
args = await reply;
131+
} else {
132+
args = await decodeReply(req.body);
133+
}
134+
const result = action.apply(null, args);
135+
try {
136+
// Wait for any mutations
137+
await result;
138+
} catch (x) {
139+
// We handle the error on the client
140+
}
141+
// Refresh the client and return the value
142+
renderApp(res, result);
118143
} else {
119-
args = await decodeReply(req.body);
144+
// This is the progressive enhancement case
145+
const UndiciRequest = require('undici').Request;
146+
const fakeRequest = new UndiciRequest('http://localhost', {
147+
method: 'POST',
148+
headers: {'Content-Type': req.headers['content-type']},
149+
body: Readable.toWeb(req),
150+
duplex: 'half',
151+
});
152+
const formData = await fakeRequest.formData();
153+
const action = await decodeAction(formData);
154+
try {
155+
// Wait for any mutations
156+
await action();
157+
} catch (x) {
158+
const {setServerState} = await import('../src/ServerState.js');
159+
setServerState('Error: ' + x.message);
160+
}
161+
renderApp(res, null);
120162
}
121-
122-
const result = action.apply(null, args);
123-
const {pipe} = renderToPipeableStream(result, {});
124-
pipe(res);
125163
});
126164

127165
app.get('/todos', function (req, res) {

fixtures/flight/src/App.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import Form from './Form.js';
1111

1212
import {like, greet} from './actions.js';
1313

14+
import {getServerState} from './ServerState.js';
15+
1416
export default async function App() {
1517
const res = await fetch('http://localhost:3001/todos');
1618
const todos = await res.json();
@@ -23,7 +25,7 @@ export default async function App() {
2325
</head>
2426
<body>
2527
<Container>
26-
<h1>Hello, world</h1>
28+
<h1>{getServerState()}</h1>
2729
<Counter />
2830
<Counter2 />
2931
<ul>

fixtures/flight/src/Button.js

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,7 @@ import ErrorBoundary from './ErrorBoundary.js';
77
function ButtonDisabledWhilePending({action, children}) {
88
const {pending} = useFormStatus();
99
return (
10-
<button
11-
disabled={pending}
12-
formAction={async () => {
13-
const result = await action();
14-
console.log(result);
15-
}}>
10+
<button disabled={pending} formAction={action}>
1611
{children}
1712
</button>
1813
);

fixtures/flight/src/Form.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,7 @@ export default function Form({action, children}) {
1414

1515
return (
1616
<ErrorBoundary>
17-
<form
18-
action={async formData => {
19-
const result = await action(formData);
20-
alert(result);
21-
}}>
17+
<form action={action}>
2218
<label>
2319
Name: <input name="name" />
2420
</label>

fixtures/flight/src/ServerState.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
let serverState = 'Hello World';
2+
3+
export function setServerState(message) {
4+
serverState = message;
5+
}
6+
7+
export function getServerState() {
8+
return serverState;
9+
}

fixtures/flight/src/actions.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
'use server';
22

3+
import {setServerState} from './ServerState.js';
4+
35
export async function like() {
6+
setServerState('Liked!');
47
return new Promise((resolve, reject) => resolve('Liked'));
58
}
69

710
export async function greet(formData) {
811
const name = formData.get('name') || 'you';
12+
setServerState('Hi ' + name);
913
const file = formData.get('file');
1014
if (file) {
1115
return `Ok, ${name}, here is ${file.name}:

fixtures/flight/src/index.js

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,44 @@
11
import * as React from 'react';
2-
import {use, Suspense} from 'react';
2+
import {use, Suspense, useState, startTransition} from 'react';
33
import ReactDOM from 'react-dom/client';
44
import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client';
55

66
// TODO: This should be a dependency of the App but we haven't implemented CSS in Node yet.
77
import './style.css';
88

9+
let updateRoot;
10+
async function callServer(id, args) {
11+
const response = fetch('/', {
12+
method: 'POST',
13+
headers: {
14+
Accept: 'text/x-component',
15+
'rsc-action': id,
16+
},
17+
body: await encodeReply(args),
18+
});
19+
const {returnValue, root} = await createFromFetch(response, {callServer});
20+
// Refresh the tree with the new RSC payload.
21+
startTransition(() => {
22+
updateRoot(root);
23+
});
24+
return returnValue;
25+
}
26+
927
let data = createFromFetch(
1028
fetch('/', {
1129
headers: {
1230
Accept: 'text/x-component',
1331
},
1432
}),
1533
{
16-
async callServer(id, args) {
17-
const response = fetch('/', {
18-
method: 'POST',
19-
headers: {
20-
Accept: 'text/x-component',
21-
'rsc-action': id,
22-
},
23-
body: await encodeReply(args),
24-
});
25-
return createFromFetch(response);
26-
},
34+
callServer,
2735
}
2836
);
2937

3038
function Shell({data}) {
31-
return use(data);
39+
const [root, setRoot] = useState(use(data));
40+
updateRoot = setRoot;
41+
return root;
3242
}
3343

3444
ReactDOM.hydrateRoot(document, <Shell data={data} />);

packages/react-client/src/ReactFlightClient.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import type {
2020

2121
import type {HintModel} from 'react-server/src/ReactFlightServerConfig';
2222

23+
import type {CallServerCallback} from './ReactFlightReplyClient';
24+
2325
import {
2426
resolveClientReference,
2527
preloadModule,
@@ -28,13 +30,16 @@ import {
2830
dispatchHint,
2931
} from './ReactFlightClientConfig';
3032

31-
import {knownServerReferences} from './ReactFlightServerReferenceRegistry';
33+
import {
34+
encodeFormAction,
35+
knownServerReferences,
36+
} from './ReactFlightReplyClient';
3237

3338
import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
3439

3540
import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
3641

37-
export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
42+
export type {CallServerCallback};
3843

3944
export type JSONValue =
4045
| number
@@ -500,6 +505,9 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
500505
return callServer(metaData.id, bound.concat(args));
501506
});
502507
};
508+
// Expose encoder for use by SSR.
509+
// TODO: Only expose this in SSR builds and not the browser client.
510+
proxy.$$FORM_ACTION = encodeFormAction;
503511
knownServerReferences.set(proxy, metaData);
504512
return proxy;
505513
}

0 commit comments

Comments
 (0)