Repository: facebook/react
Pull request: useFormState's permalink option changes form target

When the `permalink` option is passed to `useFormState`, and the form is submitted before it has hydrated, the permalink will be used as the target of the form action, enabling MPA-style form submissions.

(Note that submitting a form without hydration is a feature of Server Actions; it doesn't work with regular client actions.)

It does not have any effect after the form has hydrated.

Changed files:
filename: packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
patch: @@ -78,13 +78,13 @@ export function useFormStatus(): FormStatus {
 export function useFormState<S, P>(
   action: (S, P) => Promise<S>,
   initialState: S,
-  url?: string,
+  permalink?: string,
 ): [S, (P) => void] {
   if (!(enableFormActions && enableAsyncActions)) {
     throw new Error('Not implemented.');
   } else {
     const dispatcher = resolveDispatcher();
     // $FlowFixMe[not-a-function] This is unstable, thus optional
-    return dispatcher.useFormState(action, initialState, url);
+    return dispatcher.useFormState(action, initialState, permalink);
   }
 }

filename: packages/react-reconciler/src/ReactFiberHooks.js
patch: @@ -2008,7 +2008,7 @@ function formStateReducer<S>(oldState: S, newState: S): S {
 function mountFormState<S, P>(
   action: (S, P) => Promise<S>,
   initialState: S,
-  url?: string,
+  permalink?: string,
 ): [S, (P) => void] {
   // State hook. The state is stored in a thenable which is then unwrapped by
   // the `use` algorithm during render.
@@ -2063,7 +2063,7 @@ function mountFormState<S, P>(
 function updateFormState<S, P>(
   action: (S, P) => Promise<S>,
   initialState: S,
-  url?: string,
+  permalink?: string,
 ): [S, (P) => void] {
   const stateHook = updateWorkInProgressHook();
   const currentStateHook = ((currentHook: any): Hook);
@@ -2072,7 +2072,7 @@ function updateFormState<S, P>(
     currentStateHook,
     action,
     initialState,
-    url,
+    permalink,
   );
 }
 
@@ -2081,7 +2081,7 @@ function updateFormStateImpl<S, P>(
   currentStateHook: Hook,
   action: (S, P) => Promise<S>,
   initialState: S,
-  url?: string,
+  permalink?: string,
 ): [S, (P) => void] {
   const [thenable] = updateReducerImpl<Thenable<S>, Thenable<S>>(
     stateHook,
@@ -2121,7 +2121,7 @@ function formStateActionEffect<S, P>(
 function rerenderFormState<S, P>(
   action: (S, P) => Promise<S>,
   initialState: S,
-  url?: string,
+  permalink?: string,
 ): [S, (P) => void] {
   // Unlike useState, useFormState doesn't support render phase updates.
   // Also unlike useState, we need to replay all pending updates again in case
@@ -2140,7 +2140,7 @@ function rerenderFormState<S, P>(
       currentStateHook,
       action,
       initialState,
-      url,
+      permalink,
     );
   }
 
@@ -3628,11 +3628,11 @@ if (__DEV__) {
       function useFormState<S, P>(
         action: (S, P) => Promise<S>,
         initialState: S,
-        url?: string,
+        permalink?: string,
       ): [S, (P) => void] {
         currentHookNameInDev = 'useFormState';
         mountHookTypesDev();
-        return mountFormState(action, initialState, url);
+        return mountFormState(action, initialState, permalink);
       };
   }
   if (enableAsyncActions) {
@@ -3798,11 +3798,11 @@ if (__DEV__) {
       function useFormState<S, P>(
         action: (S, P) => Promise<S>,
         initialState: S,
-        url?: string,
+        permalink?: string,
       ): [S, (P) => void] {
         currentHookNameInDev = 'useFormState';
         updateHookTypesDev();
-        return mountFormState(action, initialState, url);
+        return mountFormState(action, initialState, permalink);
       };
   }
   if (enableAsyncActions) {
@@ -3970,11 +3970,11 @@ if (__DEV__) {
       function useFormState<S, P>(
         action: (S, P) => Promise<S>,
         initialState: S,
-        url?: string,
+        permalink?: string,
       ): [S, (P) => void] {
         currentHookNameInDev = 'useFormState';
         updateHookTypesDev();
-        return updateFormState(action, initialState, url);
+        return updateFormState(action, initialState, permalink);
       };
   }
   if (enableAsyncActions) {
@@ -4142,11 +4142,11 @@ if (__DEV__) {
       function useFormState<S, P>(
         action: (S, P) => Promise<S>,
         initialState: S,
-        url?: string,
+        permalink?: string,
       ): [S, (P) => void] {
         currentHookNameInDev = 'useFormState';
         updateHookTypesDev();
-        return rerenderFormState(action, initialState, url);
+        return rerenderFormState(action, initialState, permalink);
       };
   }
   if (enableAsyncActions) {
@@ -4335,12 +4335,12 @@ if (__DEV__) {
       function useFormState<S, P>(
         action: (S, P) => Promise<S>,
         initialState: S,
-        url?: string,
+        permalink?: string,
       ): [S, (P) => void] {
         currentHookNameInDev = 'useFormState';
         warnInvalidHookAccess();
         mountHookTypesDev();
-        return mountFormState(action, initialState, url);
+        return mountFormState(action, initialState, permalink);
       };
   }
   if (enableAsyncActions) {
@@ -4533,12 +4533,12 @@ if (__DEV__) {
       function useFormState<S, P>(
         action: (S, P) => Promise<S>,
         initialState: S,
-        url?: string,
+        permalink?: string,
       ): [S, (P) => void] {
         currentHookNameInDev = 'useFormState';
         warnInvalidHookAccess();
         updateHookTypesDev();
-        return updateFormState(action, initialState, url);
+        return updateFormState(action, initialState, permalink);
       };
   }
   if (enableAsyncActions) {
@@ -4731,12 +4731,12 @@ if (__DEV__) {
       function useFormState<S, P>(
         action: (S, P) => Promise<S>,
         initialState: S,
-        url?: string,
+        permalink?: string,
       ): [S, (P) => void] {
         currentHookNameInDev = 'useFormState';
         warnInvalidHookAccess();
         updateHookTypesDev();
-        return rerenderFormState(action, initialState, url);
+        return rerenderFormState(action, initialState, permalink);
       };
   }
   if (enableAsyncActions) {

filename: packages/react-reconciler/src/ReactInternalTypes.js
patch: @@ -417,7 +417,7 @@ export type Dispatcher = {
   useFormState?: <S, P>(
     action: (S, P) => Promise<S>,
     initialState: S,
-    url?: string,
+    permalink?: string,
   ) => [S, (P) => void],
 };
 

filename: packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMForm-test.js
patch: @@ -30,6 +30,7 @@ let React;
 let ReactDOMServer;
 let ReactServerDOMServer;
 let ReactServerDOMClient;
+let useFormState;
 
 describe('ReactFlightDOMForm', () => {
   beforeEach(() => {
@@ -47,6 +48,7 @@ describe('ReactFlightDOMForm', () => {
     ReactServerDOMServer = require('react-server-dom-webpack/server.edge');
     ReactServerDOMClient = require('react-server-dom-webpack/client.edge');
     ReactDOMServer = require('react-dom/server.edge');
+    useFormState = require('react-dom').experimental_useFormState;
     container = document.createElement('div');
     document.body.appendChild(container);
   });
@@ -308,4 +310,123 @@ describe('ReactFlightDOMForm', () => {
     expect(result).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 = {
+        count: prevState.count + parseInt(formData.get('incrementAmount'), 10),
+      };
+      serverActionResult = newState;
+      return newState;
+    });
+
+    const initialState = {count: 1};
+    function Client({action}) {
+      const [state, dispatch] = useFormState(action, initialState);
+      return (
+        <form action={dispatch}>
+          <span>Count: {state.count}</span>
+          <input type="text" name="incrementAmount" defaultValue="5" />
+        </form>
+      );
+    }
+    const ClientRef = await clientExports(Client);
+
+    const rscStream = ReactServerDOMServer.renderToReadableStream(
+      <ClientRef action={serverAction} />,
+      webpackMap,
+    );
+    const response = ReactServerDOMClient.createFromReadableStream(rscStream);
+    const ssrStream = await ReactDOMServer.renderToReadableStream(response);
+    await readIntoContainer(ssrStream);
+
+    const form = container.firstChild;
+    const span = container.getElementsByTagName('span')[0];
+    expect(span.textContent).toBe('Count: 1');
+
+    await submit(form);
+    expect(serverActionResult.count).toBe(6);
+  });
+
+  // @gate enableFormActions
+  // @gate enableAsyncActions
+  it("useFormState can change the action's target with the `permalink` argument", async () => {
+    const serverAction = serverExports(function action(prevState) {
+      return {state: prevState.count + 1};
+    });
+
+    const initialState = {count: 1};
+    function Client({action}) {
+      const [state, dispatch] = useFormState(
+        action,
+        initialState,
+        '/permalink',
+      );
+      return (
+        <form action={dispatch}>
+          <span>Count: {state.count}</span>
+        </form>
+      );
+    }
+    const ClientRef = await clientExports(Client);
+
+    const rscStream = ReactServerDOMServer.renderToReadableStream(
+      <ClientRef action={serverAction} />,
+      webpackMap,
+    );
+    const response = ReactServerDOMClient.createFromReadableStream(rscStream);
+    const ssrStream = await ReactDOMServer.renderToReadableStream(response);
+    await readIntoContainer(ssrStream);
+
+    const form = container.firstChild;
+    const span = container.getElementsByTagName('span')[0];
+    expect(span.textContent).toBe('Count: 1');
+
+    expect(form.target).toBe('/permalink');
+  });
+
+  // @gate enableFormActions
+  // @gate enableAsyncActions
+  it('useFormState `permalink` is coerced to string', async () => {
+    const serverAction = serverExports(function action(prevState) {
+      return {state: prevState.count + 1};
+    });
+
+    class Permalink {
+      toString() {
+        return '/permalink';
+      }
+    }
+
+    const permalink = new Permalink();
+
+    const initialState = {count: 1};
+    function Client({action}) {
+      const [state, dispatch] = useFormState(action, initialState, permalink);
+      return (
+        <form action={dispatch}>
+          <span>Count: {state.count}</span>
+        </form>
+      );
+    }
+    const ClientRef = await clientExports(Client);
+
+    const rscStream = ReactServerDOMServer.renderToReadableStream(
+      <ClientRef action={serverAction} />,
+      webpackMap,
+    );
+    const response = ReactServerDOMClient.createFromReadableStream(rscStream);
+    const ssrStream = await ReactDOMServer.renderToReadableStream(response);
+    await readIntoContainer(ssrStream);
+
+    const form = container.firstChild;
+    const span = container.getElementsByTagName('span')[0];
+    expect(span.textContent).toBe('Count: 1');
+
+    expect(form.target).toBe('/permalink');
+  });
 });

filename: packages/react-server/src/ReactFizzHooks.js
patch: @@ -14,6 +14,7 @@ import type {
   StartTransitionOptions,
   Thenable,
   Usable,
+  ReactCustomFormAction,
 } from 'shared/ReactTypes';
 
 import type {ResumableState} from './ReactFizzConfig';
@@ -40,6 +41,7 @@ import {
   REACT_CONTEXT_TYPE,
   REACT_MEMO_CACHE_SENTINEL,
 } from 'shared/ReactSymbols';
+import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
 
 type BasicStateAction<S> = (S => S) | S;
 type Dispatch<A> = A => void;
@@ -542,10 +544,6 @@ function unsupportedSetOptimisticState() {
   throw new Error('Cannot update optimistic state while rendering.');
 }
 
-function unsupportedDispatchFormState() {
-  throw new Error('Cannot update form state while rendering.');
-}
-
 function useOptimistic<S, A>(
   passthrough: S,
   reducer: ?(S, A) => S,
@@ -557,10 +555,41 @@ function useOptimistic<S, A>(
 function useFormState<S, P>(
   action: (S, P) => Promise<S>,
   initialState: S,
-  url?: string,
+  permalink?: string,
 ): [S, (P) => void] {
   resolveCurrentlyRenderingComponent();
-  return [initialState, unsupportedDispatchFormState];
+
+  // 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);
+
+  // Wrap the action so the return value is void.
+  const dispatch = (payload: P): void => {
+    boundAction(payload);
+  };
+
+  // $FlowIgnore[prop-missing]
+  if (typeof boundAction.$$FORM_ACTION === 'function') {
+    // $FlowIgnore[prop-missing]
+    dispatch.$$FORM_ACTION = (prefix: string) => {
+      // $FlowIgnore[prop-missing]
+      const metadata: ReactCustomFormAction = boundAction.$$FORM_ACTION(prefix);
+      // Override the target URL
+      if (permalink !== undefined) {
+        if (__DEV__) {
+          checkAttributeStringCoercion(permalink, 'target');
+        }
+        metadata.target = permalink + '';
+      }
+      return metadata;
+    };
+  } else {
+    // This is not a server action, so the permalink argument has
+    // no effect. The form will have to be hydrated before it's submitted.
+  }
+
+  return [initialState, dispatch];
 }
 
 function useId(): string {