Repository: facebook/react
Pull request: ReactDOM.requestFormReset 

Based on:

- #28808
- #28804 

---

This adds a React DOM method called requestFormReset that schedules a form reset to occur when the current transition completes.

Internally, it's the same method that's called automatically whenever a form action is submitted. It only affects uncontrolled form inputs. See https://github.com/facebook/react/pull/28804 for details.

The reason for the public API is so UI libraries can implement their own action-based APIs and maintain the form-resetting behavior, something like this:

```js
function onSubmit(event) {
  // Disable default form submission behavior
  event.preventDefault();
  const form = event.target;
  startTransition(async () => {
    // Request the form to reset once the action
    // has completed
    requestFormReset(form);

    // Call the user-provided action prop
    await action(new FormData(form));
  })
}
```

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMForm-test.js
patch: @@ -43,6 +43,7 @@ describe('ReactDOMForm', () => {
   let textCache;
   let useFormStatus;
   let useActionState;
+  let requestFormReset;
 
   beforeEach(() => {
     jest.resetModules();
@@ -58,6 +59,7 @@ describe('ReactDOMForm', () => {
     startTransition = React.startTransition;
     use = React.use;
     useFormStatus = ReactDOM.useFormStatus;
+    requestFormReset = ReactDOM.requestFormReset;
     container = document.createElement('div');
     document.body.appendChild(container);
 
@@ -1414,4 +1416,351 @@ describe('ReactDOMForm', () => {
     expect(inputRef.current.value).toBe('acdlite');
     expect(divRef.current.textContent).toEqual('Current username: acdlite');
   });
+
+  test('requestFormReset schedules a form reset after transition completes', async () => {
+    // This is the same as the previous test, except the form is updated with
+    // a userspace action instead of a built-in form action.
+
+    const formRef = React.createRef();
+    const inputRef = React.createRef();
+    const divRef = React.createRef();
+
+    function App({promiseForUsername}) {
+      // Make this suspensey to simulate RSC streaming.
+      const username = use(promiseForUsername);
+
+      return (
+        <form ref={formRef}>
+          <input
+            ref={inputRef}
+            text="text"
+            name="username"
+            defaultValue={username}
+          />
+          <div ref={divRef}>
+            <Text text={'Current username: ' + username} />
+          </div>
+        </form>
+      );
+    }
+
+    // Initial render
+    const root = ReactDOMClient.createRoot(container);
+    const promiseForInitialUsername = getText('(empty)');
+    await resolveText('(empty)');
+    await act(() =>
+      root.render(<App promiseForUsername={promiseForInitialUsername} />),
+    );
+    assertLog(['Current username: (empty)']);
+    expect(divRef.current.textContent).toEqual('Current username: (empty)');
+
+    // Dirty the uncontrolled input
+    inputRef.current.value = '  AcdLite  ';
+
+    // This is a userspace action. It does not trigger a real form submission.
+    // The practical use case is implementing a custom action prop using
+    // onSubmit without losing the built-in form resetting behavior.
+    await act(() => {
+      startTransition(async () => {
+        const form = formRef.current;
+        const formData = new FormData(form);
+        requestFormReset(form);
+
+        const rawUsername = formData.get('username');
+        const normalizedUsername = rawUsername.trim().toLowerCase();
+
+        Scheduler.log(`Async action started`);
+        await getText('Wait');
+
+        // Update the app with new data. This is analagous to re-rendering
+        // from the root with a new RSC payload.
+        startTransition(() => {
+          root.render(<App promiseForUsername={getText(normalizedUsername)} />);
+        });
+      });
+    });
+    assertLog(['Async action started']);
+    expect(inputRef.current.value).toBe('  AcdLite  ');
+
+    // Finish the async action. This will trigger a re-render from the root with
+    // new data from the "server", which suspends.
+    //
+    // The form should not reset yet because we need to update `defaultValue`
+    // first. So we wait for the render to complete.
+    await act(() => resolveText('Wait'));
+    assertLog([]);
+    // The DOM input is still dirty.
+    expect(inputRef.current.value).toBe('  AcdLite  ');
+    // The React tree is suspended.
+    expect(divRef.current.textContent).toEqual('Current username: (empty)');
+
+    // Unsuspend and finish rendering. Now the form should be reset.
+    await act(() => resolveText('acdlite'));
+    assertLog(['Current username: acdlite']);
+    // The form was reset to the new value from the server.
+    expect(inputRef.current.value).toBe('acdlite');
+    expect(divRef.current.textContent).toEqual('Current username: acdlite');
+  });
+
+  test(
+    'requestFormReset works with inputs that are not descendants ' +
+      'of the form element',
+    async () => {
+      // This is the same as the previous test, except the input is not a child
+      // of the form; it's linked with <input form="myform" />
+
+      const formRef = React.createRef();
+      const inputRef = React.createRef();
+      const divRef = React.createRef();
+
+      function App({promiseForUsername}) {
+        // Make this suspensey to simulate RSC streaming.
+        const username = use(promiseForUsername);
+
+        return (
+          <>
+            <form id="myform" ref={formRef} />
+            <input
+              form="myform"
+              ref={inputRef}
+              text="text"
+              name="username"
+              defaultValue={username}
+            />
+            <div ref={divRef}>
+              <Text text={'Current username: ' + username} />
+            </div>
+          </>
+        );
+      }
+
+      // Initial render
+      const root = ReactDOMClient.createRoot(container);
+      const promiseForInitialUsername = getText('(empty)');
+      await resolveText('(empty)');
+      await act(() =>
+        root.render(<App promiseForUsername={promiseForInitialUsername} />),
+      );
+      assertLog(['Current username: (empty)']);
+      expect(divRef.current.textContent).toEqual('Current username: (empty)');
+
+      // Dirty the uncontrolled input
+      inputRef.current.value = '  AcdLite  ';
+
+      // This is a userspace action. It does not trigger a real form submission.
+      // The practical use case is implementing a custom action prop using
+      // onSubmit without losing the built-in form resetting behavior.
+      await act(() => {
+        startTransition(async () => {
+          const form = formRef.current;
+          const formData = new FormData(form);
+          requestFormReset(form);
+
+          const rawUsername = formData.get('username');
+          const normalizedUsername = rawUsername.trim().toLowerCase();
+
+          Scheduler.log(`Async action started`);
+          await getText('Wait');
+
+          // Update the app with new data. This is analagous to re-rendering
+          // from the root with a new RSC payload.
+          startTransition(() => {
+            root.render(
+              <App promiseForUsername={getText(normalizedUsername)} />,
+            );
+          });
+        });
+      });
+      assertLog(['Async action started']);
+      expect(inputRef.current.value).toBe('  AcdLite  ');
+
+      // Finish the async action. This will trigger a re-render from the root with
+      // new data from the "server", which suspends.
+      //
+      // The form should not reset yet because we need to update `defaultValue`
+      // first. So we wait for the render to complete.
+      await act(() => resolveText('Wait'));
+      assertLog([]);
+      // The DOM input is still dirty.
+      expect(inputRef.current.value).toBe('  AcdLite  ');
+      // The React tree is suspended.
+      expect(divRef.current.textContent).toEqual('Current username: (empty)');
+
+      // Unsuspend and finish rendering. Now the form should be reset.
+      await act(() => resolveText('acdlite'));
+      assertLog(['Current username: acdlite']);
+      // The form was reset to the new value from the server.
+      expect(inputRef.current.value).toBe('acdlite');
+      expect(divRef.current.textContent).toEqual('Current username: acdlite');
+    },
+  );
+
+  test('reset multiple forms in the same transition', async () => {
+    const formRefA = React.createRef();
+    const formRefB = React.createRef();
+
+    function App({promiseForA, promiseForB}) {
+      // Make these suspensey to simulate RSC streaming.
+      const a = use(promiseForA);
+      const b = use(promiseForB);
+      return (
+        <>
+          <form ref={formRefA}>
+            <input type="text" name="inputName" defaultValue={a} />
+          </form>
+          <form ref={formRefB}>
+            <input type="text" name="inputName" defaultValue={b} />
+          </form>
+        </>
+      );
+    }
+
+    const root = ReactDOMClient.createRoot(container);
+    const initialPromiseForA = getText('A1');
+    const initialPromiseForB = getText('B1');
+    await resolveText('A1');
+    await resolveText('B1');
+    await act(() =>
+      root.render(
+        <App
+          promiseForA={initialPromiseForA}
+          promiseForB={initialPromiseForB}
+        />,
+      ),
+    );
+
+    // Dirty the uncontrolled inputs
+    formRefA.current.elements.inputName.value = '       A2       ';
+    formRefB.current.elements.inputName.value = '       B2       ';
+
+    // Trigger an async action that updates and reset both forms.
+    await act(() => {
+      startTransition(async () => {
+        const currentA = formRefA.current.elements.inputName.value;
+        const currentB = formRefB.current.elements.inputName.value;
+
+        requestFormReset(formRefA.current);
+        requestFormReset(formRefB.current);
+
+        Scheduler.log('Async action started');
+        await getText('Wait');
+
+        // Pretend the server did something with the data.
+        const normalizedA = currentA.trim();
+        const normalizedB = currentB.trim();
+
+        // Update the app with new data. This is analagous to re-rendering
+        // from the root with a new RSC payload.
+        startTransition(() => {
+          root.render(
+            <App
+              promiseForA={getText(normalizedA)}
+              promiseForB={getText(normalizedB)}
+            />,
+          );
+        });
+      });
+    });
+    assertLog(['Async action started']);
+
+    // Finish the async action. This will trigger a re-render from the root with
+    // new data from the "server", which suspends.
+    //
+    // The forms should not reset yet because we need to update `defaultValue`
+    // first. So we wait for the render to complete.
+    await act(() => resolveText('Wait'));
+
+    // The DOM inputs are still dirty.
+    expect(formRefA.current.elements.inputName.value).toBe('       A2       ');
+    expect(formRefB.current.elements.inputName.value).toBe('       B2       ');
+
+    // Unsuspend and finish rendering. Now the forms should be reset.
+    await act(() => {
+      resolveText('A2');
+      resolveText('B2');
+    });
+    // The forms were reset to the new value from the server.
+    expect(formRefA.current.elements.inputName.value).toBe('A2');
+    expect(formRefB.current.elements.inputName.value).toBe('B2');
+  });
+
+  test('requestFormReset throws if the form is not managed by React', async () => {
+    container.innerHTML = `
+      <form id="myform">
+        <input id="input" type="text" name="greeting" />
+      </form>
+    `;
+
+    const form = document.getElementById('myform');
+    const input = document.getElementById('input');
+
+    input.value = 'Hi!!!!!!!!!!!!!';
+
+    expect(() => requestFormReset(form)).toThrow('Invalid form element.');
+    // The form was not reset.
+    expect(input.value).toBe('Hi!!!!!!!!!!!!!');
+
+    // Just confirming a regular form reset works fine.
+    form.reset();
+    expect(input.value).toBe('');
+  });
+
+  test('requestFormReset throws on a non-form DOM element', async () => {
+    const root = ReactDOMClient.createRoot(container);
+    const ref = React.createRef();
+    await act(() => root.render(<div ref={ref}>Hi</div>));
+    const div = ref.current;
+    expect(div.textContent).toBe('Hi');
+
+    expect(() => requestFormReset(div)).toThrow('Invalid form element.');
+  });
+
+  test('warns if requestFormReset is called outside of a transition', async () => {
+    const formRef = React.createRef();
+    const inputRef = React.createRef();
+
+    function App() {
+      return (
+        <form ref={formRef}>
+          <input ref={inputRef} type="text" defaultValue="Initial" />
+        </form>
+      );
+    }
+
+    const root = ReactDOMClient.createRoot(container);
+    await act(() => root.render(<App />));
+
+    // Dirty the uncontrolled input
+    inputRef.current.value = '  Updated  ';
+
+    // Trigger an async action that updates and reset both forms.
+    await act(() => {
+      startTransition(async () => {
+        Scheduler.log('Action started');
+        await getText('Wait 1');
+        Scheduler.log('Request form reset');
+
+        // This happens after an `await`, and is not wrapped in startTransition,
+        // so it will be scheduled synchronously instead of with the transition.
+        // This is almost certainly a mistake, so we log a warning in dev.
+        requestFormReset(formRef.current);
+
+        await getText('Wait 2');
+        Scheduler.log('Action finished');
+      });
+    });
+    assertLog(['Action started']);
+    expect(inputRef.current.value).toBe('  Updated  ');
+
+    // This triggers a synchronous requestFormReset, and a warning
+    await expect(async () => {
+      await act(() => resolveText('Wait 1'));
+    }).toErrorDev(['requestFormReset was called outside a transition'], {
+      withoutStack: true,
+    });
+    assertLog(['Request form reset']);
+
+    // The form was reset even though the action didn't finish.
+    expect(inputRef.current.value).toBe('Initial');
+  });
 });

filename: packages/react-reconciler/src/ReactFiberHooks.js
patch: @@ -2930,74 +2930,12 @@ export function startHostTransition<F>(
     );
   }
 
-  let queue: UpdateQueue<
+  const stateHook = ensureFormComponentIsStateful(formFiber);
+
+  const queue: UpdateQueue<
     Thenable<TransitionStatus> | TransitionStatus,
     BasicStateAction<Thenable<TransitionStatus> | TransitionStatus>,
-  >;
-  if (formFiber.memoizedState === null) {
-    // Upgrade this host component fiber to be stateful. We're going to pretend
-    // it was stateful all along so we can reuse most of the implementation
-    // for function components and useTransition.
-    //
-    // Create the state hook used by TransitionAwareHostComponent. This is
-    // essentially an inlined version of mountState.
-    const newQueue: UpdateQueue<
-      Thenable<TransitionStatus> | TransitionStatus,
-      BasicStateAction<Thenable<TransitionStatus> | TransitionStatus>,
-    > = {
-      pending: null,
-      lanes: NoLanes,
-      // We're going to cheat and intentionally not create a bound dispatch
-      // method, because we can call it directly in startTransition.
-      dispatch: (null: any),
-      lastRenderedReducer: basicStateReducer,
-      lastRenderedState: NoPendingHostTransition,
-    };
-    queue = newQueue;
-
-    const stateHook: Hook = {
-      memoizedState: NoPendingHostTransition,
-      baseState: NoPendingHostTransition,
-      baseQueue: null,
-      queue: newQueue,
-      next: null,
-    };
-
-    // We use another state hook to track whether the form needs to be reset.
-    // The state is an empty object. To trigger a reset, we update the state
-    // to a new object. Then during rendering, we detect that the state has
-    // changed and schedule a commit effect.
-    const initialResetState = {};
-    const newResetStateQueue: UpdateQueue<Object, Object> = {
-      pending: null,
-      lanes: NoLanes,
-      // We're going to cheat and intentionally not create a bound dispatch
-      // method, because we can call it directly in startTransition.
-      dispatch: (null: any),
-      lastRenderedReducer: basicStateReducer,
-      lastRenderedState: initialResetState,
-    };
-    const resetStateHook: Hook = {
-      memoizedState: initialResetState,
-      baseState: initialResetState,
-      baseQueue: null,
-      queue: newResetStateQueue,
-      next: null,
-    };
-    stateHook.next = resetStateHook;
-
-    // Add the hook list to both fiber alternates. The idea is that the fiber
-    // had this hook all along.
-    formFiber.memoizedState = stateHook;
-    const alternate = formFiber.alternate;
-    if (alternate !== null) {
-      alternate.memoizedState = stateHook;
-    }
-  } else {
-    // This fiber was already upgraded to be stateful.
-    const stateHook: Hook = formFiber.memoizedState;
-    queue = stateHook.queue;
-  }
+  > = stateHook.queue;
 
   startTransition(
     formFiber,
@@ -3008,18 +2946,81 @@ export function startHostTransition<F>(
     // once more of this function is implemented.
     () => {
       // Automatically reset the form when the action completes.
-      requestFormResetImpl(formFiber);
+      requestFormReset(formFiber);
       return callback(formData);
     },
   );
 }
 
-export function requestFormReset(formFiber: Fiber) {
-  // TODO: Not yet implemented. Need to upgrade the fiber to be stateful
-  // before scheduling the form reset.
+function ensureFormComponentIsStateful(formFiber: Fiber) {
+  const existingStateHook: Hook | null = formFiber.memoizedState;
+  if (existingStateHook !== null) {
+    // This fiber was already upgraded to be stateful.
+    return existingStateHook;
+  }
+
+  // Upgrade this host component fiber to be stateful. We're going to pretend
+  // it was stateful all along so we can reuse most of the implementation
+  // for function components and useTransition.
+  //
+  // Create the state hook used by TransitionAwareHostComponent. This is
+  // essentially an inlined version of mountState.
+  const newQueue: UpdateQueue<
+    Thenable<TransitionStatus> | TransitionStatus,
+    BasicStateAction<Thenable<TransitionStatus> | TransitionStatus>,
+  > = {
+    pending: null,
+    lanes: NoLanes,
+    // We're going to cheat and intentionally not create a bound dispatch
+    // method, because we can call it directly in startTransition.
+    dispatch: (null: any),
+    lastRenderedReducer: basicStateReducer,
+    lastRenderedState: NoPendingHostTransition,
+  };
+
+  const stateHook: Hook = {
+    memoizedState: NoPendingHostTransition,
+    baseState: NoPendingHostTransition,
+    baseQueue: null,
+    queue: newQueue,
+    next: null,
+  };
+
+  // We use another state hook to track whether the form needs to be reset.
+  // The state is an empty object. To trigger a reset, we update the state
+  // to a new object. Then during rendering, we detect that the state has
+  // changed and schedule a commit effect.
+  const initialResetState = {};
+  const newResetStateQueue: UpdateQueue<Object, Object> = {
+    pending: null,
+    lanes: NoLanes,
+    // We're going to cheat and intentionally not create a bound dispatch
+    // method, because we can call it directly in startTransition.
+    dispatch: (null: any),
+    lastRenderedReducer: basicStateReducer,
+    lastRenderedState: initialResetState,
+  };
+  const resetStateHook: Hook = {
+    memoizedState: initialResetState,
+    baseState: initialResetState,
+    baseQueue: null,
+    queue: newResetStateQueue,
+    next: null,
+  };
+  stateHook.next = resetStateHook;
+
+  // Add the hook list to both fiber alternates. The idea is that the fiber
+  // had this hook all along.
+  formFiber.memoizedState = stateHook;
+  const alternate = formFiber.alternate;
+  if (alternate !== null) {
+    alternate.memoizedState = stateHook;
+  }
+
+  return stateHook;
 }
 
-function requestFormResetImpl(formFiber: Fiber) {
+export function requestFormReset(formFiber: Fiber) {
   const transition = requestCurrentTransition();
 
   if (__DEV__) {
@@ -3040,8 +3041,9 @@ function requestFormResetImpl(formFiber: Fiber) {
     }
   }
 
+  const stateHook = ensureFormComponentIsStateful(formFiber);
   const newResetState = {};
-  const resetStateHook: Hook = (formFiber.memoizedState.next: any);
+  const resetStateHook: Hook = (stateHook.next: any);
   const resetStateQueue = resetStateHook.queue;
   dispatchSetState(formFiber, resetStateQueue, newResetState);
 }