@samitouri / QOS-React-2 / commits / 612b2b6601

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_`)

Andrew Clark committed Sep 13, 2023 at 18:30 UTC 612b2b6601abb844248c384d1e288bb824b180b7
26 files changed +335 -88
fixtures/flight/server/global.js
+5 -1
@@ -138,11 +138,15 @@ app.all('/', async function (req, res, next) {
138 // For HTML, we're a "client" emulator that runs the client code,
139 // so we start by consuming the RSC payload. This needs a module
140 // 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 + );
145 // Render it into HTML by resolving the client components
146 res.set('Content-type', 'text/html');
147 const {pipe} = renderToPipeableStream(root, {
148 bootstrapScripts: mainJSChunks,
149 + experimental_formState: formState,
150 });
151 pipe(res);
152 } catch (e) {
fixtures/flight/server/region.js
+9 -6
@@ -46,7 +46,7 @@ const {readFile} = require('fs').promises;
46
47 const React = require('react');
48
49 -async function renderApp(res, returnValue) {
49 +async function renderApp(res, returnValue, formState) {
50 const {renderToPipeableStream} = await import(
51 'react-server-dom-webpack/server'
52 );
@@ -93,13 +93,13 @@ async function renderApp(res, returnValue) {
93 React.createElement(App),
94 ];
95 // 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};
97 const {pipe} = renderToPipeableStream(payload, moduleMap);
98 pipe(res);
99 }
100
101 app.get('/', async function (req, res) {
102 - await renderApp(res, null);
102 + await renderApp(res, null, null);
103 });
104
105 app.post('/', bodyParser.text(), async function (req, res) {
@@ -108,6 +108,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
108 decodeReply,
109 decodeReplyFromBusboy,
110 decodeAction,
111 + decodeFormState,
112 } = await import('react-server-dom-webpack/server');
113 const serverReference = req.get('rsc-action');
114 if (serverReference) {
@@ -139,7 +140,7 @@ app.post('/', bodyParser.text(), async function (req, res) {
140 // We handle the error on the client
141 }
142 // Refresh the client and return the value
142 - renderApp(res, result);
143 + renderApp(res, result, null);
144 } else {
145 // This is the progressive enhancement case
146 const UndiciRequest = require('undici').Request;
@@ -153,12 +154,14 @@ app.post('/', bodyParser.text(), async function (req, res) {
154 const action = await decodeAction(formData);
155 try {
156 // Wait for any mutations
156 - await action();
157 + const result = await action();
158 + const formState = decodeFormState(result, formData);
159 + renderApp(res, null, formState);
160 } catch (x) {
161 const {setServerState} = await import('../src/ServerState.js');
162 setServerState('Error: ' + x.message);
163 + renderApp(res, null, null);
164 }
161 - renderApp(res, null);
165 }
166 });
167
fixtures/flight/src/App.js
+4 -4
@@ -15,7 +15,7 @@ import {Client} from './Client.js';
15
16 import {Note} from './cjs/Note.js';
17
18 -import {like, greet} from './actions.js';
18 +import {like, greet, increment} from './actions.js';
19
20 import {getServerState} from './ServerState.js';
21
@@ -32,9 +32,9 @@ export default async function App() {
32 <body>
33 <Container>
34 <h1>{getServerState()}</h1>
35 - <Counter />
36 - <Counter2 />
37 - <Counter3 />
35 + <Counter incrementAction={increment} />
36 + <Counter2 incrementAction={increment} />
37 + <Counter3 incrementAction={increment} />
38 <ul>
39 {todos.map(todo => (
40 <li key={todo.id}>{todo.text}</li>
fixtures/flight/src/Counter.js
+6 -3
@@ -1,14 +1,17 @@
1 'use client';
2
3 import * as React from 'react';
4 +import {experimental_useFormState as useFormState} from 'react-dom';
5
6 import Container from './Container.js';
7
7 -export function Counter() {
8 - const [count, setCount] = React.useState(0);
8 +export function Counter({incrementAction}) {
9 + const [count, incrementFormAction] = useFormState(incrementAction, 0);
10 return (
11 <Container>
11 - <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
12 + <form>
13 + <button formAction={incrementFormAction}>Count: {count}</button>
14 + </form>
15 </Container>
16 );
17 }
fixtures/flight/src/actions.js
+4
@@ -18,3 +18,7 @@ export async function greet(formData) {
18 }
19 return 'Hi ' + name + '!';
20 }
21 +
22 +export async function increment(n) {
23 + return n + 1;
24 +}
fixtures/flight/src/index.js
+25 -13
@@ -24,21 +24,33 @@ async function callServer(id, args) {
24 return returnValue;
25 }
26
27 -let data = createFromFetch(
28 - fetch('/', {
29 - headers: {
30 - Accept: 'text/x-component',
31 - },
32 - }),
33 - {
34 - callServer,
35 - }
36 -);
37 -
27 function Shell({data}) {
39 - const [root, setRoot] = useState(use(data));
28 + const [root, setRoot] = useState(data);
29 updateRoot = setRoot;
30 return root;
31 }
32
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
+6 -5
@@ -7,7 +7,12 @@
7 * @flow
8 */
9
10 -import type {Thenable, ReactCustomFormAction} from 'shared/ReactTypes';
10 +import type {
11 + Thenable,
12 + FulfilledThenable,
13 + RejectedThenable,
14 + ReactCustomFormAction,
15 +} from 'shared/ReactTypes';
16
17 import {
18 REACT_ELEMENT_TYPE,
@@ -23,10 +28,6 @@ import {
28 } from 'shared/ReactSerializationErrors';
29
30 import isArray from 'shared/isArray';
26 -import type {
27 - FulfilledThenable,
28 - RejectedThenable,
29 -} from '../../shared/ReactTypes';
31
32 import {usedWithSSR} from './ReactFlightClientConfig';
33
packages/react-dom/src/client/ReactDOMLegacy.js
+1
@@ -142,6 +142,7 @@ function legacyCreateRootFromDOMContainer(
142 noopOnRecoverableError,
143 // TODO(luna) Support hydration later
144 null,
145 + null,
146 );
147 container._reactRootContainer = root;
148 markContainerAsRoot(root.current, container);
packages/react-dom/src/client/ReactDOMRoot.js
+11 -1
@@ -7,7 +7,7 @@
7 * @flow
8 */
9
10 -import type {ReactNodeList} from 'shared/ReactTypes';
10 +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
11 import type {
12 FiberRoot,
13 TransitionTracingCallbacks,
@@ -21,6 +21,8 @@ import {
21 enableHostSingletons,
22 allowConcurrentByDefault,
23 disableCommentsAsDOMContainers,
24 + enableAsyncActions,
25 + enableFormActions,
26 } from 'shared/ReactFeatureFlags';
27
28 import ReactDOMSharedInternals from '../ReactDOMSharedInternals';
@@ -55,6 +57,7 @@ export type HydrateRootOptions = {
57 unstable_transitionCallbacks?: TransitionTracingCallbacks,
58 identifierPrefix?: string,
59 onRecoverableError?: (error: mixed) => void,
60 + experimental_formState?: ReactFormState<any> | null,
61 ...
62 };
63
@@ -302,6 +305,7 @@ export function hydrateRoot(
305 let identifierPrefix = '';
306 let onRecoverableError = defaultOnRecoverableError;
307 let transitionCallbacks = null;
308 + let formState = null;
309 if (options !== null && options !== undefined) {
310 if (options.unstable_strictMode === true) {
311 isStrictMode = true;
@@ -321,6 +325,11 @@ export function hydrateRoot(
325 if (options.unstable_transitionCallbacks !== undefined) {
326 transitionCallbacks = options.unstable_transitionCallbacks;
327 }
328 + if (enableAsyncActions && enableFormActions) {
329 + if (options.experimental_formState !== undefined) {
330 + formState = options.experimental_formState;
331 + }
332 + }
333 }
334
335 const root = createHydrationContainer(
@@ -334,6 +343,7 @@ export function hydrateRoot(
343 identifierPrefix,
344 onRecoverableError,
345 transitionCallbacks,
346 + formState,
347 );
348 markContainerAsRoot(root.current, container);
349 Dispatcher.current = ReactDOMClientDispatcher;
packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+3 -1
@@ -8,7 +8,7 @@
8 */
9
10 import type {PostponedState} from 'react-server/src/ReactFizzServer';
11 -import type {ReactNodeList} from 'shared/ReactTypes';
11 +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
12 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
13 import type {ImportMap} from '../shared/ReactDOMTypes';
14
@@ -41,6 +41,7 @@ type Options = {
41 onPostpone?: (reason: string) => void,
42 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
43 importMap?: ImportMap,
44 + experimental_formState?: ReactFormState<any> | null,
45 };
46
47 type ResumeOptions = {
@@ -117,6 +118,7 @@ function renderToReadableStream(
118 onShellError,
119 onFatalError,
120 options ? options.onPostpone : undefined,
121 + options ? options.experimental_formState : undefined,
122 );
123 if (options && options.signal) {
124 const signal = options.signal;
packages/react-dom/src/server/ReactDOMFizzServerBun.js
+3 -1
@@ -7,7 +7,7 @@
7 * @flow
8 */
9
10 -import type {ReactNodeList} from 'shared/ReactTypes';
10 +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
11 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
12 import type {ImportMap} from '../shared/ReactDOMTypes';
13
@@ -39,6 +39,7 @@ type Options = {
39 onPostpone?: (reason: string) => void,
40 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
41 importMap?: ImportMap,
42 + experimental_formState?: ReactFormState<any> | null,
43 };
44
45 // TODO: Move to sub-classing ReadableStream.
@@ -108,6 +109,7 @@ function renderToReadableStream(
109 onShellError,
110 onFatalError,
111 options ? options.onPostpone : undefined,
112 + options ? options.experimental_formState : undefined,
113 );
114 if (options && options.signal) {
115 const signal = options.signal;
packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+3 -1
@@ -8,7 +8,7 @@
8 */
9
10 import type {PostponedState} from 'react-server/src/ReactFizzServer';
11 -import type {ReactNodeList} from 'shared/ReactTypes';
11 +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
12 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
13 import type {ImportMap} from '../shared/ReactDOMTypes';
14
@@ -41,6 +41,7 @@ type Options = {
41 onPostpone?: (reason: string) => void,
42 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
43 importMap?: ImportMap,
44 + experimental_formState?: ReactFormState<any> | null,
45 };
46
47 type ResumeOptions = {
@@ -117,6 +118,7 @@ function renderToReadableStream(
118 onShellError,
119 onFatalError,
120 options ? options.onPostpone : undefined,
121 + options ? options.experimental_formState : undefined,
122 );
123 if (options && options.signal) {
124 const signal = options.signal;
packages/react-dom/src/server/ReactDOMFizzServerNode.js
+3 -1
@@ -8,7 +8,7 @@
8 */
9
10 import type {Request, PostponedState} from 'react-server/src/ReactFizzServer';
11 -import type {ReactNodeList} from 'shared/ReactTypes';
11 +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
12 import type {Writable} from 'stream';
13 import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
14 import type {Destination} from 'react-server/src/ReactServerStreamConfigNode';
@@ -54,6 +54,7 @@ type Options = {
54 onPostpone?: (reason: string) => void,
55 unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
56 importMap?: ImportMap,
57 + experimental_formState?: ReactFormState<any> | null,
58 };
59
60 type ResumeOptions = {
@@ -97,6 +98,7 @@ function createRequestImpl(children: ReactNodeList, options: void | Options) {
98 options ? options.onShellError : undefined,
99 undefined,
100 options ? options.onPostpone : undefined,
101 + options ? options.experimental_formState : undefined,
102 );
103 }
104
packages/react-reconciler/src/ReactFiberHooks.js
+17 -9
@@ -2010,28 +2010,36 @@ function formStateReducer<S>(oldState: S, newState: S): S {
2010
2011 function mountFormState<S, P>(
2012 action: (S, P) => Promise<S>,
2013 - initialState: S,
2013 + initialStateProp: S,
2014 permalink?: string,
2015 ): [S, (P) => void] {
2016 + let initialState = initialStateProp;
2017 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 + }
2026 }
2027 + const initialStateThenable: Thenable<S> = {
2028 + status: 'fulfilled',
2029 + value: initialState,
2030 + then() {},
2031 + };
2032
2033 // State hook. The state is stored in a thenable which is then unwrapped by
2034 // the `use` algorithm during render.
2035 const stateHook = mountWorkInProgressHook();
2025 - stateHook.memoizedState = stateHook.baseState = {
2026 - status: 'fulfilled',
2027 - value: initialState,
2028 - };
2036 + stateHook.memoizedState = stateHook.baseState = initialStateThenable;
2037 const stateQueue: UpdateQueue<Thenable<S>, Thenable<S>> = {
2038 pending: null,
2039 lanes: NoLanes,
2040 dispatch: null,
2041 lastRenderedReducer: formStateReducer,
2034 - lastRenderedState: (initialState: any),
2042 + lastRenderedState: initialStateThenable,
2043 };
2044 stateHook.queue = stateQueue;
2045 const setState: Dispatch<Thenable<S>> = (dispatchSetState.bind(
packages/react-reconciler/src/ReactFiberReconciler.js
+4 -1
@@ -21,7 +21,7 @@ import type {
21 PublicInstance,
22 RendererInspectionConfig,
23 } from './ReactFiberConfig';
24 -import type {ReactNodeList} from 'shared/ReactTypes';
24 +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
25 import type {Lane} from './ReactFiberLane';
26 import type {SuspenseState} from './ReactFiberSuspenseComponent';
27
@@ -265,6 +265,7 @@ export function createContainer(
265 identifierPrefix,
266 onRecoverableError,
267 transitionCallbacks,
268 + null,
269 );
270 }
271
@@ -280,6 +281,7 @@ export function createHydrationContainer(
281 identifierPrefix: string,
282 onRecoverableError: (error: mixed) => void,
283 transitionCallbacks: null | TransitionTracingCallbacks,
284 + formState: ReactFormState<any> | null,
285 ): OpaqueRoot {
286 const hydrate = true;
287 const root = createFiberRoot(
@@ -293,6 +295,7 @@ export function createHydrationContainer(
295 identifierPrefix,
296 onRecoverableError,
297 transitionCallbacks,
298 + formState,
299 );
300
301 // TODO: Move this to FiberRoot constructor
packages/react-reconciler/src/ReactFiberRoot.js
+6 -1
@@ -7,7 +7,7 @@
7 * @flow
8 */
9
10 -import type {ReactNodeList} from 'shared/ReactTypes';
10 +import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
11 import type {
12 FiberRoot,
13 SuspenseHydrationCallbacks,
@@ -52,6 +52,7 @@ function FiberRootNode(
52 hydrate: any,
53 identifierPrefix: any,
54 onRecoverableError: any,
55 + formState: ReactFormState<any> | null,
56 ) {
57 this.tag = tag;
58 this.containerInfo = containerInfo;
@@ -93,6 +94,8 @@ function FiberRootNode(
94 this.hydrationCallbacks = null;
95 }
96
97 + this.formState = formState;
98 +
99 this.incompleteTransitions = new Map();
100 if (enableTransitionTracing) {
101 this.transitionCallbacks = null;
@@ -142,6 +145,7 @@ export function createFiberRoot(
145 identifierPrefix: string,
146 onRecoverableError: null | ((error: mixed) => void),
147 transitionCallbacks: null | TransitionTracingCallbacks,
148 + formState: ReactFormState<any> | null,
149 ): FiberRoot {
150 // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
151 const root: FiberRoot = (new FiberRootNode(
@@ -150,6 +154,7 @@ export function createFiberRoot(
154 hydrate,
155 identifierPrefix,
156 onRecoverableError,
157 + formState,
158 ): any);
159 if (enableSuspenseCallback) {
160 root.hydrationCallbacks = hydrationCallbacks;
packages/react-reconciler/src/ReactInternalTypes.js
+3
@@ -14,6 +14,7 @@ import type {
14 StartTransitionOptions,
15 Wakeable,
16 Usable,
17 + ReactFormState,
18 } from 'shared/ReactTypes';
19 import type {WorkTag} from './ReactWorkTags';
20 import type {TypeOfMode} from './ReactTypeOfMode';
@@ -270,6 +271,8 @@ type BaseFiberRootProperties = {
271 error: mixed,
272 errorInfo: {digest?: ?string, componentStack?: ?string},
273 ) => void,
274 +
275 + formState: ReactFormState<any> | null,
276 };
277
278 // The following attributes are only used by DevTools and are only present in DEV builds.
packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
+5 -1
@@ -36,7 +36,10 @@ import {
36 getRoot,
37 } from 'react-server/src/ReactFlightReplyServer';
38
39 -import {decodeAction} from 'react-server/src/ReactFlightActionServer';
39 +import {
40 + decodeAction,
41 + decodeFormState,
42 +} from 'react-server/src/ReactFlightActionServer';
43
44 export {
45 registerServerReference,
@@ -166,4 +169,5 @@ export {
169 decodeReplyFromBusboy,
170 decodeReply,
171 decodeAction,
172 + decodeFormState,
173 };
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+5 -2
@@ -25,7 +25,10 @@ import {
25 getRoot,
26 } from 'react-server/src/ReactFlightReplyServer';
27
28 -import {decodeAction} from 'react-server/src/ReactFlightActionServer';
28 +import {
29 + decodeAction,
30 + decodeFormState,
31 +} from 'react-server/src/ReactFlightActionServer';
32
33 export {
34 registerServerReference,
@@ -97,4 +100,4 @@ function decodeReply<T>(
100 return getRoot(response);
101 }
102
100 -export {renderToReadableStream, decodeReply, decodeAction};
103 +export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};
packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+5 -2
@@ -25,7 +25,10 @@ import {
25 getRoot,
26 } from 'react-server/src/ReactFlightReplyServer';
27
28 -import {decodeAction} from 'react-server/src/ReactFlightActionServer';
28 +import {
29 + decodeAction,
30 + decodeFormState,
31 +} from 'react-server/src/ReactFlightActionServer';
32
33 export {
34 registerServerReference,
@@ -97,4 +100,4 @@ function decodeReply<T>(
100 return getRoot(response);
101 }
102
100 -export {renderToReadableStream, decodeReply, decodeAction};
103 +export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+5 -1
@@ -36,7 +36,10 @@ import {
36 getRoot,
37 } from 'react-server/src/ReactFlightReplyServer';
38
39 -import {decodeAction} from 'react-server/src/ReactFlightActionServer';
39 +import {
40 + decodeAction,
41 + decodeFormState,
42 +} from 'react-server/src/ReactFlightActionServer';
43
44 export {
45 registerServerReference,
@@ -167,4 +170,5 @@ export {
170 decodeReplyFromBusboy,
171 decodeReply,
172 decodeAction,
173 + decodeFormState,
174 };
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+104 -21
@@ -30,7 +30,9 @@ let React;
30 let ReactDOMServer;
31 let ReactServerDOMServer;
32 let ReactServerDOMClient;
33 +let ReactDOMClient;
34 let useFormState;
35 +let act;
36
37 describe('ReactFlightDOMForm', () => {
38 beforeEach(() => {
@@ -48,6 +50,8 @@ describe('ReactFlightDOMForm', () => {
50 ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
51 ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
52 ReactDOMServer = require('react-dom/server.edge');
53 + ReactDOMClient = require('react-dom/client');
54 + act = require('react-dom/test-utils').act;
55 useFormState = require('react-dom').experimental_useFormState;
56 container = document.createElement('div');
57 document.body.appendChild(container);
@@ -62,7 +66,13 @@ describe('ReactFlightDOMForm', () => {
66 formData,
67 webpackServerMap,
68 );
65 - return boundAction();
69 + const returnValue = boundAction();
70 + const formState = ReactServerDOMServer.decodeFormState(
71 + await returnValue,
72 + formData,
73 + webpackServerMap,
74 + );
75 + return {returnValue, formState};
76 }
77
78 function submit(submitter) {
@@ -138,9 +148,9 @@ describe('ReactFlightDOMForm', () => {
148
149 expect(foo).toBe(null);
150
141 - const result = await submit(form);
151 + const {returnValue} = await submit(form);
152
143 - expect(result).toBe('hello');
153 + expect(returnValue).toBe('hello');
154 expect(foo).toBe('bar');
155 });
156
@@ -170,9 +180,9 @@ describe('ReactFlightDOMForm', () => {
180
181 expect(foo).toBe(null);
182
173 - const result = await submit(form);
183 + const {returnValue} = await submit(form);
184
175 - expect(result).toBe('hi');
185 + expect(returnValue).toBe('hi');
186
187 expect(foo).toBe('bar');
188 });
@@ -201,9 +211,9 @@ describe('ReactFlightDOMForm', () => {
211
212 expect(foo).toBe(null);
213
204 - const result = await submit(form);
214 + const {returnValue} = await submit(form);
215
206 - expect(result).toBe('hello');
216 + expect(returnValue).toBe('hello');
217 expect(foo).toBe('barobject');
218 });
219
@@ -237,9 +247,9 @@ describe('ReactFlightDOMForm', () => {
247
248 expect(foo).toBe(null);
249
240 - const result = await submit(form.getElementsByTagName('button')[1]);
250 + const {returnValue} = await submit(form.getElementsByTagName('button')[1]);
251
242 - expect(result).toBe('helloc');
252 + expect(returnValue).toBe('helloc');
253 expect(foo).toBe('barc');
254 });
255
@@ -269,9 +279,9 @@ describe('ReactFlightDOMForm', () => {
279
280 expect(foo).toBe(null);
281
272 - const result = await submit(form);
282 + const {returnValue} = await submit(form);
283
274 - expect(result).toBe('hello');
284 + expect(returnValue).toBe('hello');
285 expect(foo).toBe('barobject');
286 });
287
@@ -305,23 +315,22 @@ describe('ReactFlightDOMForm', () => {
315
316 expect(foo).toBe(null);
317
308 - const result = await submit(form);
318 + const {returnValue} = await submit(form);
319
310 - expect(result).toBe('hello');
320 + expect(returnValue).toBe('hello');
321 expect(foo).toBe('barobject');
322 });
323
324 // @gate enableFormActions
325 // @gate enableAsyncActions
326 it("useFormState's dispatch binds the initial state to the provided action", async () => {
317 - let serverActionResult = null;
318 -
319 - const serverAction = serverExports(function action(prevState, formData) {
320 - const newState = {
327 + const serverAction = serverExports(async function action(
328 + prevState,
329 + formData,
330 + ) {
331 + return {
332 count: prevState.count + parseInt(formData.get('incrementAmount'), 10),
333 };
323 - serverActionResult = newState;
324 - return newState;
334 });
335
336 const initialState = {count: 1};
@@ -348,8 +357,82 @@ describe('ReactFlightDOMForm', () => {
357 const span = container.getElementsByTagName('span')[0];
358 expect(span.textContent).toBe('Count: 1');
359
351 - await submit(form);
352 - expect(serverActionResult.count).toBe(6);
360 + const {returnValue} = await submit(form);
361 + expect(await returnValue).toEqual({count: 6});
362 + });
363 +
364 + // @gate enableFormActions
365 + // @gate enableAsyncActions
366 + it('useFormState can reuse state during MPA form submission', async () => {
367 + const serverAction = serverExports(async function action(
368 + prevState,
369 + formData,
370 + ) {
371 + return prevState + 1;
372 + });
373 +
374 + function Form({action}) {
375 + const [count, dispatch] = useFormState(action, 1);
376 + return <form action={dispatch}>{count}</form>;
377 + }
378 +
379 + function Client({action}) {
380 + return (
381 + <div>
382 + <Form action={action} />
383 + <Form action={action} />
384 + <Form action={action} />
385 + </div>
386 + );
387 + }
388 +
389 + const ClientRef = await clientExports(Client);
390 +
391 + const rscStream = ReactServerDOMServer.renderToReadableStream(
392 + <ClientRef action={serverAction} />,
393 + webpackMap,
394 + );
395 + const response = ReactServerDOMClient.createFromReadableStream(rscStream);
396 + const ssrStream = await ReactDOMServer.renderToReadableStream(response);
397 + await readIntoContainer(ssrStream);
398 +
399 + expect(container.textContent).toBe('111');
400 +
401 + // There are three identical forms. We're going to submit the second one.
402 + const form = container.getElementsByTagName('form')[1];
403 + const {formState} = await submit(form);
404 +
405 + // Simulate an MPA form submission by resetting the container and
406 + // rendering again.
407 + container.innerHTML = '';
408 +
409 + const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
410 + <ClientRef action={serverAction} />,
411 + webpackMap,
412 + );
413 + const postbackResponse =
414 + ReactServerDOMClient.createFromReadableStream(postbackRscStream);
415 + const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
416 + postbackResponse,
417 + {experimental_formState: formState},
418 + );
419 + await readIntoContainer(postbackSsrStream);
420 +
421 + // Only the second form's state should have been updated.
422 + expect(container.textContent).toBe('121');
423 +
424 + // Test that it hydrates correctly
425 + if (__DEV__) {
426 + // TODO: Can't use our internal act() util that works in production
427 + // because it works by overriding the timer APIs, which this test module
428 + // also does. Remove dev condition once FlightServer.act() is available.
429 + await act(() => {
430 + ReactDOMClient.hydrateRoot(container, postbackResponse, {
431 + experimental_formState: formState,
432 + });
433 + });
434 + expect(container.textContent).toBe('121');
435 + }
436 });
437
438 // @gate enableFormActions
packages/react-server/src/ReactFizzHooks.js
+53 -10
@@ -18,7 +18,7 @@ import type {
18 } from 'shared/ReactTypes';
19
20 import type {ResumableState} from './ReactFizzConfig';
21 -import type {Task} from './ReactFizzServer';
21 +import type {Request, Task, KeyNode} from './ReactFizzServer';
22 import type {ThenableState} from './ReactFizzThenable';
23 import type {TransitionStatus} from './ReactFizzConfig';
24
@@ -42,6 +42,7 @@ import {
42 REACT_MEMO_CACHE_SENTINEL,
43 } from 'shared/ReactSymbols';
44 import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
45 +import {getFormState} from './ReactFizzServer';
46
47 type BasicStateAction<S> = (S => S) | S;
48 type Dispatch<A> = A => void;
@@ -64,6 +65,8 @@ type Hook = {
65
66 let currentlyRenderingComponent: Object | null = null;
67 let currentlyRenderingTask: Task | null = null;
68 +let currentlyRenderingRequest: Request | null = null;
69 +let currentlyRenderingKeyPath: KeyNode | null = null;
70 let firstWorkInProgressHook: Hook | null = null;
71 let workInProgressHook: Hook | null = null;
72 // Whether the work-in-progress hook is a re-rendered hook
@@ -197,12 +200,16 @@ function createWorkInProgressHook(): Hook {
200 }
201
202 export function prepareToUseHooks(
203 + request: Request,
204 task: Task,
205 + keyPath: KeyNode | null,
206 componentIdentity: Object,
207 prevThenableState: ThenableState | null,
208 ): void {
209 currentlyRenderingComponent = componentIdentity;
210 currentlyRenderingTask = task;
211 + currentlyRenderingRequest = request;
212 + currentlyRenderingKeyPath = keyPath;
213 if (__DEV__) {
214 isInHookUserCodeInDev = false;
215 }
@@ -287,6 +294,8 @@ export function resetHooksState(): void {
294
295 currentlyRenderingComponent = null;
296 currentlyRenderingTask = null;
297 + currentlyRenderingRequest = null;
298 + currentlyRenderingKeyPath = null;
299 didScheduleRenderPhaseUpdate = false;
300 firstWorkInProgressHook = null;
301 numberOfReRenders = 0;
@@ -584,15 +593,43 @@ function useFormState<S, P>(
593 ): [S, (P) => void] {
594 resolveCurrentlyRenderingComponent();
595
587 - // Count the number of useFormState hooks per component.
588 - // TODO: We should also track which hook matches the form state passed at
589 - // the root, if any. Matching is not yet implemented.
590 - formStateCounter++;
596 + // Count the number of useFormState hooks per component. We also use this to
597 + // track the position of this useFormState hook relative to the other ones in
598 + // this component, so we can generate a unique key for each one.
599 + const formStateHookIndex = formStateCounter++;
600 + const request: Request = (currentlyRenderingRequest: any);
601 +
602 + // Append a node to the key path that represents the form state hook.
603 + const componentKey: KeyNode | null = (currentlyRenderingKeyPath: any);
604 + const key: KeyNode = [componentKey, null, formStateHookIndex];
605 + const keyJSON = JSON.stringify(key);
606 +
607 + // Get the form state. If we received form state from a previous page, then
608 + // we should reuse that, if the action identity matches. Otherwise we'll use
609 + // the initial state argument. We emit a comment marker into the stream
610 + // that indicates whether the state was reused.
611 + let state;
612 + const postbackFormState = getFormState(request);
613 + if (postbackFormState !== null) {
614 + const postbackKey = postbackFormState[1];
615 + // TODO: Compare the action identity, too
616 + // TODO: If a permalink is used, disregard the key and compare that instead.
617 + if (keyJSON === postbackKey) {
618 + // This was a match.
619 + formStateMatchingIndex = formStateHookIndex;
620 + // Reuse the state that was submitted by the form.
621 + state = postbackFormState[0];
622 + } else {
623 + state = initialState;
624 + }
625 + } else {
626 + // TODO: As an optimization, Fizz should only emit these markers if form
627 + // state is passed at the root.
628 + state = initialState;
629 + }
630
592 - // Bind the initial state to the first argument of the action.
593 - // TODO: Use the keypath (or permalink) to check if there's matching state
594 - // from the previous page.
595 - const boundAction = action.bind(null, initialState);
631 + // Bind the state to the first argument of the action.
632 + const boundAction = action.bind(null, state);
633
634 // Wrap the action so the return value is void.
635 const dispatch = (payload: P): void => {
@@ -605,6 +642,12 @@ function useFormState<S, P>(
642 dispatch.$$FORM_ACTION = (prefix: string) => {
643 // $FlowIgnore[prop-missing]
644 const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix);
645 +
646 + const formData = metadata.data;
647 + if (formData) {
648 + formData.append('$ACTION_KEY', keyJSON);
649 + }
650 +
651 // Override the action URL
652 if (permalink !== undefined) {
653 if (__DEV__) {
@@ -619,7 +662,7 @@ function useFormState<S, P>(
662 // no effect. The form will have to be hydrated before it's submitted.
663 }
664
622 - return [initialState, dispatch];
665 + return [state, dispatch];
666 }
667
668 function useId(): string {
packages/react-server/src/ReactFizzServer.js
+21 -2
@@ -19,6 +19,7 @@ import type {
19 OffscreenMode,
20 Wakeable,
21 Thenable,
22 + ReactFormState,
23 } from 'shared/ReactTypes';
24 import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
25 import type {
@@ -158,7 +159,7 @@ const ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
159 // Linked list representing the identity of a component given the component/tag name and key.
160 // The name might be minified but we assume that it's going to be the same generated name. Typically
161 // because it's just the same compiled output in practice.
161 -type KeyNode = [
162 +export type KeyNode = [
163 Root | KeyNode /* parent */,
164 string | null /* name */,
165 string | number /* key */,
@@ -311,6 +312,8 @@ export opaque type Request = {
312 // onPostpone is called when postpone() is called anywhere in the tree, which will defer
313 // rendering - e.g. to the client. This is considered intentional and not an error.
314 onPostpone: (reason: string) => void,
315 + // Form state that was the result of an MPA submission, if it was provided.
316 + formState: null | ReactFormState<any>,
317 };
318
319 // This is a default heuristic for how to split up the HTML content into progressive
@@ -349,6 +352,7 @@ export function createRequest(
352 onShellError: void | ((error: mixed) => void),
353 onFatalError: void | ((error: mixed) => void),
354 onPostpone: void | ((reason: string) => void),
355 + formState: void | null | ReactFormState<any>,
356 ): Request {
357 prepareHostDispatcher();
358 const pingedTasks: Array<Task> = [];
@@ -381,6 +385,7 @@ export function createRequest(
385 onShellReady: onShellReady === undefined ? noop : onShellReady,
386 onShellError: onShellError === undefined ? noop : onShellError,
387 onFatalError: onFatalError === undefined ? noop : onFatalError,
388 + formState: formState === undefined ? null : formState,
389 };
390 // This segment represents the root fallback.
391 const rootSegment = createPendingSegment(
@@ -482,6 +487,7 @@ export function resumeRequest(
487 onShellReady: onShellReady === undefined ? noop : onShellReady,
488 onShellError: onShellError === undefined ? noop : onShellError,
489 onFatalError: onFatalError === undefined ? noop : onFatalError,
490 + formState: null,
491 };
492 // This segment represents the root fallback.
493 const rootSegment = createPendingSegment(
@@ -956,13 +962,20 @@ function shouldConstruct(Component: any) {
962 function renderWithHooks<Props, SecondArg>(
963 request: Request,
964 task: Task,
965 + keyPath: Root | KeyNode,
966 prevThenableState: ThenableState | null,
967 Component: (p: Props, arg: SecondArg) => any,
968 props: Props,
969 secondArg: SecondArg,
970 ): any {
971 const componentIdentity = {};
965 - prepareToUseHooks(task, componentIdentity, prevThenableState);
972 + prepareToUseHooks(
973 + request,
974 + task,
975 + keyPath,
976 + componentIdentity,
977 + prevThenableState,
978 + );
979 const result = Component(props, secondArg);
980 return finishHooks(Component, props, result, secondArg);
981 }
@@ -1078,6 +1091,7 @@ function renderIndeterminateComponent(
1091 const value = renderWithHooks(
1092 request,
1093 task,
1094 + keyPath,
1095 prevThenableState,
1096 Component,
1097 props,
@@ -1309,6 +1323,7 @@ function renderForwardRef(
1323 const children = renderWithHooks(
1324 request,
1325 task,
1326 + keyPath,
1327 prevThenableState,
1328 type.render,
1329 props,
@@ -3080,6 +3095,10 @@ export function flushResources(request: Request): void {
3095 enqueueFlush(request);
3096 }
3097
3098 +export function getFormState(request: Request): ReactFormState<any> | null {
3099 + return request.formState;
3100 +}
3101 +
3102 export function getResumableState(request: Request): ResumableState {
3103 return request.resumableState;
3104 }
packages/react-server/src/ReactFlightActionServer.js
+16 -1
@@ -7,7 +7,7 @@
7 * @flow
8 */
9
10 -import type {Thenable} from 'shared/ReactTypes';
10 +import type {Thenable, ReactFormState} from 'shared/ReactTypes';
11
12 import type {
13 ServerManifest,
@@ -108,3 +108,18 @@ export function decodeAction<T>(
108 // Return the action with the remaining FormData bound to the first argument.
109 return action.then(fn => fn.bind(null, formData));
110 }
111 +
112 +// TODO: Should this be an async function to preserve the option in the future
113 +// to do async stuff in here? Would also make it consistent with decodeAction
114 +export function decodeFormState<S>(
115 + actionResult: S,
116 + body: FormData,
117 + serverManifest: ServerManifest,
118 +): ReactFormState<S> | null {
119 + const keyPath = body.get('$ACTION_KEY');
120 + if (typeof keyPath !== 'string') {
121 + // This form submission did not include any form state.
122 + return null;
123 + }
124 + return [actionResult, keyPath];
125 +}
packages/shared/ReactTypes.js
+8
@@ -174,3 +174,11 @@ export type ReactCustomFormAction = {
174 target?: string,
175 data?: null | FormData,
176 };
177 +
178 +// This is an opaque type returned by decodeFormState on the server, but it's
179 +// defined in this shared file because the same type is used by React on
180 +// the client.
181 +export type ReactFormState<S> = [
182 + S /* actual state value */,
183 + string /* key path */,
184 +];