Skip to content

Commit 889fc3f

Browse files
committed
Implement experimental_useFormStatus
This hook reads the status of its ancestor form component, if it exists. const {pending, data, action, method} = useFormStatus(); It can be used to implement a loading indicator, for example. You can think of it as a shortcut for implementing a loading state with the useTransition hook. For now, it's only available in the experimental channel. We'll share docs once its closer to being stable. There are additional APIs that will ship alongside it. Internally it's implemented using startTransition + a context object. That's a good way to think about its behavior, but the actual implementation details may change in the future. Because form elements cannot be nested, the implementation in the reconciler does not bother to keep track of multiple nested "transition providers". So although it's implemented using generic Fiber config methods, it does currently make some assumptions based on React DOM's requirements.
1 parent 234c9bc commit 889fc3f

File tree

8 files changed

+326
-35
lines changed

8 files changed

+326
-35
lines changed

packages/react-dom-bindings/src/shared/ReactDOMFormActions.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
* @flow
88
*/
99

10+
import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';
11+
1012
import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags';
13+
import ReactSharedInternals from 'shared/ReactSharedInternals';
14+
15+
const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
1116

1217
type FormStatusNotPending = {|
1318
pending: false,
@@ -38,13 +43,34 @@ export const NotPending: FormStatus = __DEV__
3843
? Object.freeze(sharedNotPendingObject)
3944
: sharedNotPendingObject;
4045

46+
function resolveDispatcher() {
47+
// Copied from react/src/ReactHooks.js. It's the same thing but in a
48+
// different package.
49+
const dispatcher = ReactCurrentDispatcher.current;
50+
if (__DEV__) {
51+
if (dispatcher === null) {
52+
console.error(
53+
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
54+
' one of the following reasons:\n' +
55+
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
56+
'2. You might be breaking the Rules of Hooks\n' +
57+
'3. You might have more than one copy of React in the same app\n' +
58+
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
59+
);
60+
}
61+
}
62+
// Will result in a null access error if accessed outside render phase. We
63+
// intentionally don't throw our own error because this is in a hot path.
64+
// Also helps ensure this is inlined.
65+
return ((dispatcher: any): Dispatcher);
66+
}
67+
4168
export function useFormStatus(): FormStatus {
4269
if (!(enableFormActions && enableAsyncActions)) {
4370
throw new Error('Not implemented.');
4471
} else {
45-
// TODO: This isn't fully implemented yet but we return a correctly typed
46-
// value so we can test that the API is exposed and gated correctly. The
47-
// real implementation will access the status via the dispatcher.
48-
return NotPending;
72+
const dispatcher = resolveDispatcher();
73+
// $FlowFixMe We know this exists because of the feature check above.
74+
return dispatcher.useHostTransitionStatus();
4975
}
5076
}

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

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -647,10 +647,16 @@ describe('ReactDOMForm', () => {
647647
it('form actions are transitions', async () => {
648648
const formRef = React.createRef();
649649

650+
function Status() {
651+
const {pending} = useFormStatus();
652+
return pending ? <Text text="Pending..." /> : null;
653+
}
654+
650655
function App() {
651656
const [state, setState] = useState('Initial');
652657
return (
653658
<form action={() => setState('Updated')} ref={formRef}>
659+
<Status />
654660
<Suspense fallback={<Text text="Loading..." />}>
655661
<AsyncText text={state} />
656662
</Suspense>
@@ -667,8 +673,8 @@ describe('ReactDOMForm', () => {
667673
// This should suspend because form actions are implicitly wrapped
668674
// in startTransition.
669675
await submit(formRef.current);
670-
assertLog(['Suspend! [Updated]', 'Loading...']);
671-
expect(container.textContent).toBe('Initial');
676+
assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']);
677+
expect(container.textContent).toBe('Pending...Initial');
672678

673679
await act(() => resolveText('Updated'));
674680
assertLog(['Updated']);
@@ -680,10 +686,16 @@ describe('ReactDOMForm', () => {
680686
it('multiple form actions', async () => {
681687
const formRef = React.createRef();
682688

689+
function Status() {
690+
const {pending} = useFormStatus();
691+
return pending ? <Text text="Pending..." /> : null;
692+
}
693+
683694
function App() {
684695
const [state, setState] = useState(0);
685696
return (
686697
<form action={() => setState(n => n + 1)} ref={formRef}>
698+
<Status />
687699
<Suspense fallback={<Text text="Loading..." />}>
688700
<AsyncText text={'Count: ' + state} />
689701
</Suspense>
@@ -699,17 +711,17 @@ describe('ReactDOMForm', () => {
699711

700712
// Update
701713
await submit(formRef.current);
702-
assertLog(['Suspend! [Count: 1]', 'Loading...']);
703-
expect(container.textContent).toBe('Count: 0');
714+
assertLog(['Pending...', 'Suspend! [Count: 1]', 'Loading...']);
715+
expect(container.textContent).toBe('Pending...Count: 0');
704716

705717
await act(() => resolveText('Count: 1'));
706718
assertLog(['Count: 1']);
707719
expect(container.textContent).toBe('Count: 1');
708720

709721
// Update again
710722
await submit(formRef.current);
711-
assertLog(['Suspend! [Count: 2]', 'Loading...']);
712-
expect(container.textContent).toBe('Count: 1');
723+
assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']);
724+
expect(container.textContent).toBe('Pending...Count: 1');
713725

714726
await act(() => resolveText('Count: 2'));
715727
assertLog(['Count: 2']);
@@ -720,6 +732,11 @@ describe('ReactDOMForm', () => {
720732
it('form actions can be asynchronous', async () => {
721733
const formRef = React.createRef();
722734

735+
function Status() {
736+
const {pending} = useFormStatus();
737+
return pending ? <Text text="Pending..." /> : null;
738+
}
739+
723740
function App() {
724741
const [state, setState] = useState('Initial');
725742
return (
@@ -730,6 +747,7 @@ describe('ReactDOMForm', () => {
730747
startTransition(() => setState('Updated'));
731748
}}
732749
ref={formRef}>
750+
<Status />
733751
<Suspense fallback={<Text text="Loading..." />}>
734752
<AsyncText text={state} />
735753
</Suspense>
@@ -744,11 +762,15 @@ describe('ReactDOMForm', () => {
744762
expect(container.textContent).toBe('Initial');
745763

746764
await submit(formRef.current);
747-
assertLog(['Async action started']);
765+
assertLog(['Async action started', 'Pending...']);
748766

749767
await act(() => resolveText('Wait'));
750768
assertLog(['Suspend! [Updated]', 'Loading...']);
751-
expect(container.textContent).toBe('Initial');
769+
expect(container.textContent).toBe('Pending...Initial');
770+
771+
await act(() => resolveText('Updated'));
772+
assertLog(['Updated']);
773+
expect(container.textContent).toBe('Updated');
752774
});
753775

754776
it('sync errors in form actions can be captured by an error boundary', async () => {
@@ -851,17 +873,53 @@ describe('ReactDOMForm', () => {
851873

852874
// @gate enableFormActions
853875
// @gate enableAsyncActions
854-
it('useFormStatus exists', async () => {
855-
// This API isn't fully implemented yet. This just tests that it's wired
856-
// up correctly.
876+
it('useFormStatus reads the status of a pending form action', async () => {
877+
const formRef = React.createRef();
878+
879+
function Status() {
880+
const {pending, data, action, method} = useFormStatus();
881+
if (!pending) {
882+
return <Text text="No pending action" />;
883+
} else {
884+
const foo = data.get('foo');
885+
return (
886+
<Text
887+
text={`Pending action ${action.name}: foo is ${foo}, method is ${method}`}
888+
/>
889+
);
890+
}
891+
}
892+
893+
async function myAction() {
894+
Scheduler.log('Async action started');
895+
await getText('Wait');
896+
Scheduler.log('Async action finished');
897+
}
857898

858899
function App() {
859-
const {pending} = useFormStatus();
860-
return 'Pending: ' + pending;
900+
return (
901+
<form action={myAction} ref={formRef}>
902+
<input type="text" name="foo" defaultValue="bar" />
903+
<Status />
904+
</form>
905+
);
861906
}
862907

863908
const root = ReactDOMClient.createRoot(container);
864909
await act(() => root.render(<App />));
865-
expect(container.textContent).toBe('Pending: false');
910+
assertLog(['No pending action']);
911+
expect(container.textContent).toBe('No pending action');
912+
913+
await submit(formRef.current);
914+
assertLog([
915+
'Async action started',
916+
'Pending action myAction: foo is bar, method is get',
917+
]);
918+
expect(container.textContent).toBe(
919+
'Pending action myAction: foo is bar, method is get',
920+
);
921+
922+
await act(() => resolveText('Wait'));
923+
assertLog(['Async action finished', 'No pending action']);
866924
});
867925
});

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import type {
3838
import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
3939
import type {RootState} from './ReactFiberRoot';
4040
import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
41+
import type {TransitionStatus} from './ReactFiberConfig';
42+
import type {Hook} from './ReactFiberHooks';
4143

4244
import checkPropTypes from 'shared/checkPropTypes';
4345
import {
@@ -176,6 +178,7 @@ import {
176178
pushHostContext,
177179
pushHostContainer,
178180
getRootHostContainer,
181+
HostTransitionContext,
179182
} from './ReactFiberHostContext';
180183
import {
181184
suspenseStackCursor,
@@ -1632,11 +1635,49 @@ function updateHostComponent(
16321635
//
16331636
// Once a fiber is upgraded to be stateful, it remains stateful for the
16341637
// rest of its lifetime.
1635-
renderTransitionAwareHostComponentWithHooks(
1638+
const newState = renderTransitionAwareHostComponentWithHooks(
16361639
current,
16371640
workInProgress,
16381641
renderLanes,
16391642
);
1643+
1644+
// If the transition state changed, propagate the change to all the
1645+
// descendents. We use Context as an implementation detail for this.
1646+
//
1647+
// This is intentionally set here instead of pushHostContext because
1648+
// pushHostContext gets called before we process the state hook, to avoid
1649+
// a state mismatch in the event that something suspends.
1650+
//
1651+
// NOTE: This assumes that there cannot be nested transition providers,
1652+
// because the only renderer that implements this feature is React DOM,
1653+
// and forms cannot be nested. If we did support nested providers, then
1654+
// we would need to push a context value even for host fibers that
1655+
// haven't been upgraded yet.
1656+
if (isPrimaryRenderer) {
1657+
HostTransitionContext._currentValue = newState;
1658+
} else {
1659+
HostTransitionContext._currentValue2 = newState;
1660+
}
1661+
if (enableLazyContextPropagation) {
1662+
// In the lazy propagation implementation, we don't scan for matching
1663+
// consumers until something bails out.
1664+
} else {
1665+
if (didReceiveUpdate) {
1666+
if (current !== null) {
1667+
const oldStateHook: Hook = current.memoizedState;
1668+
const oldState: TransitionStatus = oldStateHook.memoizedState;
1669+
// This uses regular equality instead of Object.is because we assume
1670+
// that host transition state doesn't include NaN as a valid type.
1671+
if (oldState !== newState) {
1672+
propagateContextChange(
1673+
workInProgress,
1674+
HostTransitionContext,
1675+
renderLanes,
1676+
);
1677+
}
1678+
}
1679+
}
1680+
}
16401681
}
16411682
}
16421683

0 commit comments

Comments
 (0)