Repository: facebook/react
Pull request: useFormState: MPA submissions to a different page

The permalink option of useFormState controls which page the form is submitted to during an MPA form submission (i.e. a submission that happens before hydration, or when JS is disabled). If the same useFormState appears on the resulting page, and the permalink option matches, it should receive the form state from the submission despite the fact that the keypaths do not match.

So the logic for whether a form state instance is considered a match is:
- Both instances must be passed the same action signature
- If a permalink is provided, the permalinks must match.
- If a permalink is not provided, the keypaths must match.

Currently, if there are multiple matching useFormStates, they will all match and receive the form state. We should probably only match the first one, and/or warn when this happens. I've left this as a TODO for now, pending further discussion.

Changed files:
filename: packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
patch: @@ -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 <form action={dispatch}>{count}</form>;
+    }
+
+    function Page1({action, permalink}) {
+      return <Form action={action} permalink={permalink} />;
+    }
+
+    function Page2({action, permalink}) {
+      return <Form action={action} permalink={permalink} />;
+    }
+
+    const Page1Ref = await clientExports(Page1);
+    const Page2Ref = await clientExports(Page2);
+
+    const rscStream = ReactServerDOMServer.renderToReadableStream(
+      <Page1Ref action={serverAction} permalink="/permalink" />,
+      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(
+      <Page2Ref action={serverAction} permalink="/permalink" />,
+      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(
+      <Page1Ref action={serverAction} permalink="/some-other-permalink" />,
+      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 () => {

filename: packages/react-server/src/ReactFizzHooks.js
patch: @@ -586,6 +586,20 @@ function useOptimistic<S, A>(
   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<S, P>(
   action: (S, P) => Promise<S>,
   initialState: S,
@@ -605,32 +619,42 @@ function useFormState<S, P>(
     // 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<S, P>(
       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;
       };