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

11 files changed

Lines changed: 230 additions & 5 deletions

File tree

‎packages/react-art/src/ReactFiberConfigART.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,3 +490,4 @@ export function waitForCommitToBeReady() {
490490
}
491491

492492
exportconstNotPendingTransition=null;
493+
exportfunctionresetFormInstance(){}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3441,3 +3441,8 @@ function insertStylesheetIntoRoot(
34413441
}
34423442

34433443
exportconstNotPendingTransition: TransitionStatus=NotPending;
3444+
3445+
exporttypeFormInstance=HTMLFormElement;
3446+
exportfunctionresetFormInstance(form: FormInstance): void{
3447+
form.reset();
3448+
}

‎packages/react-dom/src/__tests__/ReactDOMForm-test.js‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe('ReactDOMForm', () => {
3939
letuseState;
4040
letSuspense;
4141
letstartTransition;
42+
letuse;
4243
lettextCache;
4344
letuseFormStatus;
4445
letuseActionState;
@@ -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+
constformRef=React.createRef();
1342+
constinputRef=React.createRef();
1343+
constdivRef=React.createRef();
1344+
1345+
functionApp({promiseForUsername}){
1346+
// Make this suspensey to simulate RSC streaming.
1347+
constusername=use(promiseForUsername);
1348+
1349+
return(
1350+
<form
1351+
ref={formRef}
1352+
action={asyncformData=>{
1353+
constrawUsername=formData.get('username');
1354+
constnormalizedUsername=rawUsername.trim().toLowerCase();
1355+
1356+
Scheduler.log(`Async action started`);
1357+
awaitgetText('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+
<ApppromiseForUsername={getText(normalizedUsername)}/>,
1364+
);
1365+
});
1366+
}}>
1367+
<input
1368+
ref={inputRef}
1369+
text="text"
1370+
name="username"
1371+
defaultValue={username}
1372+
/>
1373+
<divref={divRef}>
1374+
<Texttext={'Current username: '+username}/>
1375+
</div>
1376+
</form>
1377+
);
1378+
}
1379+
1380+
// Initial render
1381+
constroot=ReactDOMClient.createRoot(container);
1382+
constpromiseForInitialUsername=getText('(empty)');
1383+
awaitresolveText('(empty)');
1384+
awaitact(()=>
1385+
root.render(<ApppromiseForUsername={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+
awaitsubmit(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+
awaitact(()=>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+
awaitact(()=>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‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,9 @@ export function waitForCommitToBeReady(): null {
515515

516516
exportconstNotPendingTransition: TransitionStatus=null;
517517

518+
exporttypeFormInstance=Instance;
519+
exportfunctionresetFormInstance(form: Instance): void{}
520+
518521
// -------------------
519522
// Microtasks
520523
// -------------------

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,3 +549,6 @@ export function waitForCommitToBeReady(): null {
549549
}
550550

551551
exportconstNotPendingTransition: TransitionStatus=null;
552+
553+
exporttypeFormInstance=Instance;
554+
exportfunctionresetFormInstance(form: Instance): void{}

‎packages/react-noop-renderer/src/createReactNoop.js‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ type SuspenseyCommitSubscription = {
9090

9191
exporttypeTransitionStatus=mixed;
9292

93+
exporttypeFormInstance=Instance;
94+
9395
constNO_CONTEXT={};
9496
constUPPERCASE_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
consthostConfig=useMutation

‎packages/react-reconciler/src/ReactFiberCommitWork.js‎

Lines changed: 53 additions & 0 deletions
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
importtype{Fiber,FiberRoot}from'./ReactInternalTypes';
2021
importtype{Lanes}from'./ReactFiberLane';
@@ -97,6 +98,7 @@ import {
9798
Visibility,
9899
ShouldSuspendCommit,
99100
MaySuspendCommit,
101+
FormReset,
100102
}from'./ReactFiberFlags';
101103
importgetComponentNameFromFiberfrom'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+
letneedsFormReset=false;
234+
229235
constPossiblyWeakSet=typeofWeakSet==='function' ? WeakSet : Set;
230236

231237
letnextEffect: 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
caseHostPortal: {
@@ -3091,6 +3126,24 @@ function commitReconciliationEffects(finishedWork: Fiber) {
30913126
}
30923127
}
30933128

3129+
functionrecursivelyResetForms(parentFiber: Fiber){
3130+
if(parentFiber.subtreeFlags&FormReset){
3131+
letchild=parentFiber.child;
3132+
while(child!==null){
3133+
resetFormOnFiber(child);
3134+
child=child.sibling;
3135+
}
3136+
}
3137+
}
3138+
3139+
functionresetFormOnFiber(fiber: Fiber){
3140+
recursivelyResetForms(fiber);
3141+
if(fiber.tag===HostComponent&&fiber.flags&FormReset){
3142+
constformInstance: FormInstance=fiber.stateNode;
3143+
resetFormInstance(formInstance);
3144+
}
3145+
}
3146+
30943147
exportfunctioncommitLayoutEffects(
30953148
finishedWork: Fiber,
30963149
root: FiberRoot,

‎packages/react-reconciler/src/ReactFiberFlags.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const StoreConsistency = /* */ 0b0000000000000100000000000000
4242
exportconstScheduleRetry=StoreConsistency;
4343
exportconstShouldSuspendCommit=Visibility;
4444
exportconstDidDefer=ContentReset;
45+
exportconstFormReset=Snapshot;
4546

4647
exportconstLifecycleEffectMask=
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
exportconstLayoutMask=Update|Callback|Ref|Visibility;
100102

101103
// TODO: Split into PassiveMountMask and PassiveUnmountMask

‎packages/react-reconciler/src/ReactFiberHooks.js‎

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import {
9191
StoreConsistency,
9292
MountLayoutDevasMountLayoutDevEffect,
9393
MountPassiveDevasMountPassiveDevEffect,
94+
FormReset,
9495
}from'./ReactFiberFlags';
9596
import{
9697
HasEffectasHookHasEffect,
@@ -844,15 +845,29 @@ export function TransitionAwareHostComponent(): TransitionStatus {
844845
if(!enableAsyncActions){
845846
thrownewError('Notimplemented.');
846847
}
848+
847849
constdispatcher: any=ReactSharedInternals.H;
848850
const[maybeThenable]=dispatcher.useState();
851+
letnextState;
849852
if(typeofmaybeThenable.then==='function'){
850853
constthenable: Thenable<TransitionStatus>=(maybeThenable: any);
851-
returnuseThenable(thenable);
854+
nextState=useThenable(thenable);
852855
}else{
853856
conststatus: TransitionStatus=maybeThenable;
854-
returnstatus;
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+
constprevResetState=
864+
currentHook!==null ? currentHook.memoizedState : null;
865+
if(prevResetState!==nextResetState){
866+
// Schedule a form reset
867+
currentlyRenderingFiber.flags|=FormReset;
855868
}
869+
870+
returnnextState;
856871
}
857872

858873
exportfunctioncheckDidRenderIdHook(): 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+
constinitialResetState={};
2971+
constnewResetStateQueue: 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+
constresetStateHook: 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
constalternate=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+
returncallback(formData);
3013+
},
29723014
);
29733015
}
29743016

3017+
functionrequestFormReset(formFiber: Fiber){
3018+
consttransition=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+
constnewResetState={};
3039+
constresetStateHook: Hook=(formFiber.memoizedState.next: any);
3040+
constresetStateQueue=resetStateHook.queue;
3041+
dispatchSetState(formFiber,resetStateQueue,newResetState);
3042+
}
3043+
29753044
functionmountTransition(): [
29763045
boolean,
29773046
(callback: ()=>void,options?: StartTransitionOptions)=>void,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export opaque type TimeoutHandle = mixed; // eslint-disable-line no-undef
3939
exportopaquetypeNoTimeout=mixed;// eslint-disable-line no-undef
4040
exportopaquetypeRendererInspectionConfig=mixed;// eslint-disable-line no-undef
4141
exportopaquetypeTransitionStatus=mixed;// eslint-disable-line no-undef
42+
exportopaquetypeFormInstance=mixed;// eslint-disable-line no-undef
4243
exporttypeEventResponder=any;
4344

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

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

0 commit comments

Comments
 (0)