Skip to content

Commit 265aa9c

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 265aa9c

File tree

8 files changed

+296
-27
lines changed

8 files changed

+296
-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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ import {
148148
import type {ThenableState} from './ReactFiberThenable';
149149
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
150150
import {requestAsyncActionContext} from './ReactFiberAsyncAction';
151+
import {HostTransitionContext} from './ReactFiberHostContext';
151152

152153
const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
153154

@@ -2645,6 +2646,14 @@ function rerenderTransition(): [
26452646
return [isPending, start];
26462647
}
26472648

2649+
function useHostTransitionStatus(): TransitionStatus {
2650+
if (!(enableFormActions && enableAsyncActions)) {
2651+
throw new Error('Not implemented.');
2652+
}
2653+
const status: TransitionStatus | null = readContext(HostTransitionContext);
2654+
return status !== null ? status : NoPendingHostTransition;
2655+
}
2656+
26482657
function mountId(): string {
26492658
const hook = mountWorkInProgressHook();
26502659

@@ -2972,6 +2981,10 @@ if (enableUseMemoCacheHook) {
29722981
if (enableUseEffectEventHook) {
29732982
(ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError;
29742983
}
2984+
if (enableFormActions && enableAsyncActions) {
2985+
(ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
2986+
throwInvalidHookError;
2987+
}
29752988

29762989
const HooksDispatcherOnMount: Dispatcher = {
29772990
readContext,
@@ -3003,6 +3016,10 @@ if (enableUseMemoCacheHook) {
30033016
if (enableUseEffectEventHook) {
30043017
(HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent;
30053018
}
3019+
if (enableFormActions && enableAsyncActions) {
3020+
(HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
3021+
useHostTransitionStatus;
3022+
}
30063023
const HooksDispatcherOnUpdate: Dispatcher = {
30073024
readContext,
30083025

@@ -3033,6 +3050,10 @@ if (enableUseMemoCacheHook) {
30333050
if (enableUseEffectEventHook) {
30343051
(HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent;
30353052
}
3053+
if (enableFormActions && enableAsyncActions) {
3054+
(HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
3055+
useHostTransitionStatus;
3056+
}
30363057

30373058
const HooksDispatcherOnRerender: Dispatcher = {
30383059
readContext,
@@ -3064,6 +3085,10 @@ if (enableUseMemoCacheHook) {
30643085
if (enableUseEffectEventHook) {
30653086
(HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent;
30663087
}
3088+
if (enableFormActions && enableAsyncActions) {
3089+
(HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
3090+
useHostTransitionStatus;
3091+
}
30673092

30683093
let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
30693094
let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null;
@@ -3250,6 +3275,10 @@ if (__DEV__) {
32503275
return mountEvent(callback);
32513276
};
32523277
}
3278+
if (enableFormActions && enableAsyncActions) {
3279+
(HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
3280+
useHostTransitionStatus;
3281+
}
32533282

32543283
HooksDispatcherOnMountWithHookTypesInDEV = {
32553284
readContext<T>(context: ReactContext<T>): T {
@@ -3404,6 +3433,10 @@ if (__DEV__) {
34043433
return mountEvent(callback);
34053434
};
34063435
}
3436+
if (enableFormActions && enableAsyncActions) {
3437+
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus =
3438+
useHostTransitionStatus;
3439+
}
34073440

34083441
HooksDispatcherOnUpdateInDEV = {
34093442
readContext<T>(context: ReactContext<T>): T {
@@ -3560,6 +3593,10 @@ if (__DEV__) {
35603593
return updateEvent(callback);
35613594
};
35623595
}
3596+
if (enableFormActions && enableAsyncActions) {
3597+
(HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
3598+
useHostTransitionStatus;
3599+
}
35633600

35643601
HooksDispatcherOnRerenderInDEV = {
35653602
readContext<T>(context: ReactContext<T>): T {
@@ -3716,6 +3753,10 @@ if (__DEV__) {
37163753
return updateEvent(callback);
37173754
};
37183755
}
3756+
if (enableFormActions && enableAsyncActions) {
3757+
(HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
3758+
useHostTransitionStatus;
3759+
}
37193760

37203761
InvalidNestedHooksDispatcherOnMountInDEV = {
37213762
readContext<T>(context: ReactContext<T>): T {
@@ -3894,6 +3935,10 @@ if (__DEV__) {
38943935
return mountEvent(callback);
38953936
};
38963937
}
3938+
if (enableFormActions && enableAsyncActions) {
3939+
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
3940+
useHostTransitionStatus;
3941+
}
38973942

38983943
InvalidNestedHooksDispatcherOnUpdateInDEV = {
38993944
readContext<T>(context: ReactContext<T>): T {
@@ -4075,6 +4120,10 @@ if (__DEV__) {
40754120
return updateEvent(callback);
40764121
};
40774122
}
4123+
if (enableFormActions && enableAsyncActions) {
4124+
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
4125+
useHostTransitionStatus;
4126+
}
40784127

40794128
InvalidNestedHooksDispatcherOnRerenderInDEV = {
40804129
readContext<T>(context: ReactContext<T>): T {
@@ -4256,4 +4305,8 @@ if (__DEV__) {
42564305
return updateEvent(callback);
42574306
};
42584307
}
4308+
if (enableFormActions && enableAsyncActions) {
4309+
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
4310+
useHostTransitionStatus;
4311+
}
42594312
}

0 commit comments

Comments
 (0)