Skip to content

Commit 6df391d

Browse files
committed
useFormState: Reuse state from previous form submission
If a Server Action is passed to useFormState, the action may be submitted before it has hydrated. This will trigger a full page (MPA-style) navigation. We can transfer the form state to the next page by comparing the key path of the hook instance. `ReactServerDOMServer.decodeFormState` is used by the server to extract the form state from the submitted action. This value can then be passed as an option when rendering the new page. It must be passed during both SSR and hydration. ```js const boundAction = await decodeAction(formData, serverManifest); const promiseForResult = boundAction(); const formState = decodeFormState(formData, serverManifest, promiseForResult); // SSR const response = createFromReadableStream(<App />); const ssrStream = await renderToReadableStream(response, { formState }) // Hydration hydrateRoot(container, <App />, { formState }); ``` If the `formState` option is omitted, then the state won't be transferred to the next page. However, it must be passed in both places, or in neither; misconfiguring will result in a hydration mismatch.
1 parent c270d47 commit 6df391d

24 files changed

+237
-62
lines changed

fixtures/flight-esm/server/region.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ const React = require('react');
2727

2828
const moduleBasePath = new URL('../src', url.pathToFileURL(__filename)).href;
2929

30-
async function renderApp(res, returnValue) {
30+
async function renderApp(res, returnValue, formState) {
3131
const {renderToPipeableStream} = await import('react-server-dom-esm/server');
3232
const m = await import('../src/App.js');
3333

3434
const App = m.default;
3535
const root = React.createElement(App);
3636
// For client-invoked server actions we refresh the tree and return a return value.
37-
const payload = returnValue ? {returnValue, root} : root;
37+
const payload = {root, returnValue, formState};
3838
const {pipe} = renderToPipeableStream(payload, moduleBasePath);
3939
pipe(res);
4040
}
@@ -80,7 +80,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
8080
// We handle the error on the client
8181
}
8282
// Refresh the client and return the value
83-
renderApp(res, result);
83+
renderApp(res, result, null);
8484
} else {
8585
// This is the progressive enhancement case
8686
const UndiciRequest = require('undici').Request;
@@ -92,14 +92,16 @@ app.post('/', bodyParser.text(), async function (req, res) {
9292
});
9393
const formData = await fakeRequest.formData();
9494
const action = await decodeAction(formData, moduleBasePath);
95+
const result = action();
96+
const formState = await decodeFormState(formData, moduleBasePath, result);
9597
try {
9698
// Wait for any mutations
97-
await action();
99+
await result;
98100
} catch (x) {
99101
const {setServerState} = await import('../src/ServerState.js');
100102
setServerState('Error: ' + x.message);
101103
}
102-
renderApp(res, null);
104+
renderApp(res, null, formState);
103105
}
104106
});
105107

fixtures/flight-esm/src/index.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async function callServer(id, args) {
2525
return returnValue;
2626
}
2727

