diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.js b/packages/react-reconciler/src/ReactFiberClassComponent.js index 54622014870a54..0a4666040eaaab 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.js @@ -19,8 +19,7 @@ import { warnAboutDeprecatedLifecycles, } from 'shared/ReactFeatureFlags'; import ReactStrictModeWarnings from './ReactStrictModeWarnings'; -import {isMounted} from 'react-reconciler/reflection'; -import {get as getInstance, set as setInstance} from 'shared/ReactInstanceMap'; +import {set as setInstance} from 'shared/ReactInstanceMap'; import shallowEqual from 'shared/shallowEqual'; import getComponentName from 'shared/getComponentName'; import invariant from 'shared/invariant'; @@ -31,13 +30,9 @@ import {resolveDefaultProps} from './ReactFiberLazyComponent'; import {StrictMode} from './ReactTypeOfMode'; import { - enqueueUpdate, processUpdateQueue, checkHasForceUpdateAfterProcessing, resetHasForceUpdateBeforeProcessing, - createUpdate, - ReplaceState, - ForceUpdate, initializeUpdateQueue, cloneUpdateQueue, } from './ReactUpdateQueue'; @@ -50,12 +45,7 @@ import { emptyContextObject, } from './ReactFiberContext'; import {readContext} from './ReactFiberNewContext'; -import { - requestCurrentTimeForUpdate, - computeExpirationForFiber, - scheduleWork, -} from './ReactFiberWorkLoop'; -import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; +import {classComponentUpdater} from './ReactFiberTransition'; const fakeInternalInstance = {}; const isArray = Array.isArray; @@ -70,7 +60,6 @@ let didWarnAboutGetSnapshotBeforeUpdateWithoutDidUpdate; let didWarnAboutLegacyLifecyclesAndDerivedState; let didWarnAboutUndefinedDerivedState; let warnOnUndefinedDerivedState; -let warnOnInvalidCallback; let didWarnAboutDirectlyAssigningPropsToState; let didWarnAboutContextTypeAndContextTypes; let didWarnAboutInvalidateContextType; @@ -85,24 +74,6 @@ if (__DEV__) { didWarnAboutContextTypeAndContextTypes = new Set(); didWarnAboutInvalidateContextType = new Set(); - const didWarnOnInvalidCallback = new Set(); - - warnOnInvalidCallback = function(callback: mixed, callerName: string) { - if (callback === null || typeof callback === 'function') { - return; - } - const key = `${callerName}_${(callback: any)}`; - if (!didWarnOnInvalidCallback.has(key)) { - didWarnOnInvalidCallback.add(key); - console.error( - '%s(...): Expected the last optional `callback` argument to be a ' + - 'function. Instead received: %s.', - callerName, - callback, - ); - } - }; - warnOnUndefinedDerivedState = function(type, partialState) { if (partialState === undefined) { const componentName = getComponentName(type) || 'Component'; @@ -178,79 +149,6 @@ export function applyDerivedStateFromProps( } } -const classComponentUpdater = { - isMounted, - enqueueSetState(inst, payload, callback) { - const fiber = getInstance(inst); - const currentTime = requestCurrentTimeForUpdate(); - const suspenseConfig = requestCurrentSuspenseConfig(); - const expirationTime = computeExpirationForFiber( - currentTime, - fiber, - suspenseConfig, - ); - - const update = createUpdate(currentTime, expirationTime, suspenseConfig); - update.payload = payload; - if (callback !== undefined && callback !== null) { - if (__DEV__) { - warnOnInvalidCallback(callback, 'setState'); - } - update.callback = callback; - } - - enqueueUpdate(fiber, update); - scheduleWork(fiber, expirationTime); - }, - enqueueReplaceState(inst, payload, callback) { - const fiber = getInstance(inst); - const currentTime = requestCurrentTimeForUpdate(); - const suspenseConfig = requestCurrentSuspenseConfig(); - const expirationTime = computeExpirationForFiber( - currentTime, - fiber, - suspenseConfig, - ); - - const update = createUpdate(currentTime, expirationTime, suspenseConfig); - update.tag = ReplaceState; - update.payload = payload; - - if (callback !== undefined && callback !== null) { - if (__DEV__) { - warnOnInvalidCallback(callback, 'replaceState'); - } - update.callback = callback; - } - - enqueueUpdate(fiber, update); - scheduleWork(fiber, expirationTime); - }, - enqueueForceUpdate(inst, callback) { - const fiber = getInstance(inst); - const currentTime = requestCurrentTimeForUpdate(); - const suspenseConfig = requestCurrentSuspenseConfig(); - const expirationTime = computeExpirationForFiber( - currentTime, - fiber, - suspenseConfig, - ); - - const update = createUpdate(currentTime, expirationTime, suspenseConfig); - update.tag = ForceUpdate; - - if (callback !== undefined && callback !== null) { - if (__DEV__) { - warnOnInvalidCallback(callback, 'forceUpdate'); - } - update.callback = callback; - } - - enqueueUpdate(fiber, update); - scheduleWork(fiber, expirationTime); - }, -}; - function checkShouldComponentUpdate( workInProgress, ctor, diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index aabb6b7ed890f5..3ef9a20c997f4d 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -19,7 +19,6 @@ import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import type {TransitionInstance} from './ReactFiberTransition'; import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; -import {preventIntermediateStates} from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import {NoWork, Sync} from './ReactFiberExpirationTime'; @@ -37,12 +36,7 @@ import { MountPassive, } from './ReactHookEffectTags'; import { - scheduleWork, - computeExpirationForFiber, - requestCurrentTimeForUpdate, warnIfNotCurrentlyActingEffectsInDEV, - warnIfNotCurrentlyActingUpdatesInDev, - warnIfNotScopedWithMatchingAct, markRenderEventTimeAndConfig, markUnprocessedUpdateTime, } from './ReactFiberWorkLoop'; @@ -51,13 +45,7 @@ import invariant from 'shared/invariant'; import getComponentName from 'shared/getComponentName'; import is from 'shared/objectIs'; import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; -import {SuspendOnTask} from './ReactFiberThrow'; -import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; -import { - startTransition, - requestCurrentTransition, - cancelPendingTransition, -} from './ReactFiberTransition'; +import {dispatch, startTransition} from './ReactFiberTransition'; import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration'; const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; @@ -104,7 +92,7 @@ export type Dispatcher = {| ): [(() => void) => void, boolean], |}; -type Update = {| +export type Update = {| // TODO: Temporary field. Will remove this by storing a map of // transition -> start time on the root. eventTime: ExpirationTime, @@ -117,7 +105,7 @@ type Update = {| priority?: ReactPriorityLevel, |}; -type UpdateQueue = {| +export type UpdateQueue = {| pending: Update | null, dispatch: (A => mixed) | null, pendingTransition: TransitionInstance | null, @@ -501,6 +489,35 @@ export function bailoutHooks( } } +export function requestRenderPhaseUpdate( + fiber: Fiber, + action: A, +): Update | null { + if ( + currentlyRenderingFiber === fiber || + (currentlyRenderingFiber !== null && + currentlyRenderingFiber.alternate === fiber) + ) { + currentlyRenderingFiber.expirationTime = renderExpirationTime; + didScheduleRenderPhaseUpdate = true; + + const renderPhaseUpdate: Update = { + eventTime: NoWork, + expirationTime: renderExpirationTime, + suspenseConfig: null, + action, + eagerReducer: null, + eagerState: null, + next: (null: any), + }; + if (__DEV__) { + renderPhaseUpdate.priority = getCurrentPriorityLevel(); + } + return renderPhaseUpdate; + } + return null; +} + export function resetHooksAfterThrow(): void { // We can assume the previous dispatcher is always this one, since we set it // at the beginning of the render phase and there's no re-entrancy. @@ -653,12 +670,12 @@ function mountReducer( lastRenderedReducer: reducer, lastRenderedState: (initialState: any), }); - const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( + const boundDispatch: Dispatch = (queue.dispatch = (dispatch.bind( null, currentlyRenderingFiber, queue, ): any)); - return [hook.memoizedState, dispatch]; + return [hook.memoizedState, boundDispatch]; } function updateReducer( @@ -705,8 +722,6 @@ function updateReducer( let newBaseQueueFirst = null; let newBaseQueueLast = null; let update = first; - let lastProcessedTransitionTime = NoWork; - let lastSkippedTransitionTime = NoWork; do { const suspenseConfig = update.suspenseConfig; const updateExpirationTime = update.expirationTime; @@ -716,8 +731,8 @@ function updateReducer( // update/state. const clone: Update = { eventTime: update.eventTime, - expirationTime: update.expirationTime, - suspenseConfig: update.suspenseConfig, + expirationTime: updateExpirationTime, + suspenseConfig: suspenseConfig, action: update.action, eagerReducer: update.eagerReducer, eagerState: update.eagerState, @@ -734,16 +749,6 @@ function updateReducer( currentlyRenderingFiber.expirationTime = updateExpirationTime; markUnprocessedUpdateTime(updateExpirationTime); } - - if (suspenseConfig !== null) { - // This update is part of a transition - if ( - lastSkippedTransitionTime === NoWork || - lastSkippedTransitionTime > updateExpirationTime - ) { - lastSkippedTransitionTime = updateExpirationTime; - } - } } else { // This update does have sufficient priority. @@ -778,31 +783,10 @@ function updateReducer( const action = update.action; newState = reducer(newState, action); } - - if (suspenseConfig !== null) { - // This update is part of a transition - if ( - lastProcessedTransitionTime === NoWork || - lastProcessedTransitionTime > updateExpirationTime - ) { - lastProcessedTransitionTime = updateExpirationTime; - } - } } update = update.next; } while (update !== null && update !== first); - if ( - preventIntermediateStates && - lastProcessedTransitionTime !== NoWork && - lastSkippedTransitionTime !== NoWork - ) { - // There are multiple updates scheduled on this queue, but only some of - // them were processed. To avoid showing an intermediate state, abort - // the current render and restart at a level that includes them all. - throw new SuspendOnTask(lastSkippedTransitionTime); - } - if (newBaseQueueLast === null) { newBaseState = newState; } else { @@ -822,8 +806,8 @@ function updateReducer( queue.lastRenderedState = newState; } - const dispatch: Dispatch = (queue.dispatch: any); - return [hook.memoizedState, dispatch]; + const dispatchMethod: Dispatch = (queue.dispatch: any); + return [hook.memoizedState, dispatchMethod]; } function rerenderReducer( @@ -842,7 +826,7 @@ function rerenderReducer( // This is a re-render. Apply the new render phase updates to the previous // work-in-progress hook. - const dispatch: Dispatch = (queue.dispatch: any); + const dispatchMethod: Dispatch = (queue.dispatch: any); const lastRenderPhaseUpdate = queue.pending; let newState = hook.memoizedState; if (lastRenderPhaseUpdate !== null) { @@ -877,7 +861,7 @@ function rerenderReducer( queue.lastRenderedState = newState; } - return [newState, dispatch]; + return [newState, dispatchMethod]; } function mountState( @@ -895,14 +879,14 @@ function mountState( lastRenderedReducer: basicStateReducer, lastRenderedState: (initialState: any), }); - const dispatch: Dispatch< + const dispatchMethod: Dispatch< BasicStateAction, - > = (queue.dispatch = (dispatchAction.bind( + > = (queue.dispatch = (dispatch.bind( null, currentlyRenderingFiber, queue, ): any)); - return [hook.memoizedState, dispatch]; + return [hook.memoizedState, dispatchMethod]; } function updateState( @@ -1253,7 +1237,9 @@ function mountTransition( const hook = mountWorkInProgressHook(); const fiber = ((currentlyRenderingFiber: any): Fiber); const instance: TransitionInstance = { - pendingExpirationTime: NoWork, + version: 0, + pendingTime: NoWork, + resolvedTime: NoWork, fiber, }; // TODO: Intentionally storing this on the queue field to avoid adding a new/ @@ -1270,12 +1256,9 @@ function mountTransition( config, ]); - const resolvedExpirationTime = NoWork; hook.memoizedState = { isPending, - - // Represents the last processed expiration time. - resolvedExpirationTime, + version: 0, }; return [start, isPending]; @@ -1286,83 +1269,72 @@ function updateTransition( ): [(() => void) => void, boolean] { const hook = updateWorkInProgressHook(); - const instance: TransitionInstance = (hook.queue: any); - - const pendingExpirationTime = instance.pendingExpirationTime; const oldState = hook.memoizedState; - const oldIsPending = oldState.isPending; - const oldResolvedExpirationTime = oldState.resolvedExpirationTime; - - // Check if the most recent transition is pending. The following logic is - // a little confusing, but it conceptually maps to same logic used to process - // state update queues (see: updateReducer). We're cheating a bit because - // we know that there is only ever a single pending transition, and the last - // one always wins. So we don't need to maintain an actual queue of updates; - // we only need to track 1) which is the most recent pending level 2) did - // we already resolve - // - // Note: This could be even simpler if we used a commit effect to mark when a - // pending transition is resolved. The cleverness that follows is meant to - // avoid the overhead of an extra effect; however, if this ends up being *too* - // clever, an effect probably isn't that bad, since it would only fire once - // per transition. - let newIsPending; - let newResolvedExpirationTime; + const oldVersion = oldState.version; - if (pendingExpirationTime === NoWork) { - // There are no pending transitions. Reset all fields. + const instance: TransitionInstance = (hook.queue: any); + const newVersion = instance.version; + + // Check if the most recent transition is pending. The following logic is a + // little confusing, but it conceptually maps to same logic used to process + // state update queues (see: updateReducer). We're cheating a bit because we + // know that there is only ever a single pending transition, and the last one + // always wins. So we don't need to maintain an actual queue of updates; we + // only need to track 1) the level at which the most recent transition is + // pending 2) the level at which it resolves 3) a version number that gets + // bumps for each new transition. The version stored in state is only updated + // once the transition has fully resolved. + // TODO: This is maaaaaybe too clever. I think it works, but we can go back to + // a queue if needed. + let newIsPending; + if (oldVersion === newVersion) { + // Already resolved newIsPending = false; - newResolvedExpirationTime = NoWork; + if (instance.pendingTime !== NoWork) { + // The resolved state already committed, so we can reset these fields. + instance.pendingTime = instance.resolvedTime = NoWork; + } } else { - // There is a pending transition. It may or may not have resolved. Compare - // the time at which we last resolved to the pending time. If the pending - // time is in the future, then we're still pending. - if ( - oldResolvedExpirationTime === NoWork || - oldResolvedExpirationTime > pendingExpirationTime - ) { - // We have not already resolved at the pending time. Check if this render - // includes the pending level. - if (renderExpirationTime <= pendingExpirationTime) { - // This render does include the pending level. Mark it as resolved. - newIsPending = false; - newResolvedExpirationTime = renderExpirationTime; - } else { - // This render does not include the pending level. Still pending. - newIsPending = true; - newResolvedExpirationTime = oldResolvedExpirationTime; - - // Mark that there's still pending work on this queue - if (pendingExpirationTime > currentlyRenderingFiber.expirationTime) { - currentlyRenderingFiber.expirationTime = pendingExpirationTime; - markUnprocessedUpdateTime(pendingExpirationTime); - } - } - } else { - // Already resolved at this expiration time. + // There's a pending transition. + const pendingTime = instance.pendingTime; + const resolvedTime = instance.resolvedTime; + const oldIsPending = oldState.isPending; + + let remainingExpirationTime; + let memoizedVersion; + if (renderExpirationTime <= resolvedTime) { + // Transition has finished. newIsPending = false; - newResolvedExpirationTime = oldResolvedExpirationTime; + remainingExpirationTime = NoWork; + // Only update the memoized version number once the transition finishes. + memoizedVersion = newVersion; + } else if (renderExpirationTime <= pendingTime) { + // Transition is pending. + newIsPending = true; + remainingExpirationTime = resolvedTime; + memoizedVersion = oldVersion; + } else { + // Outside of pending range. Reuse the old state. + newIsPending = oldIsPending; + remainingExpirationTime = pendingTime; + memoizedVersion = oldVersion; } - } - if (newIsPending !== oldIsPending) { - markWorkInProgressReceivedUpdate(); - } else if (oldIsPending === false) { - // This is a trick to mutate the instance without a commit effect. If - // neither the current nor work-in-progress hook are pending, and there's no - // pending transition at a lower priority (which we know because there can - // only be one pending level per useTransition hook), then we can be certain - // there are no pending transitions even if this render does not finish. - // It's similar to the trick we use for eager setState bailouts. Like that - // optimization, this should have no semantic effect. - instance.pendingExpirationTime = NoWork; - newResolvedExpirationTime = NoWork; - } + if (remainingExpirationTime > currentlyRenderingFiber.expirationTime) { + // Mark that there's remaining work + currentlyRenderingFiber.expirationTime = remainingExpirationTime; + markUnprocessedUpdateTime(resolvedTime); + } - hook.memoizedState = { - isPending: newIsPending, - resolvedExpirationTime: newResolvedExpirationTime, - }; + if (newIsPending !== oldIsPending) { + markWorkInProgressReceivedUpdate(); + } + + hook.memoizedState = { + version: memoizedVersion, + isPending: newIsPending, + }; + } const start = updateCallback(startTransition.bind(null, instance, config), [ config, @@ -1371,131 +1343,6 @@ function updateTransition( return [start, newIsPending]; } -function dispatchAction( - fiber: Fiber, - queue: UpdateQueue, - action: A, -) { - if (__DEV__) { - if (typeof arguments[3] === 'function') { - console.error( - "State updates from the useState() and useReducer() Hooks don't support the " + - 'second callback argument. To execute a side effect after ' + - 'rendering, declare it in the component body with useEffect().', - ); - } - } - - const currentTime = requestCurrentTimeForUpdate(); - const suspenseConfig = requestCurrentSuspenseConfig(); - const transition = requestCurrentTransition(); - const expirationTime = computeExpirationForFiber( - currentTime, - fiber, - suspenseConfig, - ); - - const update: Update = { - eventTime: currentTime, - expirationTime, - suspenseConfig, - action, - eagerReducer: null, - eagerState: null, - next: (null: any), - }; - - if (__DEV__) { - update.priority = getCurrentPriorityLevel(); - } - - // Append the update to the end of the list. - const pending = queue.pending; - if (pending === null) { - // This is the first update. Create a circular list. - update.next = update; - } else { - update.next = pending.next; - pending.next = update; - } - queue.pending = update; - - if (transition !== null) { - const prevPendingTransition = queue.pendingTransition; - if (transition !== prevPendingTransition) { - queue.pendingTransition = transition; - if (prevPendingTransition !== null) { - // There's already a pending transition on this queue. The new - // transition supersedes the old one. Turn of the `isPending` state - // of the previous transition. - cancelPendingTransition(prevPendingTransition); - } - } - } - - const alternate = fiber.alternate; - if ( - fiber === currentlyRenderingFiber || - (alternate !== null && alternate === currentlyRenderingFiber) - ) { - // This is a render phase update. Stash it in a lazily-created map of - // queue -> linked list of updates. After this render pass, we'll restart - // and apply the stashed updates on top of the work-in-progress hook. - didScheduleRenderPhaseUpdate = true; - update.expirationTime = renderExpirationTime; - currentlyRenderingFiber.expirationTime = renderExpirationTime; - } else { - if ( - fiber.expirationTime === NoWork && - (alternate === null || alternate.expirationTime === NoWork) - ) { - // The queue is currently empty, which means we can eagerly compute the - // next state before entering the render phase. If the new state is the - // same as the current state, we may be able to bail out entirely. - const lastRenderedReducer = queue.lastRenderedReducer; - if (lastRenderedReducer !== null) { - let prevDispatcher; - if (__DEV__) { - prevDispatcher = ReactCurrentDispatcher.current; - ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; - } - try { - const currentState: S = (queue.lastRenderedState: any); - const eagerState = lastRenderedReducer(currentState, action); - // Stash the eagerly computed state, and the reducer used to compute - // it, on the update object. If the reducer hasn't changed by the - // time we enter the render phase, then the eager state can be used - // without calling the reducer again. - update.eagerReducer = lastRenderedReducer; - update.eagerState = eagerState; - if (is(eagerState, currentState)) { - // Fast path. We can bail out without scheduling React to re-render. - // It's still possible that we'll need to rebase this update later, - // if the component re-renders for a different reason and by that - // time the reducer has changed. - return; - } - } catch (error) { - // Suppress the error. It will throw again in the render phase. - } finally { - if (__DEV__) { - ReactCurrentDispatcher.current = prevDispatcher; - } - } - } - } - if (__DEV__) { - // $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests - if ('undefined' !== typeof jest) { - warnIfNotScopedWithMatchingAct(fiber); - warnIfNotCurrentlyActingUpdatesInDev(fiber); - } - } - - scheduleWork(fiber, expirationTime); - } -} - export const ContextOnlyDispatcher: Dispatcher = { readContext, @@ -1576,6 +1423,15 @@ let InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher | null = null; let InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher | null = null; let InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher | null = null; +export function setInvalidNestedHooksDispatcher() { + if (__DEV__) { + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + return prevDispatcher; + } + return null; +} + if (__DEV__) { const warnInvalidContextAccess = () => { console.error( diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index 6f7b0766751c34..7809223e83f320 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -61,12 +61,6 @@ import {Sync, NoWork} from './ReactFiberExpirationTime'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; -// Throw an object with this type to abort the current render and restart at -// a different level. -export function SuspendOnTask(expirationTime: ExpirationTime) { - this.retryTime = expirationTime; -} - function createRootErrorUpdate( fiber: Fiber, errorInfo: CapturedValue, diff --git a/packages/react-reconciler/src/ReactFiberTransition.js b/packages/react-reconciler/src/ReactFiberTransition.js index 2a9a07b3920839..d78d1d9659abbd 100644 --- a/packages/react-reconciler/src/ReactFiberTransition.js +++ b/packages/react-reconciler/src/ReactFiberTransition.js @@ -8,10 +8,12 @@ */ import type {Fiber} from './ReactFiber'; +import type {UpdateQueue, Update} from './ReactFiberHooks'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; import ReactSharedInternals from 'shared/ReactSharedInternals'; +import is from 'shared/objectIs'; import { UserBlockingPriority, @@ -23,27 +25,487 @@ import { scheduleUpdateOnFiber, computeExpirationForFiber, requestCurrentTimeForUpdate, + warnIfNotScopedWithMatchingAct, + warnIfNotCurrentlyActingUpdatesInDev, + scheduleWork, } from './ReactFiberWorkLoop'; import {NoWork} from './ReactFiberExpirationTime'; +import {requestCurrentSuspenseConfig} from './ReactFiberSuspenseConfig'; +import { + requestRenderPhaseUpdate, + setInvalidNestedHooksDispatcher, +} from './ReactFiberHooks'; +import { + createUpdate, + ReplaceState, + ForceUpdate, + enqueueUpdate, +} from './ReactUpdateQueue'; +import {get as getInstance} from 'shared/ReactInstanceMap'; +import {isMounted} from 'react-reconciler/reflection'; +import {preventIntermediateStates} from 'shared/ReactFeatureFlags'; -const {ReactCurrentBatchConfig} = ReactSharedInternals; +const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; export type TransitionInstance = {| - pendingExpirationTime: ExpirationTime, + version: number, + pendingTime: ExpirationTime, + resolvedTime: ExpirationTime, fiber: Fiber, |}; +type Dispatch = ( + fiber: Fiber, + queue: UpdateQueue, + action: A, +) => void; + +type ClassSetState = ( + inst: any, + payload: mixed, + callback: ?() => mixed, +) => void; +type ClassReplaceState = ( + inst: any, + payload: mixed, + callback: ?() => mixed, +) => void; +type ClassForceUpdate = (inst: any, callback: ?() => mixed) => void; + +// The implementation of dispatch, setState, et al can be swapped out at +// runtime, e.g. when calling `startTransition`. These references point to +// the current implementation. +let dispatchImpl: Dispatch = dispatchImplDefault; +let classSetStateImpl: ClassSetState = classSetStateImplDefault; +let classReplaceStateImpl: ClassReplaceState = classReplaceStateImplDefault; +let classForceUpdateImpl: ClassForceUpdate = classForceUpdateImplDefault; + // Inside `startTransition`, this is the transition instance that corresponds to // the `useTransition` hook. let currentTransition: TransitionInstance | null = null; - +// The event time of the current transition. +let currentTransitionEventTime: ExpirationTime = NoWork; // Inside `startTransition`, this is the expiration time of the update that // turns on `isPending`. We also use it to turn off the `isPending` of previous // transitions, if they exists. -let userBlockingExpirationTime = NoWork; +let currentTransitionPendingTime: ExpirationTime = NoWork; +// The expiration time of the current transition. This is accumulated during +// `startTransition` because it depends on whether the current transition +// overlaps with any previous transitions. +let currentTransitionResolvedTime: ExpirationTime = NoWork; + +let dispatchContinuations: Array< + (ExpirationTime, ExpirationTime, SuspenseConfig | null) => void, +> | null = null; + +let warnOnInvalidCallback; +if (__DEV__) { + const didWarnOnInvalidCallback = new Set(); + + warnOnInvalidCallback = function(callback: mixed, callerName: string) { + if (callback === null || typeof callback === 'function') { + return; + } + const key = callerName + '_' + (callback: any); + if (!didWarnOnInvalidCallback.has(key)) { + didWarnOnInvalidCallback.add(key); + console.error( + '%s(...): Expected the last optional `callback` argument to be a ' + + 'function. Instead received: %s.', + callerName, + callback, + ); + } + }; +} + +export function dispatch( + fiber: Fiber, + queue: UpdateQueue, + action: A, +): void { + if (__DEV__) { + if (typeof arguments[3] === 'function') { + console.error( + "State updates from the useState() and useReducer() Hooks don't " + + 'support the second callback argument. To execute a side effect ' + + 'after rendering, declare it in the component body with useEffect().', + ); + } + } + + dispatchImpl(fiber, queue, action); +} + +export const classComponentUpdater = { + isMounted, + enqueueSetState(inst: any, payload: mixed, callback: ?() => mixed) { + classSetStateImpl(inst, payload, callback); + }, + enqueueReplaceState(inst: any, payload: mixed, callback: ?() => mixed) { + classReplaceStateImpl(inst, payload, callback); + }, + enqueueForceUpdate(inst: any, callback: ?() => mixed) { + classForceUpdateImpl(inst, callback); + }, +}; + +function dispatchImplDefault( + fiber: Fiber, + queue: UpdateQueue, + action: A, +) { + if (__DEV__) { + if (typeof arguments[3] === 'function') { + console.error( + "State updates from the useState() and useReducer() Hooks don't support the " + + 'second callback argument. To execute a side effect after ' + + 'rendering, declare it in the component body with useEffect().', + ); + } + } + const eventTime = requestCurrentTimeForUpdate(); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + eventTime, + fiber, + suspenseConfig, + ); + dispatchForExpirationTime( + fiber, + queue, + action, + eventTime, + expirationTime, + suspenseConfig, + ); +} + +function dispatchForExpirationTime( + fiber: Fiber, + queue: UpdateQueue, + action: A, + eventTime: ExpirationTime, + expirationTime: ExpirationTime, + suspenseConfig: SuspenseConfig | null, +) { + const update: Update = { + eventTime, + expirationTime, + suspenseConfig, + action, + eagerReducer: null, + eagerState: null, + next: (null: any), + }; + + if (__DEV__) { + update.priority = getCurrentPriorityLevel(); + } + + // Append the update to the end of the list. + const pending = queue.pending; + if (pending === null) { + // This is the first update. Create a circular list. + update.next = update; + } else { + update.next = pending.next; + pending.next = update; + } + queue.pending = update; + + const alternate = fiber.alternate; + if ( + fiber.expirationTime === NoWork && + (alternate === null || alternate.expirationTime === NoWork) + ) { + // The queue is currently empty, which means we can eagerly compute the + // next state before entering the render phase. If the new state is the + // same as the current state, we may be able to bail out entirely. + const lastRenderedReducer = queue.lastRenderedReducer; + if (lastRenderedReducer !== null) { + const prevDispatcher = __DEV__ ? setInvalidNestedHooksDispatcher() : null; + try { + const currentState: S = (queue.lastRenderedState: any); + const eagerState = lastRenderedReducer(currentState, action); + // Stash the eagerly computed state, and the reducer used to compute + // it, on the update object. If the reducer hasn't changed by the + // time we enter the render phase, then the eager state can be used + // without calling the reducer again. + update.eagerReducer = lastRenderedReducer; + update.eagerState = eagerState; + if (is(eagerState, currentState)) { + // Fast path. We can bail out without scheduling React to re-render. + // It's still possible that we'll need to rebase this update later, + // if the component re-renders for a different reason and by that + // time the reducer has changed. + return; + } + } catch (error) { + // Suppress the error. It will throw again in the render phase. + } finally { + if (__DEV__) { + ReactCurrentDispatcher.current = prevDispatcher; + } + } + } + } + if (__DEV__) { + // $FlowExpectedError - jest isn't a global, and isn't recognized outside of tests + if ('undefined' !== typeof jest) { + warnIfNotScopedWithMatchingAct(fiber); + warnIfNotCurrentlyActingUpdatesInDev(fiber); + } + } + + scheduleWork(fiber, expirationTime); +} + +function dispatchImplRenderPhase( + fiber: Fiber, + queue: UpdateQueue, + action: A, +) { + const update = requestRenderPhaseUpdate(fiber, action); + if (update === null) { + // This is an update on a fiber other than the one that is currently + // rendering. Fallback to default implementation + // TODO: This is undefined behavior. It should either warn or throw. + dispatchImplDefault(fiber, queue, action); + return; + } + + // Append the update to the end of the list. + const pending = queue.pending; + if (pending === null) { + // This is the first update. Create a circular list. + update.next = update; + } else { + update.next = pending.next; + pending.next = update; + } + queue.pending = update; +} + +function dispatchImplInsideTransition( + fiber: Fiber, + queue: UpdateQueue, + action: A, +) { + const transition: TransitionInstance = (currentTransition: any); + setTransition(queue, transition); + + const continuation = dispatchForExpirationTime.bind( + null, + fiber, + queue, + action, + ); + if (dispatchContinuations === null) { + dispatchContinuations = [continuation]; + } else { + dispatchContinuations.push(continuation); + } +} + +export function setRenderPhaseDispatchImpl(): Dispatch { + const prev = dispatchImpl; + dispatchImpl = dispatchImplRenderPhase; + return prev; +} + +export function restoreDispatchImpl(prevDispatchImpl: Dispatch): void { + dispatchImpl = prevDispatchImpl; +} + +function classSetStateImplDefault(inst, payload, callback) { + const fiber = getInstance(inst); + const eventTime = requestCurrentTimeForUpdate(); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + eventTime, + fiber, + suspenseConfig, + ); + classSetStateForExpirationTime( + fiber, + payload, + callback, + eventTime, + expirationTime, + suspenseConfig, + ); +} + +function classReplaceStateImplDefault(inst, payload, callback) { + const fiber = getInstance(inst); + const eventTime = requestCurrentTimeForUpdate(); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + eventTime, + fiber, + suspenseConfig, + ); + classReplaceStateForExpirationTime( + fiber, + payload, + callback, + eventTime, + expirationTime, + suspenseConfig, + ); +} + +function classForceUpdateImplDefault(inst, callback) { + const fiber = getInstance(inst); + const eventTime = requestCurrentTimeForUpdate(); + const suspenseConfig = requestCurrentSuspenseConfig(); + const expirationTime = computeExpirationForFiber( + eventTime, + fiber, + suspenseConfig, + ); + classForceUpdateForExpirationTime( + fiber, + callback, + eventTime, + expirationTime, + suspenseConfig, + ); +} + +function classSetStateForExpirationTime( + fiber, + payload, + callback, + eventTime, + expirationTime, + suspenseConfig, +) { + const update = createUpdate(eventTime, expirationTime, suspenseConfig); + update.payload = payload; + if (callback !== undefined && callback !== null) { + if (__DEV__) { + warnOnInvalidCallback(callback, 'setState'); + } + update.callback = callback; + } + enqueueUpdate(fiber, update); + scheduleWork(fiber, expirationTime); +} + +function classReplaceStateForExpirationTime( + fiber, + payload, + callback, + eventTime, + expirationTime, + suspenseConfig, +) { + const update = createUpdate(eventTime, expirationTime, suspenseConfig); + update.tag = ReplaceState; + update.payload = payload; + if (callback !== undefined && callback !== null) { + if (__DEV__) { + warnOnInvalidCallback(callback, 'replaceState'); + } + update.callback = callback; + } + enqueueUpdate(fiber, update); + scheduleWork(fiber, expirationTime); +} + +function classForceUpdateForExpirationTime( + fiber, + callback, + eventTime, + expirationTime, + suspenseConfig, +) { + const update = createUpdate(eventTime, expirationTime, suspenseConfig); + update.tag = ForceUpdate; + if (callback !== undefined && callback !== null) { + if (__DEV__) { + warnOnInvalidCallback(callback, 'forceUpdate'); + } + update.callback = callback; + } + enqueueUpdate(fiber, update); + scheduleWork(fiber, expirationTime); +} + +function classSetStateImplInsideTransition(inst, payload, callback) { + const fiber = getInstance(inst); + const updateQueue = fiber.updateQueue; + const sharedQueue = updateQueue !== null ? updateQueue.shared : null; + if (sharedQueue === null) { + // TODO: Fire warning for update on unmounted component + return; + } + + const transition: TransitionInstance = (currentTransition: any); + setTransition(sharedQueue, transition); + + const continuation = classSetStateForExpirationTime.bind( + null, + fiber, + payload, + callback, + ); + if (dispatchContinuations === null) { + dispatchContinuations = [continuation]; + } else { + dispatchContinuations.push(continuation); + } +} + +function classReplaceStateImplInsideTransition(inst, payload, callback) { + const fiber = getInstance(inst); + const updateQueue = fiber.updateQueue; + const sharedQueue = updateQueue !== null ? updateQueue.shared : null; + if (sharedQueue === null) { + // TODO: Fire warning for update on unmounted component + return; + } + + const transition: TransitionInstance = (currentTransition: any); + setTransition(sharedQueue, transition); -export function requestCurrentTransition(): TransitionInstance | null { - return currentTransition; + const continuation = classReplaceStateForExpirationTime.bind( + null, + fiber, + payload, + callback, + ); + if (dispatchContinuations === null) { + dispatchContinuations = [continuation]; + } else { + dispatchContinuations.push(continuation); + } +} + +function classForceUpdateImplInsideTransition(inst, callback) { + const fiber = getInstance(inst); + const updateQueue = fiber.updateQueue; + const sharedQueue = updateQueue !== null ? updateQueue.shared : null; + if (sharedQueue === null) { + // TODO: Fire warning for update on unmounted component + return; + } + + const transition: TransitionInstance = (currentTransition: any); + setTransition(sharedQueue, transition); + + const continuation = classForceUpdateForExpirationTime.bind( + null, + fiber, + callback, + ); + if (dispatchContinuations === null) { + dispatchContinuations = [continuation]; + } else { + dispatchContinuations.push(continuation); + } } export function startTransition( @@ -51,12 +513,21 @@ export function startTransition( config: SuspenseConfig | null | void, callback: () => void, ) { - const fiber = transitionInstance.fiber; + if (dispatchImpl === dispatchImplRenderPhase) { + // Wrapping an update in `startTransition` has no effect in the + // render phase. + // TODO: This should warn. + callback(); + return; + } - const resolvedConfig: SuspenseConfig | null = - config === undefined ? null : config; + const suspenseConfig = config === undefined ? null : config; + const fiber = transitionInstance.fiber; - const currentTime = requestCurrentTimeForUpdate(); + // Don't need to reset this at the end because it's impossible to read + // from outside of a `startTransition` callback. + currentTransitionEventTime = requestCurrentTimeForUpdate(); + currentTransitionResolvedTime = NoWork; // TODO: runWithPriority shouldn't be necessary here. React should manage its // own concept of priority, and only consult Scheduler for updates that are @@ -65,51 +536,110 @@ export function startTransition( runWithPriority( priorityLevel < UserBlockingPriority ? UserBlockingPriority : priorityLevel, () => { - userBlockingExpirationTime = computeExpirationForFiber( - currentTime, + currentTransitionPendingTime = computeExpirationForFiber( + currentTransitionEventTime, fiber, null, ); - scheduleUpdateOnFiber(fiber, userBlockingExpirationTime); }, ); runWithPriority( priorityLevel > NormalPriority ? NormalPriority : priorityLevel, () => { - let expirationTime = computeExpirationForFiber( - currentTime, - fiber, - resolvedConfig, - ); - // Set the expiration time at which the pending transition will finish. - // Because there's only a single transition per useTransition hook, we - // don't need a queue here; we can cheat by only tracking the most - // recently scheduled transition. - const oldPendingExpirationTime = transitionInstance.pendingExpirationTime; - if (oldPendingExpirationTime === expirationTime) { - expirationTime -= 1; - } - transitionInstance.pendingExpirationTime = expirationTime; - - scheduleUpdateOnFiber(fiber, expirationTime); const previousConfig = ReactCurrentBatchConfig.suspense; const previousTransition = currentTransition; - ReactCurrentBatchConfig.suspense = resolvedConfig; + const previousDispatchContinuations = dispatchContinuations; + const previousDispatchImpl = dispatchImpl; + const previousClassSetStateImpl = classSetStateImpl; + const previousClassReplaceStateImpl = classReplaceStateImpl; + const previousClassForceUpdateImpl = classForceUpdateImpl; + ReactCurrentBatchConfig.suspense = suspenseConfig; currentTransition = transitionInstance; + dispatchContinuations = null; + dispatchImpl = dispatchImplInsideTransition; + classSetStateImpl = classSetStateImplInsideTransition; + classReplaceStateImpl = classReplaceStateImplInsideTransition; + classForceUpdateImpl = classForceUpdateImplInsideTransition; try { callback(); } finally { + if (currentTransitionResolvedTime === NoWork) { + // This transition did not overlap with any previous transitions. + // Compute a new concurrent expiration time. + currentTransitionResolvedTime = computeExpirationForFiber( + currentTransitionEventTime, + fiber, + suspenseConfig, + ); + } + + // Set the expiration time at which the pending transition will finish. + // Because there's only a single transition per useTransition hook, we + // don't need a queue here; we can cheat by only tracking the most + // recently scheduled transition. + transitionInstance.pendingTime = currentTransitionPendingTime; + transitionInstance.resolvedTime = currentTransitionResolvedTime; + transitionInstance.version++; + + const continuations = dispatchContinuations; + const eventTime = currentTransitionEventTime; + const pendingTime = currentTransitionPendingTime; + const resolvedTime = currentTransitionResolvedTime; + ReactCurrentBatchConfig.suspense = previousConfig; currentTransition = previousTransition; - userBlockingExpirationTime = NoWork; + dispatchContinuations = previousDispatchContinuations; + dispatchImpl = previousDispatchImpl; + classSetStateImpl = previousClassSetStateImpl; + classReplaceStateImpl = previousClassReplaceStateImpl; + classForceUpdateImpl = previousClassForceUpdateImpl; + currentTransitionPendingTime = NoWork; + + if (continuations !== null) { + // Don't need to schedule work at the resolved time because the + // pending time is always higher priority. + scheduleUpdateOnFiber(fiber, pendingTime); + + // These continuations are internal functions that should never throw, + // but just in case, do them at the very end, after all the + // clean-up code. + for (let i = 0; i < continuations.length; i++) { + const continuation = continuations[i]; + continuation(eventTime, resolvedTime, suspenseConfig); + } + } } }, ); } -export function cancelPendingTransition(prevTransition: TransitionInstance) { - // Turn off the `isPending` state of the previous transition, at the same - // priority we use to turn on the `isPending` state of the current transition. - prevTransition.pendingExpirationTime = NoWork; - scheduleUpdateOnFiber(prevTransition.fiber, userBlockingExpirationTime); +function setTransition( + queue: {pendingTransition: TransitionInstance | null}, + transition: TransitionInstance, +) { + const prevTransition = queue.pendingTransition; + if (transition !== prevTransition) { + queue.pendingTransition = transition; + if (prevTransition !== null) { + // There's already a pending transition on this queue. The new transition + // supersedes the old one. Turn of the `isPending` state of the + // previous transition. + + // Track the expiration time of the superseded transition. If there are + // multiple, choose the highest priority one. + if (preventIntermediateStates) { + const resolvedTime = prevTransition.resolvedTime; + if (currentTransitionResolvedTime < resolvedTime) { + currentTransitionResolvedTime = resolvedTime; + } + } + + // Turn off the `isPending` state of the previous transition, at the same + // priority we use to turn on the `isPending` state of the + // current transition. + prevTransition.resolvedTime = currentTransitionPendingTime; + prevTransition.version++; + scheduleUpdateOnFiber(prevTransition.fiber, currentTransitionPendingTime); + } + } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index ecc6ff94d134fa..3ebe8074dfe766 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -127,7 +127,6 @@ import { throwException, createRootErrorUpdate, createClassErrorUpdate, - SuspendOnTask, } from './ReactFiberThrow'; import { commitBeforeMutationLifeCycles as commitBeforeMutationEffectOnFiber, @@ -183,7 +182,10 @@ import { clearCaughtError, } from 'shared/ReactErrorUtils'; import {onCommitRoot} from './ReactFiberDevToolsHook'; -import {requestCurrentTransition} from './ReactFiberTransition'; +import { + setRenderPhaseDispatchImpl, + restoreDispatchImpl, +} from './ReactFiberTransition'; const ceil = Math.ceil; @@ -203,14 +205,13 @@ const LegacyUnbatchedContext = /* */ 0b001000; const RenderContext = /* */ 0b010000; const CommitContext = /* */ 0b100000; -type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6; +type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5; const RootIncomplete = 0; const RootFatalErrored = 1; -const RootSuspendedOnTask = 2; -const RootErrored = 3; -const RootSuspended = 4; -const RootSuspendedWithDelay = 5; -const RootCompleted = 6; +const RootErrored = 2; +const RootSuspended = 3; +const RootSuspendedWithDelay = 4; +const RootCompleted = 5; export type Thenable = { then(resolve: () => mixed, reject?: () => mixed): Thenable | void, @@ -241,7 +242,7 @@ let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null; // The work left over by components that were visited during this render. Only // includes unprocessed updates, not work in bailed out children. let workInProgressRootNextUnprocessedUpdateTime: ExpirationTime = NoWork; -let workInProgressRootRestartTime: ExpirationTime = NoWork; + // If we're pinged while rendering we don't always restart immediately. // This flag determines if it might be worthwhile to restart if an opportunity // happens latere. @@ -334,13 +335,9 @@ export function computeExpirationForFiber( let expirationTime; if (suspenseConfig !== null) { - // This is a transition - const transitionInstance = requestCurrentTransition(); - if (transitionInstance !== null) { - expirationTime = transitionInstance.pendingExpirationTime; - } else { - expirationTime = computeAsyncExpiration(currentTime); - } + // If there's a SuspenseConfig, treat this as a concurrent update regardless + // of the priority. + expirationTime = computeAsyncExpiration(currentTime); } else { // Compute an expiration time based on the Scheduler priority. switch (priorityLevel) { @@ -687,6 +684,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) { const prevExecutionContext = executionContext; executionContext |= RenderContext; const prevDispatcher = pushDispatcher(root); + const prevDispatchImpl = setRenderPhaseDispatchImpl(); const prevInteractions = pushInteractions(root); startWorkLoopTimer(workInProgress); do { @@ -700,6 +698,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) { resetContextDependencies(); executionContext = prevExecutionContext; popDispatcher(prevDispatcher); + restoreDispatchImpl(prevDispatchImpl); if (enableSchedulerTracing) { popInteractions(((prevInteractions: any): Set)); } @@ -713,12 +712,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) { throw fatalError; } - if (workInProgressRootExitStatus === RootSuspendedOnTask) { - // Can't finish rendering at this level. Exit early and restart at the - // specified time. - markRootSuspendedAtTime(root, expirationTime); - root.nextKnownPendingLevel = workInProgressRootRestartTime; - } else if (workInProgress !== null) { + if (workInProgress !== null) { // There's still work left over. Exit without committing. stopInterruptedWorkLoopTimer(); } else { @@ -759,8 +753,7 @@ function finishConcurrentRender( switch (exitStatus) { case RootIncomplete: - case RootFatalErrored: - case RootSuspendedOnTask: { + case RootFatalErrored: { invariant(false, 'Root did not complete. This is a bug in React.'); } // Flow knows about invariant, so it complains if I add a break @@ -1006,6 +999,7 @@ function performSyncWorkOnRoot(root) { executionContext |= RenderContext; const prevDispatcher = pushDispatcher(root); const prevInteractions = pushInteractions(root); + const prevDispatchImpl = setRenderPhaseDispatchImpl(); startWorkLoopTimer(workInProgress); do { @@ -1019,6 +1013,7 @@ function performSyncWorkOnRoot(root) { resetContextDependencies(); executionContext = prevExecutionContext; popDispatcher(prevDispatcher); + restoreDispatchImpl(prevDispatchImpl); if (enableSchedulerTracing) { popInteractions(((prevInteractions: any): Set)); } @@ -1032,12 +1027,7 @@ function performSyncWorkOnRoot(root) { throw fatalError; } - if (workInProgressRootExitStatus === RootSuspendedOnTask) { - // Can't finish rendering at this level. Exit early and restart at the - // specified time. - markRootSuspendedAtTime(root, expirationTime); - root.nextKnownPendingLevel = workInProgressRootRestartTime; - } else if (workInProgress !== null) { + if (workInProgress !== null) { // This is a sync render, so we should have finished the whole tree. invariant( false, @@ -1265,7 +1255,6 @@ function prepareFreshStack(root, expirationTime) { workInProgressRootLatestSuspenseTimeout = Sync; workInProgressRootCanSuspendUsingConfig = null; workInProgressRootNextUnprocessedUpdateTime = NoWork; - workInProgressRootRestartTime = NoWork; workInProgressRootHasPendingPing = false; if (enableSchedulerTracing) { @@ -1286,20 +1275,6 @@ function handleError(root, thrownValue) { resetHooksAfterThrow(); resetCurrentDebugFiberInDEV(); - // Check if this is a SuspendOnTask exception. This is the one type of - // exception that is allowed to happen at the root. - // TODO: I think instanceof is OK here? A brand check seems unnecessary - // since this is always thrown by the renderer and not across realms - // or packages. - if (thrownValue instanceof SuspendOnTask) { - // Can't finish rendering at this level. Exit early and restart at - // the specified time. - workInProgressRootExitStatus = RootSuspendedOnTask; - workInProgressRootRestartTime = thrownValue.retryTime; - workInProgress = null; - return; - } - if (workInProgress === null || workInProgress.return === null) { // Expected to be working on a non-root fiber. This is a fatal error // because there's no ancestor that can handle it; the root is @@ -2623,17 +2598,15 @@ if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { try { return originalBeginWork(current, unitOfWork, expirationTime); } catch (originalError) { - // Filter out special exception types if ( originalError !== null && typeof originalError === 'object' && - // Promise - (typeof originalError.then === 'function' || - // SuspendOnTask exception - originalError instanceof SuspendOnTask) + typeof originalError.then === 'function' ) { + // Don't replay promises. Treat everything else like an error. throw originalError; } + // Keep this code in sync with handleError; any changes here must have // corresponding changes there. resetContextDependencies(); diff --git a/packages/react-reconciler/src/ReactUpdateQueue.js b/packages/react-reconciler/src/ReactUpdateQueue.js index 70908de85325a7..c7077b89459081 100644 --- a/packages/react-reconciler/src/ReactUpdateQueue.js +++ b/packages/react-reconciler/src/ReactUpdateQueue.js @@ -97,26 +97,17 @@ import { } from './ReactFiberNewContext'; import {Callback, ShouldCapture, DidCapture} from 'shared/ReactSideEffectTags'; -import { - debugRenderPhaseSideEffectsForStrictMode, - preventIntermediateStates, -} from 'shared/ReactFeatureFlags'; +import {debugRenderPhaseSideEffectsForStrictMode} from 'shared/ReactFeatureFlags'; import {StrictMode} from './ReactTypeOfMode'; import { markRenderEventTimeAndConfig, markUnprocessedUpdateTime, } from './ReactFiberWorkLoop'; -import {SuspendOnTask} from './ReactFiberThrow'; import invariant from 'shared/invariant'; import {getCurrentPriorityLevel} from './SchedulerWithReactIntegration'; -import { - requestCurrentTransition, - cancelPendingTransition, -} from './ReactFiberTransition'; - export type Update = {| // TODO: Temporary field. Will remove this by storing a map of // transition -> event time on the root. @@ -239,20 +230,6 @@ export function enqueueUpdate(fiber: Fiber, update: Update) { } sharedQueue.pending = update; - const transition = requestCurrentTransition(); - if (transition !== null) { - const prevPendingTransition = sharedQueue.pendingTransition; - if (transition !== prevPendingTransition) { - sharedQueue.pendingTransition = transition; - if (prevPendingTransition !== null) { - // There's already a pending transition on this queue. The new - // transition supersedes the old one. Turn of the `isPending` state - // of the previous transition. - cancelPendingTransition(prevPendingTransition); - } - } - } - if (__DEV__) { if ( currentlyProcessingQueue === sharedQueue && @@ -422,11 +399,8 @@ export function processUpdateQueue( if (first !== null) { let update = first; - let lastProcessedTransitionTime = NoWork; - let lastSkippedTransitionTime = NoWork; do { const updateExpirationTime = update.expirationTime; - const suspenseConfig = update.suspenseConfig; if (updateExpirationTime < renderExpirationTime) { // Priority is insufficient. Skip this update. If this is the first // skipped update, the previous update/state is the new base @@ -434,7 +408,7 @@ export function processUpdateQueue( const clone: Update = { eventTime: update.eventTime, expirationTime: updateExpirationTime, - suspenseConfig, + suspenseConfig: update.suspenseConfig, tag: update.tag, payload: update.payload, @@ -452,16 +426,6 @@ export function processUpdateQueue( if (updateExpirationTime > newExpirationTime) { newExpirationTime = updateExpirationTime; } - - if (suspenseConfig !== null) { - // This update is part of a transition - if ( - lastSkippedTransitionTime === NoWork || - lastSkippedTransitionTime > updateExpirationTime - ) { - lastSkippedTransitionTime = updateExpirationTime; - } - } } else { // This update does have sufficient priority. const eventTime = update.eventTime; @@ -508,17 +472,6 @@ export function processUpdateQueue( } } } - - if (suspenseConfig !== null) { - // This update is part of a transition - if ( - lastProcessedTransitionTime === NoWork || - lastProcessedTransitionTime > updateExpirationTime - ) { - lastProcessedTransitionTime = updateExpirationTime; - } - } - update = update.next; if (update === null || update === first) { pendingQueue = queue.shared.pending; @@ -534,17 +487,6 @@ export function processUpdateQueue( } } } while (true); - - if ( - preventIntermediateStates && - lastProcessedTransitionTime !== NoWork && - lastSkippedTransitionTime !== NoWork - ) { - // There are multiple updates scheduled on this queue, but only some of - // them were processed. To avoid showing an intermediate state, abort - // the current render and restart at a level that includes them all. - throw new SuspendOnTask(lastSkippedTransitionTime); - } } if (newBaseQueueLast === null) { diff --git a/packages/react-reconciler/src/__tests__/ReactTransition-test.internal.js b/packages/react-reconciler/src/__tests__/ReactTransition-test.internal.js index 7c3f8a43019eb2..6adc99dfa3929a 100644 --- a/packages/react-reconciler/src/__tests__/ReactTransition-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactTransition-test.internal.js @@ -106,7 +106,7 @@ describe('ReactTransition', () => { ); it.experimental( - 'isPending turns off immediately if `startTransition` does not include any updates', + 'nothing is scheduled if `startTransition` does not include any updates', async () => { let startTransition; function App() { @@ -128,13 +128,8 @@ describe('ReactTransition', () => { // No-op }); }); - expect(Scheduler).toHaveYielded([ - // Pending state is turned on then immediately back off - // TODO: As an optimization, we could avoid turning on the pending - // state entirely. - 'Pending: true', - 'Pending: false', - ]); + // Nothing is scheduled + expect(Scheduler).toHaveYielded([]); expect(root).toMatchRenderedOutput('Pending: false'); }, ); @@ -598,76 +593,60 @@ describe('ReactTransition', () => { , ); - // Navigate to tab B await act(async () => { + // Navigate to tab B tabButtonB.current.go(); - expect(Scheduler).toFlushAndYieldThrough([ + expect(Scheduler).toFlushAndYield([ // Turn on B's pending state 'Tab B (pending...)', - // Partially render B 'App', 'Tab A', 'Tab B', + 'Tab C', + 'Suspend! [B]', + 'Loading...', ]); - // While we're still in the middle of rendering B, switch to C. - tabButtonC.current.go(); - }); - expect(Scheduler).toHaveYielded([ - // Toggle the pending flags - 'Tab B', - 'Tab C (pending...)', + jest.advanceTimersByTime(2000); + Scheduler.unstable_advanceTime(2000); - // Start rendering B... - 'App', - // ...but bail out, since C is more recent. These should not be logged: - // 'Tab A', - // 'Tab B', - // 'Tab C (pending...)', - // 'Suspend! [B]', - // 'Loading...', - - // Now render C - 'App', - 'Tab A', - 'Tab B', - 'Tab C', - 'Suspend! [C]', - 'Loading...', - ]); - expect(root).toMatchRenderedOutput( - <> -
    -
  • Tab A
  • -
  • Tab B
  • -
  • Tab C (pending...)
  • -
- A - , - ); + // Navigate to tab C + tabButtonC.current.go(); + expect(Scheduler).toFlushAndYield([ + // Turn off B's pending state, and turn on C's + 'Tab B', + 'Tab C (pending...)', + // Partially render B + 'App', + 'Tab A', + 'Tab B', + 'Tab C', + 'Suspend! [C]', + 'Loading...', + ]); - // Finish loading B - await act(async () => { - ContentB.resolve(); - }); - // Should not switch to tab B because we've since clicked on C. - expect(Scheduler).toHaveYielded([]); - expect(root).toMatchRenderedOutput( - <> -
    -
  • Tab A
  • -
  • Tab B
  • -
  • Tab C (pending...)
  • -
- A - , - ); + // Finish loading B. + await ContentB.resolve(); + // B is not able to finish because C is in the same batch. + expect(Scheduler).toFlushAndYield([ + 'App', + 'Tab A', + 'Tab B', + 'Tab C', + 'Suspend! [C]', + 'Loading...', + ]); - // Finish loading C - await act(async () => { - ContentC.resolve(); + // Finish loading C. + await ContentC.resolve(); + expect(Scheduler).toFlushAndYield([ + 'App', + 'Tab A', + 'Tab B', + 'Tab C', + 'C', + ]); }); - expect(Scheduler).toHaveYielded(['App', 'Tab A', 'Tab B', 'Tab C', 'C']); expect(root).toMatchRenderedOutput( <>
    @@ -777,79 +756,60 @@ describe('ReactTransition', () => { , ); - // Navigate to tab B await act(async () => { + // Navigate to tab B tabButtonB.current.go(); - expect(Scheduler).toFlushAndYieldThrough([ + expect(Scheduler).toFlushAndYield([ // Turn on B's pending state 'Tab B (pending...)', - // Partially render B 'App', 'Tab A', 'Tab B', + 'Tab C', + 'Suspend! [B]', + 'Loading...', ]); - // While we're still in the middle of rendering B, switch to C. - tabButtonC.current.go(); - }); - expect(Scheduler).toHaveYielded([ - // Toggle the pending flags - 'Tab B', - 'Tab C (pending...)', + jest.advanceTimersByTime(2000); + Scheduler.unstable_advanceTime(2000); - // Start rendering B... - // NOTE: This doesn't get logged like in the hooks version of this - // test because the update queue bails out before entering the render - // method. - // 'App', - // ...but bail out, since C is more recent. These should not be logged: - // 'Tab A', - // 'Tab B', - // 'Tab C (pending...)', - // 'Suspend! [B]', - // 'Loading...', - - // Now render C - 'App', - 'Tab A', - 'Tab B', - 'Tab C', - 'Suspend! [C]', - 'Loading...', - ]); - expect(root).toMatchRenderedOutput( - <> -
      -
    • Tab A
    • -
    • Tab B
    • -
    • Tab C (pending...)
    • -
    - A - , - ); + // Navigate to tab C + tabButtonC.current.go(); + expect(Scheduler).toFlushAndYield([ + // Turn off B's pending state, and turn on C's + 'Tab B', + 'Tab C (pending...)', + // Partially render B + 'App', + 'Tab A', + 'Tab B', + 'Tab C', + 'Suspend! [C]', + 'Loading...', + ]); - // Finish loading B - await act(async () => { - ContentB.resolve(); - }); - // Should not switch to tab B because we've since clicked on C. - expect(Scheduler).toHaveYielded([]); - expect(root).toMatchRenderedOutput( - <> -
      -
    • Tab A
    • -
    • Tab B
    • -
    • Tab C (pending...)
    • -
    - A - , - ); + // Finish loading B. + await ContentB.resolve(); + // B is not able to finish because C is in the same batch. + expect(Scheduler).toFlushAndYield([ + 'App', + 'Tab A', + 'Tab B', + 'Tab C', + 'Suspend! [C]', + 'Loading...', + ]); - // Finish loading C - await act(async () => { - ContentC.resolve(); + // Finish loading C. + await ContentC.resolve(); + expect(Scheduler).toFlushAndYield([ + 'App', + 'Tab A', + 'Tab B', + 'Tab C', + 'C', + ]); }); - expect(Scheduler).toHaveYielded(['App', 'Tab A', 'Tab B', 'Tab C', 'C']); expect(root).toMatchRenderedOutput( <>