Skip to content

Commit 612b2b6

Browse files
authored
useFormState: Reuse state from previous form submission (#27321)
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 result = await boundAction(); const formState = decodeFormState(result, formData, serverManifest); // 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. (The `formState` option is currently prefixed with `experimental_`)
1 parent e520565 commit 612b2b6

26 files changed

+335
-88
lines changed

fixtures/flight/server/global.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,15 @@ app.all('/', async function (req, res, next) {
138138
// For HTML, we're a "client" emulator that runs the client code,
139139
// so we start by consuming the RSC payload. This needs a module
140140
// map that reverse engineers the client-side path to the SSR path.
141-
const root = await createFromNodeStream(rscResponse, moduleMap);
141+
const {root, formState} = await createFromNodeStream(
142+
rscResponse,
143+
moduleMap
144+
);
142145
// Render it into HTML by resolving the client components
143146
res.set('Content-type', 'text/html');
144147
const {pipe} = renderToPipeableStream(root, {
145148
bootstrapScripts: mainJSChunks,
149+
experimental_formState: formState,
146150
});
147151
pipe(res);
148152
} catch (e) {

fixtures/flight/server/region.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const {readFile} = require('fs').promises;
4646

4747
const React = require('react');
4848

49-
async function renderApp(res, returnValue) {
49+
async function renderApp(res, returnValue, formState) {
5050
const {renderToPipeableStream} = await import(
5151
'react-server-dom-webpack/server'
5252
);
@@ -93,13 +93,13 @@ async function renderApp(res, returnValue) {
9393
React.createElement(App),
9494
];
9595
// For client-invoked server actions we refresh the tree and return a return value.
96-
const payload = returnValue ? {returnValue, root} : root;
96+
const payload = {root, returnValue, formState};
9797
const {pipe} = renderToPipeableStream(payload, moduleMap);
9898
pipe(res);
9999
}
100100

101101
app.get('/', async function (req, res) {
102-
await renderApp(res, null);
102+
await renderApp(res, null, null);
103103
});
104104

105105
app.post('/', bodyParser.text(), async function (req, res) {
@@ -108,6 +108,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
108108
decodeReply,
109109
decodeReplyFromBusboy,
110110
decodeAction,
111+
decodeFormState,
111112
} = await import('react-server-dom-webpack/server');
112113
const serverReference = req.get('rsc-action');
113114
if (serverReference) {
@@ -139,7 +140,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
139140
// We handle the error on the client
140141
}
141142
// Refresh the client and return the value
142-
renderApp(res, result);
143+
renderApp(res, result, null);
143144
} else {
144145
// This is the progressive enhancement case
145146
const UndiciRequest = require('undici').Request;
@@ -153,12 +154,14 @@ app.post('/', bodyParser.text(), async function (req, res) {
153154
const action = await decodeAction(formData);
154155
try {
155156
// Wait for any mutations
156-
await action();
157+
const result = await action();
158+
const formState = decodeFormState(result, formData);
159+
renderApp(res, null, formState);
157160
} catch (x) {
158161
const {setServerState} = await import('../src/ServerState.js');
159162
setServerState('Error: ' + x.message);
163+
renderApp(res, null, null);
160164
}
161-
renderApp(res, null);
162165
}
163166
});
164167

fixtures/flight/src/App.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {Client} from './Client.js';
1515

1616
import {Note} from './cjs/Note.js';
1717

18-
import {like, greet} from './actions.js';
18+
import {like, greet, increment} from './actions.js';
1919

2020
import {getServerState} from './ServerState.js';
2121

@@ -32,9 +32,9 @@ export default async function App() {
3232
<body>
3333
<Container>
3434
<h1>{getServerState()}</h1>
35-
<Counter />
36-
<Counter2 />
37-
<Counter3 />
35+
<Counter incrementAction={increment} />
36+
<Counter2 incrementAction={increment} />
37+
<Counter3 incrementAction={increment} />
3838
<ul>
3939
{todos.map(todo => (
4040
<li key={todo.id}>{todo.text}</li>

fixtures/flight/src/Counter.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
'use client';
22

33
import * as React from 'react';
4+
import {experimental_useFormState as useFormState} from 'react-dom';
45

56
import Container from './Container.js';
67

7-
export function Counter() {
8-
const [count, setCount] = React.useState(0);
8+
export function Counter({incrementAction}) {
9+
const [count, incrementFormAction] = useFormState(incrementAction, 0);
910
return (
1011
<Container>
11-
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
12+
<form>
13+
<button formAction={incrementFormAction}>Count: {count}</button>
14+
</form>
1215
</Container>
1316
);
1417
}

fixtures/flight/src/actions.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ export async function greet(formData) {
1818
}
1919
return 'Hi ' + name + '!';
2020
}
21+
22+
export async function increment(n) {
23+
return n + 1;
24+
}

fixtures/flight/src/index.js

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,33 @@ async function callServer(id, args) {
2424
return returnValue;
2525
}
2626

27-
let data = createFromFetch(
28-
fetch('/', {
29-
headers: {
30-
Accept: 'text/x-component',
31-
},
32-
}),
33-
{
34-
callServer,
35-
}
36-
);
37-
3827
function Shell({data}) {
39-
const [root, setRoot] = useState(use(data));
28+
const [root, setRoot] = useState(data);
4029
updateRoot = setRoot;
4130
return root;
4231
}
4332

44-
ReactDOM.hydrateRoot(document, <Shell data={data} />);
33+
async function hydrateApp() {
34+
const {root, returnValue, formState} = await createFromFetch(
35+
fetch('/', {
36+
headers: {
37+
Accept: 'text/x-component',
38+
},
39+
}),
40+
{
41+
callServer,
42+
}
43+
);
44+
45+
ReactDOM.hydrateRoot(document, <Shell data={root} />, {
46+
// TODO: This part doesn't actually work because the server only returns
47+
// form state during the request that submitted the form. Which means it
48+
// the state needs to be transported as part of the HTML stream. We intend
49+
// to add a feature to Fizz for this, but for now it's up to the
50+
// metaframework to implement correctly.
51+
experimental_formState: formState,
52+
});
53+
}
54+
55+
// Remove this line to simulate MPA behavior
56+
hydrateApp();

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: 3 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

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

4647
type ResumeOptions = {
@@ -117,6 +118,7 @@ function renderToReadableStream(
117118
onShellError,
118119
onFatalError,
119120
options ? options.onPostpone : undefined,
121+
options ? options.experimental_formState : undefined,
120122
);
121123
if (options && options.signal) {
122124
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: 3 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

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

4647
type ResumeOptions = {
@@ -117,6 +118,7 @@ function renderToReadableStream(
117118
onShellError,
118119
onFatalError,
119120
options ? options.onPostpone : undefined,
121+
options ? options.experimental_formState : undefined,
120122
);
121123
if (options && options.signal) {
122124
const signal = options.signal;

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

Lines changed: 3 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';
@@ -54,6 +54,7 @@ type Options = {
5454
onPostpone?: (reason: string) => void,
5555
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
5656
importMap?: ImportMap,
57+
experimental_formState?: ReactFormState<any> | null,
5758
};
5859

5960
type ResumeOptions = {
@@ -97,6 +98,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
9798
options ? options.onShellError : undefined,
9899
undefined,
99100
options ? options.onPostpone : undefined,
101+
options ? options.experimental_formState : undefined,
100102
);
101103
}
102104

0 commit comments

Comments
 (0)