From 0294458954753e3e09bcec060d18d6adbb7cc2d0 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 16 Jan 2020 11:38:37 -0800 Subject: [PATCH] Prevent intermediate transition states When multiple transitions update the same queue, only the most recent one should be allowed to finish. Do not display intermediate states. For example, if you click on multiple tabs in quick succession, we should not switch to any tab that isn't the last one you clicked. --- .../src/ReactFiberClassComponent.js | 106 +-- .../react-reconciler/src/ReactFiberHooks.js | 372 ++++------- .../react-reconciler/src/ReactFiberThrow.js | 6 - .../src/ReactFiberTransition.js | 602 ++++++++++++++++-- .../src/ReactFiberWorkLoop.js | 73 +-- .../react-reconciler/src/ReactUpdateQueue.js | 62 +- .../ReactTransition-test.internal.js | 210 +++--- 7 files changed, 792 insertions(+), 639 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.js b/packages/react-reconciler/src/ReactFiberClassComponent.js index 54622014870a5..0a4666040eaaa 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 aabb6b7ed890f..3ef9a20c997f4 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 6f7b0766751c3..7809223e83f32 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 2a9a07b392083..d78d1d9659abb 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 ecc6ff94d134f..3ebe8074dfe76 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 70908de85325a..c7077b8945908 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 7c3f8a43019eb..6adc99dfa3929 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( <>