From 9b56dc2af16d9350bf4142617ec0ac471d09538c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 25 Apr 2023 22:21:59 -0400 Subject: [PATCH] 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. --- .../src/shared/ReactDOMFormActions.js | 34 ++++++- .../src/__tests__/ReactDOMForm-test.js | 48 ++++++++-- .../src/ReactFiberBeginWork.js | 43 ++++++++- .../react-reconciler/src/ReactFiberHooks.js | 56 +++++++++++ .../src/ReactFiberHostContext.js | 94 +++++++++++++++---- .../src/ReactFiberNewContext.js | 35 +++++++ .../src/ReactInternalTypes.js | 4 + packages/react-server/src/ReactFizzHooks.js | 12 +++ 8 files changed, 299 insertions(+), 27 deletions(-) diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js index bd98a29c17b49..d716782e709d8 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js @@ -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, @@ -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(); } } diff --git a/packages/react-dom/src/__tests__/ReactDOMForm-test.js b/packages/react-dom/src/__tests__/ReactDOMForm-test.js index ba312e9dd66d9..f1d5884a7ae26 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -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 ; + } else { + const foo = data.get('foo'); + return ( + + ); + } + } + + 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 ( +
+ + + + ); } const root = ReactDOMClient.createRoot(container); await act(() => root.render()); - 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']); }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index ad408f474080f..f237a26f90706 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -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 { @@ -176,6 +178,7 @@ import { pushHostContext, pushHostContainer, getRootHostContainer, + HostTransitionContext, } from './ReactFiberHostContext'; import { suspenseStackCursor, @@ -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, + ); + } + } + } + } } } diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index bd9bf2c747b0e..a12e472f97ce9 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -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; @@ -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(); @@ -2972,6 +2984,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError; } +if (enableFormActions && enableAsyncActions) { + (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = + throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -3003,6 +3019,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent; } +if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, @@ -3033,6 +3053,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent; } +if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -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; @@ -3250,6 +3278,10 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -3404,6 +3436,10 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -3560,6 +3596,10 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -3716,6 +3756,10 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -3894,6 +3938,10 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -4075,6 +4123,10 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -4256,4 +4308,8 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } } diff --git a/packages/react-reconciler/src/ReactFiberHostContext.js b/packages/react-reconciler/src/ReactFiberHostContext.js index c5733b24543d2..c02ef1ae3a602 100644 --- a/packages/react-reconciler/src/ReactFiberHostContext.js +++ b/packages/react-reconciler/src/ReactFiberHostContext.js @@ -9,16 +9,51 @@ import type {Fiber} from './ReactInternalTypes'; import type {StackCursor} from './ReactFiberStack'; -import type {Container, HostContext} from './ReactFiberConfig'; - -import {getChildHostContext, getRootHostContext} from './ReactFiberConfig'; +import type { + Container, + HostContext, + TransitionStatus, +} from './ReactFiberConfig'; +import type {Hook} from './ReactFiberHooks'; +import type {ReactContext} from 'shared/ReactTypes'; + +import { + getChildHostContext, + getRootHostContext, + isPrimaryRenderer, +} from './ReactFiberConfig'; import {createCursor, push, pop} from './ReactFiberStack'; +import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols'; const contextStackCursor: StackCursor = createCursor(null); const contextFiberStackCursor: StackCursor = createCursor(null); const rootInstanceStackCursor: StackCursor = createCursor(null); +// Represents the nearest host transition provider (in React DOM, a
) +// NOTE: Since forms cannot be nested, and this feature is only implemented by +// React DOM, we don't technically need this to be a stack. It could be a single +// module variable instead. +const hostTransitionProviderCursor: StackCursor = + createCursor(null); + +// TODO: This should initialize to NotPendingTransition, a constant +// imported from the fiber config. However, because of a cycle in the module +// graph, that value isn't defined during this module's initialization. I can't +// think of a way to work around this without moving that value out of the +// fiber config. For now, the "no provider" case is handled when reading, +// inside useHostTransitionStatus. +export const HostTransitionContext: ReactContext = { + $$typeof: REACT_CONTEXT_TYPE, + _currentValue: null, + _currentValue2: null, + _threadCount: 0, + Provider: (null: any), + Consumer: (null: any), + _defaultValue: (null: any), + _globalName: (null: any), +}; + function requiredContext(c: Value | null): Value { if (__DEV__) { if (c === null) { @@ -40,6 +75,10 @@ function getRootHostContainer(): Container { return rootInstance; } +export function getHostTransitionProvider(): Fiber | null { + return hostTransitionProviderCursor.current; +} + function pushHostContainer(fiber: Fiber, nextRootInstance: Container): void { // Push current root instance onto the stack; // This allows us to reset root when portals are popped. @@ -72,29 +111,52 @@ function getHostContext(): HostContext { } function pushHostContext(fiber: Fiber): void { + const stateHook: Hook | null = fiber.memoizedState; + if (stateHook !== null) { + // Only provide context if this fiber has been upgraded by a host + // transition. We use the same optimization for regular host context below. + push(hostTransitionProviderCursor, fiber, fiber); + } + const context: HostContext = requiredContext(contextStackCursor.current); const nextContext = getChildHostContext(context, fiber.type); // Don't push this Fiber's context unless it's unique. - if (context === nextContext) { - return; + if (context !== nextContext) { + // Track the context and the Fiber that provided it. + // This enables us to pop only Fibers that provide unique contexts. + push(contextFiberStackCursor, fiber, fiber); + push(contextStackCursor, nextContext, fiber); } - - // Track the context and the Fiber that provided it. - // This enables us to pop only Fibers that provide unique contexts. - push(contextFiberStackCursor, fiber, fiber); - push(contextStackCursor, nextContext, fiber); } function popHostContext(fiber: Fiber): void { - // Do not pop unless this Fiber provided the current context. - // pushHostContext() only pushes Fibers that provide unique contexts. - if (contextFiberStackCursor.current !== fiber) { - return; + if (contextFiberStackCursor.current === fiber) { + // Do not pop unless this Fiber provided the current context. + // pushHostContext() only pushes Fibers that provide unique contexts. + pop(contextStackCursor, fiber); + pop(contextFiberStackCursor, fiber); } - pop(contextStackCursor, fiber); - pop(contextFiberStackCursor, fiber); + if (hostTransitionProviderCursor.current === fiber) { + // Do not pop unless this Fiber provided the current context. This is mostly + // a performance optimization, but conveniently it also prevents a potential + // data race where a host provider is upgraded (i.e. memoizedState becomes + // non-null) during a concurrent event. This is a bit of a flaw in the way + // we upgrade host components, but because we're accounting for it here, it + // should be fine. + pop(hostTransitionProviderCursor, fiber); + + // When popping the transition provider, we reset the context value back + // to `null`. We can do this because you're not allowd to nest forms. If + // we allowed for multiple nested host transition providers, then we'd + // need to reset this to the parent provider's status. + if (isPrimaryRenderer) { + HostTransitionContext._currentValue = null; + } else { + HostTransitionContext._currentValue2 = null; + } + } } export { diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js index 003022a9c6265..d4f4eb048e43b 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -16,6 +16,8 @@ import type { import type {StackCursor} from './ReactFiberStack'; import type {Lanes} from './ReactFiberLane'; import type {SharedQueue} from './ReactFiberClassUpdateQueue'; +import type {TransitionStatus} from './ReactFiberConfig'; +import type {Hook} from './ReactFiberHooks'; import {isPrimaryRenderer} from './ReactFiberConfig'; import {createCursor, push, pop} from './ReactFiberStack'; @@ -43,8 +45,14 @@ import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; import { enableLazyContextPropagation, enableServerContext, + enableFormActions, + enableAsyncActions, } from 'shared/ReactFeatureFlags'; import {REACT_SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED} from 'shared/ReactSymbols'; +import { + getHostTransitionProvider, + HostTransitionContext, +} from './ReactFiberHostContext'; const valueCursor: StackCursor = createCursor(null); @@ -585,6 +593,33 @@ function propagateParentContextChanges( } } } + } else if ( + enableFormActions && + enableAsyncActions && + parent === getHostTransitionProvider() + ) { + // During a host transition, a host component can act like a context + // provider. E.g. in React DOM, this would be a . + const currentParent = parent.alternate; + if (currentParent === null) { + throw new Error('Should have a current fiber. This is a bug in React.'); + } + + const oldStateHook: Hook = currentParent.memoizedState; + const oldState: TransitionStatus = oldStateHook.memoizedState; + + const newStateHook: Hook = parent.memoizedState; + const newState: TransitionStatus = newStateHook.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) { + if (contexts !== null) { + contexts.push(HostTransitionContext); + } else { + contexts = [HostTransitionContext]; + } + } } parent = parent.return; } diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 1421181cb8ea9..bc9cfd1fb307f 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -29,6 +29,7 @@ import type { TimeoutHandle, NoTimeout, SuspenseInstance, + TransitionStatus, } from './ReactFiberConfig'; import type {Cache} from './ReactFiberCacheComponent'; import type { @@ -421,6 +422,9 @@ export type Dispatcher = { useId(): string, useCacheRefresh?: () => (?() => T, ?T) => void, useMemoCache?: (size: number) => Array, + useHostTransitionStatus?: ( + initialStatus: TransitionStatus, + ) => TransitionStatus, }; export type CacheDispatcher = { diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 62e4586f74590..d212bd2349001 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -22,6 +22,7 @@ import type { import type {ResponseState} from './ReactFizzConfig'; import type {Task} from './ReactFizzServer'; import type {ThenableState} from './ReactFizzThenable'; +import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; import {readContext as readContextImpl} from './ReactFizzNewContext'; import {getTreeId} from './ReactFizzTreeContext'; @@ -33,6 +34,8 @@ import { enableCache, enableUseEffectEventHook, enableUseMemoCacheHook, + enableAsyncActions, + enableFormActions, } from 'shared/ReactFeatureFlags'; import is from 'shared/objectIs'; import { @@ -40,6 +43,7 @@ import { REACT_CONTEXT_TYPE, REACT_MEMO_CACHE_SENTINEL, } from 'shared/ReactSymbols'; +import {NotPending} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; type BasicStateAction = (S => S) | S; type Dispatch = A => void; @@ -545,6 +549,11 @@ function useTransition(): [ return [false, unsupportedStartTransition]; } +function useHostTransitionStatus(): FormStatus { + resolveCurrentlyRenderingComponent(); + return NotPending; +} + function useId(): string { const task: Task = (currentlyRenderingTask: any); const treeId = getTreeId(task.treeContext); @@ -641,6 +650,9 @@ if (enableUseEffectEventHook) { if (enableUseMemoCacheHook) { HooksDispatcher.useMemoCache = useMemoCache; } +if (enableFormActions && enableAsyncActions) { + HooksDispatcher.useHostTransitionStatus = useHostTransitionStatus; +} export let currentResponseState: null | ResponseState = (null: any); export function setCurrentResponseState(