Skip to content

Commit

Permalink
Implement experimental_useFormStatus
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite committed Apr 26, 2023
1 parent ff44e60 commit 9b56dc2
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 27 deletions.
34 changes: 30 additions & 4 deletions packages/react-dom-bindings/src/shared/ReactDOMFormActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
* @flow
*/

import type {Dispatcher} from 'react-reconciler/src/ReactInternalTypes';

import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';

const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;

type FormStatusNotPending = {|
pending: false,
Expand Down Expand Up @@ -38,13 +43,34 @@ export const NotPending: FormStatus = __DEV__
? Object.freeze(sharedNotPendingObject)
: sharedNotPendingObject;

function resolveDispatcher() {
// Copied from react/src/ReactHooks.js. It's the same thing but in a
// different package.
const dispatcher = ReactCurrentDispatcher.current;
if (__DEV__) {
if (dispatcher === null) {
console.error(
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.',
);
}
}
// Will result in a null access error if accessed outside render phase. We
// intentionally don't throw our own error because this is in a hot path.
// Also helps ensure this is inlined.
return ((dispatcher: any): Dispatcher);
}

export function useFormStatus(): FormStatus {
if (!(enableFormActions && enableAsyncActions)) {
throw new Error('Not implemented.');
} else {
// TODO: This isn't fully implemented yet but we return a correctly typed
// value so we can test that the API is exposed and gated correctly. The
// real implementation will access the status via the dispatcher.
return NotPending;
const dispatcher = resolveDispatcher();
// $FlowFixMe We know this exists because of the feature check above.
return dispatcher.useHostTransitionStatus();
}
}
48 changes: 42 additions & 6 deletions packages/react-dom/src/__tests__/ReactDOMForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -851,17 +851,53 @@ describe('ReactDOMForm', () => {

// @gate enableFormActions
// @gate enableAsyncActions
it('useFormStatus exists', async () => {
// This API isn't fully implemented yet. This just tests that it's wired
// up correctly.
it('useFormStatus reads the status of a pending form action', async () => {
const formRef = React.createRef();

function Status() {
const {pending, data, action, method} = useFormStatus();
if (!pending) {
return <Text text="No pending action" />;
} else {
const foo = data.get('foo');
return (
<Text
text={`Pending action ${action.name}: foo is ${foo}, method is ${method}`}
/>
);
}
}

async function myAction() {
Scheduler.log('Async action started');
await getText('Wait');
Scheduler.log('Async action finished');
}

function App() {
const {pending} = useFormStatus();
return 'Pending: ' + pending;
return (
<form action={myAction} ref={formRef}>
<input type="text" name="foo" defaultValue="bar" />
<Status />
</form>
);
}

const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
expect(container.textContent).toBe('Pending: false');
assertLog(['No pending action']);
expect(container.textContent).toBe('No pending action');

await submit(formRef.current);
assertLog([
'Async action started',
'Pending action myAction: foo is bar, method is get',
]);
expect(container.textContent).toBe(
'Pending action myAction: foo is bar, method is get',
);

await act(() => resolveText('Wait'));
assertLog(['Async action finished', 'No pending action']);
});
});
43 changes: 42 additions & 1 deletion packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import type {
import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
import type {RootState} from './ReactFiberRoot';
import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
import type {TransitionStatus} from './ReactFiberConfig';
import type {Hook} from './ReactFiberHooks';

import checkPropTypes from 'shared/checkPropTypes';
import {
Expand Down Expand Up @@ -176,6 +178,7 @@ import {
pushHostContext,
pushHostContainer,
getRootHostContainer,
HostTransitionContext,
} from './ReactFiberHostContext';
import {
suspenseStackCursor,
Expand Down Expand Up @@ -1632,11 +1635,49 @@ function updateHostComponent(
//
// Once a fiber is upgraded to be stateful, it remains stateful for the
// rest of its lifetime.
renderTransitionAwareHostComponentWithHooks(
const newState = renderTransitionAwareHostComponentWithHooks(
current,
workInProgress,
renderLanes,
);

// If the transition state changed, propagate the change to all the
// descendents. We use Context as an implementation detail for this.
//
// This is intentionally set here instead of pushHostContext because
// pushHostContext gets called before we process the state hook, to avoid
// a state mismatch in the event that something suspends.
//
// NOTE: This assumes that there cannot be nested transition providers,
// because the only renderer that implements this feature is React DOM,
// and forms cannot be nested. If we did support nested providers, then
// we would need to push a context value even for host fibers that
// haven't been upgraded yet.
if (isPrimaryRenderer) {
HostTransitionContext._currentValue = newState;
} else {
HostTransitionContext._currentValue2 = newState;
}
if (enableLazyContextPropagation) {
// In the lazy propagation implementation, we don't scan for matching
// consumers until something bails out.
} else {
if (didReceiveUpdate) {
if (current !== null) {
const oldStateHook: Hook = current.memoizedState;
const oldState: TransitionStatus = oldStateHook.memoizedState;
// This uses regular equality instead of Object.is because we assume
// that host transition state doesn't include NaN as a valid type.
if (oldState !== newState) {
propagateContextChange(
workInProgress,
HostTransitionContext,
renderLanes,
);
}
}
}
}
}
}

Expand Down
56 changes: 56 additions & 0 deletions packages/react-reconciler/src/ReactFiberHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ import {
import type {ThenableState} from './ReactFiberThenable';
import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent';
import {requestAsyncActionContext} from './ReactFiberAsyncAction';
import {
HostTransitionContext,
getHostTransitionProvider,
} from './ReactFiberHostContext';

const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;

Expand Down Expand Up @@ -2645,6 +2649,14 @@ function rerenderTransition(): [
return [isPending, start];
}

function useHostTransitionStatus(): TransitionStatus {
if (!(enableFormActions && enableAsyncActions)) {
throw new Error('Not implemented.');
}
const status: TransitionStatus | null = readContext(HostTransitionContext);
return status !== null ? status : NoPendingHostTransition;
}

function mountId(): string {
const hook = mountWorkInProgressHook();

Expand Down Expand Up @@ -2972,6 +2984,10 @@ if (enableUseMemoCacheHook) {
if (enableUseEffectEventHook) {
(ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError;
}
if (enableFormActions && enableAsyncActions) {
(ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
throwInvalidHookError;
}

const HooksDispatcherOnMount: Dispatcher = {
readContext,
Expand Down Expand Up @@ -3003,6 +3019,10 @@ if (enableUseMemoCacheHook) {
if (enableUseEffectEventHook) {
(HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent;
}
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}
const HooksDispatcherOnUpdate: Dispatcher = {
readContext,

Expand Down Expand Up @@ -3033,6 +3053,10 @@ if (enableUseMemoCacheHook) {
if (enableUseEffectEventHook) {
(HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent;
}
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}

const HooksDispatcherOnRerender: Dispatcher = {
readContext,
Expand Down Expand Up @@ -3064,6 +3088,10 @@ if (enableUseMemoCacheHook) {
if (enableUseEffectEventHook) {
(HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent;
}
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}

let HooksDispatcherOnMountInDEV: Dispatcher | null = null;
let HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher | null = null;
Expand Down Expand Up @@ -3250,6 +3278,10 @@ if (__DEV__) {
return mountEvent(callback);
};
}
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}

HooksDispatcherOnMountWithHookTypesInDEV = {
readContext<T>(context: ReactContext<T>): T {
Expand Down Expand Up @@ -3404,6 +3436,10 @@ if (__DEV__) {
return mountEvent(callback);
};
}
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}

HooksDispatcherOnUpdateInDEV = {
readContext<T>(context: ReactContext<T>): T {
Expand Down Expand Up @@ -3560,6 +3596,10 @@ if (__DEV__) {
return updateEvent(callback);
};
}
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}

HooksDispatcherOnRerenderInDEV = {
readContext<T>(context: ReactContext<T>): T {
Expand Down Expand Up @@ -3716,6 +3756,10 @@ if (__DEV__) {
return updateEvent(callback);
};
}
if (enableFormActions && enableAsyncActions) {
(HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}

InvalidNestedHooksDispatcherOnMountInDEV = {
readContext<T>(context: ReactContext<T>): T {
Expand Down Expand Up @@ -3894,6 +3938,10 @@ if (__DEV__) {
return mountEvent(callback);
};
}
if (enableFormActions && enableAsyncActions) {
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}

InvalidNestedHooksDispatcherOnUpdateInDEV = {
readContext<T>(context: ReactContext<T>): T {
Expand Down Expand Up @@ -4075,6 +4123,10 @@ if (__DEV__) {
return updateEvent(callback);
};
}
if (enableFormActions && enableAsyncActions) {
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}

InvalidNestedHooksDispatcherOnRerenderInDEV = {
readContext<T>(context: ReactContext<T>): T {
Expand Down Expand Up @@ -4256,4 +4308,8 @@ if (__DEV__) {
return updateEvent(callback);
};
}
if (enableFormActions && enableAsyncActions) {
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
useHostTransitionStatus;
}
}
Loading

0 comments on commit 9b56dc2

Please sign in to comment.