Skip to content

Commit

Permalink
useFormState: Reuse state from previous form submission (facebook#27321)
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 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_`)
  • Loading branch information
acdlite authored and AndyPengc12 committed Apr 15, 2024
1 parent 5e262e8 commit 20a6e42
Show file tree
Hide file tree
Showing 26 changed files with 335 additions and 88 deletions.
6 changes: 5 additions & 1 deletion fixtures/flight/server/global.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,15 @@ app.all('/', async function (req, res, next) {
// For HTML, we're a "client" emulator that runs the client code,
// so we start by consuming the RSC payload. This needs a module
// map that reverse engineers the client-side path to the SSR path.
const root = await createFromNodeStream(rscResponse, moduleMap);
const {root, formState} = await createFromNodeStream(
rscResponse,
moduleMap
);
// Render it into HTML by resolving the client components
res.set('Content-type', 'text/html');
const {pipe} = renderToPipeableStream(root, {
bootstrapScripts: mainJSChunks,
experimental_formState: formState,
});
pipe(res);
} catch (e) {
Expand Down
15 changes: 9 additions & 6 deletions fixtures/flight/server/region.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const {readFile} = require('fs').promises;

const React = require('react');

async function renderApp(res, returnValue) {
async function renderApp(res, returnValue, formState) {
const {renderToPipeableStream} = await import(
'react-server-dom-webpack/server'
);
Expand Down Expand Up @@ -93,13 +93,13 @@ async function renderApp(res, returnValue) {
React.createElement(App),
];
// For client-invoked server actions we refresh the tree and return a return value.
const payload = returnValue ? {returnValue, root} : root;
const payload = {root, returnValue, formState};
const {pipe} = renderToPipeableStream(payload, moduleMap);
pipe(res);
}

app.get('/', async function (req, res) {
await renderApp(res, null);
await renderApp(res, null, null);
});

app.post('/', bodyParser.text(), async function (req, res) {
Expand All @@ -108,6 +108,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
decodeReply,
decodeReplyFromBusboy,
decodeAction,
decodeFormState,
} = await import('react-server-dom-webpack/server');
const serverReference = req.get('rsc-action');
if (serverReference) {
Expand Down Expand Up @@ -139,7 +140,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
// We handle the error on the client
}
// Refresh the client and return the value
renderApp(res, result);
renderApp(res, result, null);
} else {
// This is the progressive enhancement case
const UndiciRequest = require('undici').Request;
Expand All @@ -153,12 +154,14 @@ app.post('/', bodyParser.text(), async function (req, res) {
const action = await decodeAction(formData);
try {
// Wait for any mutations
await action();
const result = await action();
const formState = decodeFormState(result, formData);
renderApp(res, null, formState);
} catch (x) {
const {setServerState} = await import('../src/ServerState.js');
setServerState('Error: ' + x.message);
renderApp(res, null, null);
}
renderApp(res, null);
}
});

Expand Down
8 changes: 4 additions & 4 deletions fixtures/flight/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {Client} from './Client.js';

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

import {like, greet} from './actions.js';
import {like, greet, increment} from './actions.js';

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

Expand All @@ -32,9 +32,9 @@ export default async function App() {
<body>
<Container>
<h1>{getServerState()}</h1>
<Counter />
<Counter2 />
<Counter3 />
<Counter incrementAction={increment} />
<Counter2 incrementAction={increment} />
<Counter3 incrementAction={increment} />
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
Expand Down
9 changes: 6 additions & 3 deletions fixtures/flight/src/Counter.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
'use client';

import * as React from 'react';
import {experimental_useFormState as useFormState} from 'react-dom';

import Container from './Container.js';

export function Counter() {
const [count, setCount] = React.useState(0);
export function Counter({incrementAction}) {
const [count, incrementFormAction] = useFormState(incrementAction, 0);
return (
<Container>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<form>
<button formAction={incrementFormAction}>Count: {count}</button>
</form>
</Container>
);
}
4 changes: 4 additions & 0 deletions fixtures/flight/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ export async function greet(formData) {
}
return 'Hi ' + name + '!';
}

export async function increment(n) {
return n + 1;
}
38 changes: 25 additions & 13 deletions fixtures/flight/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,33 @@ async function callServer(id, args) {
return returnValue;
}

let data = createFromFetch(
fetch('/', {
headers: {
Accept: 'text/x-component',
},
}),
{
callServer,
}
);

function Shell({data}) {
const [root, setRoot] = useState(use(data));
const [root, setRoot] = useState(data);
updateRoot = setRoot;
return root;
}

ReactDOM.hydrateRoot(document, <Shell data={data} />);
async function hydrateApp() {
const {root, returnValue, formState} = await createFromFetch(
fetch('/', {
headers: {
Accept: 'text/x-component',
},
}),
{
callServer,
}
);

ReactDOM.hydrateRoot(document, <Shell data={root} />, {
// TODO: This part doesn't actually work because the server only returns
// form state during the request that submitted the form. Which means it
// the state needs to be transported as part of the HTML stream. We intend
// to add a feature to Fizz for this, but for now it's up to the
// metaframework to implement correctly.
experimental_formState: formState,
});
}

// Remove this line to simulate MPA behavior
hydrateApp();
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
Loading

0 comments on commit 20a6e42

Please sign in to comment.