Skip to content

Commit 9b56dc2

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 ff44e60 commit 9b56dc2

File tree

8 files changed

+299
-27
lines changed

8 files changed

+299
-27
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: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -851,17 +851,53 @@ describe('ReactDOMForm', () => {
851851

852852
// @gate enableFormActions
853853
// @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.
854+
it('useFormStatus reads the status of a pending form action', async () => {
855+
const formRef = React.createRef();
856+
857+
function Status() {
858+
const {pending, data, action, method} = useFormStatus();
859+
if (!pending) {
860+
return <Text text="No pending action" />;
861+
} else {
862+
const foo = data.get('foo');
863+
return (
864+
<Text
865+
text={`Pending action ${action.name}: foo is ${foo}, method is ${method}`}
866+
/>
867+
);
868+
}
869+
}
870+
871+
async function myAction() {
872+
Scheduler.log('Async action started');
873+
await getText('Wait');
874+
Scheduler.log('Async action finished');
875+
}
857876

858877
function App() {
859-
const {pending} = useFormStatus();
860-
return 'Pending: ' + pending;
878+
return (
879+
<form action={myAction} ref={formRef}>
880+
<input type="text" name="foo" defaultValue="bar" />
881+
<Status />
882+
</form>
883+
);
861884
}
862885

863886
const root = ReactDOMClient.createRoot(container);
864887
await act(() => root.render(<App />));
865-
expect(container.textContent).toBe('Pending: false');
888+
assertLog(['No pending action']);
889+
expect(container.textContent).toBe('No pending action');
890+
891+
await submit(formRef.current);
892+
assertLog([
893+
'Async action started',
894+
'Pending action myAction: foo is bar, method is get',
895+
]);
896+
expect(container.textContent).toBe(
897+
'Pending action myAction: foo is bar, method is get',
898+
);
899+
900+
await act(() => resolveText('Wait'));
901+
assertLog(['Async action finished', 'No pending action']);
866902
});
867903
});

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

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ import {
148148
import type {ThenableState} from './ReactFiberThenable';
149149
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
150150
import {requestAsyncActionContext} from './ReactFiberAsyncAction';
151+
import {
152+
HostTransitionContext,
153+
getHostTransitionProvider,
154+
} from './ReactFiberHostContext';
151155

152156
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
153157

@@ -2645,6 +2649,14 @@ function rerenderTransition(): [
26452649
return [isPending, start];
26462650
}
26472651

2652+
function useHostTransitionStatus(): TransitionStatus {
2653+
if (!(enableFormActions && enableAsyncActions)) {
2654+
throw new Error('Not implemented.');
2655+
}
2656+
const status: TransitionStatus | null = readContext(HostTransitionContext);
2657+
return status !== null ? status : NoPendingHostTransition;
2658+
}
2659+
26482660
function mountId(): string {
26492661
const hook = mountWorkInProgressHook();
26502662

@@ -2972,6 +2984,10 @@ if (enableUseMemoCacheHook) {
29722984
if (enableUseEffectEventHook) {
29732985
(ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError;
29742986
}
2987+
if (enableFormActions && enableAsyncActions) {
2988+
(ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
2989+
throwInvalidHookError;
2990+
}
29752991

29762992
const HooksDispatcherOnMount: Dispatcher = {
29772993
readContext,
@@ -3003,6 +3019,10 @@ if (enableUseMemoCacheHook) {
30033019
if (enableUseEffectEventHook) {
30043020
(HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent;
30053021
}
3022+
if (enableFormActions && enableAsyncActions) {
3023+
(HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
3024+
useHostTransitionStatus;
3025+
}
30063026
const HooksDispatcherOnUpdate: Dispatcher = {
30073027
readContext,
30083028

@@ -3033,6 +3053,10 @@ if (enableUseMemoCacheHook) {
30333053
if (enableUseEffectEventHook) {
30343054
(HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent;
30353055
}
3056+
if (enableFormActions && enableAsyncActions) {
3057+
(HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
3058+
useHostTransitionStatus;
3059+
}
30363060

30373061
const HooksDispatcherOnRerender: Dispatcher = {
30383062
readContext,
@@ -3064,6 +3088,10 @@ if (enableUseMemoCacheHook) {
30643088
if (enableUseEffectEventHook) {
30653089
(HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent;
30663090
}
3091+
if (enableFormActions && enableAsyncActions) {
3092+
(HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
3093+
useHostTransitionStatus;
3094+
}
30673095

30683096
let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
30693097
let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null;
@@ -3250,6 +3278,10 @@ if (__DEV__) {
32503278
return mountEvent(callback);
32513279
};
32523280
}
3281+
if (enableFormActions && enableAsyncActions) {
3282+
(HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
3283+
useHostTransitionStatus;
3284+
}
32533285

32543286
HooksDispatcherOnMountWithHookTypesInDEV = {
32553287
readContext<T>(context: ReactContext<T>): T {
@@ -3404,6 +3436,10 @@ if (__DEV__) {
34043436
return mountEvent(callback);
34053437
};
34063438
}
3439+
if (enableFormActions && enableAsyncActions) {
3440+
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus =
3441+
useHostTransitionStatus;
3442+
}
34073443

34083444
HooksDispatcherOnUpdateInDEV = {
34093445
readContext<T>(context: ReactContext<T>): T {
@@ -3560,6 +3596,10 @@ if (__DEV__) {
35603596
return updateEvent(callback);
35613597
};
35623598
}
3599+
if (enableFormActions && enableAsyncActions) {
3600+
(HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
3601+
useHostTransitionStatus;
3602+
}
35633603

35643604
HooksDispatcherOnRerenderInDEV = {
35653605
readContext<T>(context: ReactContext<T>): T {
@@ -3716,6 +3756,10 @@ if (__DEV__) {
37163756
return updateEvent(callback);
37173757
};
37183758
}
3759+
if (enableFormActions && enableAsyncActions) {
3760+
(HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
3761+
useHostTransitionStatus;
3762+
}
37193763

37203764
InvalidNestedHooksDispatcherOnMountInDEV = {
37213765
readContext<T>(context: ReactContext<T>): T {
@@ -3894,6 +3938,10 @@ if (__DEV__) {
38943938
return mountEvent(callback);
38953939
};
38963940
}
3941+
if (enableFormActions && enableAsyncActions) {
3942+
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
3943+
useHostTransitionStatus;
3944+
}
38973945

38983946
InvalidNestedHooksDispatcherOnUpdateInDEV = {
38993947
readContext<T>(context: ReactContext<T>): T {
@@ -4075,6 +4123,10 @@ if (__DEV__) {
40754123
return updateEvent(callback);
40764124
};
40774125
}
4126+
if (enableFormActions && enableAsyncActions) {
4127+
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
4128+
useHostTransitionStatus;
4129+
}
40784130

40794131
InvalidNestedHooksDispatcherOnRerenderInDEV = {
40804132
readContext<T>(context: ReactContext<T>): T {
@@ -4256,4 +4308,8 @@ if (__DEV__) {
42564308
return updateEvent(callback);
42574309
};
42584310
}
4311+
if (enableFormActions && enableAsyncActions) {
4312+
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
4313+
useHostTransitionStatus;
4314+
}
42594315
}

0 commit comments

Comments
 (0)