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 8116216fe8c6f..7bcd16b3ecfd7 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
@@ -603,6 +603,90 @@ describe('ReactFlightDOMForm', () => {
expect(container.textContent).toBe('111');
});
+ // @gate enableFormActions
+ // @gate enableAsyncActions
+ it('when permalink is provided, useFormState compares that instead of the keypath', async () => {
+ const serverAction = serverExports(async function action(
+ prevState,
+ formData,
+ ) {
+ return prevState + 1;
+ });
+
+ function Form({action, permalink}) {
+ const [count, dispatch] = useFormState(action, 1, permalink);
+ return
;
+ }
+
+ function Page1({action, permalink}) {
+ return ;
+ }
+
+ function Page2({action, permalink}) {
+ return ;
+ }
+
+ const Page1Ref = await clientExports(Page1);
+ const Page2Ref = await clientExports(Page2);
+
+ const rscStream = ReactServerDOMServer.renderToReadableStream(
+ ,
+ webpackMap,
+ );
+ const response = ReactServerDOMClient.createFromReadableStream(rscStream);
+ const ssrStream = await ReactDOMServer.renderToReadableStream(response);
+ await readIntoContainer(ssrStream);
+
+ expect(container.textContent).toBe('1');
+
+ // Submit the form
+ const form = container.getElementsByTagName('form')[0];
+ const {formState} = await submit(form);
+
+ // Simulate an MPA form submission by resetting the container and
+ // rendering again.
+ container.innerHTML = '';
+
+ // On the next page, the same server action is rendered again, but in
+ // a different component tree. However, because a permalink option was
+ // passed, the state should be preserved.
+ const postbackRscStream = ReactServerDOMServer.renderToReadableStream(
+ ,
+ webpackMap,
+ );
+ const postbackResponse =
+ ReactServerDOMClient.createFromReadableStream(postbackRscStream);
+ const postbackSsrStream = await ReactDOMServer.renderToReadableStream(
+ postbackResponse,
+ {experimental_formState: formState},
+ );
+ await readIntoContainer(postbackSsrStream);
+
+ expect(container.textContent).toBe('2');
+
+ // Now submit the form again. This time, the permalink will be different, so
+ // the state is not preserved.
+ const form2 = container.getElementsByTagName('form')[0];
+ const {formState: formState2} = await submit(form2);
+
+ container.innerHTML = '';
+
+ const postbackRscStream2 = ReactServerDOMServer.renderToReadableStream(
+ ,
+ webpackMap,
+ );
+ const postbackResponse2 =
+ ReactServerDOMClient.createFromReadableStream(postbackRscStream2);
+ const postbackSsrStream2 = await ReactDOMServer.renderToReadableStream(
+ postbackResponse2,
+ {experimental_formState: formState2},
+ );
+ await readIntoContainer(postbackSsrStream2);
+
+ // The state was reset because the permalink didn't match
+ expect(container.textContent).toBe('1');
+ });
+
// @gate enableFormActions
// @gate enableAsyncActions
it('useFormState can change the action URL with the `permalink` argument', async () => {
diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js
index f006c87c3369a..19256491e12d1 100644
--- a/packages/react-server/src/ReactFizzHooks.js
+++ b/packages/react-server/src/ReactFizzHooks.js
@@ -586,6 +586,20 @@ function useOptimistic(
return [passthrough, unsupportedSetOptimisticState];
}
+function createPostbackFormStateKey(
+ permalink: string | void,
+ componentKeyPath: KeyNode | null,
+ hookIndex: number,
+): string {
+ if (permalink !== undefined) {
+ return 'p' + permalink;
+ } else {
+ // Append a node to the key path that represents the form state hook.
+ const keyPath: KeyNode = [componentKeyPath, null, hookIndex];
+ return 'k' + JSON.stringify(keyPath);
+ }
+}
+
function useFormState(
action: (S, P) => Promise,
initialState: S,
@@ -605,32 +619,42 @@ function useFormState(
// This is a server action. These have additional features to enable
// MPA-style form submissions with progressive enhancement.
+ // TODO: If the same permalink is passed to multiple useFormStates, and
+ // they all have the same action signature, Fizz will pass the postback
+ // state to all of them. We should probably only pass it to the first one,
+ // and/or warn.
+
+ // The key is lazily generated and deduped so the that the keypath doesn't
+ // get JSON.stringify-ed unnecessarily, and at most once.
+ let nextPostbackStateKey = null;
+
// Determine the current form state. If we received state during an MPA form
// submission, then we will reuse that, if the action identity matches.
// Otherwise we'll use the initial state argument. We will emit a comment
// marker into the stream that indicates whether the state was reused.
let state = initialState;
-
- // 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);
-
+ const componentKeyPath = (currentlyRenderingKeyPath: any);
const postbackFormState = getFormState(request);
// $FlowIgnore[prop-missing]
const isSignatureEqual = action.$$IS_SIGNATURE_EQUAL;
if (postbackFormState !== null && typeof isSignatureEqual === 'function') {
- const postbackKeyJSON = postbackFormState[1];
+ const postbackKey = postbackFormState[1];
const postbackReferenceId = postbackFormState[2];
const postbackBoundArity = postbackFormState[3];
if (
- postbackKeyJSON === keyJSON &&
isSignatureEqual.call(action, postbackReferenceId, postbackBoundArity)
) {
- // This was a match
- formStateMatchingIndex = formStateHookIndex;
- // Reuse the state that was submitted by the form.
- state = postbackFormState[0];
+ nextPostbackStateKey = createPostbackFormStateKey(
+ permalink,
+ componentKeyPath,
+ formStateHookIndex,
+ );
+ if (postbackKey === nextPostbackStateKey) {
+ // This was a match
+ formStateMatchingIndex = formStateHookIndex;
+ // Reuse the state that was submitted by the form.
+ state = postbackFormState[0];
+ }
}
}
@@ -648,17 +672,26 @@ function useFormState(
dispatch.$$FORM_ACTION = (prefix: string) => {
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__) {
checkAttributeStringCoercion(permalink, 'target');
}
- metadata.action = permalink + '';
+ permalink += '';
+ metadata.action = permalink;
+ }
+
+ const formData = metadata.data;
+ if (formData) {
+ if (nextPostbackStateKey === null) {
+ nextPostbackStateKey = createPostbackFormStateKey(
+ permalink,
+ componentKeyPath,
+ formStateHookIndex,
+ );
+ }
+ formData.append('$ACTION_KEY', nextPostbackStateKey);
}
return metadata;
};