diff --git a/fixtures/flight/server/global.js b/fixtures/flight/server/global.js
index 16184287b1228..70ccb05ca83ac 100644
--- a/fixtures/flight/server/global.js
+++ b/fixtures/flight/server/global.js
@@ -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) {
diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js
index 4beae03ff35ee..e89f113b7cad6 100644
--- a/fixtures/flight/server/region.js
+++ b/fixtures/flight/server/region.js
@@ -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'
);
@@ -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) {
@@ -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) {
@@ -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;
@@ -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);
}
});
diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js
index 71eb4fa5d97a0..7a14beb460def 100644
--- a/fixtures/flight/src/App.js
+++ b/fixtures/flight/src/App.js
@@ -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';
@@ -32,9 +32,9 @@ export default async function App() {
{getServerState()}
-
-
-
+
+
+
{todos.map(todo => (
- {todo.text}
diff --git a/fixtures/flight/src/Counter.js b/fixtures/flight/src/Counter.js
index 8785424ca469f..5af74369a1eb8 100644
--- a/fixtures/flight/src/Counter.js
+++ b/fixtures/flight/src/Counter.js
@@ -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 (
-
+
);
}
diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js
index 3d26189979c2f..aa19871a9dcbb 100644
--- a/fixtures/flight/src/actions.js
+++ b/fixtures/flight/src/actions.js
@@ -18,3 +18,7 @@ export async function greet(formData) {
}
return 'Hi ' + name + '!';
}
+
+export async function increment(n) {
+ return n + 1;
+}
diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js
index d75feee56ec36..dd5a6b02b7681 100644
--- a/fixtures/flight/src/index.js
+++ b/fixtures/flight/src/index.js
@@ -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, );
+async function hydrateApp() {
+ const {root, returnValue, formState} = await createFromFetch(
+ fetch('/', {
+ headers: {
+ Accept: 'text/x-component',
+ },
+ }),
+ {
+ callServer,
+ }
+ );
+
+ ReactDOM.hydrateRoot(document, , {
+ // 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();
diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js
index bedf5a90496f0..96ebd193b162c 100644
--- a/packages/react-client/src/ReactFlightReplyClient.js
+++ b/packages/react-client/src/ReactFlightReplyClient.js
@@ -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,
@@ -23,10 +28,6 @@ import {
} from 'shared/ReactSerializationErrors';
import isArray from 'shared/isArray';
-import type {
- FulfilledThenable,
- RejectedThenable,
-} from '../../shared/ReactTypes';
import {usedWithSSR} from './ReactFlightClientConfig';
diff --git a/packages/react-dom/src/client/ReactDOMLegacy.js b/packages/react-dom/src/client/ReactDOMLegacy.js
index b8b1fc43239a9..ce0c9ff609ebd 100644
--- a/packages/react-dom/src/client/ReactDOMLegacy.js
+++ b/packages/react-dom/src/client/ReactDOMLegacy.js
@@ -142,6 +142,7 @@ function legacyCreateRootFromDOMContainer(
noopOnRecoverableError,
// TODO(luna) Support hydration later
null,
+ null,
);
container._reactRootContainer = root;
markContainerAsRoot(root.current, container);
diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js
index b55b09eb2124d..55bbc6627922c 100644
--- a/packages/react-dom/src/client/ReactDOMRoot.js
+++ b/packages/react-dom/src/client/ReactDOMRoot.js
@@ -7,7 +7,7 @@
* @flow
*/
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {
FiberRoot,
TransitionTracingCallbacks,
@@ -21,6 +21,8 @@ import {
enableHostSingletons,
allowConcurrentByDefault,
disableCommentsAsDOMContainers,
+ enableAsyncActions,
+ enableFormActions,
} from 'shared/ReactFeatureFlags';
import ReactDOMSharedInternals from '../ReactDOMSharedInternals';
@@ -55,6 +57,7 @@ export type HydrateRootOptions = {
unstable_transitionCallbacks?: TransitionTracingCallbacks,
identifierPrefix?: string,
onRecoverableError?: (error: mixed) => void,
+ experimental_formState?: ReactFormState | null,
...
};
@@ -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;
@@ -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(
@@ -334,6 +343,7 @@ export function hydrateRoot(
identifierPrefix,
onRecoverableError,
transitionCallbacks,
+ formState,
);
markContainerAsRoot(root.current, container);
Dispatcher.current = ReactDOMClientDispatcher;
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
index 44df0fb2deecc..0cd08fcdb33a9 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
@@ -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';
@@ -41,6 +41,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
+ experimental_formState?: ReactFormState | null,
};
type ResumeOptions = {
@@ -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;
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.js
index 997934e1a3d1a..3de5fa51e51aa 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerBun.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.js
@@ -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';
@@ -39,6 +39,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
+ experimental_formState?: ReactFormState | null,
};
// TODO: Move to sub-classing ReadableStream.
@@ -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;
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js
index 44df0fb2deecc..0cd08fcdb33a9 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerEdge.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerEdge.js
@@ -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';
@@ -41,6 +41,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
+ experimental_formState?: ReactFormState | null,
};
type ResumeOptions = {
@@ -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;
diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js
index c7de8e073e16e..af1022dc013b1 100644
--- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js
+++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js
@@ -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';
@@ -54,6 +54,7 @@ type Options = {
onPostpone?: (reason: string) => void,
unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor,
importMap?: ImportMap,
+ experimental_formState?: ReactFormState | null,
};
type ResumeOptions = {
@@ -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,
);
}
diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js
index f8f935846f86f..1f8438b035edd 100644
--- a/packages/react-reconciler/src/ReactFiberHooks.js
+++ b/packages/react-reconciler/src/ReactFiberHooks.js
@@ -2010,28 +2010,36 @@ function formStateReducer(oldState: S, newState: S): S {
function mountFormState(
action: (S, P) => Promise,
- 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) {
+ initialState = ssrFormState[0];
+ }
}
+ const initialStateThenable: Thenable = {
+ 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> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: formStateReducer,
- lastRenderedState: (initialState: any),
+ lastRenderedState: initialStateThenable,
};
stateHook.queue = stateQueue;
const setState: Dispatch> = (dispatchSetState.bind(
diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js
index 4b5193585a0b0..599fa854c0a79 100644
--- a/packages/react-reconciler/src/ReactFiberReconciler.js
+++ b/packages/react-reconciler/src/ReactFiberReconciler.js
@@ -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';
@@ -265,6 +265,7 @@ export function createContainer(
identifierPrefix,
onRecoverableError,
transitionCallbacks,
+ null,
);
}
@@ -280,6 +281,7 @@ export function createHydrationContainer(
identifierPrefix: string,
onRecoverableError: (error: mixed) => void,
transitionCallbacks: null | TransitionTracingCallbacks,
+ formState: ReactFormState | null,
): OpaqueRoot {
const hydrate = true;
const root = createFiberRoot(
@@ -293,6 +295,7 @@ export function createHydrationContainer(
identifierPrefix,
onRecoverableError,
transitionCallbacks,
+ formState,
);
// TODO: Move this to FiberRoot constructor
diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js
index e65e25b97df6b..c686dba7c7e87 100644
--- a/packages/react-reconciler/src/ReactFiberRoot.js
+++ b/packages/react-reconciler/src/ReactFiberRoot.js
@@ -7,7 +7,7 @@
* @flow
*/
-import type {ReactNodeList} from 'shared/ReactTypes';
+import type {ReactNodeList, ReactFormState} from 'shared/ReactTypes';
import type {
FiberRoot,
SuspenseHydrationCallbacks,
@@ -52,6 +52,7 @@ function FiberRootNode(
hydrate: any,
identifierPrefix: any,
onRecoverableError: any,
+ formState: ReactFormState | null,
) {
this.tag = tag;
this.containerInfo = containerInfo;
@@ -93,6 +94,8 @@ function FiberRootNode(
this.hydrationCallbacks = null;
}
+ this.formState = formState;
+
this.incompleteTransitions = new Map();
if (enableTransitionTracing) {
this.transitionCallbacks = null;
@@ -142,6 +145,7 @@ export function createFiberRoot(
identifierPrefix: string,
onRecoverableError: null | ((error: mixed) => void),
transitionCallbacks: null | TransitionTracingCallbacks,
+ formState: ReactFormState | null,
): FiberRoot {
// $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
const root: FiberRoot = (new FiberRootNode(
@@ -150,6 +154,7 @@ export function createFiberRoot(
hydrate,
identifierPrefix,
onRecoverableError,
+ formState,
): any);
if (enableSuspenseCallback) {
root.hydrationCallbacks = hydrationCallbacks;
diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js
index 8dae4fa10e07f..e6b002d0e357b 100644
--- a/packages/react-reconciler/src/ReactInternalTypes.js
+++ b/packages/react-reconciler/src/ReactInternalTypes.js
@@ -14,6 +14,7 @@ import type {
StartTransitionOptions,
Wakeable,
Usable,
+ ReactFormState,
} from 'shared/ReactTypes';
import type {WorkTag} from './ReactWorkTags';
import type {TypeOfMode} from './ReactTypeOfMode';
@@ -270,6 +271,8 @@ type BaseFiberRootProperties = {
error: mixed,
errorInfo: {digest?: ?string, componentStack?: ?string},
) => void,
+
+ formState: ReactFormState | null,
};
// The following attributes are only used by DevTools and are only present in DEV builds.
diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
index def3a58478e57..289c79b3dfa10 100644
--- a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
+++ b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js
@@ -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,
@@ -166,4 +169,5 @@ export {
decodeReplyFromBusboy,
decodeReply,
decodeAction,
+ decodeFormState,
};
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
index 08214a4182ab2..f2904f8a84e68 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
@@ -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,
@@ -97,4 +100,4 @@ function decodeReply(
return getRoot(response);
}
-export {renderToReadableStream, decodeReply, decodeAction};
+export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
index 08214a4182ab2..f2904f8a84e68 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
@@ -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,
@@ -97,4 +100,4 @@ function decodeReply(
return getRoot(response);
}
-export {renderToReadableStream, decodeReply, decodeAction};
+export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
index 1e39d000ffef4..4818582ecf28e 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
@@ -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,
@@ -167,4 +170,5 @@ export {
decodeReplyFromBusboy,
decodeReply,
decodeAction,
+ decodeFormState,
};
diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
index 2d55e05c7d142..4249055bbef9f 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
@@ -30,7 +30,9 @@ let React;
let ReactDOMServer;
let ReactServerDOMServer;
let ReactServerDOMClient;
+let ReactDOMClient;
let useFormState;
+let act;
describe('ReactFlightDOMForm', () => {
beforeEach(() => {
@@ -48,6 +50,8 @@ describe('ReactFlightDOMForm', () => {
ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
ReactDOMServer = require('react-dom/server.edge');
+ ReactDOMClient = require('react-dom/client');
+ act = require('react-dom/test-utils').act;
useFormState = require('react-dom').experimental_useFormState;
container = document.createElement('div');
document.body.appendChild(container);
@@ -62,7 +66,13 @@ describe('ReactFlightDOMForm', () => {
formData,
webpackServerMap,
);
- return boundAction();
+ const returnValue = boundAction();
+ const formState = ReactServerDOMServer.decodeFormState(
+ await returnValue,
+ formData,
+ webpackServerMap,
+ );
+ return {returnValue, formState};
}
function submit(submitter) {
@@ -138,9 +148,9 @@ describe('ReactFlightDOMForm', () => {
expect(foo).toBe(null);
- const result = await submit(form);
+ const {returnValue} = await submit(form);
- expect(result).toBe('hello');
+ expect(returnValue).toBe('hello');
expect(foo).toBe('bar');
});
@@ -170,9 +180,9 @@ describe('ReactFlightDOMForm', () => {
expect(foo).toBe(null);
- const result = await submit(form);
+ const {returnValue} = await submit(form);
- expect(result).toBe('hi');
+ expect(returnValue).toBe('hi');
expect(foo).toBe('bar');
});
@@ -201,9 +211,9 @@ describe('ReactFlightDOMForm', () => {
expect(foo).toBe(null);
- const result = await submit(form);
+ const {returnValue} = await submit(form);
- expect(result).toBe('hello');
+ expect(returnValue).toBe('hello');
expect(foo).toBe('barobject');
});
@@ -237,9 +247,9 @@ describe('ReactFlightDOMForm', () => {
expect(foo).toBe(null);
- const result = await submit(form.getElementsByTagName('button')[1]);
+ const {returnValue} = await submit(form.getElementsByTagName('button')[1]);
- expect(result).toBe('helloc');
+ expect(returnValue).toBe('helloc');
expect(foo).toBe('barc');
});
@@ -269,9 +279,9 @@ describe('ReactFlightDOMForm', () => {
expect(foo).toBe(null);
- const result = await submit(form);
+ const {returnValue} = await submit(form);
- expect(result).toBe('hello');
+ expect(returnValue).toBe('hello');
expect(foo).toBe('barobject');
});
@@ -305,23 +315,22 @@ describe('ReactFlightDOMForm', () => {
expect(foo).toBe(null);
- const result = await submit(form);
+ const {returnValue} = await submit(form);
- expect(result).toBe('hello');
+ expect(returnValue).toBe('hello');
expect(foo).toBe('barobject');
});
// @gate enableFormActions
// @gate enableAsyncActions
it("useFormState's dispatch binds the initial state to the provided action", async () => {
- let serverActionResult = null;
-
- const serverAction = serverExports(function action(prevState, formData) {
- const newState = {
+ const serverAction = serverExports(async function action(
+ prevState,
+ formData,
+ ) {
+ return {
count: prevState.count + parseInt(formData.get('incrementAmount'), 10),
};
- serverActionResult = newState;
- return newState;
});
const initialState = {count: 1};
@@ -348,8 +357,82 @@ describe('ReactFlightDOMForm', () => {
const span = container.getElementsByTagName('span')[0];
expect(span.textContent).toBe('Count: 1');
- await submit(form);
- expect(serverActionResult.count).toBe(6);
+ const {returnValue} = await submit(form);
+ expect(await returnValue).toEqual({count: 6});
+ });
+
+ // @gate enableFormActions
+ // @gate enableAsyncActions
+ it('useFormState can reuse state during MPA form submission', async () => {
+ const serverAction = serverExports(async function action(
+ prevState,
+ formData,
+ ) {
+ return prevState + 1;
+ });
+
+ function Form({action}) {
+ const [count, dispatch] = useFormState(action, 1);
+ return ;
+ }
+
+ function Client({action}) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const ClientRef = await clientExports(Client);
+
+ const rscStream = ReactServerDOMServer.renderToReadableStream(
+ ,
+ webpackMap,
+ );
+ const response = ReactServerDOMClient.createFromReadableStream(rscStream);
+ const ssrStream = await ReactDOMServer.renderToReadableStream(response);
+ await readIntoContainer(ssrStream);
+
+ expect(container.textContent).toBe('111');
+
+ // There are three identical forms. We're going to submit the second one.
+ const form = container.getElementsByTagName('form')[1];
+ const {formState} = await submit(form);
+
+ // Simulate an MPA form submission by resetting the container and
+ // rendering again.
+ container.innerHTML = '';
+
+ const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
+ ,
+ webpackMap,
+ );
+ const postbackResponse =
+ ReactServerDOMClient.createFromReadableStream(postbackRscStream);
+ const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
+ postbackResponse,
+ {experimental_formState: formState},
+ );
+ await readIntoContainer(postbackSsrStream);
+
+ // Only the second form's state should have been updated.
+ expect(container.textContent).toBe('121');
+
+ // Test that it hydrates correctly
+ if (__DEV__) {
+ // TODO: Can't use our internal act() util that works in production
+ // because it works by overriding the timer APIs, which this test module
+ // also does. Remove dev condition once FlightServer.act() is available.
+ await act(() => {
+ ReactDOMClient.hydrateRoot(container, postbackResponse, {
+ experimental_formState: formState,
+ });
+ });
+ expect(container.textContent).toBe('121');
+ }
});
// @gate enableFormActions
diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js
index e18fa1509acbf..533c9658a8a38 100644
--- a/packages/react-server/src/ReactFizzHooks.js
+++ b/packages/react-server/src/ReactFizzHooks.js
@@ -18,7 +18,7 @@ import type {
} from 'shared/ReactTypes';
import type {ResumableState} from './ReactFizzConfig';
-import type {Task} from './ReactFizzServer';
+import type {Request, Task, KeyNode} from './ReactFizzServer';
import type {ThenableState} from './ReactFizzThenable';
import type {TransitionStatus} from './ReactFizzConfig';
@@ -42,6 +42,7 @@ import {
REACT_MEMO_CACHE_SENTINEL,
} from 'shared/ReactSymbols';
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
+import {getFormState} from './ReactFizzServer';
type BasicStateAction = (S => S) | S;
type Dispatch = A => void;
@@ -64,6 +65,8 @@ type Hook = {
let currentlyRenderingComponent: Object | null = null;
let currentlyRenderingTask: Task | null = null;
+let currentlyRenderingRequest: Request | null = null;
+let currentlyRenderingKeyPath: KeyNode | null = null;
let firstWorkInProgressHook: Hook | null = null;
let workInProgressHook: Hook | null = null;
// Whether the work-in-progress hook is a re-rendered hook
@@ -197,12 +200,16 @@ function createWorkInProgressHook(): Hook {
}
export function prepareToUseHooks(
+ request: Request,
task: Task,
+ keyPath: KeyNode | null,
componentIdentity: Object,
prevThenableState: ThenableState | null,
): void {
currentlyRenderingComponent = componentIdentity;
currentlyRenderingTask = task;
+ currentlyRenderingRequest = request;
+ currentlyRenderingKeyPath = keyPath;
if (__DEV__) {
isInHookUserCodeInDev = false;
}
@@ -287,6 +294,8 @@ export function resetHooksState(): void {
currentlyRenderingComponent = null;
currentlyRenderingTask = null;
+ currentlyRenderingRequest = null;
+ currentlyRenderingKeyPath = null;
didScheduleRenderPhaseUpdate = false;
firstWorkInProgressHook = null;
numberOfReRenders = 0;
@@ -584,15 +593,43 @@ function useFormState(
): [S, (P) => void] {
resolveCurrentlyRenderingComponent();
- // Count the number of useFormState hooks per component.
- // TODO: We should also track which hook matches the form state passed at
- // the root, if any. Matching is not yet implemented.
- formStateCounter++;
+ // Count the number of useFormState hooks per component. We also use this to
+ // track the position of this useFormState hook relative to the other ones in
+ // this component, so we can generate a unique key for each one.
+ const formStateHookIndex = formStateCounter++;
+ const request: Request = (currentlyRenderingRequest: any);
+
+ // Append a node to the key path that represents the form state hook.
+ const componentKey: KeyNode | null = (currentlyRenderingKeyPath: any);
+ const key: KeyNode = [componentKey, null, formStateHookIndex];
+ const keyJSON = JSON.stringify(key);
+
+ // Get the form state. If we received form state from a previous page, then
+ // we should reuse that, if the action identity matches. Otherwise we'll use
+ // the initial state argument. We emit a comment marker into the stream
+ // that indicates whether the state was reused.
+ let state;
+ const postbackFormState = getFormState(request);
+ if (postbackFormState !== null) {
+ const postbackKey = postbackFormState[1];
+ // TODO: Compare the action identity, too
+ // TODO: If a permalink is used, disregard the key and compare that instead.
+ if (keyJSON === postbackKey) {
+ // This was a match.
+ formStateMatchingIndex = formStateHookIndex;
+ // Reuse the state that was submitted by the form.
+ state = postbackFormState[0];
+ } else {
+ state = initialState;
+ }
+ } else {
+ // TODO: As an optimization, Fizz should only emit these markers if form
+ // state is passed at the root.
+ state = initialState;
+ }
- // Bind the initial state to the first argument of the action.
- // TODO: Use the keypath (or permalink) to check if there's matching state
- // from the previous page.
- const boundAction = action.bind(null, initialState);
+ // Bind the state to the first argument of the action.
+ const boundAction = action.bind(null, state);
// Wrap the action so the return value is void.
const dispatch = (payload: P): void => {
@@ -605,6 +642,12 @@ function useFormState(
dispatch.$$FORM_ACTION = (prefix: string) => {
// $FlowIgnore[prop-missing]
const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix);
+
+ const formData = metadata.data;
+ if (formData) {
+ formData.append('$ACTION_KEY', keyJSON);
+ }
+
// Override the action URL
if (permalink !== undefined) {
if (__DEV__) {
@@ -619,7 +662,7 @@ function useFormState(
// no effect. The form will have to be hydrated before it's submitted.
}
- return [initialState, dispatch];
+ return [state, dispatch];
}
function useId(): string {
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index 4d035062bfa25..05debbfa0f2a6 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -19,6 +19,7 @@ import type {
OffscreenMode,
Wakeable,
Thenable,
+ ReactFormState,
} from 'shared/ReactTypes';
import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
import type {
@@ -158,7 +159,7 @@ const ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
// Linked list representing the identity of a component given the component/tag name and key.
// The name might be minified but we assume that it's going to be the same generated name. Typically
// because it's just the same compiled output in practice.
-type KeyNode = [
+export type KeyNode = [
Root | KeyNode /* parent */,
string | null /* name */,
string | number /* key */,
@@ -311,6 +312,8 @@ export opaque type Request = {
// onPostpone is called when postpone() is called anywhere in the tree, which will defer
// rendering - e.g. to the client. This is considered intentional and not an error.
onPostpone: (reason: string) => void,
+ // Form state that was the result of an MPA submission, if it was provided.
+ formState: null | ReactFormState,
};
// This is a default heuristic for how to split up the HTML content into progressive
@@ -349,6 +352,7 @@ export function createRequest(
onShellError: void | ((error: mixed) => void),
onFatalError: void | ((error: mixed) => void),
onPostpone: void | ((reason: string) => void),
+ formState: void | null | ReactFormState,
): Request {
prepareHostDispatcher();
const pingedTasks: Array = [];
@@ -381,6 +385,7 @@ export function createRequest(
onShellReady: onShellReady === undefined ? noop : onShellReady,
onShellError: onShellError === undefined ? noop : onShellError,
onFatalError: onFatalError === undefined ? noop : onFatalError,
+ formState: formState === undefined ? null : formState,
};
// This segment represents the root fallback.
const rootSegment = createPendingSegment(
@@ -482,6 +487,7 @@ export function resumeRequest(
onShellReady: onShellReady === undefined ? noop : onShellReady,
onShellError: onShellError === undefined ? noop : onShellError,
onFatalError: onFatalError === undefined ? noop : onFatalError,
+ formState: null,
};
// This segment represents the root fallback.
const rootSegment = createPendingSegment(
@@ -956,13 +962,20 @@ function shouldConstruct(Component: any) {
function renderWithHooks(
request: Request,
task: Task,
+ keyPath: Root | KeyNode,
prevThenableState: ThenableState | null,
Component: (p: Props, arg: SecondArg) => any,
props: Props,
secondArg: SecondArg,
): any {
const componentIdentity = {};
- prepareToUseHooks(task, componentIdentity, prevThenableState);
+ prepareToUseHooks(
+ request,
+ task,
+ keyPath,
+ componentIdentity,
+ prevThenableState,
+ );
const result = Component(props, secondArg);
return finishHooks(Component, props, result, secondArg);
}
@@ -1078,6 +1091,7 @@ function renderIndeterminateComponent(
const value = renderWithHooks(
request,
task,
+ keyPath,
prevThenableState,
Component,
props,
@@ -1309,6 +1323,7 @@ function renderForwardRef(
const children = renderWithHooks(
request,
task,
+ keyPath,
prevThenableState,
type.render,
props,
@@ -3080,6 +3095,10 @@ export function flushResources(request: Request): void {
enqueueFlush(request);
}
+export function getFormState(request: Request): ReactFormState | null {
+ return request.formState;
+}
+
export function getResumableState(request: Request): ResumableState {
return request.resumableState;
}
diff --git a/packages/react-server/src/ReactFlightActionServer.js b/packages/react-server/src/ReactFlightActionServer.js
index ee735c2e8f9a2..19a944df714cb 100644
--- a/packages/react-server/src/ReactFlightActionServer.js
+++ b/packages/react-server/src/ReactFlightActionServer.js
@@ -7,7 +7,7 @@
* @flow
*/
-import type {Thenable} from 'shared/ReactTypes';
+import type {Thenable, ReactFormState} from 'shared/ReactTypes';
import type {
ServerManifest,
@@ -108,3 +108,18 @@ export function decodeAction(
// Return the action with the remaining FormData bound to the first argument.
return action.then(fn => fn.bind(null, formData));
}
+
+// TODO: Should this be an async function to preserve the option in the future
+// to do async stuff in here? Would also make it consistent with decodeAction
+export function decodeFormState(
+ actionResult: S,
+ body: FormData,
+ serverManifest: ServerManifest,
+): ReactFormState | null {
+ const keyPath = body.get('$ACTION_KEY');
+ if (typeof keyPath !== 'string') {
+ // This form submission did not include any form state.
+ return null;
+ }
+ return [actionResult, keyPath];
+}
diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js
index 625d687e8829f..a6d2298df9eb1 100644
--- a/packages/shared/ReactTypes.js
+++ b/packages/shared/ReactTypes.js
@@ -174,3 +174,11 @@ export type ReactCustomFormAction = {
target?: string,
data?: null | FormData,
};
+
+// This is an opaque type returned by decodeFormState on the server, but it's
+// defined in this shared file because the same type is used by React on
+// the client.
+export type ReactFormState = [
+ S /* actual state value */,
+ string /* key path */,
+];