diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js
index 0900c50f7e94e..1fee29d43dad0 100644
--- a/packages/react-art/src/ReactFiberConfigART.js
+++ b/packages/react-art/src/ReactFiberConfigART.js
@@ -490,3 +490,4 @@ export function waitForCommitToBeReady() {
}
export const NotPendingTransition = null;
+export function resetFormInstance() {}
diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
index 91e401b667329..ac76fafd5ad86 100644
--- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
@@ -3441,3 +3441,8 @@ function insertStylesheetIntoRoot(
}
export const NotPendingTransition: TransitionStatus = NotPending;
+
+export type FormInstance = HTMLFormElement;
+export function resetFormInstance(form: FormInstance): void {
+ form.reset();
+}
diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js
index ba721c215fc64..37261020e4627 100644
--- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js
@@ -39,6 +39,7 @@ describe('ReactDOMForm', () => {
let useState;
let Suspense;
let startTransition;
+ let use;
let textCache;
let useFormStatus;
let useActionState;
@@ -55,6 +56,7 @@ describe('ReactDOMForm', () => {
useState = React.useState;
Suspense = React.Suspense;
startTransition = React.startTransition;
+ use = React.use;
useFormStatus = ReactDOM.useFormStatus;
container = document.createElement('div');
document.body.appendChild(container);
@@ -1334,4 +1336,82 @@ describe('ReactDOMForm', () => {
assertLog(['1']);
expect(container.textContent).toBe('1');
});
+
+ test('uncontrolled form inputs are reset after the action completes', async () => {
+ 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 ';
+
+ // Submit the form. This will trigger an async action.
+ await submit(formRef.current);
+ 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');
+ });
});
diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabric.js b/packages/react-native-renderer/src/ReactFiberConfigFabric.js
index 0a4f30c901531..f1b6859ab7ea2 100644
--- a/packages/react-native-renderer/src/ReactFiberConfigFabric.js
+++ b/packages/react-native-renderer/src/ReactFiberConfigFabric.js
@@ -515,6 +515,9 @@ export function waitForCommitToBeReady(): null {
export const NotPendingTransition: TransitionStatus = null;
+export type FormInstance = Instance;
+export function resetFormInstance(form: Instance): void {}
+
// -------------------
// Microtasks
// -------------------
diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js
index c24c3a0c95f4f..20a3f150fd111 100644
--- a/packages/react-native-renderer/src/ReactFiberConfigNative.js
+++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js
@@ -549,3 +549,6 @@ export function waitForCommitToBeReady(): null {
}
export const NotPendingTransition: TransitionStatus = null;
+
+export type FormInstance = Instance;
+export function resetFormInstance(form: Instance): void {}
diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js
index f64499c6dc801..12b7420dae209 100644
--- a/packages/react-noop-renderer/src/createReactNoop.js
+++ b/packages/react-noop-renderer/src/createReactNoop.js
@@ -90,6 +90,8 @@ type SuspenseyCommitSubscription = {
export type TransitionStatus = mixed;
+export type FormInstance = Instance;
+
const NO_CONTEXT = {};
const UPPERCASE_CONTEXT = {};
if (__DEV__) {
@@ -632,6 +634,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
waitForCommitToBeReady,
NotPendingTransition: (null: TransitionStatus),
+
+ resetFormInstance(form: Instance) {},
};
const hostConfig = useMutation
diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js
index 213ff26d82e2c..87cbb2c13487c 100644
--- a/packages/react-reconciler/src/ReactFiberCommitWork.js
+++ b/packages/react-reconciler/src/ReactFiberCommitWork.js
@@ -15,6 +15,7 @@ import type {
ChildSet,
UpdatePayload,
HoistableRoot,
+ FormInstance,
} from './ReactFiberConfig';
import type {Fiber, FiberRoot} from './ReactInternalTypes';
import type {Lanes} from './ReactFiberLane';
@@ -97,6 +98,7 @@ import {
Visibility,
ShouldSuspendCommit,
MaySuspendCommit,
+ FormReset,
} from './ReactFiberFlags';
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
import {
@@ -163,6 +165,7 @@ import {
prepareToCommitHoistables,
suspendInstance,
suspendResource,
+ resetFormInstance,
} from './ReactFiberConfig';
import {
captureCommitPhaseError,
@@ -226,6 +229,9 @@ if (__DEV__) {
let offscreenSubtreeIsHidden: boolean = false;
let offscreenSubtreeWasHidden: boolean = false;
+// Used to track if a form needs to be reset at the end of the mutation phase.
+let needsFormReset = false;
+
const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
let nextEffect: Fiber | null = null;
@@ -2776,6 +2782,20 @@ function commitMutationEffectsOnFiber(
}
}
}
+
+ if (flags & FormReset) {
+ needsFormReset = true;
+ if (__DEV__) {
+ if (finishedWork.type !== 'form') {
+ // Paranoid coding. In case we accidentally start using the
+ // FormReset bit for something else.
+ console.error(
+ 'Unexpected host component type. Expected a form. This is a ' +
+ 'bug in React.',
+ );
+ }
+ }
+ }
}
return;
}
@@ -2852,6 +2872,21 @@ function commitMutationEffectsOnFiber(
}
}
}
+
+ if (needsFormReset) {
+ // A form component requested to be reset during this commit. We do this
+ // after all mutations in the rest of the tree so that `defaultValue`
+ // will already be updated. This way you can update `defaultValue` using
+ // data sent by the server as a result of the form submission.
+ //
+ // Theoretically we could check finishedWork.subtreeFlags & FormReset,
+ // but the FormReset bit is overloaded with other flags used by other
+ // fiber types. So this extra variable lets us skip traversing the tree
+ // except when a form was actually submitted.
+ needsFormReset = false;
+ recursivelyResetForms(finishedWork);
+ }
+
return;
}
case HostPortal: {
@@ -3091,6 +3126,24 @@ function commitReconciliationEffects(finishedWork: Fiber) {
}
}
+function recursivelyResetForms(parentFiber: Fiber) {
+ if (parentFiber.subtreeFlags & FormReset) {
+ let child = parentFiber.child;
+ while (child !== null) {
+ resetFormOnFiber(child);
+ child = child.sibling;
+ }
+ }
+}
+
+function resetFormOnFiber(fiber: Fiber) {
+ recursivelyResetForms(fiber);
+ if (fiber.tag === HostComponent && fiber.flags & FormReset) {
+ const formInstance: FormInstance = fiber.stateNode;
+ resetFormInstance(formInstance);
+ }
+}
+
export function commitLayoutEffects(
finishedWork: Fiber,
root: FiberRoot,
diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js
index 718add62948e0..49aebe2f9b11c 100644
--- a/packages/react-reconciler/src/ReactFiberFlags.js
+++ b/packages/react-reconciler/src/ReactFiberFlags.js
@@ -42,6 +42,7 @@ export const StoreConsistency = /* */ 0b0000000000000100000000000000
export const ScheduleRetry = StoreConsistency;
export const ShouldSuspendCommit = Visibility;
export const DidDefer = ContentReset;
+export const FormReset = Snapshot;
export const LifecycleEffectMask =
Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
@@ -95,7 +96,8 @@ export const MutationMask =
ContentReset |
Ref |
Hydrating |
- Visibility;
+ Visibility |
+ FormReset;
export const LayoutMask = Update | Callback | Ref | Visibility;
// TODO: Split into PassiveMountMask and PassiveUnmountMask
diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js
index a06303451c49b..b32c9c120166d 100644
--- a/packages/react-reconciler/src/ReactFiberHooks.js
+++ b/packages/react-reconciler/src/ReactFiberHooks.js
@@ -91,6 +91,7 @@ import {
StoreConsistency,
MountLayoutDev as MountLayoutDevEffect,
MountPassiveDev as MountPassiveDevEffect,
+ FormReset,
} from './ReactFiberFlags';
import {
HasEffect as HookHasEffect,
@@ -844,15 +845,29 @@ export function TransitionAwareHostComponent(): TransitionStatus {
if (!enableAsyncActions) {
throw new Error('Not implemented.');
}
+
const dispatcher: any = ReactSharedInternals.H;
const [maybeThenable] = dispatcher.useState();
+ let nextState;
if (typeof maybeThenable.then === 'function') {
const thenable: Thenable = (maybeThenable: any);
- return useThenable(thenable);
+ nextState = useThenable(thenable);
} else {
const status: TransitionStatus = maybeThenable;
- return status;
+ nextState = status;
+ }
+
+ // The "reset state" is an object. If it changes, that means something
+ // requested that we reset the form.
+ const [nextResetState] = dispatcher.useState();
+ const prevResetState =
+ currentHook !== null ? currentHook.memoizedState : null;
+ if (prevResetState !== nextResetState) {
+ // Schedule a form reset
+ currentlyRenderingFiber.flags |= FormReset;
}
+
+ return nextState;
}
export function checkDidRenderIdHook(): boolean {
@@ -2948,7 +2963,30 @@ export function startHostTransition(
next: null,
};
- // Add the state hook to both fiber alternates. The idea is that the fiber
+ // 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