From 55fec73d295d4436ea201b84d0e415cffc79f2b8 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 9 Apr 2024 23:35:09 -0400 Subject: [PATCH] ReactDOM.requestFormReset 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 #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)); }) } ``` --- .../src/__tests__/ReactDOMForm-test.js | 349 ++++++++++++++++++ .../react-reconciler/src/ReactFiberHooks.js | 146 ++++---- 2 files changed, 423 insertions(+), 72 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index 37261020e4627..123619d2908c9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -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 ( +
+ +
+ +
+
+ ); + } + + // Initial render + const root = ReactDOMClient.createRoot(container); + const promiseForInitialUsername = getText('(empty)'); + await resolveText('(empty)'); + await act(() => + root.render(), + ); + 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(); + }); + }); + }); + 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 + + 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 ( + <> +
+ +
+ +
+ + ); + } + + // Initial render + const root = ReactDOMClient.createRoot(container); + const promiseForInitialUsername = getText('(empty)'); + await resolveText('(empty)'); + await act(() => + root.render(), + ); + 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( + , + ); + }); + }); + }); + 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 ( + <> + + + +
+ +
+ + ); + } + + const root = ReactDOMClient.createRoot(container); + const initialPromiseForA = getText('A1'); + const initialPromiseForB = getText('B1'); + await resolveText('A1'); + await resolveText('B1'); + await act(() => + root.render( + , + ), + ); + + // 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( + , + ); + }); + }); + }); + 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 = ` +
+ +
+ `; + + 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(
Hi
)); + 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 ( +
+ +
+ ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + + // 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'); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index c712eb6c387d8..cb5aec4fb4e2c 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -2930,74 +2930,12 @@ export function startHostTransition( ); } - let queue: UpdateQueue< + const stateHook = ensureFormComponentIsStateful(formFiber); + + const queue: UpdateQueue< Thenable | TransitionStatus, BasicStateAction | 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, - BasicStateAction | 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 = { - 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( // 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, + BasicStateAction | 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 = { + 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); }