diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 7ef93739039e0..facc33f2f4ba9 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -14,6 +14,7 @@ import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig'; import type {Thenable} from './ReactFiberWorkLoop'; import type {Interaction} from 'scheduler/src/Tracing'; import type {SuspenseHydrationCallbacks} from './ReactFiberSuspenseComponent'; +import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; import {noTimeout} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber'; @@ -23,6 +24,7 @@ import { enableSuspenseCallback, } from 'shared/ReactFeatureFlags'; import {unstable_getThreadID} from 'scheduler/tracing'; +import {NoPriority} from './SchedulerWithReactIntegration'; // TODO: This should be lifted into the renderer. export type Batch = { @@ -69,12 +71,21 @@ type BaseFiberRootProperties = {| callbackNode: *, // Expiration of the callback associated with this root callbackExpirationTime: ExpirationTime, + // Priority of the callback associated with this root + callbackPriority: ReactPriorityLevel, // The earliest pending expiration time that exists in the tree firstPendingTime: ExpirationTime, // The latest pending expiration time that exists in the tree lastPendingTime: ExpirationTime, - // The time at which a suspended component pinged the root to render again - pingTime: ExpirationTime, + // The earliest suspended expiration time that exists in the tree + firstSuspendedTime: ExpirationTime, + // The latest suspended expiration time that exists in the tree + lastSuspendedTime: ExpirationTime, + // The next known expiration time after the suspended range + nextKnownPendingLevel: ExpirationTime, + // The latest time at which a suspended component pinged the root to + // render again + lastPingedTime: ExpirationTime, |}; // The following attributes are only used by interaction tracing builds. @@ -117,10 +128,13 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.hydrate = hydrate; this.firstBatch = null; this.callbackNode = null; - this.callbackExpirationTime = NoWork; + this.callbackPriority = NoPriority; this.firstPendingTime = NoWork; this.lastPendingTime = NoWork; - this.pingTime = NoWork; + this.firstSuspendedTime = NoWork; + this.lastSuspendedTime = NoWork; + this.nextKnownPendingLevel = NoWork; + this.lastPingedTime = NoWork; if (enableSchedulerTracing) { this.interactionThreadID = unstable_getThreadID(); @@ -151,3 +165,98 @@ export function createFiberRoot( return root; } + +export function isRootSuspendedAtTime( + root: FiberRoot, + expirationTime: ExpirationTime, +): boolean { + const firstSuspendedTime = root.firstSuspendedTime; + const lastSuspendedTime = root.lastSuspendedTime; + return ( + firstSuspendedTime !== NoWork && + (firstSuspendedTime >= expirationTime && + lastSuspendedTime <= expirationTime) + ); +} + +export function markRootSuspendedAtTime( + root: FiberRoot, + expirationTime: ExpirationTime, +): void { + const firstSuspendedTime = root.firstSuspendedTime; + const lastSuspendedTime = root.lastSuspendedTime; + if (firstSuspendedTime < expirationTime) { + root.firstSuspendedTime = expirationTime; + } + if (lastSuspendedTime > expirationTime || firstSuspendedTime === NoWork) { + root.lastSuspendedTime = expirationTime; + } + + if (expirationTime <= root.lastPingedTime) { + root.lastPingedTime = NoWork; + } +} + +export function markRootUpdatedAtTime( + root: FiberRoot, + expirationTime: ExpirationTime, +): void { + // Update the range of pending times + const firstPendingTime = root.firstPendingTime; + if (expirationTime > firstPendingTime) { + root.firstPendingTime = expirationTime; + } + const lastPendingTime = root.lastPendingTime; + if (lastPendingTime === NoWork || expirationTime < lastPendingTime) { + root.lastPendingTime = expirationTime; + } + + // Update the range of suspended times. Treat everything lower priority or + // equal to this update as unsuspended. + const firstSuspendedTime = root.firstSuspendedTime; + if (firstSuspendedTime !== NoWork) { + if (expirationTime >= firstSuspendedTime) { + // The entire suspended range is now unsuspended. + root.firstSuspendedTime = root.lastSuspendedTime = root.nextKnownPendingLevel = NoWork; + } else if (expirationTime >= root.lastSuspendedTime) { + root.lastSuspendedTime = expirationTime + 1; + } + + // This is a pending level. Check if it's higher priority than the next + // known pending level. + if (expirationTime > root.nextKnownPendingLevel) { + root.nextKnownPendingLevel = expirationTime; + } + } +} + +export function markRootFinishedAtTime( + root: FiberRoot, + finishedExpirationTime: ExpirationTime, + remainingExpirationTime: ExpirationTime, +): void { + // Update the range of pending times + root.firstPendingTime = remainingExpirationTime; + if (remainingExpirationTime < root.lastPendingTime) { + // This usually means we've finished all the work, but it can also happen + // when something gets downprioritized during render, like a hidden tree. + root.lastPendingTime = remainingExpirationTime; + } + + // Update the range of suspended times. Treat everything higher priority or + // equal to this update as unsuspended. + if (finishedExpirationTime <= root.lastSuspendedTime) { + // The entire suspended range is now unsuspended. + root.firstSuspendedTime = root.lastSuspendedTime = root.nextKnownPendingLevel = NoWork; + } else if (finishedExpirationTime <= root.firstSuspendedTime) { + // Part of the suspended range is now unsuspended. Narrow the range to + // include everything between the unsuspended time (non-inclusive) and the + // last suspended time. + root.firstSuspendedTime = finishedExpirationTime - 1; + } + + if (finishedExpirationTime <= root.lastPingedTime) { + // Clear the pinged time + root.lastPingedTime = NoWork; + } +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 9d9dad28a41aa..8f2915291845f 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -66,6 +66,12 @@ import { } from './ReactFiberHostConfig'; import {createWorkInProgress, assignFiberPropertiesInDEV} from './ReactFiber'; +import { + isRootSuspendedAtTime, + markRootSuspendedAtTime, + markRootFinishedAtTime, + markRootUpdatedAtTime, +} from './ReactFiberRoot'; import { NoMode, StrictMode, @@ -377,8 +383,6 @@ export function scheduleUpdateOnFiber( return; } - root.pingTime = NoWork; - checkForInterruption(fiber, expirationTime); recordScheduleUpdate(); @@ -404,7 +408,8 @@ export function scheduleUpdateOnFiber( callback = callback(true); } } else { - scheduleCallbackForRoot(root, ImmediatePriority, Sync); + ensureRootIsScheduled(root); + schedulePendingInteractions(root, expirationTime); if (executionContext === NoContext) { // Flush the synchronous work now, wnless we're already working or inside // a batch. This is intentionally inside scheduleUpdateOnFiber instead of @@ -415,7 +420,8 @@ export function scheduleUpdateOnFiber( } } } else { - scheduleCallbackForRoot(root, priorityLevel, expirationTime); + ensureRootIsScheduled(root); + schedulePendingInteractions(root, expirationTime); } if ( @@ -483,20 +489,36 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) { } if (root !== null) { - // Update the first and last pending expiration times in this root - const firstPendingTime = root.firstPendingTime; - if (expirationTime > firstPendingTime) { - root.firstPendingTime = expirationTime; - } - const lastPendingTime = root.lastPendingTime; - if (lastPendingTime === NoWork || expirationTime < lastPendingTime) { - root.lastPendingTime = expirationTime; - } + markRootUpdatedAtTime(root, expirationTime); } return root; } +function getNextRootExpirationTimeToWorkOn(root: FiberRoot): ExpirationTime { + // Determines the next expiration time that the root should render, taking + // into account levels that may be suspended, or levels that may have + // received a ping. + // + // "Pending" refers to any update that hasn't committed yet, including if it + // suspended. The "suspended" range is therefore a subset. + + const firstPendingTime = root.firstPendingTime; + if (!isRootSuspendedAtTime(root, firstPendingTime)) { + // The highest priority pending time is not suspended. Let's work on that. + return firstPendingTime; + } + + // If the first pending time is suspended, check if there's a lower priority + // pending level that we know about. Or check if we received a ping. Work + // on whichever is higher priority. + const lastPingedTime = root.lastPingedTime; + const nextKnownPendingLevel = root.nextKnownPendingLevel; + return lastPingedTime > nextKnownPendingLevel + ? lastPingedTime + : nextKnownPendingLevel; +} + // Use this function, along with runRootCallback, to ensure that only a single // callback per root is scheduled. It's still possible to call renderRoot // directly, but scheduling via this function helps avoid excessive callbacks. @@ -505,53 +527,79 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) { // should cancel the previous one. It also relies on commitRoot scheduling a // callback to render the next level, because that means we don't need a // separate callback per expiration time. -function scheduleCallbackForRoot( - root: FiberRoot, - priorityLevel: ReactPriorityLevel, - expirationTime: ExpirationTime, -) { - const existingCallbackExpirationTime = root.callbackExpirationTime; - if (existingCallbackExpirationTime < expirationTime) { - // New callback has higher priority than the existing one. - const existingCallbackNode = root.callbackNode; - if (existingCallbackNode !== null) { - cancelCallback(existingCallbackNode); - } - root.callbackExpirationTime = expirationTime; - - if (expirationTime === Sync) { - // Sync React callbacks are scheduled on a special internal queue - root.callbackNode = scheduleSyncCallback( - runRootCallback.bind( - null, - root, - renderRoot.bind(null, root, expirationTime), - ), - ); - } else { - let options = null; - if ( - !disableSchedulerTimeoutBasedOnReactExpirationTime && - expirationTime !== Never - ) { - let timeout = expirationTimeToMs(expirationTime) - now(); - options = {timeout}; - } +function ensureRootIsScheduled(root: FiberRoot) { + const expirationTime = getNextRootExpirationTimeToWorkOn(root); + if (expirationTime === NoWork) { + // Nothing to work on. + return; + } - root.callbackNode = scheduleCallback( - priorityLevel, - runRootCallback.bind( - null, - root, - renderRoot.bind(null, root, expirationTime), - ), - options, - ); + // TODO: If this is an update, we already read the current time. Pass the + // time as an argument. + const currentTime = requestCurrentTime(); + const priorityLevel = inferPriorityFromExpirationTime( + currentTime, + expirationTime, + ); + + // 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. + const existingCallbackNode = root.callbackNode; + if (existingCallbackNode !== null) { + const existingCallbackPriority = root.callbackPriority; + const existingCallbackExpirationTime = root.callbackExpirationTime; + if ( + // Callback must have the exact same expiration time. + existingCallbackExpirationTime === expirationTime && + // Callback must have greater or equal priority. + existingCallbackPriority >= priorityLevel + ) { + // Existing callback is sufficient. + return; } + // Need to schedule a new task. + // TODO: Instead of scheduling a new task, we should be able to change the + // priority of the existing one. + cancelCallback(existingCallbackNode); } - // Associate the current interactions with this new root+priority. - schedulePendingInteractions(root, expirationTime); + root.callbackExpirationTime = expirationTime; + root.callbackPriority = priorityLevel; + + let callbackNode; + if (expirationTime === Sync) { + // Sync React callbacks are scheduled on a special internal queue + callbackNode = scheduleSyncCallback( + runRootCallback.bind( + null, + root, + renderRoot.bind(null, root, expirationTime), + ), + ); + } else if (disableSchedulerTimeoutBasedOnReactExpirationTime) { + callbackNode = scheduleCallback( + priorityLevel, + runRootCallback.bind( + null, + root, + renderRoot.bind(null, root, expirationTime), + ), + ); + } else { + callbackNode = scheduleCallback( + priorityLevel, + runRootCallback.bind( + null, + root, + renderRoot.bind(null, root, expirationTime), + ), + // Compute a task timeout based on the expiration time. This also affects + // ordering because tasks are processed in timeout order. + {timeout: expirationTimeToMs(expirationTime) - now()}, + ); + } + + root.callbackNode = callbackNode; } function runRootCallback(root, callback, isSync) { @@ -572,6 +620,7 @@ function runRootCallback(root, callback, isSync) { if (continuation === null && prevCallbackNode === root.callbackNode) { root.callbackNode = null; root.callbackExpirationTime = NoWork; + root.callbackPriority = NoPriority; } } } @@ -807,18 +856,12 @@ function renderRoot( 'Should not already be working.', ); - if (root.firstPendingTime < expirationTime) { - // If there's no work left at this expiration time, exit immediately. This - // happens when multiple callbacks are scheduled for a single root, but an - // earlier callback flushes the work of a later one. - return null; - } - if (isSync && root.finishedExpirationTime === expirationTime) { // There's already a pending commit at this expiration time. // TODO: This is poorly factored. This case only exists for the // batch.commit() API. - return commitRoot.bind(null, root); + commitRoot(root); + return null; } flushPassiveEffects(); @@ -828,26 +871,6 @@ function renderRoot( if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) { prepareFreshStack(root, expirationTime); startWorkOnPendingInteractions(root, expirationTime); - } else if (workInProgressRootExitStatus === RootSuspendedWithDelay) { - // We could've received an update at a lower priority while we yielded. - // We're suspended in a delayed state. Once we complete this render we're - // just going to try to recover at the last pending time anyway so we might - // as well start doing that eagerly. - // Ideally we should be able to do this even for retries but we don't yet - // know if we're going to process an update which wants to commit earlier, - // and this path happens very early so it would happen too often. Instead, - // for that case, we'll wait until we complete. - if (workInProgressRootHasPendingPing) { - // We have a ping at this expiration. Let's restart to see if we get unblocked. - prepareFreshStack(root, expirationTime); - } else { - const lastPendingTime = root.lastPendingTime; - if (lastPendingTime < expirationTime) { - // There's lower priority work. It might be unsuspended. Try rendering - // at that level immediately, while preserving the position in the queue. - return renderRoot.bind(null, root, lastPendingTime); - } - } } // If we have a work-in-progress fiber, it means there's still work to do @@ -918,6 +941,7 @@ function renderRoot( // boundary. prepareFreshStack(root, expirationTime); executionContext = prevExecutionContext; + markRootSuspendedAtTime(root, expirationTime); throw thrownValue; } @@ -958,7 +982,8 @@ function renderRoot( // something suspended, wait to commit it after a timeout. stopFinishedWorkLoopTimer(); - root.finishedWork = root.current.alternate; + const finishedWork: Fiber = ((root.finishedWork = + root.current.alternate): any); root.finishedExpirationTime = expirationTime; const isLocked = resolveLocksOnRoot(root, expirationTime); @@ -1002,6 +1027,11 @@ function renderRoot( return commitRoot.bind(null, root); } case RootSuspended: { + markRootSuspendedAtTime(root, expirationTime); + const lastSuspendedTime = root.lastSuspendedTime; + if (expirationTime === lastSuspendedTime) { + root.nextKnownPendingLevel = getRemainingExpirationTime(finishedWork); + } flushSuspensePriorityWarningInDEV(); // We have an acceptable loading state. We need to figure out if we should @@ -1038,11 +1068,20 @@ function renderRoot( prepareFreshStack(root, expirationTime); return renderRoot.bind(null, root, expirationTime); } - const lastPendingTime = root.lastPendingTime; - if (lastPendingTime < expirationTime) { + + const nextKnownPendingLevel = root.nextKnownPendingLevel; + if (nextKnownPendingLevel !== NoWork) { // There's lower priority work. It might be unsuspended. Try rendering // at that level. - return renderRoot.bind(null, root, lastPendingTime); + return renderRoot.bind(null, root, nextKnownPendingLevel); + } + if ( + lastSuspendedTime !== NoWork && + lastSuspendedTime !== expirationTime + ) { + // We should prefer to render the fallback of at the last + // suspended level. + return renderRoot.bind(null, root, lastSuspendedTime); } // The render is suspended, it hasn't timed out, and there's no lower // priority work to do. Instead of committing the fallback @@ -1058,6 +1097,11 @@ function renderRoot( return commitRoot.bind(null, root); } case RootSuspendedWithDelay: { + markRootSuspendedAtTime(root, expirationTime); + const lastSuspendedTime = root.lastSuspendedTime; + if (expirationTime === lastSuspendedTime) { + root.nextKnownPendingLevel = getRemainingExpirationTime(finishedWork); + } flushSuspensePriorityWarningInDEV(); if ( @@ -1077,11 +1121,20 @@ function renderRoot( prepareFreshStack(root, expirationTime); return renderRoot.bind(null, root, expirationTime); } - const lastPendingTime = root.lastPendingTime; - if (lastPendingTime < expirationTime) { + + const nextKnownPendingLevel = root.nextKnownPendingLevel; + if (nextKnownPendingLevel !== NoWork) { // There's lower priority work. It might be unsuspended. Try rendering - // at that level immediately. - return renderRoot.bind(null, root, lastPendingTime); + // at that level. + return renderRoot.bind(null, root, nextKnownPendingLevel); + } + if ( + lastSuspendedTime !== NoWork && + lastSuspendedTime !== expirationTime + ) { + // We should prefer to render the fallback of at the last + // suspended level. + return renderRoot.bind(null, root, lastSuspendedTime); } let msUntilTimeout; @@ -1425,6 +1478,14 @@ function completeUnitOfWork(unitOfWork: Fiber): Fiber | null { return null; } +function getRemainingExpirationTime(fiber: Fiber) { + const updateExpirationTime = fiber.expirationTime; + const childExpirationTime = fiber.childExpirationTime; + return updateExpirationTime > childExpirationTime + ? updateExpirationTime + : childExpirationTime; +} + function resetChildExpirationTime(completedWork: Fiber) { if ( renderExpirationTime !== Never && @@ -1535,23 +1596,21 @@ 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.nextKnownPendingLevel = NoWork; startCommitTimer(); // Update the first and last pending times on this root. The new first // pending time is whatever is left on the root fiber. - const updateExpirationTimeBeforeCommit = finishedWork.expirationTime; - const childExpirationTimeBeforeCommit = finishedWork.childExpirationTime; - const firstPendingTimeBeforeCommit = - childExpirationTimeBeforeCommit > updateExpirationTimeBeforeCommit - ? childExpirationTimeBeforeCommit - : updateExpirationTimeBeforeCommit; - root.firstPendingTime = firstPendingTimeBeforeCommit; - if (firstPendingTimeBeforeCommit < root.lastPendingTime) { - // This usually means we've finished all the work, but it can also happen - // when something gets downprioritized during render, like a hidden tree. - root.lastPendingTime = firstPendingTimeBeforeCommit; - } + const remainingExpirationTimeBeforeCommit = getRemainingExpirationTime( + finishedWork, + ); + markRootFinishedAtTime( + root, + expirationTime, + remainingExpirationTimeBeforeCommit, + ); if (root === workInProgressRoot) { // We can reset these now that they are finished. @@ -1753,12 +1812,6 @@ function commitRootImpl(root, renderPriorityLevel) { // Check if there's remaining work on this root const remainingExpirationTime = root.firstPendingTime; if (remainingExpirationTime !== NoWork) { - const currentTime = requestCurrentTime(); - const priorityLevel = inferPriorityFromExpirationTime( - currentTime, - remainingExpirationTime, - ); - if (enableSchedulerTracing) { if (spawnedWorkDuringRender !== null) { const expirationTimes = spawnedWorkDuringRender; @@ -1772,8 +1825,8 @@ function commitRootImpl(root, renderPriorityLevel) { } } } - - scheduleCallbackForRoot(root, priorityLevel, remainingExpirationTime); + ensureRootIsScheduled(root); + schedulePendingInteractions(root, expirationTime); } else { // If there's no remaining work, we can clear the set of already failed // error boundaries. @@ -2061,7 +2114,8 @@ function captureCommitPhaseErrorOnRoot( enqueueUpdate(rootFiber, update); const root = markUpdateTimeFromFiberToRoot(rootFiber, Sync); if (root !== null) { - scheduleCallbackForRoot(root, ImmediatePriority, Sync); + ensureRootIsScheduled(root); + schedulePendingInteractions(root, Sync); } } @@ -2096,7 +2150,8 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) { enqueueUpdate(fiber, update); const root = markUpdateTimeFromFiberToRoot(fiber, Sync); if (root !== null) { - scheduleCallbackForRoot(root, ImmediatePriority, Sync); + ensureRootIsScheduled(root); + schedulePendingInteractions(root, Sync); } return; } @@ -2148,20 +2203,19 @@ export function pingSuspendedRoot( return; } - const lastPendingTime = root.lastPendingTime; - if (lastPendingTime < suspendedTime) { + if (!isRootSuspendedAtTime(root, suspendedTime)) { // The root is no longer suspended at this time. return; } - const pingTime = root.pingTime; - if (pingTime !== NoWork && pingTime < suspendedTime) { + const lastPingedTime = root.lastPingedTime; + if (lastPingedTime !== NoWork && lastPingedTime < suspendedTime) { // There's already a lower priority ping scheduled. return; } // Mark the time at which this ping was scheduled. - root.pingTime = suspendedTime; + root.lastPingedTime = suspendedTime; if (root.finishedExpirationTime === suspendedTime) { // If there's a pending fallback waiting to commit, throw it away. @@ -2169,12 +2223,8 @@ export function pingSuspendedRoot( root.finishedWork = null; } - const currentTime = requestCurrentTime(); - const priorityLevel = inferPriorityFromExpirationTime( - currentTime, - suspendedTime, - ); - scheduleCallbackForRoot(root, priorityLevel, suspendedTime); + ensureRootIsScheduled(root); + schedulePendingInteractions(root, suspendedTime); } function retryTimedOutBoundary( @@ -2185,9 +2235,9 @@ function retryTimedOutBoundary( // previously was rendered in its fallback state. One of the promises that // suspended it has resolved, which means at least part of the tree was // likely unblocked. Try rendering again, at a new expiration time. - const currentTime = requestCurrentTime(); if (retryTime === Never) { const suspenseConfig = null; // Retries don't carry over the already committed update. + const currentTime = requestCurrentTime(); retryTime = computeExpirationForFiber( currentTime, boundaryFiber, @@ -2195,10 +2245,10 @@ function retryTimedOutBoundary( ); } // TODO: Special case idle priority? - const priorityLevel = inferPriorityFromExpirationTime(currentTime, retryTime); const root = markUpdateTimeFromFiberToRoot(boundaryFiber, retryTime); if (root !== null) { - scheduleCallbackForRoot(root, priorityLevel, retryTime); + ensureRootIsScheduled(root); + schedulePendingInteractions(root, retryTime); } } diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index 65e672d87dceb..60dfcb0a55983 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -518,6 +518,71 @@ describe('ReactSuspenseWithNoopRenderer', () => { expect(ReactNoop.getChildren()).toEqual([span('(empty)')]); }); + it('tries each subsequent level after suspending', async () => { + const root = ReactNoop.createRoot(); + + function App({step, shouldSuspend}) { + return ( + + + {shouldSuspend ? ( + + ) : ( + + )} + + ); + } + + function interrupt() { + // React has a heuristic to batch all updates that occur within the same + // event. This is a trick to circumvent that heuristic. + ReactNoop.flushSync(() => { + ReactNoop.renderToRootWithID(null, 'other-root'); + }); + } + + // Mount the Suspense boundary without suspending, so that the subsequent + // updates suspend with a delay. + await ReactNoop.act(async () => { + root.render(); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded(['Sibling', 'Step 0']); + + // Schedule an update at several distinct expiration times + await ReactNoop.act(async () => { + root.render(); + Scheduler.unstable_advanceTime(1000); + expect(Scheduler).toFlushAndYieldThrough(['Sibling']); + interrupt(); + + root.render(); + Scheduler.unstable_advanceTime(1000); + expect(Scheduler).toFlushAndYieldThrough(['Sibling']); + interrupt(); + + root.render(); + Scheduler.unstable_advanceTime(1000); + expect(Scheduler).toFlushAndYieldThrough(['Sibling']); + interrupt(); + + root.render(); + }); + + // Should suspend at each distinct level + expect(Scheduler).toHaveYielded([ + 'Sibling', + 'Suspend! [Step 1]', + 'Sibling', + 'Suspend! [Step 2]', + 'Sibling', + 'Suspend! [Step 3]', + 'Sibling', + 'Step 4', + ]); + }); + it('forces an expiration after an update times out', async () => { ReactNoop.render(