diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 10a49b447d9e0..0e4bf4669ec8d 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -31,6 +31,8 @@ import type { PrecomputedChunk, } from 'react-server/src/ReactServerStreamConfig'; +import type {FormStatus} from '../shared/ReactDOMFormActions'; + import { writeChunk, writeChunkAndReturn, @@ -82,6 +84,8 @@ import { describeDifferencesForPreloadOverImplicitPreload, } from '../shared/ReactDOMResourceValidation'; +import {NotPending} from '../shared/ReactDOMFormActions'; + import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher; @@ -5562,3 +5566,6 @@ function getAsResourceDEV( ); } } + +export type TransitionStatus = FormStatus; +export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index 4feafb782dae6..474921d69a5e5 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -31,6 +31,10 @@ import type { PrecomputedChunk, } from 'react-server/src/ReactServerStreamConfig'; +import type {FormStatus} from '../shared/ReactDOMFormActions'; + +import {NotPending} from '../shared/ReactDOMFormActions'; + export const isPrimaryRenderer = false; export type ResponseState = { @@ -226,3 +230,6 @@ export function writeEndClientRenderedSuspenseBoundary( } return writeEndClientRenderedSuspenseBoundaryImpl(destination, responseState); } + +export type TransitionStatus = FormStatus; +export const NotPendingTransition: TransitionStatus = NotPending; 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..50ef3d0212875 100644 --- a/packages/react-dom/src/__tests__/ReactDOMForm-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMForm-test.js @@ -647,10 +647,16 @@ describe('ReactDOMForm', () => { it('form actions are transitions', async () => { const formRef = React.createRef(); + function Status() { + const {pending} = useFormStatus(); + return pending ? : null; + } + function App() { const [state, setState] = useState('Initial'); return (
setState('Updated')} ref={formRef}> + }> @@ -667,8 +673,8 @@ describe('ReactDOMForm', () => { // This should suspend because form actions are implicitly wrapped // in startTransition. await submit(formRef.current); - assertLog(['Suspend! [Updated]', 'Loading...']); - expect(container.textContent).toBe('Initial'); + assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']); + expect(container.textContent).toBe('Pending...Initial'); await act(() => resolveText('Updated')); assertLog(['Updated']); @@ -680,10 +686,16 @@ describe('ReactDOMForm', () => { it('multiple form actions', async () => { const formRef = React.createRef(); + function Status() { + const {pending} = useFormStatus(); + return pending ? : null; + } + function App() { const [state, setState] = useState(0); return ( setState(n => n + 1)} ref={formRef}> + }> @@ -699,8 +711,8 @@ describe('ReactDOMForm', () => { // Update await submit(formRef.current); - assertLog(['Suspend! [Count: 1]', 'Loading...']); - expect(container.textContent).toBe('Count: 0'); + assertLog(['Pending...', 'Suspend! [Count: 1]', 'Loading...']); + expect(container.textContent).toBe('Pending...Count: 0'); await act(() => resolveText('Count: 1')); assertLog(['Count: 1']); @@ -708,8 +720,8 @@ describe('ReactDOMForm', () => { // Update again await submit(formRef.current); - assertLog(['Suspend! [Count: 2]', 'Loading...']); - expect(container.textContent).toBe('Count: 1'); + assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']); + expect(container.textContent).toBe('Pending...Count: 1'); await act(() => resolveText('Count: 2')); assertLog(['Count: 2']); @@ -720,6 +732,11 @@ describe('ReactDOMForm', () => { it('form actions can be asynchronous', async () => { const formRef = React.createRef(); + function Status() { + const {pending} = useFormStatus(); + return pending ? : null; + } + function App() { const [state, setState] = useState('Initial'); return ( @@ -730,6 +747,7 @@ describe('ReactDOMForm', () => { startTransition(() => setState('Updated')); }} ref={formRef}> + }> @@ -744,11 +762,15 @@ describe('ReactDOMForm', () => { expect(container.textContent).toBe('Initial'); await submit(formRef.current); - assertLog(['Async action started']); + assertLog(['Async action started', 'Pending...']); await act(() => resolveText('Wait')); assertLog(['Suspend! [Updated]', 'Loading...']); - expect(container.textContent).toBe('Initial'); + expect(container.textContent).toBe('Pending...Initial'); + + await act(() => resolveText('Updated')); + assertLog(['Updated']); + expect(container.textContent).toBe('Updated'); }); it('sync errors in form actions can be captured by an error boundary', async () => { @@ -851,17 +873,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-native-renderer/src/server/ReactFizzConfigNative.js b/packages/react-native-renderer/src/server/ReactFizzConfigNative.js index 4e348d1e8ac20..61f746b4b987d 100644 --- a/packages/react-native-renderer/src/server/ReactFizzConfigNative.js +++ b/packages/react-native-renderer/src/server/ReactFizzConfigNative.js @@ -354,3 +354,6 @@ export function writeResourcesForBoundary( ): boolean { return true; } + +export type TransitionStatus = mixed; +export const NotPendingTransition: TransitionStatus = null; 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..10ae6565e6b2f 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -148,6 +148,7 @@ import { import type {ThenableState} from './ReactFiberThenable'; import type {BatchConfigTransition} from './ReactFiberTracingMarkerComponent'; import {requestAsyncActionContext} from './ReactFiberAsyncAction'; +import {HostTransitionContext} from './ReactFiberHostContext'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -2645,6 +2646,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 +2981,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError; } +if (enableFormActions && enableAsyncActions) { + (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = + throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -3003,6 +3016,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent; } +if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, @@ -3033,6 +3050,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent; } +if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -3064,6 +3085,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 +3275,10 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -3404,6 +3433,10 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -3560,6 +3593,10 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -3716,6 +3753,10 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -3894,6 +3935,10 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -4075,6 +4120,10 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -4256,4 +4305,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..d909002dc97f2 100644 --- a/packages/react-reconciler/src/ReactFiberHostContext.js +++ b/packages/react-reconciler/src/ReactFiberHostContext.js @@ -9,16 +9,52 @@ 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'; +import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags'; 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 +76,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 +112,56 @@ function getHostContext(): HostContext { } function pushHostContext(fiber: Fiber): void { + if (enableFormActions && enableAsyncActions) { + 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 (enableFormActions && enableAsyncActions) { + 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..332c1721d5e6e 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -22,17 +22,20 @@ import type { import type {ResponseState} from './ReactFizzConfig'; import type {Task} from './ReactFizzServer'; import type {ThenableState} from './ReactFizzThenable'; +import type {TransitionStatus} from './ReactFizzConfig'; import {readContext as readContextImpl} from './ReactFizzNewContext'; import {getTreeId} from './ReactFizzTreeContext'; import {createThenableState, trackUsedThenable} from './ReactFizzThenable'; -import {makeId} from './ReactFizzConfig'; +import {makeId, NotPendingTransition} from './ReactFizzConfig'; import { enableCache, enableUseEffectEventHook, enableUseMemoCacheHook, + enableAsyncActions, + enableFormActions, } from 'shared/ReactFeatureFlags'; import is from 'shared/objectIs'; import { @@ -545,6 +548,11 @@ function useTransition(): [ return [false, unsupportedStartTransition]; } +function useHostTransitionStatus(): TransitionStatus { + resolveCurrentlyRenderingComponent(); + return NotPendingTransition; +} + function useId(): string { const task: Task = (currentlyRenderingTask: any); const treeId = getTreeId(task.treeContext); @@ -641,6 +649,9 @@ if (enableUseEffectEventHook) { if (enableUseMemoCacheHook) { HooksDispatcher.useMemoCache = useMemoCache; } +if (enableFormActions && enableAsyncActions) { + HooksDispatcher.useHostTransitionStatus = useHostTransitionStatus; +} export let currentResponseState: null | ResponseState = (null: any); export function setCurrentResponseState( diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index 4b44462d9d412..1c55f57a00e3d 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -32,6 +32,7 @@ export opaque type Resources = mixed; export opaque type BoundaryResources = mixed; export opaque type FormatContext = mixed; export opaque type SuspenseBoundaryID = mixed; +export opaque type TransitionStatus = mixed; export const isPrimaryRenderer = false; @@ -74,6 +75,7 @@ export const writeCompletedBoundaryInstruction = export const writeClientRenderBoundaryInstruction = $$$config.writeClientRenderBoundaryInstruction; export const prepareHostDispatcher = $$$config.prepareHostDispatcher; +export const NotPendingTransition = $$$config.NotPendingTransition; // ------------------------- // Resources