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/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 new file mode 100644 index 0000000000000..d716782e709d8 --- /dev/null +++ b/packages/react-dom-bindings/src/shared/ReactDOMFormActions.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @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, + data: null, + method: null, + action: null, +|}; + +type FormStatusPending = {| + pending: true, + data: FormData, + method: string, + action: string | (FormData => void | Promise), +|}; + +export type FormStatus = FormStatusPending | FormStatusNotPending; + +// Since the "not pending" value is always the same, we can reuse the +// same object across all transitions. +const sharedNotPendingObject = { + pending: false, + data: null, + method: null, + action: null, +}; + +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 { + const dispatcher = resolveDispatcher(); + // $FlowFixMe We know this exists because of the feature check above. + return dispatcher.useHostTransitionStatus(); + } +} diff --git a/packages/react-dom/src/ReactDOMFormActions.js b/packages/react-dom/src/ReactDOMFormActions.js deleted file mode 100644 index b5a763976d03f..0000000000000 --- a/packages/react-dom/src/ReactDOMFormActions.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import {enableAsyncActions, enableFormActions} from 'shared/ReactFeatureFlags'; - -type FormStatusNotPending = {| - pending: false, - data: null, - method: null, - action: null, -|}; - -type FormStatusPending = {| - pending: true, - data: FormData, - method: string, - action: string | (FormData => void | Promise), -|}; - -export type FormStatus = FormStatusPending | FormStatusNotPending; - -// Since the "not pending" value is always the same, we can reuse the -// same object across all transitions. -const sharedNotPendingObject = { - pending: false, - data: null, - method: null, - action: null, -}; - -const NotPending: FormStatus = __DEV__ - ? Object.freeze(sharedNotPendingObject) - : sharedNotPendingObject; - -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; - } -} 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-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 ( 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-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-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/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 f6576273aa71d..10ae6565e6b2f 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, @@ -146,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; @@ -757,9 +760,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,13 +774,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 [isPending] = dispatcher.useTransition(); - return isPending; + 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 { @@ -2517,6 +2526,7 @@ function startTransition( export function startHostTransition( formFiber: Fiber, + pendingState: TransitionStatus, callback: F => mixed, formData: F, ): void { @@ -2545,46 +2555,32 @@ 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, + 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; - // 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,15 +2588,15 @@ export function startHostTransition( } } else { // This fiber was already upgraded to be stateful. - const transitionHook: Hook = formFiber.memoizedState.next; - const dispatch: (Thenable | boolean) => void = - transitionHook.memoizedState; + const stateHook: Hook = formFiber.memoizedState; + 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. @@ -2650,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(); @@ -2977,6 +2981,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError; } +if (enableFormActions && enableAsyncActions) { + (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = + throwInvalidHookError; +} const HooksDispatcherOnMount: Dispatcher = { readContext, @@ -3008,6 +3016,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent; } +if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; +} const HooksDispatcherOnUpdate: Dispatcher = { readContext, @@ -3038,6 +3050,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent; } +if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; +} const HooksDispatcherOnRerender: Dispatcher = { readContext, @@ -3069,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; @@ -3255,6 +3275,10 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } HooksDispatcherOnMountWithHookTypesInDEV = { readContext(context: ReactContext): T { @@ -3409,6 +3433,10 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } HooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -3565,6 +3593,10 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } HooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -3721,6 +3753,10 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } InvalidNestedHooksDispatcherOnMountInDEV = { readContext(context: ReactContext): T { @@ -3899,6 +3935,10 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } InvalidNestedHooksDispatcherOnUpdateInDEV = { readContext(context: ReactContext): T { @@ -4080,6 +4120,10 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableFormActions && enableAsyncActions) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = + useHostTransitionStatus; + } InvalidNestedHooksDispatcherOnRerenderInDEV = { readContext(context: ReactContext): T { @@ -4261,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-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-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 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;