Skip to content

Commit

Permalink
useFormState: Reuse state from previous form submission
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite committed Sep 13, 2023
1 parent 1c945c3 commit 3778b22
Show file tree
Hide file tree
Showing 20 changed files with 299 additions and 69 deletions.
11 changes: 6 additions & 5 deletions packages/react-client/src/ReactFlightReplyClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
* @flow
*/

import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes';
import type {
Thenable,
FulfilledThenable,
RejectedThenable,
ReactCustomFormAction,
} from 'shared/ReactTypes';

import {
REACT_ELEMENT_TYPE,
Expand All @@ -23,10 +28,6 @@ import {
} from 'shared/ReactSerializationErrors';

import isArray from 'shared/isArray';
import type {
FulfilledThenable,
RejectedThenable,
} from '../../shared/ReactTypes';

import {usedWithSSR} from './ReactFlightClientConfig';

Expand Down
1 change: 1 addition & 0 deletions packages/react-dom/src/client/ReactDOMLegacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ function legacyCreateRootFromDOMContainer(
noopOnRecoverableError,
// TODO(luna) Support hydration later
null,
null,
);
container._reactRootContainer = root;
markContainerAsRoot(root.current, container);
Expand Down
12 changes: 11 additions & 1 deletion packages/react-dom/src/client/ReactDOMRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @flow
*/

import type {ReactNodeList} from 'shared/ReactTypes';
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {
FiberRoot,
TransitionTracingCallbacks,
Expand All @@ -21,6 +21,8 @@ import {
enableHostSingletons,
allowConcurrentByDefault,
disableCommentsAsDOMContainers,
enableAsyncActions,
enableFormActions,
} from 'shared/ReactFeatureFlags';

import ReactDOMSharedInternals from '../ReactDOMSharedInternals';
Expand Down Expand Up @@ -55,6 +57,7 @@ export type HydrateRootOptions = {
unstable_transitionCallbacks?: TransitionTracingCallbacks,
identifierPrefix?: string,
onRecoverableError?: (error: mixed) => void,
experimental_formState?: ReactFormState<any> | null,
...
};

Expand Down Expand Up @@ -302,6 +305,7 @@ export function hydrateRoot(
let identifierPrefix = '';
let onRecoverableError = defaultOnRecoverableError;
let transitionCallbacks = null;
let formState = null;
if (options !== null && options !== undefined) {
if (options.unstable_strictMode === true) {
isStrictMode = true;
Expand All @@ -321,6 +325,11 @@ export function hydrateRoot(
if (options.unstable_transitionCallbacks !== undefined) {
transitionCallbacks = options.unstable_transitionCallbacks;
}
if (enableAsyncActions && enableFormActions) {
if (options.experimental_formState !== undefined) {
formState = options.experimental_formState;
}
}
}

const root = createHydrationContainer(
Expand All @@ -334,6 +343,7 @@ export function hydrateRoot(
identifierPrefix,
onRecoverableError,
transitionCallbacks,
formState,
);
markContainerAsRoot(root.current, container);
Dispatcher.current = ReactDOMClientDispatcher;
Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

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

Expand Down Expand Up @@ -41,6 +41,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type ResumeOptions = {
Expand Down Expand Up @@ -117,6 +118,7 @@ function renderToReadableStream(
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerBun.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @flow
*/

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

Expand Down Expand Up @@ -39,6 +39,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

// TODO: Move to sub-classing ReadableStream.
Expand Down Expand Up @@ -108,6 +109,7 @@ function renderToReadableStream(
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerEdge.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

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

Expand Down Expand Up @@ -41,6 +41,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type ResumeOptions = {
Expand Down Expand Up @@ -117,6 +118,7 @@ function renderToReadableStream(
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import type {Request, PostponedState} from 'react-server/src/ReactFizzServer';
import type {ReactNodeList} from 'shared/ReactTypes';
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {Writable} from 'stream';
import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
Expand Down Expand Up @@ -54,6 +54,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type ResumeOptions = {
Expand Down Expand Up @@ -97,6 +98,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
options ? options.onShellError : undefined,
undefined,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
}

Expand Down
27 changes: 18 additions & 9 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -2010,28 +2010,37 @@ function formStateReducer<S>(oldState: S, newState: S): S {

function mountFormState<S, P>(
action: (S, P) => Promise<S>,
initialState: S,
initialStateProp: S,
permalink?: string,
): [S, (P) => void] {
let initialState = initialStateProp;
if (getIsHydrating()) {
// TODO: If this function returns true, it means we should use the form
// state passed to hydrateRoot instead of initialState.
tryToClaimNextHydratableFormMarkerInstance(currentlyRenderingFiber);
const isMatching = tryToClaimNextHydratableFormMarkerInstance(
currentlyRenderingFiber,
);
const root: FiberRoot = (getWorkInProgressRoot(): any);
const ssrFormState = root.formState;
if (ssrFormState !== null && isMatching) {
const promiseForState = ssrFormState[0];
initialState = useThenable(promiseForState);
}
}
const initialStateThenable: Thenable<S> = {
status: 'fulfilled',
value: initialState,
then() {},
};

// State hook. The state is stored in a thenable which is then unwrapped by
// the `use` algorithm during render.
const stateHook = mountWorkInProgressHook();
stateHook.memoizedState = stateHook.baseState = {
status: 'fulfilled',
value: initialState,
};
stateHook.memoizedState = stateHook.baseState = initialStateThenable;
const stateQueue: UpdateQueue<Thenable<S>, Thenable<S>> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: formStateReducer,
lastRenderedState: (initialState: any),
lastRenderedState: initialStateThenable,
};
stateHook.queue = stateQueue;
const setState: Dispatch<Thenable<S>> = (dispatchSetState.bind(
Expand Down
5 changes: 4 additions & 1 deletion packages/react-reconciler/src/ReactFiberReconciler.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type {
PublicInstance,
RendererInspectionConfig,
} from './ReactFiberConfig';
import type {ReactNodeList} from 'shared/ReactTypes';
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {Lane} from './ReactFiberLane';
import type {SuspenseState} from './ReactFiberSuspenseComponent';

Expand Down Expand Up @@ -265,6 +265,7 @@ export function createContainer(
identifierPrefix,
onRecoverableError,
transitionCallbacks,
null,
);
}

Expand All @@ -280,6 +281,7 @@ export function createHydrationContainer(
identifierPrefix: string,
onRecoverableError: (error: mixed) => void,
transitionCallbacks: null | TransitionTracingCallbacks,
formState: ReactFormState<any> | null,
): OpaqueRoot {
const hydrate = true;
const root = createFiberRoot(
Expand All @@ -293,6 +295,7 @@ export function createHydrationContainer(
identifierPrefix,
onRecoverableError,
transitionCallbacks,
formState,
);

// TODO: Move this to FiberRoot constructor
Expand Down
7 changes: 6 additions & 1 deletion packages/react-reconciler/src/ReactFiberRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @flow
*/

import type {ReactNodeList} from 'shared/ReactTypes';
import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {
FiberRoot,
SuspenseHydrationCallbacks,
Expand Down Expand Up @@ -52,6 +52,7 @@ function FiberRootNode(
hydrate: any,
identifierPrefix: any,
onRecoverableError: any,
formState: ReactFormState<any> | null,
) {
this.tag = tag;
this.containerInfo = containerInfo;
Expand Down Expand Up @@ -93,6 +94,8 @@ function FiberRootNode(
this.hydrationCallbacks = null;
}

this.formState = formState;

this.incompleteTransitions = new Map();
if (enableTransitionTracing) {
this.transitionCallbacks = null;
Expand Down Expand Up @@ -142,6 +145,7 @@ export function createFiberRoot(
identifierPrefix: string,
onRecoverableError: null | ((error: mixed) => void),
transitionCallbacks: null | TransitionTracingCallbacks,
formState: ReactFormState<any> | null,
): FiberRoot {
// $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
const root: FiberRoot = (new FiberRootNode(
Expand All @@ -150,6 +154,7 @@ export function createFiberRoot(
hydrate,
identifierPrefix,
onRecoverableError,
formState,
): any);
if (enableSuspenseCallback) {
root.hydrationCallbacks = hydrationCallbacks;
Expand Down
3 changes: 3 additions & 0 deletions packages/react-reconciler/src/ReactInternalTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
StartTransitionOptions,
Wakeable,
Usable,
ReactFormState,
} from 'shared/ReactTypes';
import type {WorkTag} from './ReactWorkTags';
import type {TypeOfMode} from './ReactTypeOfMode';
Expand Down Expand Up @@ -270,6 +271,8 @@ type BaseFiberRootProperties = {
error: mixed,
errorInfo: {digest?: ?string, componentStack?: ?string},
) => void,

formState: ReactFormState<any> | null,
};

// The following attributes are only used by DevTools and are only present in DEV builds.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ import {
getRoot,
} from 'react-server/src/ReactFlightReplyServer';

import {decodeAction} from 'react-server/src/ReactFlightActionServer';
import {
decodeAction,
decodeFormState,
} from 'react-server/src/ReactFlightActionServer';

export {
registerServerReference,
Expand Down Expand Up @@ -166,4 +169,5 @@ export {
decodeReplyFromBusboy,
decodeReply,
decodeAction,
decodeFormState,
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import {
getRoot,
} from 'react-server/src/ReactFlightReplyServer';

import {decodeAction} from 'react-server/src/ReactFlightActionServer';
import {
decodeAction,
decodeFormState,
} from 'react-server/src/ReactFlightActionServer';

export {
registerServerReference,
Expand Down Expand Up @@ -97,4 +100,4 @@ function decodeReply<T>(
return getRoot(response);
}

export {renderToReadableStream, decodeReply, decodeAction};
export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import {
getRoot,
} from 'react-server/src/ReactFlightReplyServer';

import {decodeAction} from 'react-server/src/ReactFlightActionServer';
import {
decodeAction,
decodeFormState,
} from 'react-server/src/ReactFlightActionServer';

export {
registerServerReference,
Expand Down Expand Up @@ -97,4 +100,4 @@ function decodeReply<T>(
return getRoot(response);
}

export {renderToReadableStream, decodeReply, decodeAction};
export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ import {
getRoot,
} from 'react-server/src/ReactFlightReplyServer';

import {decodeAction} from 'react-server/src/ReactFlightActionServer';
import {
decodeAction,
decodeFormState,
} from 'react-server/src/ReactFlightActionServer';

export {
registerServerReference,
Expand Down Expand Up @@ -167,4 +170,5 @@ export {
decodeReplyFromBusboy,
decodeReply,
decodeAction,
decodeFormState,
};
Loading

0 comments on commit 3778b22

Please sign in to comment.