Skip to content

Commit 422ad8a

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 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.
1 parent e520565 commit 422ad8a

20 files changed

+282
-61
lines changed

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

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,28 +2010,36 @@ function formStateReducer<S>(oldState: S, newState: S): S {
20102010

20112011
function mountFormState<S, P>(
20122012
action: (S, P) => Promise<S>,
2013-
initialState: S,
2013+
initialStateProp: S,
20142014
permalink?: string,
20152015
): [S, (P) => void] {
2016+
let initialState = initialStateProp;
20162017
if (getIsHydrating()) {
2017-
// TODO: If this function returns true, it means we should use the form
2018-
// state passed to hydrateRoot instead of initialState.
2019-
tryToClaimNextHydratableFormMarkerInstance(currentlyRenderingFiber);
2018+
const isMatching = tryToClaimNextHydratableFormMarkerInstance(
2019+
currentlyRenderingFiber,
2020+
);
2021+
const root: FiberRoot = (getWorkInProgressRoot(): any);
2022+
const ssrFormState = root.formState;
2023+
if (ssrFormState !== null && isMatching) {
2024+
initialState = ssrFormState[0];
2025+
}
20202026
}
2027+
const initialStateThenable: Thenable<S> = {
2028+
status: 'fulfilled',
2029+
value: initialState,
2030+
then() {},
2031+
};
20212032

20222033
// State hook. The state is stored in a thenable which is then unwrapped by
20232034
// the `use` algorithm during render.
20242035
const stateHook = mountWorkInProgressHook();
2025-
stateHook.memoizedState = stateHook.baseState = {
2026-
status: 'fulfilled',
2027-
value: initialState,
2028-
};
2036+
stateHook.memoizedState = stateHook.baseState = initialStateThenable;
20292037
const stateQueue: UpdateQueue<Thenable<S>, Thenable<S>> = {
20302038
pending: null,
20312039
lanes: NoLanes,
20322040
dispatch: null,
20332041
lastRenderedReducer: formStateReducer,
2034-
lastRenderedState: (initialState: any),
2042+
lastRenderedState: initialStateThenable,
20352043
};
20362044
stateHook.queue = stateQueue;
20372045
const setState: Dispatch<Thenable<S>> = (dispatchSetState.bind(

packages/react-reconciler/src/ReactFiberReconciler.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type {
2121
PublicInstance,
2222
RendererInspectionConfig,
2323
} from './ReactFiberConfig';
24-
import type {ReactNodeList} from 'shared/ReactTypes';
24+
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
2525
import type {Lane} from './ReactFiberLane';
2626
import type {SuspenseState} from './ReactFiberSuspenseComponent';
2727

@@ -265,6 +265,7 @@ export function createContainer(
265265
identifierPrefix,
266266
onRecoverableError,
267267
transitionCallbacks,
268+
null,
268269
);
269270
}
270271

@@ -280,6 +281,7 @@ export function createHydrationContainer(
280281
identifierPrefix: string,
281282
onRecoverableError: (error: mixed) => void,
282283
transitionCallbacks: null | TransitionTracingCallbacks,
284+
formState: ReactFormState<any> | null,
283285
): OpaqueRoot {
284286
const hydrate = true;
285287
const root = createFiberRoot(
@@ -293,6 +295,7 @@ export function createHydrationContainer(
293295
identifierPrefix,
294296
onRecoverableError,
295297
transitionCallbacks,
298+
formState,
296299
);
297300

298301
// TODO: Move this to FiberRoot constructor

packages/react-reconciler/src/ReactFiberRoot.js

Lines changed: 6 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
SuspenseHydrationCallbacks,
@@ -52,6 +52,7 @@ function FiberRootNode(
5252
hydrate: any,
5353
identifierPrefix: any,
5454
onRecoverableError: any,
55+
formState: ReactFormState<any> | null,
5556
) {
5657
this.tag = tag;
5758
this.containerInfo = containerInfo;
@@ -93,6 +94,8 @@ function FiberRootNode(
9394
this.hydrationCallbacks = null;
9495
}
9596

97+
this.formState = formState;
98+
9699
this.incompleteTransitions = new Map();
97100
if (enableTransitionTracing) {
98101
this.transitionCallbacks = null;
@@ -142,6 +145,7 @@ export function createFiberRoot(
142145
identifierPrefix: string,
143146
onRecoverableError: null | ((error: mixed) => void),
144147
transitionCallbacks: null | TransitionTracingCallbacks,
148+
formState: ReactFormState<any> | null,
145149
): FiberRoot {
146150
// $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
147151
const root: FiberRoot = (new FiberRootNode(
@@ -150,6 +154,7 @@ export function createFiberRoot(
150154
hydrate,
151155
identifierPrefix,
152156
onRecoverableError,
157+
formState,
153158
): any);
154159
if (enableSuspenseCallback) {
155160
root.hydrationCallbacks = hydrationCallbacks;

packages/react-reconciler/src/ReactInternalTypes.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
StartTransitionOptions,
1515
Wakeable,
1616
Usable,
17+
ReactFormState,
1718
} from 'shared/ReactTypes';
1819
import type {WorkTag} from './ReactWorkTags';
1920
import type {TypeOfMode} from './ReactTypeOfMode';
@@ -270,6 +271,8 @@ type BaseFiberRootProperties = {
270271
error: mixed,
271272
errorInfo: {digest?: ?string, componentStack?: ?string},
272273
) => void,
274+
275+
formState: ReactFormState<any> | null,
273276
};
274277

275278
// The following attributes are only used by DevTools and are only present in DEV builds.

packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ import {
3636
getRoot,
3737
} from 'react-server/src/ReactFlightReplyServer';
3838

39-
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
39+
import {
40+
decodeAction,
41+
decodeFormState,
42+
} from 'react-server/src/ReactFlightActionServer';
4043

4144
export {
4245
registerServerReference,
@@ -166,4 +169,5 @@ export {
166169
decodeReplyFromBusboy,
167170
decodeReply,
168171
decodeAction,
172+
decodeFormState,
169173
};

packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ import {
2525
getRoot,
2626
} from 'react-server/src/ReactFlightReplyServer';
2727

28-
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
28+
import {
29+
decodeAction,
30+
decodeFormState,
31+
} from 'react-server/src/ReactFlightActionServer';
2932

3033
export {
3134
registerServerReference,
@@ -97,4 +100,4 @@ function decodeReply<T>(
97100
return getRoot(response);
98101
}
99102

100-
export {renderToReadableStream, decodeReply, decodeAction};
103+
export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};

packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ import {
2525
getRoot,
2626
} from 'react-server/src/ReactFlightReplyServer';
2727

28-
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
28+
import {
29+
decodeAction,
30+
decodeFormState,
31+
} from 'react-server/src/ReactFlightActionServer';
2932

3033
export {
3134
registerServerReference,
@@ -97,4 +100,4 @@ function decodeReply<T>(
97100
return getRoot(response);
98101
}
99102

100-
export {renderToReadableStream, decodeReply, decodeAction};
103+
export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};

packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ import {
3636
getRoot,
3737
} from 'react-server/src/ReactFlightReplyServer';
3838

39-
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
39+
import {
40+
decodeAction,
41+
decodeFormState,
42+
} from 'react-server/src/ReactFlightActionServer';
4043

4144
export {
4245
registerServerReference,
@@ -167,4 +170,5 @@ export {
167170
decodeReplyFromBusboy,
168171
decodeReply,
169172
decodeAction,
173+
decodeFormState,
170174
};

0 commit comments

Comments
 (0)