Repository: facebook/react
Pull request: Support useFormStatus in progressively-enhanced forms

Before this change, `useFormStatus` is only activated if a form is submitted by an action function (either `<form action={actionFn}>` or `<button formAction={actionFn}>`).

After this change, `useFormStatus` will also be activated if you call `startTransition(actionFn)` inside a submit event handler that is `preventDefault`-ed.

This is the last missing piece for implementing a custom `action` prop that is progressively enhanced using `onSubmit` while maintaining the same behavior as built-in form actions.

Here's the basic recipe for implementing a progressively-enhanced form action. This would typically be implemented in your UI component library, not regular application code:

```js
import {requestFormReset} from 'react-dom';

// To implement progressive enhancement, pass both a form action *and* a
// submit event handler. The action is used for submissions that happen
// before hydration, and the submit handler is used for submissions that
// happen after.
<form
  action={action}
  onSubmit={(event) => {
    // After hydration, we upgrade the form with additional client-
    // only behavior.
    event.preventDefault();

    // Manually dispatch the action.
    startTransition(async () => {
      // (Optional) Reset any uncontrolled inputs once the action is
      // complete, like built-in form actions do.
      requestFormReset(event.target);

      // ...Do extra action-y stuff in here, like setting a custom
      // optimistic state...

      // Call the user-provided action
      const formData = new FormData(event.target);
      await action(formData);
    });
  }}
/>
```

Changed files:
filename: packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js
patch: @@ -14,11 +14,61 @@ import type {EventSystemFlags} from '../EventSystemFlags';
 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
 import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions';
 
+import {enableTrustedTypesIntegration} from 'shared/ReactFeatureFlags';
 import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree';
 import {startHostTransition} from 'react-reconciler/src/ReactFiberReconciler';
+import {didCurrentEventScheduleTransition} from 'react-reconciler/src/ReactFiberRootScheduler';
+import sanitizeURL from 'react-dom-bindings/src/shared/sanitizeURL';
+import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
 
 import {SyntheticEvent} from '../SyntheticEvent';
 