28-
let data = createFromFetch(
28+
let {root, formState} = createFromFetch(
2929
fetch('/', {
3030
headers: {
3131
Accept: 'text/x-component',
@@ -43,4 +43,6 @@ function Shell({data}) {
4343
return root;
4444
}
4545

46-
ReactDOM.hydrateRoot(document, React.createElement(Shell, {data}));
46+
ReactDOM.hydrateRoot(document, React.createElement(Shell, {data: root}), {
47+
experimental_formState,
48+
});

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
* @flow
88
*/
99

10-
import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes';
10+
import type {
11+
Thenable,
12+
FulfilledThenable,
13+
RejectedThenable,
14+
ReactCustomFormAction,
15+
} from 'shared/ReactTypes';
1116

1217
import {
1318
REACT_ELEMENT_TYPE,
@@ -23,10 +28,6 @@ import {
2328
} from 'shared/ReactSerializationErrors';
2429

2530
import isArray from 'shared/isArray';
26-
import type {
27-
FulfilledThenable,
28-
RejectedThenable,
29-
} from '../../shared/ReactTypes';
3031

3132
import {usedWithSSR} from './ReactFlightClientConfig';
3233

packages/react-dom/src/client/ReactDOMLegacy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ function legacyCreateRootFromDOMContainer(
142142
noopOnRecoverableError,
143143
// TODO(luna) Support hydration later
144144
null,
145+
null,
145146
);
146147
container._reactRootContainer = root;
147148
markContainerAsRoot(root.current, container);

packages/react-dom/src/client/ReactDOMRoot.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import type {ReactNodeList} from 'shared/ReactTypes';
10+
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
1111
import type {
1212
FiberRoot,
1313
TransitionTracingCallbacks,
@@ -21,6 +21,8 @@ import {
2121
enableHostSingletons,
2222
allowConcurrentByDefault,
2323
disableCommentsAsDOMContainers,
24+
enableAsyncActions,
25+
enableFormActions,
2426
} from 'shared/ReactFeatureFlags';
2527

2628
import ReactDOMSharedInternals from '../ReactDOMSharedInternals';
@@ -55,6 +57,7 @@ export type HydrateRootOptions = {
5557
unstable_transitionCallbacks?: TransitionTracingCallbacks,
5658
identifierPrefix?: string,
5759
onRecoverableError?: (error: mixed) => void,
60+
experimental_formState?: ReactFormState<any> | null,
5861
...
5962
};
6063

@@ -302,6 +305,7 @@ export function hydrateRoot(
302305
let identifierPrefix = '';
303306
let onRecoverableError = defaultOnRecoverableError;
304307
let transitionCallbacks = null;
308+
let formState = null;
305309
if (options !== null && options !== undefined) {
306310
if (options.unstable_strictMode === true) {
307311
isStrictMode = true;
@@ -321,6 +325,11 @@ export function hydrateRoot(
321325
if (options.unstable_transitionCallbacks !== undefined) {
322326
transitionCallbacks = options.unstable_transitionCallbacks;
323327
}
328+
if (enableAsyncActions && enableFormActions) {
329+
if (options.experimental_formState !== undefined) {
330+
formState = options.experimental_formState;
331+
}
332+
}
324333
}
325334

326335
const root = createHydrationContainer(
@@ -334,6 +343,7 @@ export function hydrateRoot(
334343
identifierPrefix,
335344
onRecoverableError,
336345
transitionCallbacks,
346+
formState,
337347
);
338348
markContainerAsRoot(root.current, container);
339349
Dispatcher.current = ReactDOMClientDispatcher;

packages/react-dom/src/server/ReactDOMFizzServerBrowser.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type {PostponedState} from 'react-server/src/ReactFizzServer';
11-
import type {ReactNodeList} from 'shared/ReactTypes';
11+
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
1212
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
1313
import type {ImportMap} from '../shared/ReactDOMTypes';
1414

@@ -40,6 +40,7 @@ type Options = {
4040
onPostpone?: (reason: string) => void,
4141
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
4242
importMap?: ImportMap,
43+
experimental_formState?: ReactFormState<any> | null,
4344
};
4445

4546
type ResumeOptions = {
@@ -48,6 +49,7 @@ type ResumeOptions = {
4849
onError?: (error: mixed) => ?string,
4950
onPostpone?: (reason: string) => void,
5051
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
52+
experimental_formState?: ReactFormState<any> | null,
5153
};
5254

5355
// TODO: Move to sub-classing ReadableStream.
@@ -116,6 +118,7 @@ function renderToReadableStream(
116118
onShellError,
117119
onFatalError,
118120
options ? options.onPostpone : undefined,
121+
options ? options.experimental_formState : undefined,
119122
);
120123
if (options && options.signal) {
121124
const signal = options.signal;
@@ -187,6 +190,7 @@ function resume(
187190
onShellError,
188191
onFatalError,
189192
options ? options.onPostpone : undefined,
193+
options ? options.experimental_formState : undefined,
190194
);
191195
if (options && options.signal) {
192196
const signal = options.signal;

packages/react-dom/src/server/ReactDOMFizzServerBun.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import type {ReactNodeList} from 'shared/ReactTypes';
10+
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
1111
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
1212
import type {ImportMap} from '../shared/ReactDOMTypes';
1313

@@ -39,6 +39,7 @@ type Options = {
3939
onPostpone?: (reason: string) => void,
4040
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
4141
importMap?: ImportMap,
42+
experimental_formState?: ReactFormState<any> | null,
4243
};
4344

4445
// TODO: Move to sub-classing ReadableStream.
@@ -108,6 +109,7 @@ function renderToReadableStream(
108109
onShellError,
109110
onFatalError,
110111
options ? options.onPostpone : undefined,
112+
options ? options.experimental_formState : undefined,
111113
);
112114
if (options && options.signal) {
113115
const signal = options.signal;

packages/react-dom/src/server/ReactDOMFizzServerEdge.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type {PostponedState} from 'react-server/src/ReactFizzServer';
11-
import type {ReactNodeList} from 'shared/ReactTypes';
11+
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
1212
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
1313
import type {ImportMap} from '../shared/ReactDOMTypes';
1414

@@ -40,6 +40,7 @@ type Options = {
4040
onPostpone?: (reason: string) => void,
4141
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
4242
importMap?: ImportMap,
43+
experimental_formState?: ReactFormState<any> | null,
4344
};
4445

4546
type ResumeOptions = {
@@ -48,6 +49,7 @@ type ResumeOptions = {
4849
onError?: (error: mixed) => ?string,
4950
onPostpone?: (reason: string) => void,
5051
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
52+
experimental_formState?: ReactFormState<any> | null,
5153
};
5254

5355
// TODO: Move to sub-classing ReadableStream.
@@ -116,6 +118,7 @@ function renderToReadableStream(
116118
onShellError,
117119
onFatalError,
118120
options ? options.onPostpone : undefined,
121+
options ? options.experimental_formState : undefined,
119122
);
120123
if (options && options.signal) {
121124
const signal = options.signal;
@@ -187,6 +190,7 @@ function resume(
187190
onShellError,
188191
onFatalError,
189192
options ? options.onPostpone : undefined,
193+
options ? options.experimental_formState : undefined,
190194
);
191195
if (options && options.signal) {
192196
const signal = options.signal;

packages/react-dom/src/server/ReactDOMFizzServerNode.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type {Request, PostponedState} from 'react-server/src/ReactFizzServer';
11-
import type {ReactNodeList} from 'shared/ReactTypes';
11+
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
1212
import type {Writable} from 'stream';
1313
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
1414
import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
@@ -53,6 +53,7 @@ type Options = {
5353
onPostpone?: (reason: string) => void,
5454
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
5555
importMap?: ImportMap,
56+
experimental_formState?: ReactFormState<any> | null,
5657
};
5758

5859
type ResumeOptions = {
@@ -62,6 +63,7 @@ type ResumeOptions = {
6263
onAllReady?: () => void,
6364
onError?: (error: mixed) => ?string,
6465
onPostpone?: (reason: string) => void,
66+
experimental_formState?: ReactFormState<any> | null,
6567
};
6668

6769
type PipeableStream = {
@@ -96,6 +98,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
9698
options ? options.onShellError : undefined,
9799
undefined,
98100
options ? options.onPostpone : undefined,
101+
options ? options.experimental_formState : undefined,
99102
);
100103
}
101104

@@ -156,6 +159,7 @@ function resumeRequestImpl(
156159
options ? options.onShellError : undefined,
157160
undefined,
158161
options ? options.onPostpone : undefined,
162+
options ? options.experimental_formState : undefined,
159163
);
160164
}
161165

packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import type {ReactNodeList} from 'shared/ReactTypes';
10+
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
1111
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
1212
import type {PostponedState} from 'react-server/src/ReactFizzServer';
1313
import type {ImportMap} from '../shared/ReactDOMTypes';
@@ -40,6 +40,7 @@ type Options = {
4040
onPostpone?: (reason: string) => void,
4141
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
4242
importMap?: ImportMap,
43+
experimental_formState?: ReactFormState<any> | null,
4344
};
4445

4546
type StaticResult = {
@@ -96,6 +97,7 @@ function prerender(
9697
undefined,
9798
onFatalError,
9899
options ? options.onPostpone : undefined,
100+
options ? options.experimental_formState : undefined,
99101
);
100102
if (options && options.signal) {
101103
const signal = options.signal;

0 commit comments

Comments
 (0)