Skip to content

Commit 41950d1

Browse files
authored
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.
1 parent dc6a7e0 commit 41950d1

File tree

11 files changed

+230
-5
lines changed

11 files changed

+230
-5
lines changed

packages/react-art/src/ReactFiberConfigART.js

+1
Original file line numberDiff line numberDiff line change
@@ -490,3 +490,4 @@ export function waitForCommitToBeReady() {
490490
}
491491

492492
export const NotPendingTransition = null;
493+
export function resetFormInstance() {}

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

+5
Original file line numberDiff line numberDiff line change
@@ -3441,3 +3441,8 @@ function insertStylesheetIntoRoot(
34413441
}
34423442

34433443
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
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe('ReactDOMForm', () => {
3939
let useState;
4040
let Suspense;
4141
let startTransition;
42+
let use;
4243
let textCache;
4344
let useFormStatus;
4445
let useActionState;
@@ -55,6 +56,7 @@ describe('ReactDOMForm', () => {
5556
useState = React.useState;
5657
Suspense = React.Suspense;
5758
startTransition = React.startTransition;
59+
use = React.use;
5860
useFormStatus = ReactDOM.useFormStatus;
5961
container = document.createElement('div');
6062
document.body.appendChild(container);
@@ -1334,4 +1336,82 @@ describe('ReactDOMForm', () => {
13341336
assertLog(['1']);
13351337
expect(container.textContent).toBe('1');
13361338
});
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+
});
13371417
});

packages/react-native-renderer/src/ReactFiberConfigFabric.js

