diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index c7b9e24cea..4c26ae98a0 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -112,6 +112,7 @@ import { SyncLane, OffscreenLane, DefaultHydrationLane, + NoTimestamp, includesSomeLane, laneToLanes, removeLanes, @@ -2301,7 +2302,9 @@ function updateDehydratedSuspenseComponent( // is one of the very rare times where we mutate the current tree // during the render phase. suspenseState.retryLane = attemptHydrationAtLane; - scheduleUpdateOnFiber(current, attemptHydrationAtLane); + // TODO: Ideally this would inherit the event time of the current render + const eventTime = NoTimestamp; + scheduleUpdateOnFiber(current, attemptHydrationAtLane, eventTime); } else { // We have already tried to ping at a higher priority than we're rendering with // so if we got here, we must have failed to hydrate at those levels. We must diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.new.js b/packages/react-reconciler/src/ReactFiberClassComponent.new.js index 20e544b00a..0024e89291 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.new.js @@ -202,7 +202,7 @@ const classComponentUpdater = { } enqueueUpdate(fiber, update); - scheduleUpdateOnFiber(fiber, lane); + scheduleUpdateOnFiber(fiber, lane, eventTime); }, enqueueReplaceState(inst, payload, callback) { const fiber = getInstance(inst); @@ -222,7 +222,7 @@ const classComponentUpdater = { } enqueueUpdate(fiber, update); - scheduleUpdateOnFiber(fiber, lane); + scheduleUpdateOnFiber(fiber, lane, eventTime); }, enqueueForceUpdate(inst, callback) { const fiber = getInstance(inst); @@ -241,7 +241,7 @@ const classComponentUpdater = { } enqueueUpdate(fiber, update); - scheduleUpdateOnFiber(fiber, lane); + scheduleUpdateOnFiber(fiber, lane, eventTime); }, }; diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 64c17405dc..6d75ea215e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -1720,7 +1720,7 @@ function dispatchAction( warnIfNotCurrentlyActingUpdatesInDev(fiber); } } - scheduleUpdateOnFiber(fiber, lane); + scheduleUpdateOnFiber(fiber, lane, eventTime); } } diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.new.js b/packages/react-reconciler/src/ReactFiberHotReloading.new.js index 0fe04f2cc9..ed962e9d32 100644 --- a/packages/react-reconciler/src/ReactFiberHotReloading.new.js +++ b/packages/react-reconciler/src/ReactFiberHotReloading.new.js @@ -20,7 +20,7 @@ import { } from './ReactFiberWorkLoop.new'; import {updateContainer} from './ReactFiberReconciler.new'; import {emptyContextObject} from './ReactFiberContext.new'; -import {SyncLane} from './ReactFiberLane'; +import {SyncLane, NoTimestamp} from './ReactFiberLane'; import { ClassComponent, FunctionComponent, @@ -319,7 +319,7 @@ function scheduleFibersWithFamiliesRecursively( fiber._debugNeedsRemount = true; } if (needsRemount || needsRender) { - scheduleUpdateOnFiber(fiber, SyncLane); + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp); } if (child !== null && !needsRemount) { scheduleFibersWithFamiliesRecursively( diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js index 65d35d7640..68c3afbd54 100644 --- a/packages/react-reconciler/src/ReactFiberLane.js +++ b/packages/react-reconciler/src/ReactFiberLane.js @@ -29,6 +29,7 @@ export opaque type LanePriority = | 16; export opaque type Lanes = number; export opaque type Lane = number; +export opaque type LaneMap = Array; import invariant from 'shared/invariant'; @@ -66,7 +67,7 @@ const IdleLanePriority: LanePriority = 2; const OffscreenLanePriority: LanePriority = 1; -const NoLanePriority: LanePriority = 0; +export const NoLanePriority: LanePriority = 0; const TotalLanes = 31; @@ -117,6 +118,8 @@ const IdleUpdateRangeEnd = 30; export const OffscreenLane: Lane = /* */ 0b1000000000000000000000000000000; +export const NoTimestamp = -1; + // "Registers" used to "return" multiple values // Used by getHighestPriorityLanes and getNextLanes: let return_highestLanePriority: LanePriority = DefaultLanePriority; @@ -365,6 +368,63 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes { return nextLanes; } +function computeExpirationTime(lane: Lane, currentTime: number) { + // TODO: Expiration heuristic is constant per lane, so could use a map. + getHighestPriorityLanes(lane); + const priority = return_highestLanePriority; + if (priority >= InputContinuousLanePriority) { + // User interactions should expire slightly more quickly. + return currentTime + 1000; + } else if (priority >= TransitionLongLanePriority) { + return currentTime + 5000; + } else { + // Anything idle priority or lower should never expire. + return NoTimestamp; + } +} + +export function markStarvedLanesAsExpired( + root: FiberRoot, + currentTime: number, +): void { + // TODO: This gets called every time we yield. We can optimize by storing + // the earliest expiration time on the root. Then use that to quickly bail out + // of this function. + + const pendingLanes = root.pendingLanes; + const suspendedLanes = root.suspendedLanes; + const pingedLanes = root.pingedLanes; + const expirationTimes = root.expirationTimes; + + // Iterate through the pending lanes and check if we've reached their + // expiration time. If so, we'll assume the update is being starved and mark + // it as expired to force it to finish. + let lanes = pendingLanes; + while (lanes > 0) { + const index = ctrz(lanes); + const lane = 1 << index; + + const expirationTime = expirationTimes[index]; + if (expirationTime === NoTimestamp) { + // Found a pending lane with no expiration time. If it's not suspended, or + // if it's pinged, assume it's CPU-bound. Compute a new expiration time + // using the current time. + if ( + (lane & suspendedLanes) === NoLanes || + (lane & pingedLanes) !== NoLanes + ) { + // Assumes timestamps are monotonically increasing. + expirationTimes[index] = computeExpirationTime(lane, currentTime); + } + } else if (expirationTime <= currentTime) { + // This lane expired + root.expiredLanes |= lane; + } + + lanes &= ~lane; + } +} + // This returns the highest priority pending lanes regardless of whether they // are suspended. export function getHighestPriorityPendingLanes(root: FiberRoot) { @@ -555,6 +615,10 @@ export function higherPriorityLane(a: Lane, b: Lane) { return a !== NoLane && a < b ? a : b; } +export function createLaneMap(initial: T): LaneMap { + return new Array(TotalLanes).fill(initial); +} + export function markRootUpdated(root: FiberRoot, updateLane: Lane) { root.pendingLanes |= updateLane; @@ -570,6 +634,7 @@ export function markRootUpdated(root: FiberRoot, updateLane: Lane) { // Unsuspend any update at equal or lower priority. const higherPriorityLanes = updateLane - 1; // Turns 0b1000 into 0b0111 + root.suspendedLanes &= higherPriorityLanes; root.pingedLanes &= higherPriorityLanes; } @@ -577,9 +642,25 @@ export function markRootUpdated(root: FiberRoot, updateLane: Lane) { export function markRootSuspended(root: FiberRoot, suspendedLanes: Lanes) { root.suspendedLanes |= suspendedLanes; root.pingedLanes &= ~suspendedLanes; + + // The suspended lanes are no longer CPU-bound. Clear their expiration times. + const expirationTimes = root.expirationTimes; + let lanes = suspendedLanes; + while (lanes > 0) { + const index = ctrz(lanes); + const lane = 1 << index; + + expirationTimes[index] = NoTimestamp; + + lanes &= ~lane; + } } -export function markRootPinged(root: FiberRoot, pingedLanes: Lanes) { +export function markRootPinged( + root: FiberRoot, + pingedLanes: Lanes, + eventTime: number, +) { root.pingedLanes |= root.suspendedLanes & pingedLanes; } @@ -600,6 +681,8 @@ export function markRootMutableRead(root: FiberRoot, updateLane: Lane) { } export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { + const noLongerPendingLanes = root.pendingLanes & ~remainingLanes; + root.pendingLanes = remainingLanes; // Let's try everything again @@ -608,6 +691,18 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) { root.expiredLanes &= remainingLanes; root.mutableReadLanes &= remainingLanes; + + const expirationTimes = root.expirationTimes; + let lanes = noLongerPendingLanes; + while (lanes > 0) { + const index = ctrz(lanes); + const lane = 1 << index; + + // Clear the expiration time + expirationTimes[index] = -1; + + lanes &= ~lane; + } } export function getBumpedLaneForHydration( @@ -671,18 +766,25 @@ export function getBumpedLaneForHydration( const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback; -// Taken from: +// Count leading zeros. Only used on lanes, so assume input is an integer. +// Based on: // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32 const log = Math.log; const LN2 = Math.LN2; -function clz32Fallback(x) { - // Let n be ToUint32(x). - // Let p be the number of leading zero bits in - // the 32-bit binary representation of n. - // Return p. - const asUint = x >>> 0; - if (asUint === 0) { +function clz32Fallback(lanes: Lanes | Lane) { + if (lanes === 0) { return 32; } - return (31 - ((log(asUint) / LN2) | 0)) | 0; // the "| 0" acts like math.floor + return (31 - ((log(lanes) / LN2) | 0)) | 0; +} + +// Count trailing zeros. Only used on lanes, so assume input is an integer. +function ctrz(lanes: Lanes | Lane) { + let bits = lanes; + bits |= bits << 16; + bits |= bits << 8; + bits |= bits << 4; + bits |= bits << 2; + bits |= bits << 1; + return 32 - clz32(~bits); } diff --git a/packages/react-reconciler/src/ReactFiberNewContext.new.js b/packages/react-reconciler/src/ReactFiberNewContext.new.js index ab603a08e5..aa3141c3d5 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.new.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.new.js @@ -22,6 +22,7 @@ import { } from './ReactWorkTags'; import { NoLanes, + NoTimestamp, isSubsetOfLanes, includesSomeLane, mergeLanes, @@ -209,7 +210,7 @@ export function propagateContextChange( if (fiber.tag === ClassComponent) { // Schedule a force update on the work-in-progress. const update = createUpdate( - -1, + NoTimestamp, pickArbitraryLane(renderLanes), null, ); diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.js b/packages/react-reconciler/src/ReactFiberReconciler.new.js index ded0241bb2..eb3eb6a760 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -76,6 +76,7 @@ import { SyncLane, InputDiscreteHydrationLane, SelectiveHydrationLane, + NoTimestamp, getHighestPriorityPendingLanes, higherPriorityLane, } from './ReactFiberLane'; @@ -307,7 +308,7 @@ export function updateContainer( } enqueueUpdate(current, update); - scheduleUpdateOnFiber(current, lane); + scheduleUpdateOnFiber(current, lane, eventTime); return lane; } @@ -352,7 +353,8 @@ export function attemptSynchronousHydration(fiber: Fiber): void { } break; case SuspenseComponent: - flushSync(() => scheduleUpdateOnFiber(fiber, SyncLane)); + const eventTime = requestEventTime(); + flushSync(() => scheduleUpdateOnFiber(fiber, SyncLane, eventTime)); // If we're still blocked after this, we need to increase // the priority of any promises resolving within this // boundary so that they next attempt also has higher pri. @@ -389,8 +391,9 @@ export function attemptUserBlockingHydration(fiber: Fiber): void { // Suspense. return; } + const eventTime = requestEventTime(); const lane = InputDiscreteHydrationLane; - scheduleUpdateOnFiber(fiber, lane); + scheduleUpdateOnFiber(fiber, lane, eventTime); markRetryLaneIfNotHydrated(fiber, lane); } @@ -402,8 +405,9 @@ export function attemptContinuousHydration(fiber: Fiber): void { // Suspense. return; } + const eventTime = requestEventTime(); const lane = SelectiveHydrationLane; - scheduleUpdateOnFiber(fiber, lane); + scheduleUpdateOnFiber(fiber, lane, eventTime); markRetryLaneIfNotHydrated(fiber, lane); } @@ -413,8 +417,9 @@ export function attemptHydrationAtCurrentPriority(fiber: Fiber): void { // their priority other than synchronously flush it. return; } + const eventTime = requestEventTime(); const lane = requestUpdateLane(fiber, null); - scheduleUpdateOnFiber(fiber, lane); + scheduleUpdateOnFiber(fiber, lane, eventTime); markRetryLaneIfNotHydrated(fiber, lane); } @@ -497,7 +502,7 @@ if (__DEV__) { // Shallow cloning props works as a workaround for now to bypass the bailout check. fiber.memoizedProps = {...fiber.memoizedProps}; - scheduleUpdateOnFiber(fiber, SyncLane); + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp); } }; @@ -507,11 +512,11 @@ if (__DEV__) { if (fiber.alternate) { fiber.alternate.pendingProps = fiber.pendingProps; } - scheduleUpdateOnFiber(fiber, SyncLane); + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp); }; scheduleUpdate = (fiber: Fiber) => { - scheduleUpdateOnFiber(fiber, SyncLane); + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp); }; setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => { diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index b3fc36d517..2a116e5855 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -12,7 +12,12 @@ import type {RootTag} from './ReactRootTags'; import {noTimeout} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber.new'; -import {NoLanes} from './ReactFiberLane'; +import { + NoLanes, + NoLanePriority, + NoTimestamp, + createLaneMap, +} from './ReactFiberLane'; import { enableSchedulerTracing, enableSuspenseCallback, @@ -33,8 +38,8 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.hydrate = hydrate; this.callbackNode = null; this.callbackId = NoLanes; - this.callbackIsSync = false; - this.expiresAt = -1; + this.callbackPriority_new = NoLanePriority; + this.expirationTimes = createLaneMap(NoTimestamp); this.pendingLanes = NoLanes; this.suspendedLanes = NoLanes; diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js index 60339c86e8..72d03a5969 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -36,7 +36,7 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.pendingContext = null; this.hydrate = hydrate; this.callbackNode = null; - this.callbackPriority = NoPriority; + this.callbackPriority_old = NoPriority; this.firstPendingTime = NoWork; this.lastPendingTime = NoWork; this.firstSuspendedTime = NoWork; diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 190ca9d473..39f2f08d0e 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -56,6 +56,7 @@ import {logCapturedError} from './ReactFiberErrorLogger'; import { SyncLane, + NoTimestamp, includesSomeLane, mergeLanes, pickArbitraryLane, @@ -68,7 +69,7 @@ function createRootErrorUpdate( errorInfo: CapturedValue, lane: Lane, ): Update { - const update = createUpdate(-1, lane, null); + const update = createUpdate(NoTimestamp, lane, null); // Unmount the root by rendering null. update.tag = CaptureUpdate; // Caution: React DevTools currently depends on this property @@ -87,7 +88,7 @@ function createClassErrorUpdate( errorInfo: CapturedValue, lane: Lane, ): Update { - const update = createUpdate(-1, lane, null); + const update = createUpdate(NoTimestamp, lane, null); update.tag = CaptureUpdate; const getDerivedStateFromError = fiber.type.getDerivedStateFromError; if (typeof getDerivedStateFromError === 'function') { @@ -254,7 +255,7 @@ function throwException( // When we try rendering again, we should not reuse the current fiber, // since it's known to be in an inconsistent state. Use a force update to // prevent a bail out. - const update = createUpdate(-1, SyncLane, null); + const update = createUpdate(NoTimestamp, SyncLane, null); update.tag = ForceUpdate; enqueueUpdate(sourceFiber, update); } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index f1bc9f900b..bf54a23a5f 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -109,6 +109,7 @@ import { HydratingAndUpdate, } from './ReactSideEffectTags'; import { + NoLanePriority, SyncLanePriority, InputDiscreteLanePriority, TransitionShortLanePriority, @@ -118,6 +119,7 @@ import { SyncLane, SyncBatchedLane, OffscreenLane, + NoTimestamp, findUpdateLane, findTransitionLane, includesSomeLane, @@ -129,6 +131,7 @@ import { hasUpdatePriority, getNextLanes, returnNextLanesPriority, + markStarvedLanesAsExpired, getLanesToRetrySynchronouslyOnError, markRootUpdated, markRootSuspended as markRootSuspended_dontCallThisOneDirectly, @@ -253,8 +256,8 @@ let workInProgressRootExitStatus: RootExitStatus = RootIncomplete; // A fatal error, if one is thrown let workInProgressRootFatalError: mixed = null; // Most recent event time among processed updates during this render. -let workInProgressRootLatestProcessedEventTime: number = -1; -let workInProgressRootLatestSuspenseTimeout: number = -1; +let workInProgressRootLatestProcessedEventTime: number = NoTimestamp; +let workInProgressRootLatestSuspenseTimeout: number = NoTimestamp; let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null; // "Included" lanes refer to lanes that were worked on during this render. It's // slightly different than `renderLanes` because `renderLanes` can change as you @@ -310,7 +313,7 @@ let spawnedWorkDuringRender: null | Array = null; // If two updates are scheduled within the same event, we should treat their // event times as simultaneous, even if the actual clock time has advanced // between the first and second call. -let currentEventTime: number = -1; +let currentEventTime: number = NoTimestamp; let currentEventWipLanes: Lanes = NoLanes; let currentEventPendingLanes: Lanes = NoLanes; @@ -331,7 +334,7 @@ export function requestEventTime() { return now(); } // We're not inside React, so we may be in the middle of a browser event. - if (currentEventTime !== -1) { + if (currentEventTime !== NoTimestamp) { // Use the same start time for all updates until we enter React again. return currentEventTime; } @@ -434,7 +437,11 @@ export function requestUpdateLane( return lane; } -export function scheduleUpdateOnFiber(fiber: Fiber, lane: Lane) { +export function scheduleUpdateOnFiber( + fiber: Fiber, + lane: Lane, + eventTime: number, +) { checkForNestedUpdates(); warnAboutRenderPhaseUpdatesInDEV(fiber); @@ -463,7 +470,7 @@ export function scheduleUpdateOnFiber(fiber: Fiber, lane: Lane) { // should be deferred until the end of the batch. performSyncWorkOnRoot(root); } else { - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, eventTime); schedulePendingInteractions(root, lane); if (executionContext === NoContext) { // Flush the synchronous work now, unless we're already working or inside @@ -492,7 +499,7 @@ export function scheduleUpdateOnFiber(fiber: Fiber, lane: Lane) { } } // Schedule other updates after in case the callback is sync. - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, eventTime); schedulePendingInteractions(root, lane); } @@ -592,40 +599,39 @@ function markUpdateLaneFromFiberToRoot( // expiration time of the existing task is the same as the expiration time of // the next level that the root has work on. This function is called on every // update, and right before exiting a task. -function ensureRootIsScheduled(root: FiberRoot) { +function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { const existingCallbackNode = root.callbackNode; + // Check if any lanes are being starved by other work. If so, mark them as + // expired so we know to work on those next. + markStarvedLanesAsExpired(root, currentTime); + + // Determine the next lanes to work on, and their priority. const newCallbackId = getNextLanes( root, root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, ); // This returns the priority level computed during the `getNextLanes` call. - const newCallbackPriorityLevel = returnNextLanesPriority(); + const newCallbackPriority = returnNextLanesPriority(); if (newCallbackId === NoLanes) { // Special case: There's nothing to work on. if (existingCallbackNode !== null) { cancelCallback(existingCallbackNode); - root.expiresAt = -1; root.callbackNode = null; - root.callbackIsSync = false; + root.callbackPriority_new = NoLanePriority; root.callbackId = NoLanes; } return; } - const newTaskIsSync = newCallbackPriorityLevel === SyncLanePriority; - // Check if there's an existing task. We may be able to reuse it. - const existingTaskId = root.callbackId; - const existingCallbackIsSync = root.callbackIsSync; - if (existingTaskId !== NoLanes) { - if (newCallbackId === existingTaskId) { + const existingCallbackId = root.callbackId; + const existingCallbackPriority = root.callbackPriority_new; + if (existingCallbackId !== NoLanes) { + if (newCallbackId === existingCallbackId) { // This task is already scheduled. Let's check its priority. - if ( - (newTaskIsSync && existingCallbackIsSync) || - (!newTaskIsSync && !existingCallbackIsSync) - ) { + if (existingCallbackPriority === newCallbackPriority) { // The priority hasn't changed. Exit. return; } @@ -637,7 +643,7 @@ function ensureRootIsScheduled(root: FiberRoot) { // Schedule a new callback. let newCallbackNode; - if (newTaskIsSync) { + if (newCallbackPriority === SyncLanePriority) { // Special case: Sync React callbacks are scheduled on a special // internal queue newCallbackNode = scheduleSyncCallback( @@ -645,7 +651,7 @@ function ensureRootIsScheduled(root: FiberRoot) { ); } else { const schedulerPriorityLevel = lanePriorityToSchedulerPriority( - newCallbackPriorityLevel, + newCallbackPriority, ); newCallbackNode = scheduleCallback( schedulerPriorityLevel, @@ -654,8 +660,8 @@ function ensureRootIsScheduled(root: FiberRoot) { } root.callbackId = newCallbackId; + root.callbackPriority_new = newCallbackPriority; root.callbackNode = newCallbackNode; - root.callbackIsSync = newTaskIsSync; } // This is the entry point for every concurrent task, i.e. anything that @@ -663,7 +669,7 @@ function ensureRootIsScheduled(root: FiberRoot) { function performConcurrentWorkOnRoot(root, didTimeout) { // Since we know we're in a React event, we can clear the current // event time. The next update will compute a new event time. - currentEventTime = -1; + currentEventTime = NoTimestamp; currentEventWipLanes = NoLanes; currentEventPendingLanes = NoLanes; @@ -677,29 +683,16 @@ function performConcurrentWorkOnRoot(root, didTimeout) { return null; } - // Check if any work has expired. - const rootExpiresAt = root.expiresAt; - if (rootExpiresAt !== -1 && rootExpiresAt < now()) { + // TODO: We only check `didTimeout` defensively, to account for a Scheduler + // bug where `shouldYield` sometimes returns `true` even if `didTimeout` is + // true, which leads to an infinite loop. Once the bug in Scheduler is + // fixed, we can remove this, since we track expiration ourselves. + if (didTimeout) { // Something expired. Flush synchronously until there's no expired // work left. - // TODO: Should flush only the lanes that have expired, and maybe any lanes - // that are higher priority than that. markRootExpired(root, lanes); // This will schedule a synchronous callback. - ensureRootIsScheduled(root); - return null; - } - // Similar branch, but for Scheduler. - // TODO: This is only here to account for a Scheduler bug where `shouldYield` - // sometimes returns `true` even if `didTimeout` is true, which leads to - // an infinite loop. Once the bug in Scheduler is fixed, we can remove this, - // since we track expiration times ourselves. - if (didTimeout) { - // The Scheduler task took too long to complete. Mark the root as expired to - // prevent yielding to other tasks until this one finishes. - markRootExpired(root, lanes); - // This will schedule a synchronous callback. - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, now()); return null; } @@ -742,7 +735,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) { const fatalError = workInProgressRootFatalError; prepareFreshStack(root, NoLanes); markRootSuspended(root, lanes); - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, now()); throw fatalError; } @@ -754,7 +747,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) { finishConcurrentRender(root, finishedWork, exitStatus, lanes); } - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, now()); if (root.callbackNode === originalCallbackNode) { // The task node scheduled for this root is the same one that's // currently executed. Need to return a continuation. @@ -788,7 +781,7 @@ function finishConcurrentRender(root, finishedWork, exitStatus, lanes) { // have a new loading state ready. We want to ensure that we commit // that as soon as possible. const hasNotProcessedNewUpdates = - workInProgressRootLatestProcessedEventTime === -1; + workInProgressRootLatestProcessedEventTime === NoTimestamp; if ( hasNotProcessedNewUpdates && // do not delay if we're inside an act() scope @@ -815,7 +808,8 @@ function finishConcurrentRender(root, finishedWork, exitStatus, lanes) { // suspended level. Ping the last suspended level to try // rendering it again. // FIXME: What if the suspended lanes are Idle? Should not restart. - markRootPinged(root, suspendedLanes); + const eventTime = requestEventTime(); + markRootPinged(root, suspendedLanes, eventTime); break; } @@ -853,16 +847,17 @@ function finishConcurrentRender(root, finishedWork, exitStatus, lanes) { // suspended level. Ping the last suspended level to try // rendering it again. // FIXME: What if the suspended lanes are Idle? Should not restart. - markRootPinged(root, suspendedLanes); + const eventTime = requestEventTime(); + markRootPinged(root, suspendedLanes, eventTime); break; } let msUntilTimeout; - if (workInProgressRootLatestSuspenseTimeout !== -1) { + if (workInProgressRootLatestSuspenseTimeout !== NoTimestamp) { // We have processed a suspense config whose expiration time we // can use as the timeout. msUntilTimeout = workInProgressRootLatestSuspenseTimeout - now(); - } else if (workInProgressRootLatestProcessedEventTime === -1) { + } else if (workInProgressRootLatestProcessedEventTime === NoTimestamp) { // This should never normally happen because only new updates // cause delayed states, so we should have processed something. // However, this could also happen in an offscreen tree. @@ -896,7 +891,7 @@ function finishConcurrentRender(root, finishedWork, exitStatus, lanes) { if ( // do not delay if we're inside an act() scope !shouldForceFlushFallbacksInDEV() && - workInProgressRootLatestProcessedEventTime !== -1 && + workInProgressRootLatestProcessedEventTime !== NoTimestamp && workInProgressRootCanSuspendUsingConfig !== null ) { // If we have exceeded the minimum loading delay, which probably @@ -992,7 +987,7 @@ function performSyncWorkOnRoot(root) { const fatalError = workInProgressRootFatalError; prepareFreshStack(root, NoLanes); markRootSuspended(root, lanes); - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, now()); throw fatalError; } @@ -1005,14 +1000,14 @@ function performSyncWorkOnRoot(root) { // Before exiting, make sure there's a callback scheduled for the next // pending level. - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, now()); return null; } export function flushRoot(root: FiberRoot, lanes: Lanes) { markRootExpired(root, lanes); - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, now()); if ((executionContext & (RenderContext | CommitContext)) === NoContext) { flushSyncCallbackQueue(); } @@ -1059,7 +1054,7 @@ function flushPendingDiscreteUpdates() { rootsWithPendingDiscreteUpdates = null; roots.forEach(root => { markDiscreteUpdatesExpired(root); - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, now()); }); } // Now flush the immediate queue. @@ -1214,8 +1209,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) { workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes; workInProgressRootExitStatus = RootIncomplete; workInProgressRootFatalError = null; - workInProgressRootLatestProcessedEventTime = -1; - workInProgressRootLatestSuspenseTimeout = -1; + workInProgressRootLatestProcessedEventTime = NoTimestamp; + workInProgressRootLatestSuspenseTimeout = NoTimestamp; workInProgressRootCanSuspendUsingConfig = null; workInProgressRootSkippedLanes = NoLanes; workInProgressRootUpdatedLanes = NoLanes; @@ -1785,11 +1780,6 @@ function commitRootImpl(root, renderPriorityLevel) { // So we can clear these now to allow a new callback to be scheduled. root.callbackNode = null; root.callbackId = NoLanes; - // TODO: Use LanePriority instead of SchedulerPriority - if (renderPriorityLevel < ImmediateSchedulerPriority) { - // If this was a concurrent render, we can reset the expiration time. - root.expiresAt = -1; - } // Update the first and last pending times on this root. The new first // pending time is whatever is left on the root fiber. @@ -2046,7 +2036,7 @@ function commitRootImpl(root, renderPriorityLevel) { // Always call this before exiting `commitRoot`, to ensure that any // additional work on this root is scheduled. - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, now()); if (hasUncaughtError) { hasUncaughtError = false; @@ -2504,9 +2494,10 @@ function captureCommitPhaseErrorOnRoot( const errorInfo = createCapturedValue(error, sourceFiber); const update = createRootErrorUpdate(rootFiber, errorInfo, (SyncLane: Lane)); enqueueUpdate(rootFiber, update); + const eventTime = requestEventTime(); const root = markUpdateLaneFromFiberToRoot(rootFiber, (SyncLane: Lane)); if (root !== null) { - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, eventTime); schedulePendingInteractions(root, SyncLane); } } @@ -2539,9 +2530,10 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) { (SyncLane: Lane), ); enqueueUpdate(fiber, update); + const eventTime = requestEventTime(); const root = markUpdateLaneFromFiberToRoot(fiber, (SyncLane: Lane)); if (root !== null) { - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, eventTime); schedulePendingInteractions(root, SyncLane); } return; @@ -2563,7 +2555,8 @@ export function pingSuspendedRoot( pingCache.delete(wakeable); } - markRootPinged(root, pingedLanes); + const eventTime = requestEventTime(); + markRootPinged(root, pingedLanes, eventTime); if ( workInProgressRoot === root && @@ -2585,7 +2578,7 @@ export function pingSuspendedRoot( if ( workInProgressRootExitStatus === RootSuspendedWithDelay || (workInProgressRootExitStatus === RootSuspended && - workInProgressRootLatestProcessedEventTime === -1 && + workInProgressRootLatestProcessedEventTime === NoTimestamp && now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS) ) { // Restart from the root. @@ -2600,7 +2593,7 @@ export function pingSuspendedRoot( } } - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, eventTime); schedulePendingInteractions(root, pingedLanes); } @@ -2616,9 +2609,10 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) { retryLane = requestUpdateLane(boundaryFiber, suspenseConfig); } // TODO: Special case idle priority? + const eventTime = requestEventTime(); const root = markUpdateLaneFromFiberToRoot(boundaryFiber, retryLane); if (root !== null) { - ensureRootIsScheduled(root); + ensureRootIsScheduled(root, eventTime); schedulePendingInteractions(root, retryLane); } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 95110e375f..b984a3fa66 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -620,7 +620,7 @@ function ensureRootIsScheduled(root: FiberRoot) { if (lastExpiredTime !== NoWork) { // Special case: Expired work should flush synchronously. root.callbackExpirationTime = Sync; - root.callbackPriority = ImmediatePriority; + root.callbackPriority_old = ImmediatePriority; root.callbackNode = scheduleSyncCallback( performSyncWorkOnRoot.bind(null, root), ); @@ -634,7 +634,7 @@ function ensureRootIsScheduled(root: FiberRoot) { if (existingCallbackNode !== null) { root.callbackNode = null; root.callbackExpirationTime = NoWork; - root.callbackPriority = NoPriority; + root.callbackPriority_old = NoPriority; } return; } @@ -650,7 +650,7 @@ function ensureRootIsScheduled(root: FiberRoot) { // If there's an existing render task, confirm it has the correct priority and // expiration time. Otherwise, we'll cancel it and schedule a new one. if (existingCallbackNode !== null) { - const existingCallbackPriority = root.callbackPriority; + const existingCallbackPriority = root.callbackPriority_old; const existingCallbackExpirationTime = root.callbackExpirationTime; if ( // Callback must have the exact same expiration time. @@ -668,7 +668,7 @@ function ensureRootIsScheduled(root: FiberRoot) { } root.callbackExpirationTime = expirationTime; - root.callbackPriority = priorityLevel; + root.callbackPriority_old = priorityLevel; let callbackNode; if (expirationTime === Sync) { @@ -1878,7 +1878,7 @@ function commitRootImpl(root, renderPriorityLevel) { // So we can clear these now to allow a new callback to be scheduled. root.callbackNode = null; root.callbackExpirationTime = NoWork; - root.callbackPriority = NoPriority; + root.callbackPriority_old = NoPriority; // Update the first and last pending times on this root. The new first // pending time is whatever is left on the root fiber. diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 0f6daf6e05..246d68fac3 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -23,7 +23,7 @@ import type {WorkTag} from './ReactWorkTags'; import type {TypeOfMode} from './ReactTypeOfMode'; import type {SideEffectTag} from './ReactSideEffectTags'; import type {ExpirationTime} from './ReactFiberExpirationTime.old'; -import type {Lane, Lanes} from './ReactFiberLane'; +import type {Lane, LanePriority, Lanes, LaneMap} from './ReactFiberLane'; import type {HookType} from './ReactFiberHooks.old'; import type {RootTag} from './ReactRootTags'; import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig'; @@ -226,7 +226,7 @@ type BaseFiberRootProperties = {| // Expiration of the callback associated with this root callbackExpirationTime: ExpirationTime, // Priority of the callback associated with this root - callbackPriority: ReactPriorityLevel, + callbackPriority_old: ReactPriorityLevel, finishedExpirationTime: ExpirationTime, // The earliest pending expiration time that exists in the tree @@ -252,17 +252,8 @@ type BaseFiberRootProperties = {| // Represents the next task that the root should work on, or the current one // if it's already working. callbackId: Lanes, - // Whether the currently scheduled task for this root is synchronous or - // batched/concurrent. We have to track this because Scheduler does not - // support synchronous tasks, so we put those on a separate queue. So you - // could also think of this as "which queue is the callback scheduled with?" - callbackIsSync: boolean, - // Timestamp at which we will synchronously finish the current task to - // prevent starvation. - // TODO: There should be a separate expiration per lane. - // NOTE: This is not an "ExpirationTime" as used by the old reconciler. It's a - // timestamp, in milliseconds. - expiresAt: number, + callbackPriority_new: LanePriority, + expirationTimes: LaneMap, pendingLanes: Lanes, suspendedLanes: Lanes, diff --git a/packages/react-reconciler/src/__tests__/ReactExpiration-test.js b/packages/react-reconciler/src/__tests__/ReactExpiration-test.js index bdb512d774..d19a14e58c 100644 --- a/packages/react-reconciler/src/__tests__/ReactExpiration-test.js +++ b/packages/react-reconciler/src/__tests__/ReactExpiration-test.js @@ -12,6 +12,8 @@ let React; let ReactNoop; let Scheduler; +let readText; +let resolveText; describe('ReactExpiration', () => { beforeEach(() => { @@ -20,6 +22,53 @@ describe('ReactExpiration', () => { React = require('react'); ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); + + const textCache = new Map(); + + readText = text => { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + throw record.promise; + case 'rejected': + throw Error('Failed to load: ' + text); + case 'resolved': + return text; + } + } else { + let ping; + const promise = new Promise(resolve => (ping = resolve)); + const newRecord = { + status: 'pending', + ping: ping, + promise, + }; + textCache.set(text, newRecord); + throw promise; + } + }; + + resolveText = text => { + const record = textCache.get(text); + if (record !== undefined) { + if (record.status === 'pending') { + Scheduler.unstable_yieldValue(`Promise resolved [${text}]`); + record.ping(); + record.ping = null; + record.status = 'resolved'; + clearTimeout(record.promise._timer); + record.promise = null; + } + } else { + const newRecord = { + ping: null, + status: 'resolved', + promise: null, + }; + textCache.set(text, newRecord); + } + }; }); function Text(props) { @@ -27,6 +76,27 @@ describe('ReactExpiration', () => { return props.text; } + function AsyncText(props) { + const text = props.text; + try { + readText(text); + Scheduler.unstable_yieldValue(text); + return text; + } catch (promise) { + if (typeof promise.then === 'function') { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + if (typeof props.ms === 'number' && promise._timer === undefined) { + promise._timer = setTimeout(() => { + resolveText(text); + }, props.ms); + } + } else { + Scheduler.unstable_yieldValue(`Error! [${text}]`); + } + throw promise; + } + } + function span(prop) { return {type: 'span', children: [], prop, hidden: false}; } @@ -348,4 +418,408 @@ describe('ReactExpiration', () => { expect(Scheduler).toFlushExpired([]); expect(ReactNoop).toMatchRenderedOutput('Hi'); }); + + it('prevents starvation by high priority updates', async () => { + const {useState} = React; + + let updateHighPri; + let updateNormalPri; + function App() { + const [highPri, setHighPri] = useState(0); + const [normalPri, setNormalPri] = useState(0); + updateHighPri = () => + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => setHighPri(n => n + 1), + ); + updateNormalPri = () => setNormalPri(n => n + 1); + return ( + <> + + {', '} + + + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['High pri: 0', 'Normal pri: 0']); + expect(root).toMatchRenderedOutput('High pri: 0, Normal pri: 0'); + + // First demonstrate what happens when there's no starvation + await ReactNoop.act(async () => { + updateNormalPri(); + expect(Scheduler).toFlushAndYieldThrough(['High pri: 0']); + updateHighPri(); + }); + expect(Scheduler).toHaveYielded([ + // Interrupt high pri update to render sync update + 'High pri: 1', + 'Normal pri: 0', + // Now render normal pri + 'High pri: 1', + 'Normal pri: 1', + ]); + expect(root).toMatchRenderedOutput('High pri: 1, Normal pri: 1'); + + // Do the same thing, but starve the first update + await ReactNoop.act(async () => { + updateNormalPri(); + expect(Scheduler).toFlushAndYieldThrough(['High pri: 1']); + + // This time, a lot of time has elapsed since the normal pri update + // started rendering. (This should advance time by some number that's + // definitely bigger than the constant heuristic we use to detect + // starvation of normal priority updates.) + Scheduler.unstable_advanceTime(10000); + + // So when we get a high pri update, we shouldn't interrupt + updateHighPri(); + }); + expect(Scheduler).toHaveYielded([ + // Finish normal pri update + 'Normal pri: 2', + // Then do high pri update + 'High pri: 2', + 'Normal pri: 2', + ]); + expect(root).toMatchRenderedOutput('High pri: 2, Normal pri: 2'); + }); + + // @gate new + it('prevents starvation by sync updates', async () => { + const {useState} = React; + + let updateSyncPri; + let updateHighPri; + function App() { + const [syncPri, setSyncPri] = useState(0); + const [highPri, setHighPri] = useState(0); + updateSyncPri = () => ReactNoop.flushSync(() => setSyncPri(n => n + 1)); + updateHighPri = () => + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => setHighPri(n => n + 1), + ); + return ( + <> + + {', '} + + + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Sync pri: 0', 'High pri: 0']); + expect(root).toMatchRenderedOutput('Sync pri: 0, High pri: 0'); + + // First demonstrate what happens when there's no starvation + await ReactNoop.act(async () => { + updateHighPri(); + expect(Scheduler).toFlushAndYieldThrough(['Sync pri: 0']); + updateSyncPri(); + }); + expect(Scheduler).toHaveYielded([ + // Interrupt high pri update to render sync update + 'Sync pri: 1', + 'High pri: 0', + // Now render high pri + 'Sync pri: 1', + 'High pri: 1', + ]); + expect(root).toMatchRenderedOutput('Sync pri: 1, High pri: 1'); + + // Do the same thing, but starve the first update + await ReactNoop.act(async () => { + updateHighPri(); + expect(Scheduler).toFlushAndYieldThrough(['Sync pri: 1']); + + // This time, a lot of time has elapsed since the high pri update started + // rendering. (This should advance time by some number that's definitely + // bigger than the constant heuristic we use to detect starvation of user + // interactions, but not as high as the onse used for normal pri updates.) + Scheduler.unstable_advanceTime(1500); + + // So when we get a sync update, we shouldn't interrupt + updateSyncPri(); + }); + expect(Scheduler).toHaveYielded([ + // Finish high pri update + 'High pri: 2', + // Then do sync update + 'Sync pri: 2', + 'High pri: 2', + ]); + expect(root).toMatchRenderedOutput('Sync pri: 2, High pri: 2'); + }); + + it('idle work never expires', async () => { + const {useState} = React; + + let updateSyncPri; + let updateIdlePri; + function App() { + const [syncPri, setSyncPri] = useState(0); + const [highPri, setIdlePri] = useState(0); + updateSyncPri = () => ReactNoop.flushSync(() => setSyncPri(n => n + 1)); + updateIdlePri = () => + Scheduler.unstable_runWithPriority( + Scheduler.unstable_IdlePriority, + () => setIdlePri(n => n + 1), + ); + return ( + <> + + {', '} + + + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Sync pri: 0', 'Idle pri: 0']); + expect(root).toMatchRenderedOutput('Sync pri: 0, Idle pri: 0'); + + // First demonstrate what happens when there's no starvation + await ReactNoop.act(async () => { + updateIdlePri(); + expect(Scheduler).toFlushAndYieldThrough(['Sync pri: 0']); + updateSyncPri(); + }); + expect(Scheduler).toHaveYielded([ + // Interrupt idle update to render sync update + 'Sync pri: 1', + 'Idle pri: 0', + // Now render idle + 'Sync pri: 1', + 'Idle pri: 1', + ]); + expect(root).toMatchRenderedOutput('Sync pri: 1, Idle pri: 1'); + + // Do the same thing, but starve the first update + await ReactNoop.act(async () => { + updateIdlePri(); + expect(Scheduler).toFlushAndYieldThrough(['Sync pri: 1']); + + // Advance a ridiculously large amount of time to demonstrate that the + // idle work never expires + Scheduler.unstable_advanceTime(100000); + + updateSyncPri(); + }); + // Same thing should happen as last time + expect(Scheduler).toHaveYielded([ + // Interrupt idle update to render sync update + 'Sync pri: 2', + 'Idle pri: 1', + // Now render idle + 'Sync pri: 2', + 'Idle pri: 2', + ]); + expect(root).toMatchRenderedOutput('Sync pri: 2, Idle pri: 2'); + }); + + // @gate new + it('a single update can expire without forcing all other updates to expire', async () => { + const {useState} = React; + + let updateHighPri; + let updateNormalPri; + function App() { + const [highPri, setHighPri] = useState(0); + const [normalPri, setNormalPri] = useState(0); + updateHighPri = () => + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => setHighPri(n => n + 1), + ); + updateNormalPri = () => setNormalPri(n => n + 1); + return ( + <> + + {', '} + + {', '} + + + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 'High pri: 0', + 'Normal pri: 0', + 'Sibling', + ]); + expect(root).toMatchRenderedOutput('High pri: 0, Normal pri: 0, Sibling'); + + await ReactNoop.act(async () => { + // Partially render an update + updateNormalPri(); + expect(Scheduler).toFlushAndYieldThrough(['High pri: 0']); + // Some time goes by. In an interleaved event, schedule another update. + // This will be placed into a separate batch. + Scheduler.unstable_advanceTime(4000); + updateNormalPri(); + // Keep rendering the first update + expect(Scheduler).toFlushAndYieldThrough(['Normal pri: 1']); + // More time goes by. Enough to expire the first batch, but not the + // second one. + Scheduler.unstable_advanceTime(1000); + // Attempt to interrupt with a high pri update. + updateHighPri(); + + // The first update expired, so first will finish it without interrupting. + // But not the second update, which hasn't expired yet. + expect(Scheduler).toFlushExpired(['Sibling']); + }); + expect(Scheduler).toHaveYielded([ + // Then render the high pri update + 'High pri: 1', + 'Normal pri: 1', + 'Sibling', + // Then the second normal pri update + 'High pri: 1', + 'Normal pri: 2', + 'Sibling', + ]); + }); + + it('detects starvation in multiple batches', async () => { + const {useState} = React; + + let updateHighPri; + let updateNormalPri; + function App() { + const [highPri, setHighPri] = useState(0); + const [normalPri, setNormalPri] = useState(0); + updateHighPri = () => + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => setHighPri(n => n + 1), + ); + updateNormalPri = () => setNormalPri(n => n + 1); + return ( + <> + + {', '} + + {', '} + + + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded([ + 'High pri: 0', + 'Normal pri: 0', + 'Sibling', + ]); + expect(root).toMatchRenderedOutput('High pri: 0, Normal pri: 0, Sibling'); + + await ReactNoop.act(async () => { + // Partially render an update + updateNormalPri(); + expect(Scheduler).toFlushAndYieldThrough(['High pri: 0']); + // Some time goes by. In an interleaved event, schedule another update. + // This will be placed into a separate batch. + Scheduler.unstable_advanceTime(4000); + updateNormalPri(); + // Keep rendering the first update + expect(Scheduler).toFlushAndYieldThrough(['Normal pri: 1']); + // More time goes by. This expires both of the updates just scheduled. + Scheduler.unstable_advanceTime(10000); + + // Attempt to interrupt with a high pri update. + updateHighPri(); + + // Both normal pri updates should have expired. + expect(Scheduler).toFlushExpired([ + 'Sibling', + // Note: we also flushed the high pri update here, because in the + // current implementation, once we pick the next lanes to work on, we + // entangle it with all pending at equal or higher priority. We could + // feasibly change this heuristic so that the high pri update doesn't + // render until after the expired updates have finished. But the + // important thing in this test is that the normal updates expired. + 'High pri: 1', + 'Normal pri: 2', + 'Sibling', + ]); + }); + }); + + // @gate new + it('updates do not expire while they are IO-bound', async () => { + const {Suspense} = React; + + function App({text}) { + return ( + }> + + {', '} + + + ); + } + + const root = ReactNoop.createRoot(); + await ReactNoop.act(async () => { + await resolveText('A'); + root.render(); + }); + expect(Scheduler).toHaveYielded(['A', 'Sibling']); + expect(root).toMatchRenderedOutput('A, Sibling'); + + await ReactNoop.act(async () => { + root.render(); + expect(Scheduler).toFlushAndYield([ + 'Suspend! [B]', + 'Sibling', + 'Loading...', + ]); + + // Lots of time elapses before the promise resolves + Scheduler.unstable_advanceTime(10000); + await resolveText('B'); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + + // But the update doesn't expire, because it was IO bound. So we can + // partially rendering without finishing. + expect(Scheduler).toFlushAndYieldThrough(['B']); + expect(root).toMatchRenderedOutput('A, Sibling'); + + // Lots more time elapses. We're CPU-bound now, so we should treat this + // as starvation. + Scheduler.unstable_advanceTime(10000); + + // Attempt to interrupt with a sync update. + ReactNoop.flushSync(() => root.render()); + expect(Scheduler).toHaveYielded([ + // Because the previous update had already expired, we don't interrupt + // it. Finish rendering it first. + 'Sibling', + // Then do the sync update. + 'A', + 'Sibling', + ]); + }); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.js b/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.js index 055a2ee5c6..d852480f27 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.js @@ -488,8 +488,8 @@ describe('ReactIncrementalUpdates', () => { expect(ReactNoop.getChildren()).toEqual([span('derived state')]); }); - // Note: This test doesn't really make sense in the new model, but we might - // want to port it once lanes are implemented. + // Note: This test doesn't really make sense in the new model. The + // corresponding concept is tested in ReactExpiration-test. // @gate old it('flushes all expired updates in a single batch', () => { const {useEffect} = React; diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index b3019fc280..9b1ae8f0f2 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -295,13 +295,26 @@ describe('Profiler', () => { ); // Should be called two times: - // 2. To compute the update expiration time - // 3. To record the commit time + // 1. To compute the update expiration time + // 2. To record the commit time // No additional calls from ProfilerTimer are expected. - expect(Scheduler).toHaveYielded([ - 'read current time', - 'read current time', - ]); + expect(Scheduler).toHaveYielded( + gate(flags => + flags.new + ? [ + // The new reconciler reads the current time in more places, + // to detect starvation. This is unrelated to the profiler, + // which happens to use the same Scheduler method that we + // mocked above. We should rewrite this test so that it's + // less fragile. + 'read current time', + 'read current time', + 'read current time', + 'read current time', + ] + : ['read current time', 'read current time'], + ), + ); // Restore original mock jest.mock('scheduler', () =>