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 Aug 31, 2023
1 parent c270d47 commit 79ca08f
Show file tree
Hide file tree
Showing 22 changed files with 226 additions and 55 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
6 changes: 5 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 @@ -40,6 +40,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type ResumeOptions = {
Expand All @@ -48,6 +49,7 @@ type ResumeOptions = {
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
experimental_formState?: ReactFormState<any> | null,
};

// TODO: Move to sub-classing ReadableStream.
Expand Down Expand Up @@ -116,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 Expand Up @@ -187,6 +190,7 @@ function resume(
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
6 changes: 5 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 @@ -40,6 +40,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type ResumeOptions = {
Expand All @@ -48,6 +49,7 @@ type ResumeOptions = {
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
experimental_formState?: ReactFormState<any> | null,
};

// TODO: Move to sub-classing ReadableStream.
Expand Down Expand Up @@ -116,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 Expand Up @@ -187,6 +190,7 @@ function resume(
onShellError,
onFatalError,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
if (options && options.signal) {
const signal = options.signal;
Expand Down
6 changes: 5 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 @@ -53,6 +53,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type ResumeOptions = {
Expand All @@ -62,6 +63,7 @@ type ResumeOptions = {
onAllReady?: () => void,
onError?: (error: mixed) => ?string,
onPostpone?: (reason: string) => void,
experimental_formState?: ReactFormState<any> | null,
};

type PipeableStream = {
Expand Down Expand Up @@ -96,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 Expand Up @@ -156,6 +159,7 @@ function resumeRequestImpl(
options ? options.onShellError : undefined,
undefined,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzStaticBrowser.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 {PostponedState} from 'react-server/src/ReactFizzServer';
import type {ImportMap} from '../shared/ReactDOMTypes';
Expand Down Expand Up @@ -40,6 +40,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type StaticResult = {
Expand Down Expand Up @@ -96,6 +97,7 @@ function prerender(
undefined,
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/ReactDOMFizzStaticEdge.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 {PostponedState} from 'react-server/src/ReactFizzServer';
import type {ImportMap} from '../shared/ReactDOMTypes';
Expand Down Expand Up @@ -40,6 +40,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type StaticResult = {
Expand Down Expand Up @@ -96,6 +97,7 @@ function prerender(
undefined,
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/ReactDOMFizzStaticNode.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 {PostponedState} from 'react-server/src/ReactFizzServer';
import type {ImportMap} from '../shared/ReactDOMTypes';
Expand Down Expand Up @@ -42,6 +42,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
experimental_formState?: ReactFormState<any> | null,
};

type StaticResult = {
Expand Down Expand Up @@ -110,6 +111,7 @@ function prerenderToNodeStream(
undefined,
onFatalError,
options ? options.onPostpone : undefined,
options ? options.experimental_formState : undefined,
);
if (options && options.signal) {
const signal = options.signal;
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
Loading

0 comments on commit 79ca08f

Please sign in to comment.