+3
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,9 @@ export function waitForCommitToBeReady(): null {
515515

516516
export const NotPendingTransition: TransitionStatus = null;
517517

518+
export type FormInstance = Instance;
519+
export function resetFormInstance(form: Instance): void {}
520+
518521
// -------------------
519522
// Microtasks
520523
// -------------------

packages/react-native-renderer/src/ReactFiberConfigNative.js

+3
Original file line numberDiff line numberDiff line change
@@ -549,3 +549,6 @@ export function waitForCommitToBeReady(): null {
549549
}
550550

551551
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
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ type SuspenseyCommitSubscription = {
9090

9191
export type TransitionStatus = mixed;
9292

93+
export type FormInstance = Instance;
94+
9395
const NO_CONTEXT = {};
9496
const UPPERCASE_CONTEXT = {};
9597
if (__DEV__) {
@@ -632,6 +634,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
632634
waitForCommitToBeReady,
633635

634636
NotPendingTransition: (null: TransitionStatus),
637+
638+
resetFormInstance(form: Instance) {},
635639
};
636640

637641
const hostConfig = useMutation

packages/react-reconciler/src/ReactFiberCommitWork.js

+53
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ChildSet,
1616
UpdatePayload,
1717
HoistableRoot,
18+
FormInstance,
1819
} from './ReactFiberConfig';
1920
import type {Fiber, FiberRoot} from './ReactInternalTypes';
2021
import type {Lanes} from './ReactFiberLane';
@@ -97,6 +98,7 @@ import {
9798
Visibility,
9899
ShouldSuspendCommit,
99100
MaySuspendCommit,
101+
FormReset,
100102
} from './ReactFiberFlags';
101103
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
102104
import {
@@ -163,6 +165,7 @@ import {
163165
prepareToCommitHoistables,
164166
suspendInstance,
165167
suspendResource,
168+
resetFormInstance,
166169
} from './ReactFiberConfig';
167170
import {
168171
captureCommitPhaseError,
@@ -226,6 +229,9 @@ if (__DEV__) {
226229
let offscreenSubtreeIsHidden: boolean = false;
227230
let offscreenSubtreeWasHidden: boolean = false;
228231

232+
// Used to track if a form needs to be reset at the end of the mutation phase.
233+
let needsFormReset = false;
234+
229235
const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
230236

231237
let nextEffect: Fiber | null = null;
@@ -2776,6 +2782,20 @@ function commitMutationEffectsOnFiber(
27762782
}
27772783
}
27782784
}
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+
}
27792799
}
27802800
return;
27812801
}
@@ -2852,6 +2872,21 @@ function commitMutationEffectsOnFiber(
28522872
}
28532873
}
28542874
}
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+
28552890
return;
28562891
}
28572892
case HostPortal: {
@@ -3091,6 +3126,24 @@ function commitReconciliationEffects(finishedWork: Fiber) {
30913126
}
30923127
}
30933128

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+
30943147
export function commitLayoutEffects(
30953148
finishedWork: Fiber,
30963149
root: FiberRoot,

packages/react-reconciler/src/ReactFiberFlags.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const StoreConsistency = /* */ 0b0000000000000100000000000000
4242
export const ScheduleRetry = StoreConsistency;
4343
export const ShouldSuspendCommit = Visibility;
4444
export const DidDefer = ContentReset;
45+
export const FormReset = Snapshot;
4546

4647
export const LifecycleEffectMask =
4748
Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
@@ -95,7 +96,8 @@ export const MutationMask =
9596
ContentReset |
9697
Ref |
9798
Hydrating |
98-
Visibility;
99+
Visibility |
100+
FormReset;
99101
export const LayoutMask = Update | Callback | Ref | Visibility;
100102

101103
// TODO: Split into PassiveMountMask and PassiveUnmountMask

packages/react-reconciler/src/ReactFiberHooks.js

+73-4
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import {
9191
StoreConsistency,
9292
MountLayoutDev as MountLayoutDevEffect,
9393
MountPassiveDev as MountPassiveDevEffect,
94+
FormReset,
9495
} from './ReactFiberFlags';
9596
import {
9697
HasEffect as HookHasEffect,
@@ -844,15 +845,29 @@ export function TransitionAwareHostComponent(): TransitionStatus {
844845
if (!enableAsyncActions) {
845846
throw new Error('Not implemented.');
846847
}
848+
847849
const dispatcher: any = ReactSharedInternals.H;
848850
const [maybeThenable] = dispatcher.useState();
851+
let nextState;
849852
if (typeof maybeThenable.then === 'function') {
850853
const thenable: Thenable<TransitionStatus> = (maybeThenable: any);
851-
return useThenable(thenable);
854+
nextState = useThenable(thenable);
852855
} else {
853856
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;
855868
}
869+
870+
return nextState;
856871
}
857872

858873
export function checkDidRenderIdHook(): boolean {
@@ -2948,7 +2963,30 @@ export function startHostTransition<F>(
29482963
next: null,
29492964
};
29502965

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
29522990
// had this hook all along.
29532991
formFiber.memoizedState = stateHook;
29542992
const alternate = formFiber.alternate;
@@ -2968,10 +3006,41 @@ export function startHostTransition<F>(
29683006
NoPendingHostTransition,
29693007
// TODO: We can avoid this extra wrapper, somehow. Figure out layering
29703008
// 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+
},
29723014
);
29733015
}
29743016

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+
29753044
function mountTransition(): [
29763045
boolean,
29773046
(callback: () => void, options?: StartTransitionOptions) => void,

packages/react-reconciler/src/forks/ReactFiberConfig.custom.js

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export opaque type TimeoutHandle = mixed; // eslint-disable-line no-undef
3939
export opaque type NoTimeout = mixed; // eslint-disable-line no-undef
4040
export opaque type RendererInspectionConfig = mixed; // eslint-disable-line no-undef
4141
export opaque type TransitionStatus = mixed; // eslint-disable-line no-undef
42+
export opaque type FormInstance = mixed; // eslint-disable-line no-undef
4243
export type EventResponder = any;
4344

4445
export const getPublicInstance = $$$config.getPublicInstance;
@@ -78,6 +79,7 @@ export const startSuspendingCommit = $$$config.startSuspendingCommit;
7879
export const suspendInstance = $$$config.suspendInstance;
7980
export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady;
8081
export const NotPendingTransition = $$$config.NotPendingTransition;
82+
export const resetFormInstance = $$$config.resetFormInstance;
8183

8284
// -------------------
8385
// Microtasks

0 commit comments

Comments
 (0)