From 6e2db5fd361ccc9c4637050fdcaacd9a85f1923d Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 25 Apr 2023 20:51:52 -0400 Subject: [PATCH 1/4] Move ReactDOMFormActions into react-dom-bindings The DOM fiber config will import stuff from this module, so I think it makes sense for it to live in the bindings package, alongside the fiber config. --- .../src/shared}/ReactDOMFormActions.js | 0 packages/react-dom/src/client/ReactDOM.js | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/{react-dom/src => react-dom-bindings/src/shared}/ReactDOMFormActions.js (100%) diff --git a/packages/react-dom/src/ReactDOMFormActions.js b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js similarity index 100% rename from packages/react-dom/src/ReactDOMFormActions.js rename to packages/react-dom-bindings/src/shared/ReactDOMFormActions.js diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index f31dcee5bae1e..ec465dfaef14b 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -56,7 +56,7 @@ import { import Internals from '../ReactDOMSharedInternals'; export {prefetchDNS, preconnect, preload, preinit} from '../ReactDOMFloat'; -export {useFormStatus} from '../ReactDOMFormActions'; +export {useFormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; if (__DEV__) { if ( From 3b2f716ed66f39c3e69cb6bcad8f6c7e98655411 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 24 Apr 2023 19:16:44 -0400 Subject: [PATCH 2/4] Refactor host transitions to call useState directly The useTransition hook is a thin wrapper around useState; all it really does it map over the return value. We going to change the return value for form actions, anyway, so we should call useState directly instead. --- .../react-reconciler/src/ReactFiberHooks.js | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index f6576273aa71d..01b23a93eb578 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -776,8 +776,11 @@ export function TransitionAwareHostComponent(): boolean { return false; } const dispatcher = ReactCurrentDispatcher.current; - const [isPending] = dispatcher.useTransition(); - return isPending; + const [booleanOrThenable] = dispatcher.useState(); + return typeof booleanOrThenable === 'boolean' + ? booleanOrThenable + : // This will suspend until the async action scope has finished. + useThenable(booleanOrThenable); } export function checkDidRenderIdHook(): boolean { @@ -2545,8 +2548,8 @@ export function startHostTransition( // it was stateful all along so we can reuse most of the implementation // for function components and useTransition. // - // Create the initial hooks used by useTransition. This is essentially an - // inlined version of mountTransition. + // Create the state hook used by TransitionAwareHostComponent. This is + // essentially an inlined version of mountState. const queue: UpdateQueue< Thenable | boolean, Thenable | boolean, @@ -2569,22 +2572,8 @@ export function startHostTransition( (dispatchSetState.bind(null, formFiber, queue): any); setPending = queue.dispatch = dispatch; - // TODO: The only reason this second hook exists is to save a reference to - // the `dispatch` function. But we already store this on the state hook. So - // we can cheat and read it from there. Need to make this change to the - // regular `useTransition` implementation, too. - const transitionHook: Hook = { - memoizedState: dispatch, - baseState: null, - baseQueue: null, - queue: null, - next: null, - }; - - stateHook.next = transitionHook; - - // Add the initial list of hooks to both fiber alternates. The idea is that - // the fiber had these hooks all along. + // Add the state hook to both fiber alternates. The idea is that the fiber + // had this hook all along. formFiber.memoizedState = stateHook; const alternate = formFiber.alternate; if (alternate !== null) { @@ -2592,9 +2581,9 @@ export function startHostTransition( } } else { // This fiber was already upgraded to be stateful. - const transitionHook: Hook = formFiber.memoizedState.next; + const stateHook: Hook = formFiber.memoizedState; const dispatch: (Thenable | boolean) => void = - transitionHook.memoizedState; + stateHook.queue.dispatch; setPending = dispatch; } From 432bcfb69d7d650480125e2a4eba3824e6317917 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 24 Apr 2023 19:30:42 -0400 Subject: [PATCH 3/4] Parameterize pending state type of host transitions Updates the internals of async form actions so they can use a custom pending state type, instead of a boolean. I'm not sure this is how we'll end up doing it once optimistic state is implemented, but it fits with how we handle the isPending state of useTransition. The next step is to connect this to useFormStatus, which will read the value of the nearest pending form state using context. --- packages/react-art/src/ReactFiberConfigART.js | 2 + .../src/client/ReactFiberConfigDOM.js | 6 +++ .../src/events/ReactDOMEventReplaying.js | 2 +- .../events/plugins/FormActionEventPlugin.js | 24 ++++++++++- .../src/shared/ReactDOMFormActions.js | 2 +- .../src/ReactFiberConfigFabric.js | 3 ++ .../src/ReactFiberConfigNative.js | 3 ++ .../src/createReactNoop.js | 4 ++ .../react-reconciler/src/ReactFiberHooks.js | 42 +++++++++++-------- .../src/forks/ReactFiberConfig.custom.js | 2 + .../src/ReactFiberConfigTestHost.js | 3 ++ 11 files changed, 71 insertions(+), 22 deletions(-) diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index ace8b81a6e313..89c5489ebbca6 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -479,3 +479,5 @@ export function suspendInstance(type, props) {} export function waitForCommitToBeReady() { return null; } + +export const NotPendingTransition = null; diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 6886b0156203d..a0ca9013067ca 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -18,7 +18,9 @@ import type { } from 'react-reconciler/src/ReactTestSelectors'; import type {ReactScopeInstance} from 'shared/ReactTypes'; import type {AncestorInfoDev} from './validateDOMNesting'; +import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; +import {NotPending} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext'; import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities'; // TODO: Remove this deep import when we delete the legacy root API @@ -164,6 +166,8 @@ export type TimeoutHandle = TimeoutID; export type NoTimeout = -1; export type RendererInspectionConfig = $ReadOnly<{}>; +export type TransitionStatus = FormStatus; + type SelectionInformation = { focusedElem: null | HTMLElement, selectionRange: mixed, @@ -3448,3 +3452,5 @@ function insertStylesheetIntoRoot( } resource.state.loading |= Inserted; } + +export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js index 7960e6eced30d..a7846ab455214 100644 --- a/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js @@ -473,7 +473,7 @@ function replayUnblockedFormActions(formReplayingQueue: FormReplayingQueue) { // We're ready to replay this. Let's delete it from the queue. formReplayingQueue.splice(i, 3); i -= 3; - dispatchReplayedFormAction(formInst, submitterOrAction, formData); + dispatchReplayedFormAction(formInst, form, submitterOrAction, formData); // Continue without incrementing the index. continue; } diff --git a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js index f2800af5e12a5..d862ae678f38b 100644 --- a/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js +++ b/packages/react-dom-bindings/src/events/plugins/FormActionEventPlugin.js @@ -12,6 +12,7 @@ import type {DOMEventName} from '../DOMEventNames'; import type {DispatchQueue} from '../DOMPluginEventSystem'; import type {EventSystemFlags} from '../EventSystemFlags'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; +import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; import {getFiberCurrentPropsFromNode} from '../../client/ReactDOMComponentTree'; import {startHostTransition} from 'react-reconciler/src/ReactFiberReconciler'; @@ -98,7 +99,16 @@ function extractEvents( formData = new FormData(form); } - startHostTransition(formInst, action, formData); + const pendingState: FormStatus = { + pending: true, + data: formData, + method: form.method, + action: action, + }; + if (__DEV__) { + Object.freeze(pendingState); + } + startHostTransition(formInst, pendingState, action, formData); } dispatchQueue.push({ @@ -117,8 +127,18 @@ export {extractEvents}; export function dispatchReplayedFormAction( formInst: Fiber, + form: HTMLFormElement, action: FormData => void | Promise, formData: FormData, ): void { - startHostTransition(formInst, action, formData); + const pendingState: FormStatus = { + pending: true, + data: formData, + method: form.method, + action: action, + }; + if (__DEV__) { + Object.freeze(pendingState); + } + startHostTransition(formInst, pendingState, action, formData); } diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js index b5a763976d03f..bd98a29c17b49 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js @@ -34,7 +34,7 @@ const sharedNotPendingObject = { action: null, }; -const NotPending: FormStatus = __DEV__ +export const NotPending: FormStatus = __DEV__ ? Object.freeze(sharedNotPendingObject) : sharedNotPendingObject; diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabric.js b/packages/react-native-renderer/src/ReactFiberConfigFabric.js index 6722459a40a82..31c293728d264 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabric.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabric.js @@ -90,6 +90,7 @@ export type UpdatePayload = Object; export type TimeoutHandle = TimeoutID; export type NoTimeout = -1; +export type TransitionStatus = mixed; export type RendererInspectionConfig = $ReadOnly<{ // Deprecated. Replaced with getInspectorDataForViewAtPoint. @@ -489,3 +490,5 @@ export function suspendInstance(type: Type, props: Props): void {} export function waitForCommitToBeReady(): null { return null; } + +export const NotPendingTransition: TransitionStatus = null; diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index 4a1dffa1c63da..e88e7e370f72a 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -43,6 +43,7 @@ export type ChildSet = void; // Unused export type TimeoutHandle = TimeoutID; export type NoTimeout = -1; +export type TransitionStatus = mixed; export type RendererInspectionConfig = $ReadOnly<{ // Deprecated. Replaced with getInspectorDataForViewAtPoint. @@ -542,3 +543,5 @@ export function suspendInstance(type: Type, props: Props): void {} export function waitForCommitToBeReady(): null { return null; } + +export const NotPendingTransition: TransitionStatus = null; diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 0b38f9fd2e6c7..520b1278c5549 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -78,6 +78,8 @@ type SuspenseyCommitSubscription = { commit: null | (() => void), }; +export type TransitionStatus = mixed; + const NO_CONTEXT = {}; const UPPERCASE_CONTEXT = {}; const UPDATE_SIGNAL = {}; @@ -629,6 +631,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { }, waitForCommitToBeReady, + + NotPendingTransition: (null: TransitionStatus), }; const hostConfig = useMutation diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 01b23a93eb578..bd9bf2c747b0e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -27,7 +27,9 @@ import type { import type {Lanes, Lane} from './ReactFiberLane'; import type {HookFlags} from './ReactHookEffectTags'; import type {Flags} from './ReactFiberFlags'; +import type {TransitionStatus} from './ReactFiberConfig'; +import {NotPendingTransition as NoPendingHostTransition} from './ReactFiberConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { enableDebugTracing, @@ -757,9 +759,9 @@ export function renderTransitionAwareHostComponentWithHooks( current: Fiber | null, workInProgress: Fiber, lanes: Lanes, -): boolean { +): TransitionStatus { if (!(enableFormActions && enableAsyncActions)) { - return false; + throw new Error('Not implemented.'); } return renderWithHooks( current, @@ -771,16 +773,19 @@ export function renderTransitionAwareHostComponentWithHooks( ); } -export function TransitionAwareHostComponent(): boolean { +export function TransitionAwareHostComponent(): TransitionStatus { if (!(enableFormActions && enableAsyncActions)) { - return false; + throw new Error('Not implemented.'); } const dispatcher = ReactCurrentDispatcher.current; - const [booleanOrThenable] = dispatcher.useState(); - return typeof booleanOrThenable === 'boolean' - ? booleanOrThenable - : // This will suspend until the async action scope has finished. - useThenable(booleanOrThenable); + const [maybeThenable] = dispatcher.useState(); + if (typeof maybeThenable.then === 'function') { + const thenable: Thenable = (maybeThenable: any); + return useThenable(thenable); + } else { + const status: TransitionStatus = maybeThenable; + return status; + } } export function checkDidRenderIdHook(): boolean { @@ -2520,6 +2525,7 @@ function startTransition( export function startHostTransition( formFiber: Fiber, + pendingState: TransitionStatus, callback: F => mixed, formData: F, ): void { @@ -2551,24 +2557,24 @@ export function startHostTransition( // Create the state hook used by TransitionAwareHostComponent. This is // essentially an inlined version of mountState. const queue: UpdateQueue< - Thenable | boolean, - Thenable | boolean, + Thenable | TransitionStatus, + Thenable | TransitionStatus, > = { pending: null, lanes: NoLanes, dispatch: null, lastRenderedReducer: basicStateReducer, - lastRenderedState: false, + lastRenderedState: NoPendingHostTransition, }; const stateHook: Hook = { - memoizedState: false, - baseState: false, + memoizedState: NoPendingHostTransition, + baseState: NoPendingHostTransition, baseQueue: null, queue: queue, next: null, }; - const dispatch: (Thenable | boolean) => void = + const dispatch: (Thenable | TransitionStatus) => void = (dispatchSetState.bind(null, formFiber, queue): any); setPending = queue.dispatch = dispatch; @@ -2582,14 +2588,14 @@ export function startHostTransition( } else { // This fiber was already upgraded to be stateful. const stateHook: Hook = formFiber.memoizedState; - const dispatch: (Thenable | boolean) => void = + const dispatch: (Thenable | TransitionStatus) => void = stateHook.queue.dispatch; setPending = dispatch; } startTransition( - true, - false, + pendingState, + NoPendingHostTransition, setPending, // TODO: We can avoid this extra wrapper, somehow. Figure out layering // once more of this function is implemented. diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index ea895be643061..0efe7967b9919 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -38,6 +38,7 @@ export opaque type ChildSet = mixed; // eslint-disable-line no-undef export opaque type TimeoutHandle = mixed; // eslint-disable-line no-undef export opaque type NoTimeout = mixed; // eslint-disable-line no-undef export opaque type RendererInspectionConfig = mixed; // eslint-disable-line no-undef +export opaque type TransitionStatus = mixed; // eslint-disable-line no-undef export type EventResponder = any; export const getPublicInstance = $$$config.getPublicInstance; @@ -75,6 +76,7 @@ export const preloadInstance = $$$config.preloadInstance; export const startSuspendingCommit = $$$config.startSuspendingCommit; export const suspendInstance = $$$config.suspendInstance; export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady; +export const NotPendingTransition = $$$config.NotPendingTransition; // ------------------- // Microtasks diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index 2460160cce921..9f7c2eff39418 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -41,6 +41,7 @@ export type NoTimeout = -1; export type EventResponder = any; export type RendererInspectionConfig = $ReadOnly<{}>; +export type TransitionStatus = mixed; export * from 'react-reconciler/src/ReactFiberConfigWithNoPersistence'; export * from 'react-reconciler/src/ReactFiberConfigWithNoHydration'; @@ -343,3 +344,5 @@ export function suspendInstance(type: Type, props: Props): void {} export function waitForCommitToBeReady(): null { return null; } + +export const NotPendingTransition: TransitionStatus = null; From 127993552632c9f4bb4d6b32de53b5591384e3ec Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 25 Apr 2023 22:21:59 -0400 Subject: [PATCH 4/4] 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/server/ReactFizzConfigDOM.js | 7 ++ .../src/server/ReactFizzConfigDOMLegacy.js | 7 ++ .../src/shared/ReactDOMFormActions.js | 34 ++++++- .../src/__tests__/ReactDOMForm-test.js | 86 +++++++++++++--- .../src/server/ReactFizzConfigNative.js | 3 + .../src/ReactFiberBeginWork.js | 43 +++++++- .../react-reconciler/src/ReactFiberHooks.js | 53 ++++++++++ .../src/ReactFiberHostContext.js | 99 ++++++++++++++++--- .../src/ReactFiberNewContext.js | 35 +++++++ .../src/ReactInternalTypes.js | 4 + packages/react-server/src/ReactFizzHooks.js | 13 ++- .../src/forks/ReactFizzConfig.custom.js | 3 + 12 files changed, 351 insertions(+), 36 deletions(-) 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..f94b1eebc1b24 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -24,6 +24,7 @@ // really an argument to a top-level wrapping function. import type {Request} from 'react-server/src/ReactFizzServer'; +import type {TransitionStatus} from 'react-reconciler/src/ReactFiberConfig'; declare var $$$config: any; export opaque type Destination = mixed; // eslint-disable-line no-undef @@ -32,6 +33,7 @@ export opaque type Resources = mixed; export opaque type BoundaryResources = mixed; export opaque type FormatContext = mixed; export opaque type SuspenseBoundaryID = mixed; +export type {TransitionStatus}; export const isPrimaryRenderer = false; @@ -74,6 +76,7 @@ export const writeCompletedBoundaryInstruction = export const writeClientRenderBoundaryInstruction = $$$config.writeClientRenderBoundaryInstruction; export const prepareHostDispatcher = $$$config.prepareHostDispatcher; +export const NotPendingTransition = $$$config.NotPendingTransition; // ------------------------- // Resources