+function coerceFormActionProp(
+  actionProp: mixed,
+): string | (FormData => void | Promise<void>) | null {
+  // This should match the logic in ReactDOMComponent
+  if (
+    actionProp == null ||
+    typeof actionProp === 'symbol' ||
+    typeof actionProp === 'boolean'
+  ) {
+    return null;
+  } else if (typeof actionProp === 'function') {
+    return (actionProp: any);
+  } else {
+    if (__DEV__) {
+      checkAttributeStringCoercion(actionProp, 'action');
+    }
+    return (sanitizeURL(
+      enableTrustedTypesIntegration ? actionProp : '' + (actionProp: any),
+    ): any);
+  }
+}
+
+function createFormDataWithSubmitter(
+  form: HTMLFormElement,
+  submitter: HTMLInputElement | HTMLButtonElement,
+) {
+  // The submitter's value should be included in the FormData.
+  // It should be in the document order in the form.
+  // Since the FormData constructor invokes the formdata event it also
+  // needs to be available before that happens so after construction it's too
+  // late. We use a temporary fake node for the duration of this event.
+  // TODO: FormData takes a second argument that it's the submitter but this
+  // is fairly new so not all browsers support it yet. Switch to that technique
+  // when available.
+  const temp = submitter.ownerDocument.createElement('input');
+  temp.name = submitter.name;
+  temp.value = submitter.value;
+  if (form.id) {
+    temp.setAttribute('form', form.id);
+  }
+  (submitter.parentNode: any).insertBefore(temp, submitter);
+  const formData = new FormData(form);
+  (temp.parentNode: any).removeChild(temp);
+  return formData;
+}
+
 /**
  * This plugin invokes action functions on forms, inputs and buttons if
  * the form doesn't prevent default.
@@ -42,16 +92,19 @@ function extractEvents(
   }
   const formInst = maybeTargetInst;
   const form: HTMLFormElement = (nativeEventTarget: any);
-  let action = (getFiberCurrentPropsFromNode(form): any).action;
-  let submitter: null | HTMLInputElement | HTMLButtonElement =
+  let action = coerceFormActionProp(
+    (getFiberCurrentPropsFromNode(form): any).action,
+  );
+  let submitter: null | void | HTMLInputElement | HTMLButtonElement =
     (nativeEvent: any).submitter;
   let submitterAction;
   if (submitter) {
     const submitterProps = getFiberCurrentPropsFromNode(submitter);
     submitterAction = submitterProps
-      ? (submitterProps: any).formAction
-      : submitter.getAttribute('formAction');
-    if (submitterAction != null) {
+      ? coerceFormActionProp((submitterProps: any).formAction)
+      : // The built-in Flow type is ?string, wider than the spec
+        ((submitter.getAttribute('formAction'): any): string | null);
+    if (submitterAction !== null) {
       // The submitter overrides the form action.
       action = submitterAction;
       // If the action is a function, we don't want to pass its name
@@ -60,10 +113,6 @@ function extractEvents(
     }
   }
 
-  if (typeof action !== 'function') {
-    return;
-  }
-
   const event = new SyntheticEvent(
     'action',
     'action',
@@ -74,44 +123,60 @@ function extractEvents(
 
   function submitForm() {
     if (nativeEvent.defaultPrevented) {
-      // We let earlier events to prevent the action from submitting.
-      return;
-    }
-    // Prevent native navigation.
-    event.preventDefault();
-    let formData;
-    if (submitter) {
-      // The submitter's value should be included in the FormData.
-      // It should be in the document order in the form.
-      // Since the FormData constructor invokes the formdata event it also
-      // needs to be available before that happens so after construction it's too
-      // late. We use a temporary fake node for the duration of this event.
-      // TODO: FormData takes a second argument that it's the submitter but this
-      // is fairly new so not all browsers support it yet. Switch to that technique
-      // when available.
-      const temp = submitter.ownerDocument.createElement('input');
-      temp.name = submitter.name;
-      temp.value = submitter.value;
-      if (form.id) {
-        temp.setAttribute('form', form.id);
+      // An earlier event prevented form submission. If a transition update was
+      // also scheduled, we should trigger a pending form status — even if
+      // no action function was provided.
+      if (didCurrentEventScheduleTransition()) {
+        // We're going to set the pending form status, but because the submission
+        // was prevented, we should not fire the action function.
+        const formData = submitter
+          ? createFormDataWithSubmitter(form, submitter)
+          : new FormData(form);
+        const pendingState: FormStatus = {
+          pending: true,
+          data: formData,
+          method: form.method,
+          action: action,
+        };
+        if (__DEV__) {
+          Object.freeze(pendingState);
+        }
+        startHostTransition(
+          formInst,
+          pendingState,
+          // Pass `null` as the action
+          // TODO: Consider splitting up startHostTransition into two separate
+          // functions, one that sets the form status and one that invokes
+          // the action.
+          null,
+          formData,
+        );
+      } else {
+        // No earlier event scheduled a transition. Exit without setting a
+        // pending form status.
       }
-      (submitter.parentNode: any).insertBefore(temp, submitter);
-      formData = new FormData(form);
-      (temp.parentNode: any).removeChild(temp);
-    } else {
-      formData = new FormData(form);
-    }
+    } else if (typeof action === 'function') {
+      // A form action was provided. Prevent native navigation.
+      event.preventDefault();
 
-    const pendingState: FormStatus = {
-      pending: true,
-      data: formData,
-      method: form.method,
-      action: action,
-    };
-    if (__DEV__) {
-      Object.freeze(pendingState);
+      // Dispatch the action and set a pending form status.
+      const formData = submitter
+        ? createFormDataWithSubmitter(form, submitter)
+        : new FormData(form);
+      const pendingState: FormStatus = {
+        pending: true,
+        data: formData,
+        method: form.method,
+        action: action,
+      };
+      if (__DEV__) {
+        Object.freeze(pendingState);
+      }
+      startHostTransition(formInst, pendingState, action, formData);
+    } else {
+      // No earlier event prevented the default submission, and no action was
+      // provided. Exit without setting a pending form status.
     }
-    startHostTransition(formInst, pendingState, action, formData);
   }
 
   dispatchQueue.push({

filename: packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
patch: @@ -25,7 +25,7 @@ type FormStatusPending = {|
   pending: true,
   data: FormData,
   method: string,
-  action: string | (FormData => void | Promise<void>),
+  action: string | (FormData => void | Promise<void>) | null,
 |};
 
 export type FormStatus = FormStatusPending | FormStatusNotPending;

filename: packages/react-dom/src/__tests__/ReactDOMForm-test.js
patch: @@ -35,10 +35,12 @@ describe('ReactDOMForm', () => {
   let ReactDOMClient;
   let Scheduler;
   let assertLog;
+  let assertConsoleErrorDev;
   let waitForThrow;
   let useState;
   let Suspense;
   let startTransition;
+  let useTransition;
   let use;
   let textCache;
   let useFormStatus;
@@ -54,9 +56,12 @@ describe('ReactDOMForm', () => {
     act = require('internal-test-utils').act;
     assertLog = require('internal-test-utils').assertLog;
     waitForThrow = require('internal-test-utils').waitForThrow;
+    assertConsoleErrorDev =
+      require('internal-test-utils').assertConsoleErrorDev;
     useState = React.useState;
     Suspense = React.Suspense;
     startTransition = React.startTransition;
+    useTransition = React.useTransition;
     use = React.use;
     useFormStatus = ReactDOM.useFormStatus;
     requestFormReset = ReactDOM.requestFormReset;
@@ -1782,4 +1787,306 @@ describe('ReactDOMForm', () => {
     // The form was reset even though the action didn't finish.
     expect(inputRef.current.value).toBe('Initial');
   });
+
+  test("regression: submitter's formAction prop is coerced correctly before checking if it exists", async () => {
+    function App({submitterAction}) {
+      return (
+        <form action={() => Scheduler.log('Form action')}>
+          <button ref={buttonRef} type="submit" formAction={submitterAction} />
+        </form>
+      );
+    }
+
+    const buttonRef = React.createRef();
+    const root = ReactDOMClient.createRoot(container);
+
+    await act(() =>
+      root.render(
+        <App submitterAction={() => Scheduler.log('Button action')} />,
+      ),
+    );
+    await submit(buttonRef.current);
+    assertLog(['Button action']);
+
+    // When there's no button action, the form action should fire
+    await act(() => root.render(<App submitterAction={null} />));
+    await submit(buttonRef.current);
+    assertLog(['Form action']);
+
+    // Symbols are coerced to null, so this should fire the form action
+    await act(() => root.render(<App submitterAction={Symbol()} />));
+    assertConsoleErrorDev(['Invalid value for prop `formAction`']);
+    await submit(buttonRef.current);
+    assertLog(['Form action']);
+
+    // Booleans are coerced to null, so this should fire the form action
+    await act(() => root.render(<App submitterAction={true} />));
+    await submit(buttonRef.current);
+    assertLog(['Form action']);
+
+    // A string on the submitter should prevent the form action from firing
+    // and trigger the native behavior
+    await act(() => root.render(<App submitterAction="https://react.dev/" />));
+    await expect(submit(buttonRef.current)).rejects.toThrow(
+      'Navigate to: https://react.dev/',
+    );
+  });
+
+  test(
+    'useFormStatus is activated if startTransition is called ' +
+      'inside preventDefault-ed submit event',
+    async () => {
+      function Output({value}) {
+        const {pending} = useFormStatus();
+        return <Text text={pending ? `${value} (pending...)` : value} />;
+      }
+
+      function App({value}) {
+        const [, startFormTransition] = useTransition();
+
+        function onSubmit(event) {
+          event.preventDefault();
+          startFormTransition(async () => {
+            const updatedValue = event.target.elements.search.value;
+            Scheduler.log('Action started');
+            await getText('Wait');
+            Scheduler.log('Action finished');
+            startTransition(() => root.render(<App value={updatedValue} />));
+          });
+        }
+        return (
+          <form ref={formRef} onSubmit={onSubmit}>
+            <input
+              ref={inputRef}
+              type="text"
+              name="search"
+              defaultValue={value}
+            />
+            <div ref={outputRef}>
+              <Output value={value} />
+            </div>
+          </form>
+        );
+      }
+
+      const formRef = React.createRef();
+      const inputRef = React.createRef();
+      const outputRef = React.createRef();
+      const root = ReactDOMClient.createRoot(container);
+      await act(() => root.render(<App value="Initial" />));
+      assertLog(['Initial']);
+
+      // Update the input to something different
+      inputRef.current.value = 'Updated';
+
+      // Submit the form.
+      await submit(formRef.current);
+      // The form switches into a pending state.
+      assertLog(['Action started', 'Initial (pending...)']);
+      expect(outputRef.current.textContent).toBe('Initial (pending...)');
+
+      // While the submission is still pending, update the input again so we
+      // can check whether the form is reset after the action finishes.
+      inputRef.current.value = 'Updated again after submission';
+
+      // Resolve the async action
+      await act(() => resolveText('Wait'));
+      assertLog(['Action finished', 'Updated']);
+      expect(outputRef.current.textContent).toBe('Updated');
+
+      // Confirm that the form was not automatically reset (should call
+      // requestFormReset(formRef.current) to opt into this behavior)
+      expect(inputRef.current.value).toBe('Updated again after submission');
+    },
+  );
+
+  test('useFormStatus is not activated if startTransition is not called', async () => {
+    function Output({value}) {
+      const {pending} = useFormStatus();
+
+      return (
+        <Text
+          text={
+            pending
+              ? 'Should be unreachable! This test should never activate the pending state.'
+              : value
+          }
+        />
+      );
+    }
+
+    function App({value}) {
+      async function onSubmit(event) {
+        event.preventDefault();
+        const updatedValue = event.target.elements.search.value;
+        Scheduler.log('Async event handler started');
+        await getText('Wait');
+        Scheduler.log('Async event handler finished');
+        startTransition(() => root.render(<App value={updatedValue} />));
+      }
+      return (
+        <form ref={formRef} onSubmit={onSubmit}>
+          <input
+            ref={inputRef}
+            type="text"
+            name="search"
+            defaultValue={value}
+          />
+          <div ref={outputRef}>
+            <Output value={value} />
+          </div>
+        </form>
+      );
+    }
+
+    const formRef = React.createRef();
+    const inputRef = React.createRef();
+    const outputRef = React.createRef();
+    const root = ReactDOMClient.createRoot(container);
+    await act(() => root.render(<App value="Initial" />));
+    assertLog(['Initial']);
+
+    // Update the input to something different
+    inputRef.current.value = 'Updated';
+
+    // Submit the form.
+    await submit(formRef.current);
+    // Unlike the previous test, which uses startTransition to manually dispatch
+    // an action, this test uses a regular event handler, so useFormStatus is
+    // not activated.
+    assertLog(['Async event handler started']);
+    expect(outputRef.current.textContent).toBe('Initial');
+
+    // While the submission is still pending, update the input again so we
+    // can check whether the form is reset after the action finishes.
+    inputRef.current.value = 'Updated again after submission';
+
+    // Resolve the async action
+    await act(() => resolveText('Wait'));
+    assertLog(['Async event handler finished', 'Updated']);
+    expect(outputRef.current.textContent).toBe('Updated');
+
+    // Confirm that the form was not automatically reset (should call
+    // requestFormReset(formRef.current) to opt into this behavior)
+    expect(inputRef.current.value).toBe('Updated again after submission');
+  });
+
+  test('useFormStatus is not activated if event is not preventDefault-ed ', async () => {
+    function Output({value}) {
+      const {pending} = useFormStatus();
+      return <Text text={pending ? `${value} (pending...)` : value} />;
+    }
+
+    function App({value}) {
+      const [, startFormTransition] = useTransition();
+
+      function onSubmit(event) {
+        // This event is not preventDefault-ed, so the default form submission
+        // happens, and useFormStatus is not activated.
+        startFormTransition(async () => {
+          const updatedValue = event.target.elements.search.value;
+          Scheduler.log('Action started');
+          await getText('Wait');
+          Scheduler.log('Action finished');
+          startTransition(() => root.render(<App value={updatedValue} />));
+        });
+      }
+      return (
+        <form ref={formRef} onSubmit={onSubmit}>
+          <input
+            ref={inputRef}
+            type="text"
+            name="search"
+            defaultValue={value}
+          />
+          <div ref={outputRef}>
+            <Output value={value} />
+          </div>
+        </form>
+      );
+    }
+
+    const formRef = React.createRef();
+    const inputRef = React.createRef();
+    const outputRef = React.createRef();
+    const root = ReactDOMClient.createRoot(container);
+    await act(() => root.render(<App value="Initial" />));
+    assertLog(['Initial']);
+
+    // Update the input to something different
+    inputRef.current.value = 'Updated';
+
+    // Submitting the form should trigger the default navigation behavior
+    await expect(submit(formRef.current)).rejects.toThrow(
+      'Navigate to: http://localhost/',
+    );
+
+    // The useFormStatus hook was not activated
+    assertLog(['Action started', 'Initial']);
+    expect(outputRef.current.textContent).toBe('Initial');
+  });
+
+  test('useFormStatus coerces the value of the "action" prop', async () => {
+    function Status() {
+      const {pending, action} = useFormStatus();
+
+      if (pending) {
+        Scheduler.log(action);
+        return 'Pending';
+      } else {
+        return 'Not pending';
+      }
+    }
+
+    function Form({action}) {
+      const [, startFormTransition] = useTransition();
+
+      function onSubmit(event) {
+        event.preventDefault();
+        // Schedule an empty action for no other purpose than to trigger the
+        // pending state.
+        startFormTransition(async () => {});
+      }
+      return (
+        <form ref={formRef} action={action} onSubmit={onSubmit}>
+          <Status />
+        </form>
+      );
+    }
+
+    const formRef = React.createRef();
+    const root = ReactDOMClient.createRoot(container);
+
+    // Symbols are coerced to null
+    await act(() => root.render(<Form action={Symbol()} />));
+    assertConsoleErrorDev(['Invalid value for prop `action`']);
+    await submit(formRef.current);
+    assertLog([null]);
+
+    // Booleans are coerced to null
+    await act(() => root.render(<Form action={true} />));
+    await submit(formRef.current);
+    assertLog([null]);
+
+    // Strings are passed through
+    await act(() => root.render(<Form action="https://react.dev" />));
+    await submit(formRef.current);
+    assertLog(['https://react.dev']);
+
+    // Functions are passed through
+    const actionFn = () => {};
+    await act(() => root.render(<Form action={actionFn} />));
+    await submit(formRef.current);
+    assertLog([actionFn]);
+
+    // Everything else is toString-ed
+    class MyAction {
+      toString() {
+        return 'stringified action';
+      }
+    }
+    await act(() => root.render(<Form action={new MyAction()} />));
+    await submit(formRef.current);
+    assertLog(['stringified action']);
+  });
 });

filename: packages/react-reconciler/src/ReactFiberHooks.js
patch: @@ -2944,16 +2944,20 @@ function startTransition<S>(
   }
 }
 
+const noop = () => {};
+
 export function startHostTransition<F>(
   formFiber: Fiber,
   pendingState: TransitionStatus,
-  callback: F => mixed,
+  action: (F => mixed) | null,
   formData: F,
 ): void {
   if (!enableAsyncActions) {
     // Form actions are enabled, but async actions are not. Call the function,
     // but don't handle any pending or error states.
-    callback(formData);
+    if (action !== null) {
+      action(formData);
+    }
     return;
   }
 
@@ -2976,13 +2980,19 @@ export function startHostTransition<F>(
     queue,
     pendingState,
     NoPendingHostTransition,
-    // TODO: We can avoid this extra wrapper, somehow. Figure out layering
-    // once more of this function is implemented.
-    () => {
-      // Automatically reset the form when the action completes.
-      requestFormReset(formFiber);
-      return callback(formData);
-    },
+    // TODO: `startTransition` both sets the pending state and dispatches
+    // the action, if one is provided. Consider refactoring these two
+    // concerns to avoid the extra lambda.
+
+    action === null
+      ? // No action was provided, but we still call `startTransition` to
+        // set the pending form status.
+        noop
+      : () => {
+          // Automatically reset the form when the action completes.
+          requestFormReset(formFiber);
+          return action(formData);
+        },
   );
 }
 

filename: packages/react-reconciler/src/ReactFiberRootScheduler.js
patch: @@ -482,3 +482,7 @@ export function requestTransitionLane(
   }
   return currentEventTransitionLane;
 }
+
+export function didCurrentEventScheduleTransition(): boolean {
+  return currentEventTransitionLane !== NoLane;
+}