@samitouri / QOS-React / commits / 41950d14a5

Automatically reset forms after action finishes (#28804)

This updates the behavior of form actions to automatically reset the form's uncontrolled inputs after the action finishes. This is a frequent feature request for people using actions and it aligns the behavior of client-side form submissions more closely with MPA form submissions. It has no impact on controlled form inputs. It's the same as if you called `form.reset()` manually, except React handles the timing of when the reset happens, which is tricky/impossible to get exactly right in userspace. The reset shouldn't happen until the UI has updated with the result of the action. So, resetting inside the action is too early. Resetting in `useEffect` is better, but it's later than ideal because any effects that run before it will observe the state of the form before it's been reset. It needs to happen in the mutation phase of the transition. More specifically, after all the DOM mutations caused by the transition have been applied. That way the `defaultValue` of the inputs are updated before the values are reset. The idea is that the `defaultValue` represents the current, canonical value sent by the server. Note: this change has no effect on form submissions that aren't triggered by an action.

Andrew Clark committed Apr 10, 2024 at 16:54 UTC 41950d14a538aa7411b00b28bcd94ae95a45976e
11 files changed +230 -5
packages/react-art/src/ReactFiberConfigART.js
+1
@@ -490,3 +490,4 @@ export function waitForCommitToBeReady() {
490 }
491
492 export const NotPendingTransition = null;
493 +export function resetFormInstance() {}
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+5
@@ -3441,3 +3441,8 @@ function insertStylesheetIntoRoot(
3441 }
3442
3443 export const NotPendingTransition: TransitionStatus = NotPending;
3444 +
3445 +export type FormInstance = HTMLFormElement;
3446 +export function resetFormInstance(form: FormInstance): void {
3447 + form.reset();
3448 +}
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+80
@@ -39,6 +39,7 @@ describe('ReactDOMForm', () => {
39 let useState;
40 let Suspense;
41 let startTransition;
42 + let use;
43 let textCache;
44 let useFormStatus;
45 let useActionState;
@@ -55,6 +56,7 @@ describe('ReactDOMForm', () => {
56 useState = React.useState;
57 Suspense = React.Suspense;
58 startTransition = React.startTransition;
59 + use = React.use;
60 useFormStatus = ReactDOM.useFormStatus;
61 container = document.createElement('div');
62 document.body.appendChild(container);
@@ -1334,4 +1336,82 @@ describe('ReactDOMForm', () => {
1336 assertLog(['1']);
1337 expect(container.textContent).toBe('1');
1338 });
1339 +
1340 + test('uncontrolled form inputs are reset after the action completes', async () => {
1341 + const formRef = React.createRef();
1342 + const inputRef = React.createRef();
1343 + const divRef = React.createRef();
1344 +
1345 + function App({promiseForUsername}) {
1346 + // Make this suspensey to simulate RSC streaming.
1347 + const username = use(promiseForUsername);
1348 +
1349 + return (
1350 + <form
1351 + ref={formRef}
1352 + action={async formData => {
1353 + const rawUsername = formData.get('username');
1354 + const normalizedUsername = rawUsername.trim().toLowerCase();
1355 +
1356 + Scheduler.log(`Async action started`);
1357 + await getText('Wait');
1358 +
1359 + // Update the app with new data. This is analagous to re-rendering
1360 + // from the root with a new RSC payload.
1361 + startTransition(() => {
1362 + root.render(
1363 + <App promiseForUsername={getText(normalizedUsername)} />,
1364 + );
1365 + });
1366 + }}>
1367 + <input
1368 + ref={inputRef}
1369 + text="text"
1370 + name="username"
1371 + defaultValue={username}
1372 + />
1373 + <div ref={divRef}>
1374 + <Text text={'Current username: ' + username} />
1375 + </div>
1376 + </form>
1377 + );
1378 + }
1379 +
1380 + // Initial render
1381 + const root = ReactDOMClient.createRoot(container);
1382 + const promiseForInitialUsername = getText('(empty)');
1383 + await resolveText('(empty)');
1384 + await act(() =>
1385 + root.render(<App promiseForUsername={promiseForInitialUsername} />),
1386 + );
1387 + assertLog(['Current username: (empty)']);
1388 + expect(divRef.current.textContent).toEqual('Current username: (empty)');
1389 +
1390 + // Dirty the uncontrolled input
1391 + inputRef.current.value = ' AcdLite ';
1392 +
1393 + // Submit the form. This will trigger an async action.
1394 + await submit(formRef.current);
1395 + assertLog(['Async action started']);
1396 + expect(inputRef.current.value).toBe(' AcdLite ');
1397 +
1398 + // Finish the async action. This will trigger a re-render from the root with
1399 + // new data from the "server", which suspends.
1400 + //
1401 + // The form should not reset yet because we need to update `defaultValue`
1402 + // first. So we wait for the render to complete.
1403 + await act(() => resolveText('Wait'));
1404 + assertLog([]);
1405 + // The DOM input is still dirty.
1406 + expect(inputRef.current.value).toBe(' AcdLite ');
1407 + // The React tree is suspended.
1408 + expect(divRef.current.textContent).toEqual('Current username: (empty)');
1409 +
1410 + // Unsuspend and finish rendering. Now the form should be reset.
1411 + await act(() => resolveText('acdlite'));
1412 + assertLog(['Current username: acdlite']);
1413 + // The form was reset to the new value from the server.
1414 + expect(inputRef.current.value).toBe('acdlite');
1415 + expect(divRef.current.textContent).toEqual('Current username: acdlite');
1416 + });
1417 });
packages/react-native-renderer/src/ReactFiberConfigFabric.js
+3
@@ -515,6 +515,9 @@ export function waitForCommitToBeReady(): null {
515
516 export const NotPendingTransition: TransitionStatus = null;
517
518 +export type FormInstance = Instance;
519 +export function resetFormInstance(form: Instance): void {}
520 +
521 // -------------------
522 // Microtasks
523 // -------------------
packages/react-native-renderer/src/ReactFiberConfigNative.js
+3
@@ -549,3 +549,6 @@ export function waitForCommitToBeReady(): null {
549 }
550
551 export const NotPendingTransition: TransitionStatus = null;
552 +
553 +export type FormInstance = Instance;
554 +export function resetFormInstance(form: Instance): void {}
packages/react-noop-renderer/src/createReactNoop.js
+4
@@ -90,6 +90,8 @@ type SuspenseyCommitSubscription = {
90
91 export type TransitionStatus = mixed;
92
93 +export type FormInstance = Instance;
94 +
95 const NO_CONTEXT = {};
96 const UPPERCASE_CONTEXT = {};
97 if (__DEV__) {
@@ -632,6 +634,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
634 waitForCommitToBeReady,
635
636 NotPendingTransition: (null: TransitionStatus),
637 +
638 + resetFormInstance(form: Instance) {},
639 };
640
641 const hostConfig = useMutation
packages/react-reconciler/src/ReactFiberCommitWork.js
+53
@@ -15,6 +15,7 @@ import type {
15 ChildSet,
16 UpdatePayload,
17 HoistableRoot,
18 + FormInstance,
19 } from './ReactFiberConfig';
20 import type {Fiber, FiberRoot} from './ReactInternalTypes';
21 import type {Lanes} from './ReactFiberLane';
@@ -97,6 +98,7 @@ import {
98 Visibility,
99 ShouldSuspendCommit,
100 MaySuspendCommit,
101 + FormReset,
102 } from './ReactFiberFlags';
103 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
104 import {
@@ -163,6 +165,7 @@ import {
165 prepareToCommitHoistables,
166 suspendInstance,
167 suspendResource,
168 + resetFormInstance,
169 } from './ReactFiberConfig';
170 import {
171 captureCommitPhaseError,
@@ -226,6 +229,9 @@ if (__DEV__) {
229 let offscreenSubtreeIsHidden: boolean = false;
230 let offscreenSubtreeWasHidden: boolean = false;
231
232 +// Used to track if a form needs to be reset at the end of the mutation phase.
233 +let needsFormReset = false;
234 +
235 const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
236
237 let nextEffect: Fiber | null = null;
@@ -2776,6 +2782,20 @@ function commitMutationEffectsOnFiber(
2782 }
2783 }
2784 }
2785 +
2786 + if (flags & FormReset) {
2787 + needsFormReset = true;
2788 + if (__DEV__) {
2789 + if (finishedWork.type !== 'form') {
2790 + // Paranoid coding. In case we accidentally start using the
2791 + // FormReset bit for something else.
2792 + console.error(
2793 + 'Unexpected host component type. Expected a form. This is a ' +
2794 + 'bug in React.',
2795 + );
2796 + }
2797 + }
2798 + }
2799 }
2800 return;
2801 }
@@ -2852,6 +2872,21 @@ function commitMutationEffectsOnFiber(
2872 }
2873 }
2874 }
2875 +
2876 + if (needsFormReset) {
2877 + // A form component requested to be reset during this commit. We do this
2878 + // after all mutations in the rest of the tree so that `defaultValue`
2879 + // will already be updated. This way you can update `defaultValue` using
2880 + // data sent by the server as a result of the form submission.
2881 + //
2882 + // Theoretically we could check finishedWork.subtreeFlags & FormReset,
2883 + // but the FormReset bit is overloaded with other flags used by other
2884 + // fiber types. So this extra variable lets us skip traversing the tree
2885 + // except when a form was actually submitted.
2886 + needsFormReset = false;
2887 + recursivelyResetForms(finishedWork);
2888 + }
2889 +
2890 return;
2891 }
2892 case HostPortal: {
@@ -3091,6 +3126,24 @@ function commitReconciliationEffects(finishedWork: Fiber) {
3126 }
3127 }
3128
3129 +function recursivelyResetForms(parentFiber: Fiber) {
3130 + if (parentFiber.subtreeFlags & FormReset) {
3131 + let child = parentFiber.child;
3132 + while (child !== null) {
3133 + resetFormOnFiber(child);
3134 + child = child.sibling;
3135 + }
3136 + }
3137 +}
3138 +
3139 +function resetFormOnFiber(fiber: Fiber) {
3140 + recursivelyResetForms(fiber);
3141 + if (fiber.tag === HostComponent && fiber.flags & FormReset) {
3142 + const formInstance: FormInstance = fiber.stateNode;
3143 + resetFormInstance(formInstance);
3144 + }
3145 +}
3146 +
3147 export function commitLayoutEffects(
3148 finishedWork: Fiber,
3149 root: FiberRoot,
packages/react-reconciler/src/ReactFiberFlags.js
+3 -1
@@ -42,6 +42,7 @@ export const StoreConsistency = /* */ 0b0000000000000100000000000000
42 export const ScheduleRetry = StoreConsistency;
43 export const ShouldSuspendCommit = Visibility;
44 export const DidDefer = ContentReset;
45 +export const FormReset = Snapshot;
46
47 export const LifecycleEffectMask =
48 Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
@@ -95,7 +96,8 @@ export const MutationMask =
96 ContentReset |
97 Ref |
98 Hydrating |
98 - Visibility;
99 + Visibility |
100 + FormReset;
101 export const LayoutMask = Update | Callback | Ref | Visibility;
102
103 // TODO: Split into PassiveMountMask and PassiveUnmountMask
packages/react-reconciler/src/ReactFiberHooks.js
+73 -4
@@ -91,6 +91,7 @@ import {
91 StoreConsistency,
92 MountLayoutDev as MountLayoutDevEffect,
93 MountPassiveDev as MountPassiveDevEffect,
94 + FormReset,
95 } from './ReactFiberFlags';
96 import {
97 HasEffect as HookHasEffect,
@@ -844,15 +845,29 @@ export function TransitionAwareHostComponent(): TransitionStatus {
845 if (!enableAsyncActions) {
846 throw new Error('Not implemented.');
847 }
848 +
849 const dispatcher: any = ReactSharedInternals.H;
850 const [maybeThenable] = dispatcher.useState();
851 + let nextState;
852 if (typeof maybeThenable.then === 'function') {
853 const thenable: Thenable<TransitionStatus> = (maybeThenable: any);
851 - return useThenable(thenable);
854 + nextState = useThenable(thenable);
855 } else {
856 const status: TransitionStatus = maybeThenable;
854 - return status;
857 + nextState = status;
858 + }
859 +
860 + // The "reset state" is an object. If it changes, that means something
861 + // requested that we reset the form.
862 + const [nextResetState] = dispatcher.useState();
863 + const prevResetState =
864 + currentHook !== null ? currentHook.memoizedState : null;
865 + if (prevResetState !== nextResetState) {
866 + // Schedule a form reset
867 + currentlyRenderingFiber.flags |= FormReset;
868 }
869 +
870 + return nextState;
871 }
872
873 export function checkDidRenderIdHook(): boolean {
@@ -2948,7 +2963,30 @@ export function startHostTransition<F>(
2963 next: null,
2964 };
2965
2951 - // Add the state hook to both fiber alternates. The idea is that the fiber
2966 + // We use another state hook to track whether the form needs to be reset.
2967 + // The state is an empty object. To trigger a reset, we update the state
2968 + // to a new object. Then during rendering, we detect that the state has
2969 + // changed and schedule a commit effect.
2970 + const initialResetState = {};
2971 + const newResetStateQueue: UpdateQueue<Object, Object> = {
2972 + pending: null,
2973 + lanes: NoLanes,
2974 + // We're going to cheat and intentionally not create a bound dispatch
2975 + // method, because we can call it directly in startTransition.
2976 + dispatch: (null: any),
2977 + lastRenderedReducer: basicStateReducer,
2978 + lastRenderedState: initialResetState,
2979 + };
2980 + const resetStateHook: Hook = {
2981 + memoizedState: initialResetState,
2982 + baseState: initialResetState,
2983 + baseQueue: null,
2984 + queue: newResetStateQueue,
2985 + next: null,
2986 + };
2987 + stateHook.next = resetStateHook;
2988 +
2989 + // Add the hook list to both fiber alternates. The idea is that the fiber
2990 // had this hook all along.
2991 formFiber.memoizedState = stateHook;
2992 const alternate = formFiber.alternate;
@@ -2968,10 +3006,41 @@ export function startHostTransition<F>(
3006 NoPendingHostTransition,
3007 // TODO: We can avoid this extra wrapper, somehow. Figure out layering
3008 // once more of this function is implemented.
2971 - () => callback(formData),
3009 + () => {
3010 + // Automatically reset the form when the action completes.
3011 + requestFormReset(formFiber);
3012 + return callback(formData);
3013 + },
3014 );
3015 }
3016
3017 +function requestFormReset(formFiber: Fiber) {
3018 + const transition = requestCurrentTransition();
3019 +
3020 + if (__DEV__) {
3021 + if (transition === null) {
3022 + // An optimistic update occurred, but startTransition is not on the stack.
3023 + // The form reset will be scheduled at default (sync) priority, which
3024 + // is probably not what the user intended. Most likely because the
3025 + // requestFormReset call happened after an `await`.
3026 + // TODO: Theoretically, requestFormReset is still useful even for
3027 + // non-transition updates because it allows you to update defaultValue
3028 + // synchronously and then wait to reset until after the update commits.
3029 + // I've chosen to warn anyway because it's more likely the `await` mistake
3030 + // described above. But arguably we shouldn't.
3031 + console.error(
3032 + 'requestFormReset was called outside a transition or action. To ' +
3033 + 'fix, move to an action, or wrap with startTransition.',
3034 + );
3035 + }
3036 + }
3037 +
3038 + const newResetState = {};
3039 + const resetStateHook: Hook = (formFiber.memoizedState.next: any);
3040 + const resetStateQueue = resetStateHook.queue;
3041 + dispatchSetState(formFiber, resetStateQueue, newResetState);
3042 +}
3043 +
3044 function mountTransition(): [
3045 boolean,
3046 (callback: () => void, options?: StartTransitionOptions) => void,
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+2
@@ -39,6 +39,7 @@ export opaque type TimeoutHandle = mixed; // eslint-disable-line no-undef
39 export opaque type NoTimeout = mixed; // eslint-disable-line no-undef
40 export opaque type RendererInspectionConfig = mixed; // eslint-disable-line no-undef
41 export opaque type TransitionStatus = mixed; // eslint-disable-line no-undef
42 +export opaque type FormInstance = mixed; // eslint-disable-line no-undef
43 export type EventResponder = any;
44
45 export const getPublicInstance = $$$config.getPublicInstance;
@@ -78,6 +79,7 @@ export const startSuspendingCommit = $$$config.startSuspendingCommit;
79 export const suspendInstance = $$$config.suspendInstance;
80 export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady;
81 export const NotPendingTransition = $$$config.NotPendingTransition;
82 +export const resetFormInstance = $$$config.resetFormInstance;
83
84 // -------------------
85 // Microtasks
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+3
@@ -349,3 +349,6 @@ export function waitForCommitToBeReady(): null {
349 }
350
351 export const NotPendingTransition: TransitionStatus = null;
352 +
353 +export type FormInstance = Instance;
354 +export function resetFormInstance(form: Instance): void {}