@samitouri / QOS-React-2 / commits / aef7ce5547

[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.

Sebastian Markbåge committed May 3, 2023 at 18:36 UTC aef7ce5547c9489dc48e31f69b002cd17206e0cb
18 files changed +589 -96
fixtures/flight/server/global.js
+2
@@ -95,6 +95,8 @@ app.all('/', async function (req, res, next) {
95 if (req.get('rsc-action')) {
96 proxiedHeaders['Content-type'] = req.get('Content-type');
97 proxiedHeaders['rsc-action'] = req.get('rsc-action');
98 + } else if (req.get('Content-type')) {
99 + proxiedHeaders['Content-type'] = req.get('Content-type');
100 }
101
102 const promiseForData = request(
fixtures/flight/server/region.js
+63 -25
@@ -36,6 +36,7 @@ const bodyParser = require('body-parser');
36 const busboy = require('busboy');
37 const app = express();
38 const compress = require('compression');
39 +const {Readable} = require('node:stream');
40
41 app.use(compress());
42
@@ -45,7 +46,7 @@ const {readFile} = require('fs').promises;
46
47 const React = require('react');
48
48 -app.get('/', async function (req, res) {
49 +async function renderApp(res, returnValue) {
50 const {renderToPipeableStream} = await import(
51 'react-server-dom-webpack/server'
52 );
@@ -91,37 +92,74 @@ app.get('/', async function (req, res) {
92 ),
93 React.createElement(App),
94 ];
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);
98 pipe(res);
99 +}
100 +
101 +app.get('/', async function (req, res) {
102 + await renderApp(res, null);
103 });
104
105 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');
112 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);
143 } 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);
162 }
121 -
122 - const result = action.apply(null, args);
123 - const {pipe} = renderToPipeableStream(result, {});
124 - pipe(res);
163 });
164
165 app.get('/todos', function (req, res) {
fixtures/flight/src/App.js
+3 -1
@@ -11,6 +11,8 @@ import Form from './Form.js';
11
12 import {like, greet} from './actions.js';
13
14 +import {getServerState} from './ServerState.js';
15 +
16 export default async function App() {
17 const res = await fetch('http://localhost:3001/todos');
18 const todos = await res.json();
@@ -23,7 +25,7 @@ export default async function App() {
25 </head>
26 <body>
27 <Container>
26 - <h1>Hello, world</h1>
28 + <h1>{getServerState()}</h1>
29 <Counter />
30 <Counter2 />
31 <ul>
fixtures/flight/src/Button.js
+1 -6
@@ -7,12 +7,7 @@ import ErrorBoundary from './ErrorBoundary.js';
7 function ButtonDisabledWhilePending({action, children}) {
8 const {pending} = useFormStatus();
9 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}>
11 {children}
12 </button>
13 );
fixtures/flight/src/Form.js
+1 -5
@@ -14,11 +14,7 @@ export default function Form({action, children}) {
14
15 return (
16 <ErrorBoundary>
17 - <form
18 - action={async formData => {
19 - const result = await action(formData);
20 - alert(result);
21 - }}>
17 + <form action={action}>
18 <label>
19 Name: <input name="name" />
20 </label>
fixtures/flight/src/ServerState.js new
+9
@@ -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
+4
@@ -1,11 +1,15 @@
1 'use server';
2
3 +import {setServerState} from './ServerState.js';
4 +
5 export async function like() {
6 + setServerState('Liked!');
7 return new Promise((resolve, reject) => resolve('Liked'));
8 }
9
10 export async function greet(formData) {
11 const name = formData.get('name') || 'you';
12 + setServerState('Hi ' + name);
13 const file = formData.get('file');
14 if (file) {
15 return `Ok, ${name}, here is ${file.name}:
fixtures/flight/src/index.js
+23 -13
@@ -1,11 +1,29 @@
1 import * as React from 'react';
2 -import {use, Suspense} from 'react';
2 +import {use, Suspense, useState, startTransition} from 'react';
3 import ReactDOM from 'react-dom/client';
4 import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client';
5
6 // TODO: This should be a dependency of the App but we haven't implemented CSS in Node yet.
7 import './style.css';
8
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 +
27 let data = createFromFetch(
28 fetch('/', {
29 headers: {
@@ -13,22 +31,14 @@ let data = createFromFetch(
31 },
32 }),
33 {
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,
35 }
36 );
37
38 function Shell({data}) {
31 - return use(data);
39 + const [root, setRoot] = useState(use(data));
40 + updateRoot = setRoot;
41 + return root;
42 }
43
44 ReactDOM.hydrateRoot(document, <Shell data={data} />);
packages/react-client/src/ReactFlightClient.js
+10 -2
@@ -20,6 +20,8 @@ import type {
20
21 import type {HintModel} from 'react-server/src/ReactFlightServerConfig';
22
23 +import type {CallServerCallback} from './ReactFlightReplyClient';
24 +
25 import {
26 resolveClientReference,
27 preloadModule,
@@ -28,13 +30,16 @@ import {
30 dispatchHint,
31 } from './ReactFlightClientConfig';
32
31 -import {knownServerReferences} from './ReactFlightServerReferenceRegistry';
33 +import {
34 + encodeFormAction,
35 + knownServerReferences,
36 +} from './ReactFlightReplyClient';
37
38 import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
39
40 import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry';
41
37 -export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
42 +export type {CallServerCallback};
43
44 export type JSONValue =
45 | number
@@ -500,6 +505,9 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
505 return callServer(metaData.id, bound.concat(args));
506 });
507 };
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;
511 knownServerReferences.set(proxy, metaData);
512 return proxy;
513 }
packages/react-client/src/ReactFlightReplyClient.js
+115 -7
@@ -7,12 +7,7 @@
7 * @flow
8 */
9
10 -import type {Thenable} from 'shared/ReactTypes';
11 -
12 -import {
13 - knownServerReferences,
14 - createServerReference,
15 -} from './ReactFlightServerReferenceRegistry';
10 +import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes';
11
12 import {
13 REACT_ELEMENT_TYPE,
@@ -28,6 +23,10 @@ import {
23 } from 'shared/ReactSerializationErrors';
24
25 import isArray from 'shared/isArray';
26 +import type {
27 + FulfilledThenable,
28 + RejectedThenable,
29 +} from '../../shared/ReactTypes';
30
31 type ReactJSONValue =
32 | string
@@ -39,6 +38,15 @@ type ReactJSONValue =
38
39 export opaque type ServerReference<T> = T;
40
41 +export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
42 +
43 +export type ServerReferenceId = any;
44 +
45 +export const knownServerReferences: WeakMap<
46 + Function,
47 + {id: ServerReferenceId, bound: null | Thenable<Array<any>>},
48 +> = new WeakMap();
49 +
50 // Serializable values
51 export type ReactServerValue =
52 // References are passed by their value
@@ -363,4 +371,104 @@ export function processReply(
371 }
372 }
373
366 -export {createServerReference};
374 +const boundCache: WeakMap<
375 + {id: ServerReferenceId, bound: null | Thenable<Array<any>>},
376 + Thenable<FormData>,
377 +> = new WeakMap();
378 +
379 +function encodeFormData(reference: any): Thenable<FormData> {
380 + let resolve, reject;
381 + // We need to have a handle on the thenable so that we can synchronously set
382 + // its status from processReply, when it can complete synchronously.
383 + const thenable: Thenable<FormData> = new Promise((res, rej) => {
384 + resolve = res;
385 + reject = rej;
386 + });
387 + processReply(
388 + reference,
389 + '',
390 + (body: string | FormData) => {
391 + if (typeof body === 'string') {
392 + const data = new FormData();
393 + data.append('0', body);
394 + body = data;
395 + }
396 + const fulfilled: FulfilledThenable<FormData> = (thenable: any);
397 + fulfilled.status = 'fulfilled';
398 + fulfilled.value = body;
399 + resolve(body);
400 + },
401 + e => {
402 + const rejected: RejectedThenable<FormData> = (thenable: any);
403 + rejected.status = 'rejected';
404 + rejected.reason = e;
405 + reject(e);
406 + },
407 + );
408 + return thenable;
409 +}
410 +
411 +export function encodeFormAction(
412 + this: any => Promise<any>,
413 + identifierPrefix: string,
414 +): ReactCustomFormAction {
415 + const reference = knownServerReferences.get(this);
416 + if (!reference) {
417 + throw new Error(
418 + 'Tried to encode a Server Action from a different instance than the encoder is from. ' +
419 + 'This is a bug in React.',
420 + );
421 + }
422 + let data: null | FormData = null;
423 + let name;
424 + const boundPromise = reference.bound;
425 + if (boundPromise !== null) {
426 + let thenable = boundCache.get(reference);
427 + if (!thenable) {
428 + thenable = encodeFormData(reference);
429 + boundCache.set(reference, thenable);
430 + }
431 + if (thenable.status === 'rejected') {
432 + throw thenable.reason;
433 + } else if (thenable.status !== 'fulfilled') {
434 + throw thenable;
435 + }
436 + const encodedFormData = thenable.value;
437 + // This is hacky but we need the identifier prefix to be added to
438 + // all fields but the suspense cache would break since we might get
439 + // a new identifier each time. So we just append it at the end instead.
440 + const prefixedData = new FormData();
441 + // $FlowFixMe[prop-missing]
442 + encodedFormData.forEach((value: string | File, key: string) => {
443 + prefixedData.append('$ACTION_' + identifierPrefix + ':' + key, value);
444 + });
445 + data = prefixedData;
446 + // We encode the name of the prefix containing the data.
447 + name = '$ACTION_REF_' + identifierPrefix;
448 + } else {
449 + // This is the simple case so we can just encode the ID.
450 + name = '$ACTION_ID_' + reference.id;
451 + }
452 + return {
453 + name: name,
454 + method: 'POST',
455 + encType: 'multipart/form-data',
456 + data: data,
457 + };
458 +}
459 +
460 +export function createServerReference<A: Iterable<any>, T>(
461 + id: ServerReferenceId,
462 + callServer: CallServerCallback,
463 +): (...A) => Promise<T> {
464 + const proxy = function (): Promise<T> {
465 + // $FlowFixMe[method-unbinding]
466 + const args = Array.prototype.slice.call(arguments);
467 + return callServer(id, args);
468 + };
469 + // Expose encoder for use by SSR.
470 + // TODO: Only expose this in SSR builds and not the browser client.
471 + proxy.$$FORM_ACTION = encodeFormAction;
472 + knownServerReferences.set(proxy, {id: id, bound: null});
473 + return proxy;
474 +}
packages/react-client/src/ReactFlightServerReferenceRegistry.js deleted
-32
@@ -1,32 +0,0 @@
1 -/**
2 - * Copyright (c) Meta Platforms, Inc. and affiliates.
3 - *
4 - * This source code is licensed under the MIT license found in the
5 - * LICENSE file in the root directory of this source tree.
6 - *
7 - * @flow
8 - */
9 -
10 -import type {Thenable} from 'shared/ReactTypes';
11 -
12 -export type CallServerCallback = <A, T>(id: any, args: A) => Promise<T>;
13 -
14 -type ServerReferenceId = any;
15 -
16 -export const knownServerReferences: WeakMap<
17 - Function,
18 - {id: ServerReferenceId, bound: null | Thenable<Array<any>>},
19 -> = new WeakMap();
20 -
21 -export function createServerReference<A: Iterable<any>, T>(
22 - id: ServerReferenceId,
23 - callServer: CallServerCallback,
24 -): (...A) => Promise<T> {
25 - const proxy = function (): Promise<T> {
26 - // $FlowFixMe[method-unbinding]
27 - const args = Array.prototype.slice.call(arguments);
28 - return callServer(id, args);
29 - };
30 - knownServerReferences.set(proxy, {id: id, bound: null});
31 - return proxy;
32 -}
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+1 -1
@@ -672,7 +672,7 @@ function makeFormFieldPrefix(responseState: ResponseState): string {
672 // I'm just reusing this counter. It's not really the same namespace as "name".
673 // It could just be its own counter.
674 const id = responseState.nextSuspenseID++;
675 - return responseState.idPrefix + '$ACTION:' + id + ':';
675 + return responseState.idPrefix + id;
676 }
677
678 // Since this will likely be repeated a lot in the HTML, we use a more concise message
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+3 -1
@@ -25,6 +25,8 @@ import {
25 getRoot,
26 } from 'react-server/src/ReactFlightReplyServer';
27
28 +import {decodeAction} from 'react-server/src/ReactFlightActionServer';
29 +
30 type Options = {
31 identifierPrefix?: string,
32 signal?: AbortSignal,
@@ -87,4 +89,4 @@ function decodeReply<T>(
89 return getRoot(response);
90 }
91
90 -export {renderToReadableStream, decodeReply};
92 +export {renderToReadableStream, decodeReply, decodeAction};
packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+3 -1
@@ -25,6 +25,8 @@ import {
25 getRoot,
26 } from 'react-server/src/ReactFlightReplyServer';
27
28 +import {decodeAction} from 'react-server/src/ReactFlightActionServer';
29 +
30 type Options = {
31 identifierPrefix?: string,
32 signal?: AbortSignal,
@@ -87,4 +89,4 @@ function decodeReply<T>(
89 return getRoot(response);
90 }
91
90 -export {renderToReadableStream, decodeReply};
92 +export {renderToReadableStream, decodeReply, decodeAction};
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+8 -1
@@ -36,6 +36,8 @@ import {
36 getRoot,
37 } from 'react-server/src/ReactFlightReplyServer';
38
39 +import {decodeAction} from 'react-server/src/ReactFlightActionServer';
40 +
41 function createDrainHandler(destination: Destination, request: Request) {
42 return () => startFlowing(request, destination);
43 }
@@ -148,4 +150,9 @@ function decodeReply<T>(
150 return getRoot(response);
151 }
152
151 -export {renderToPipeableStream, decodeReplyFromBusboy, decodeReply};
153 +export {
154 + renderToPipeableStream,
155 + decodeReplyFromBusboy,
156 + decodeReply,
157 + decodeAction,
158 +};
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js new
+231
@@ -0,0 +1,231 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +import {insertNodesAndExecuteScripts} from 'react-dom/src/test-utils/FizzTestUtils';
13 +
14 +// Polyfills for test environment
15 +global.ReadableStream =
16 + require('web-streams-polyfill/ponyfill/es6').ReadableStream;
17 +global.TextEncoder = require('util').TextEncoder;
18 +global.TextDecoder = require('util').TextDecoder;
19 +
20 +let container;
21 +let serverExports;
22 +let webpackServerMap;
23 +let React;
24 +let ReactDOMServer;
25 +let ReactServerDOMServer;
26 +let ReactServerDOMClient;
27 +
28 +describe('ReactFlightDOMReply', () => {
29 + beforeEach(() => {
30 + jest.resetModules();
31 + const WebpackMock = require('./utils/WebpackMock');
32 + serverExports = WebpackMock.serverExports;
33 + webpackServerMap = WebpackMock.webpackServerMap;
34 + React = require('react');
35 + ReactServerDOMServer = require('react-server-dom-webpack/server.browser');
36 + ReactServerDOMClient = require('react-server-dom-webpack/client');
37 + ReactDOMServer = require('react-dom/server.browser');
38 + container = document.createElement('div');
39 + document.body.appendChild(container);
40 + });
41 +
42 + afterEach(() => {
43 + document.body.removeChild(container);
44 + });
45 +
46 + async function POST(formData) {
47 + const boundAction = await ReactServerDOMServer.decodeAction(
48 + formData,
49 + webpackServerMap,
50 + );
51 + return boundAction();
52 + }
53 +
54 + function submit(submitter) {
55 + const form = submitter.form || submitter;
56 + if (!submitter.form) {
57 + submitter = undefined;
58 + }
59 + const submitEvent = new Event('submit', {bubbles: true, cancelable: true});
60 + submitEvent.submitter = submitter;
61 + const returnValue = form.dispatchEvent(submitEvent);
62 + if (!returnValue) {
63 + return;
64 + }
65 + const action =
66 + (submitter && submitter.getAttribute('formaction')) || form.action;
67 + if (!/\s*javascript:/i.test(action)) {
68 + const method = (submitter && submitter.formMethod) || form.method;
69 + const encType = (submitter && submitter.formEnctype) || form.enctype;
70 + if (method === 'post' && encType === 'multipart/form-data') {
71 + let formData;
72 + if (submitter) {
73 + const temp = document.createElement('input');
74 + temp.name = submitter.name;
75 + temp.value = submitter.value;
76 + submitter.parentNode.insertBefore(temp, submitter);
77 + formData = new FormData(form);
78 + temp.parentNode.removeChild(temp);
79 + } else {
80 + formData = new FormData(form);
81 + }
82 + return POST(formData);
83 + }
84 + throw new Error('Navigate to: ' + action);
85 + }
86 + }
87 +
88 + async function readIntoContainer(stream) {
89 + const reader = stream.getReader();
90 + let result = '';
91 + while (true) {
92 + const {done, value} = await reader.read();
93 + if (done) {
94 + break;
95 + }
96 + result += Buffer.from(value).toString('utf8');
97 + }
98 + const temp = document.createElement('div');
99 + temp.innerHTML = result;
100 + insertNodesAndExecuteScripts(temp, container, null);
101 + }
102 +
103 + // @gate enableFormActions
104 + it('can submit a passed server action without hydrating it', async () => {
105 + let foo = null;
106 +
107 + const serverAction = serverExports(function action(formData) {
108 + foo = formData.get('foo');
109 + return 'hello';
110 + });
111 + function App() {
112 + return (
113 + <form action={serverAction}>
114 + <input type="text" name="foo" defaultValue="bar" />
115 + </form>
116 + );
117 + }
118 + const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
119 + const response = ReactServerDOMClient.createFromReadableStream(rscStream);
120 + const ssrStream = await ReactDOMServer.renderToReadableStream(response);
121 + await readIntoContainer(ssrStream);
122 +
123 + const form = container.firstChild;
124 +
125 + expect(foo).toBe(null);
126 +
127 + const result = await submit(form);
128 +
129 + expect(result).toBe('hello');
130 + expect(foo).toBe('bar');
131 + });
132 +
133 + // @gate enableFormActions
134 + it('can submit an imported server action without hydrating it', async () => {
135 + let foo = null;
136 +
137 + const ServerModule = serverExports(function action(formData) {
138 + foo = formData.get('foo');
139 + return 'hi';
140 + });
141 + const serverAction = ReactServerDOMClient.createServerReference(
142 + ServerModule.$$id,
143 + );
144 + function App() {
145 + return (
146 + <form action={serverAction}>
147 + <input type="text" name="foo" defaultValue="bar" />
148 + </form>
149 + );
150 + }
151 +
152 + const ssrStream = await ReactDOMServer.renderToReadableStream(<App />);
153 + await readIntoContainer(ssrStream);
154 +
155 + const form = container.firstChild;
156 +
157 + expect(foo).toBe(null);
158 +
159 + const result = await submit(form);
160 +
161 + expect(result).toBe('hi');
162 +
163 + expect(foo).toBe('bar');
164 + });
165 +
166 + // @gate enableFormActions
167 + it('can submit a complex closure server action without hydrating it', async () => {
168 + let foo = null;
169 +
170 + const serverAction = serverExports(function action(bound, formData) {
171 + foo = formData.get('foo') + bound.complex;
172 + return 'hello';
173 + });
174 + function App() {
175 + return (
176 + <form action={serverAction.bind(null, {complex: 'object'})}>
177 + <input type="text" name="foo" defaultValue="bar" />
178 + </form>
179 + );
180 + }
181 + const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
182 + const response = ReactServerDOMClient.createFromReadableStream(rscStream);
183 + const ssrStream = await ReactDOMServer.renderToReadableStream(response);
184 + await readIntoContainer(ssrStream);
185 +
186 + const form = container.firstChild;
187 +
188 + expect(foo).toBe(null);
189 +
190 + const result = await submit(form);
191 +
192 + expect(result).toBe('hello');
193 + expect(foo).toBe('barobject');
194 + });
195 +
196 + // @gate enableFormActions
197 + it('can submit a multiple complex closure server action without hydrating it', async () => {
198 + let foo = null;
199 +
200 + const serverAction = serverExports(function action(bound, formData) {
201 + foo = formData.get('foo') + bound.complex;
202 + return 'hello' + bound.complex;
203 + });
204 + function App() {
205 + return (
206 + <form action={serverAction.bind(null, {complex: 'a'})}>
207 + <input type="text" name="foo" defaultValue="bar" />
208 + <button formAction={serverAction.bind(null, {complex: 'b'})} />
209 + <button formAction={serverAction.bind(null, {complex: 'c'})} />
210 + <input
211 + type="submit"
212 + formAction={serverAction.bind(null, {complex: 'd'})}
213 + />
214 + </form>
215 + );
216 + }
217 + const rscStream = ReactServerDOMServer.renderToReadableStream(<App />);
218 + const response = ReactServerDOMClient.createFromReadableStream(rscStream);
219 + const ssrStream = await ReactDOMServer.renderToReadableStream(response);
220 + await readIntoContainer(ssrStream);
221 +
222 + const form = container.firstChild;
223 +
224 + expect(foo).toBe(null);
225 +
226 + const result = await submit(form.getElementsByTagName('button')[1]);
227 +
228 + expect(result).toBe('helloc');
229 + expect(foo).toBe('barc');
230 + });
231 +});
packages/react-server/src/ReactFlightActionServer.js new
+110
@@ -0,0 +1,110 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {Thenable} from 'shared/ReactTypes';
11 +
12 +import type {
13 + ServerManifest,
14 + ClientReference as ServerReference,
15 +} from 'react-client/src/ReactFlightClientConfig';
16 +
17 +import {
18 + resolveServerReference,
19 + preloadModule,
20 + requireModule,
21 +} from 'react-client/src/ReactFlightClientConfig';
22 +
23 +import {createResponse, close, getRoot} from './ReactFlightReplyServer';
24 +
25 +type ServerReferenceId = any;
26 +
27 +function bindArgs(fn: any, args: any) {
28 + return fn.bind.apply(fn, [null].concat(args));
29 +}
30 +
31 +function loadServerReference<T>(
32 + bundlerConfig: ServerManifest,
33 + id: ServerReferenceId,
34 + bound: null | Thenable<Array<any>>,
35 +): Promise<T> {
36 + const serverReference: ServerReference<T> =
37 + resolveServerReference<$FlowFixMe>(bundlerConfig, id);
38 + // We expect most servers to not really need this because you'd just have all
39 + // the relevant modules already loaded but it allows for lazy loading of code
40 + // if needed.
41 + const preloadPromise = preloadModule(serverReference);
42 + if (bound) {
43 + return Promise.all([(bound: any), preloadPromise]).then(
44 + ([args]: Array<any>) => bindArgs(requireModule(serverReference), args),
45 + );
46 + } else if (preloadPromise) {
47 + return Promise.resolve(preloadPromise).then(() =>
48 + requireModule(serverReference),
49 + );
50 + } else {
51 + // Synchronously available
52 + return Promise.resolve(requireModule(serverReference));
53 + }
54 +}
55 +
56 +export function decodeAction<T>(
57 + body: FormData,
58 + serverManifest: ServerManifest,
59 +): Promise<() => T> | null {
60 + // We're going to create a new formData object that holds all the fields except
61 + // the implementation details of the action data.
62 + const formData = new FormData();
63 +
64 + let action: Promise<(formData: FormData) => T> | null = null;
65 +
66 + // $FlowFixMe[prop-missing]
67 + body.forEach((value: string | File, key: string) => {
68 + if (!key.startsWith('$ACTION_')) {
69 + formData.append(key, value);
70 + return;
71 + }
72 + // Later actions may override earlier actions if a button is used to override the default
73 + // form action.
74 + if (key.startsWith('$ACTION_REF_')) {
75 + const formFieldPrefix = '$ACTION_' + key.slice(12) + ':';
76 + // The data for this reference is encoded in multiple fields under this prefix.
77 + const actionResponse = createResponse(
78 + serverManifest,
79 + formFieldPrefix,
80 + body,
81 + );
82 + close(actionResponse);
83 + const refPromise = getRoot<{
84 + id: ServerReferenceId,
85 + bound: null | Promise<Array<any>>,
86 + }>(actionResponse);
87 + // Force it to initialize
88 + // $FlowFixMe
89 + refPromise.then(() => {});
90 + if (refPromise.status !== 'fulfilled') {
91 + // $FlowFixMe
92 + throw refPromise.reason;
93 + }
94 + const metaData = refPromise.value;
95 + action = loadServerReference(serverManifest, metaData.id, metaData.bound);
96 + return;
97 + }
98 + if (key.startsWith('$ACTION_ID_')) {
99 + const id = key.slice(11);
100 + action = loadServerReference(serverManifest, id, null);
101 + return;
102 + }
103 + });
104 +
105 + if (action === null) {
106 + return null;
107 + }
108 + // Return the action with the remaining FormData bound to the first argument.
109 + return action.then(fn => fn.bind(null, formData));
110 +}
scripts/error-codes/codes.json
+2 -1
@@ -465,5 +465,6 @@
465 "477": "React Internal Error: processHintChunk is not implemented for Native-Relay. The fact that this method was called means there is a bug in React.",
466 "478": "Thenable should have already resolved. This is a bug in React.",
467 "479": "Cannot update optimistic state while rendering.",
468 - "480": "File/Blob fields are not yet supported in progressive forms. It probably means you are closing over binary data or FormData in a Server Action."
468 + "480": "File/Blob fields are not yet supported in progressive forms. It probably means you are closing over binary data or FormData in a Server Action.",
469 + "481": "Tried to encode a Server Action from a different instance than the encoder is from. This is a bug in React."
470 }