@samitouri / QOS-React-2 / commits / 6edaf6f764

Detect and prevent render starvation, per lane (#18864)

* Detect and prevent render starvation, per lane If an update is CPU-bound for longer than expected according to its priority, we assume it's being starved by other work on the main thread. To detect this, we keep track of the elapsed time using a fixed-size array where each slot corresponds to a lane. What we actually store is the event time when the lane first became CPU-bound. Then, when receiving a new update or yielding to the main thread, we check how long each lane has been pending. If the time exceeds a threshold constant corresponding to its priority, we mark it as expired to force it to synchronously finish. We don't want to mistake time elapsed while an update is IO-bound (waiting for data to resolve) for time when it is CPU-bound. So when a lane suspends, we clear its associated event time from the array. When it receives a signal to try again, either a ping or an update, we assign a new event time to restart the clock. * Store as expiration time, not start time I originally stored the start time because I thought I could use this in the future to also measure Suspense timeouts. (Event times are currently stored on each update object for this purpose.) But that won't work because in the case of expiration times, we reset the clock whenever the update becomes IO-bound. So to replace the per-update field, I'm going to have to track those on the room separately from expiration times.

Andrew Clark committed May 8, 2020 at 12:47 UTC 6edaf6f764f23043f0cd1c2da355b42f641afd8b
16 files changed +719 -130
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+4 -1
@@ -112,6 +112,7 @@ import {
112 SyncLane,
113 OffscreenLane,
114 DefaultHydrationLane,
115 + NoTimestamp,
116 includesSomeLane,
117 laneToLanes,
118 removeLanes,
@@ -2301,7 +2302,9 @@ function updateDehydratedSuspenseComponent(
2302 // is one of the very rare times where we mutate the current tree
2303 // during the render phase.
2304 suspenseState.retryLane = attemptHydrationAtLane;
2304 - scheduleUpdateOnFiber(current, attemptHydrationAtLane);
2305 + // TODO: Ideally this would inherit the event time of the current render
2306 + const eventTime = NoTimestamp;
2307 + scheduleUpdateOnFiber(current, attemptHydrationAtLane, eventTime);
2308 } else {
2309 // We have already tried to ping at a higher priority than we're rendering with
2310 // so if we got here, we must have failed to hydrate at those levels. We must
packages/react-reconciler/src/ReactFiberClassComponent.new.js
+3 -3
@@ -202,7 +202,7 @@ const classComponentUpdater = {
202 }
203
204 enqueueUpdate(fiber, update);
205 - scheduleUpdateOnFiber(fiber, lane);
205 + scheduleUpdateOnFiber(fiber, lane, eventTime);
206 },
207 enqueueReplaceState(inst, payload, callback) {
208 const fiber = getInstance(inst);
@@ -222,7 +222,7 @@ const classComponentUpdater = {
222 }
223
224 enqueueUpdate(fiber, update);
225 - scheduleUpdateOnFiber(fiber, lane);
225 + scheduleUpdateOnFiber(fiber, lane, eventTime);
226 },
227 enqueueForceUpdate(inst, callback) {
228 const fiber = getInstance(inst);
@@ -241,7 +241,7 @@ const classComponentUpdater = {
241 }
242
243 enqueueUpdate(fiber, update);
244 - scheduleUpdateOnFiber(fiber, lane);
244 + scheduleUpdateOnFiber(fiber, lane, eventTime);
245 },
246 };
247
packages/react-reconciler/src/ReactFiberHooks.new.js
+1 -1
@@ -1720,7 +1720,7 @@ function dispatchAction<S, A>(
1720 warnIfNotCurrentlyActingUpdatesInDev(fiber);
1721 }
1722 }
1723 - scheduleUpdateOnFiber(fiber, lane);
1723 + scheduleUpdateOnFiber(fiber, lane, eventTime);
1724 }
1725 }
1726
packages/react-reconciler/src/ReactFiberHotReloading.new.js
+2 -2
@@ -20,7 +20,7 @@ import {
20 } from './ReactFiberWorkLoop.new';
21 import {updateContainer} from './ReactFiberReconciler.new';
22 import {emptyContextObject} from './ReactFiberContext.new';
23 -import {SyncLane} from './ReactFiberLane';
23 +import {SyncLane, NoTimestamp} from './ReactFiberLane';
24 import {
25 ClassComponent,
26 FunctionComponent,
@@ -319,7 +319,7 @@ function scheduleFibersWithFamiliesRecursively(
319 fiber._debugNeedsRemount = true;
320 }
321 if (needsRemount || needsRender) {
322 - scheduleUpdateOnFiber(fiber, SyncLane);
322 + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
323 }
324 if (child !== null && !needsRemount) {
325 scheduleFibersWithFamiliesRecursively(
packages/react-reconciler/src/ReactFiberLane.js
+113 -11
@@ -29,6 +29,7 @@ export opaque type LanePriority =
29 | 16;
30 export opaque type Lanes = number;
31 export opaque type Lane = number;
32 +export opaque type LaneMap<T> = Array<T>;
33
34 import invariant from 'shared/invariant';
35
@@ -66,7 +67,7 @@ const IdleLanePriority: LanePriority = 2;
67
68 const OffscreenLanePriority: LanePriority = 1;
69
69 -const NoLanePriority: LanePriority = 0;
70 +export const NoLanePriority: LanePriority = 0;
71
72 const TotalLanes = 31;
73
@@ -117,6 +118,8 @@ const IdleUpdateRangeEnd = 30;
118
119 export const OffscreenLane: Lane = /* */ 0b1000000000000000000000000000000;
120
121 +export const NoTimestamp = -1;
122 +
123 // "Registers" used to "return" multiple values
124 // Used by getHighestPriorityLanes and getNextLanes:
125 let return_highestLanePriority: LanePriority = DefaultLanePriority;
@@ -365,6 +368,63 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
368 return nextLanes;
369 }
370
371 +function computeExpirationTime(lane: Lane, currentTime: number) {
372 + // TODO: Expiration heuristic is constant per lane, so could use a map.
373 + getHighestPriorityLanes(lane);
374 + const priority = return_highestLanePriority;
375 + if (priority >= InputContinuousLanePriority) {
376 + // User interactions should expire slightly more quickly.
377 + return currentTime + 1000;
378 + } else if (priority >= TransitionLongLanePriority) {
379 + return currentTime + 5000;
380 + } else {
381 + // Anything idle priority or lower should never expire.
382 + return NoTimestamp;
383 + }
384 +}
385 +
386 +export function markStarvedLanesAsExpired(
387 + root: FiberRoot,
388 + currentTime: number,
389 +): void {
390 + // TODO: This gets called every time we yield. We can optimize by storing
391 + // the earliest expiration time on the root. Then use that to quickly bail out
392 + // of this function.
393 +
394 + const pendingLanes = root.pendingLanes;
395 + const suspendedLanes = root.suspendedLanes;
396 + const pingedLanes = root.pingedLanes;
397 + const expirationTimes = root.expirationTimes;
398 +
399 + // Iterate through the pending lanes and check if we've reached their
400 + // expiration time. If so, we'll assume the update is being starved and mark
401 + // it as expired to force it to finish.
402 + let lanes = pendingLanes;
403 + while (lanes > 0) {
404 + const index = ctrz(lanes);
405 + const lane = 1 << index;
406 +
407 + const expirationTime = expirationTimes[index];
408 + if (expirationTime === NoTimestamp) {
409 + // Found a pending lane with no expiration time. If it's not suspended, or
410 + // if it's pinged, assume it's CPU-bound. Compute a new expiration time
411 + // using the current time.
412 + if (
413 + (lane & suspendedLanes) === NoLanes ||
414 + (lane & pingedLanes) !== NoLanes
415 + ) {
416 + // Assumes timestamps are monotonically increasing.
417 + expirationTimes[index] = computeExpirationTime(lane, currentTime);
418 + }
419 + } else if (expirationTime <= currentTime) {
420 + // This lane expired
421 + root.expiredLanes |= lane;
422 + }
423 +
424 + lanes &= ~lane;
425 + }
426 +}
427 +
428 // This returns the highest priority pending lanes regardless of whether they
429 // are suspended.
430 export function getHighestPriorityPendingLanes(root: FiberRoot) {
@@ -555,6 +615,10 @@ export function higherPriorityLane(a: Lane, b: Lane) {
615 return a !== NoLane && a < b ? a : b;
616 }
617
618 +export function createLaneMap<T>(initial: T): LaneMap<T> {
619 + return new Array(TotalLanes).fill(initial);
620 +}
621 +
622 export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
623 root.pendingLanes |= updateLane;
624
@@ -570,6 +634,7 @@ export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
634
635 // Unsuspend any update at equal or lower priority.
636 const higherPriorityLanes = updateLane - 1; // Turns 0b1000 into 0b0111
637 +
638 root.suspendedLanes &= higherPriorityLanes;
639 root.pingedLanes &= higherPriorityLanes;
640 }
@@ -577,9 +642,25 @@ export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
642 export function markRootSuspended(root: FiberRoot, suspendedLanes: Lanes) {
643 root.suspendedLanes |= suspendedLanes;
644 root.pingedLanes &= ~suspendedLanes;
645 +
646 + // The suspended lanes are no longer CPU-bound. Clear their expiration times.
647 + const expirationTimes = root.expirationTimes;
648 + let lanes = suspendedLanes;
649 + while (lanes > 0) {
650 + const index = ctrz(lanes);
651 + const lane = 1 << index;
652 +
653 + expirationTimes[index] = NoTimestamp;
654 +
655 + lanes &= ~lane;
656 + }
657 }
658
582 -export function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
659 +export function markRootPinged(
660 + root: FiberRoot,
661 + pingedLanes: Lanes,
662 + eventTime: number,
663 +) {
664 root.pingedLanes |= root.suspendedLanes & pingedLanes;
665 }
666
@@ -600,6 +681,8 @@ export function markRootMutableRead(root: FiberRoot, updateLane: Lane) {
681 }
682
683 export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
684 + const noLongerPendingLanes = root.pendingLanes & ~remainingLanes;
685 +
686 root.pendingLanes = remainingLanes;
687
688 // Let's try everything again
@@ -608,6 +691,18 @@ export function markRootFinished(root: FiberRoot, remainingLanes: Lanes) {
691
692 root.expiredLanes &= remainingLanes;
693 root.mutableReadLanes &= remainingLanes;
694 +
695 + const expirationTimes = root.expirationTimes;
696 + let lanes = noLongerPendingLanes;
697 + while (lanes > 0) {
698 + const index = ctrz(lanes);
699 + const lane = 1 << index;
700 +
701 + // Clear the expiration time
702 + expirationTimes[index] = -1;
703 +
704 + lanes &= ~lane;
705 + }
706 }
707
708 export function getBumpedLaneForHydration(
@@ -671,18 +766,25 @@ export function getBumpedLaneForHydration(
766
767 const clz32 = Math.clz32 ? Math.clz32 : clz32Fallback;
768
674 -// Taken from:
769 +// Count leading zeros. Only used on lanes, so assume input is an integer.
770 +// Based on:
771 // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32
772 const log = Math.log;
773 const LN2 = Math.LN2;
678 -function clz32Fallback(x) {
679 - // Let n be ToUint32(x).
680 - // Let p be the number of leading zero bits in
681 - // the 32-bit binary representation of n.
682 - // Return p.
683 - const asUint = x >>> 0;
684 - if (asUint === 0) {
774 +function clz32Fallback(lanes: Lanes | Lane) {
775 + if (lanes === 0) {
776 return 32;
777 }
687 - return (31 - ((log(asUint) / LN2) | 0)) | 0; // the "| 0" acts like math.floor
778 + return (31 - ((log(lanes) / LN2) | 0)) | 0;
779 +}
780 +
781 +// Count trailing zeros. Only used on lanes, so assume input is an integer.
782 +function ctrz(lanes: Lanes | Lane) {
783 + let bits = lanes;
784 + bits |= bits << 16;
785 + bits |= bits << 8;
786 + bits |= bits << 4;
787 + bits |= bits << 2;
788 + bits |= bits << 1;
789 + return 32 - clz32(~bits);
790 }
packages/react-reconciler/src/ReactFiberNewContext.new.js
+2 -1
@@ -22,6 +22,7 @@ import {
22 } from './ReactWorkTags';
23 import {
24 NoLanes,
25 + NoTimestamp,
26 isSubsetOfLanes,
27 includesSomeLane,
28 mergeLanes,
@@ -209,7 +210,7 @@ export function propagateContextChange(
210 if (fiber.tag === ClassComponent) {
211 // Schedule a force update on the work-in-progress.
212 const update = createUpdate(
212 - -1,
213 + NoTimestamp,
214 pickArbitraryLane(renderLanes),
215 null,
216 );
packages/react-reconciler/src/ReactFiberReconciler.new.js
+13 -8
@@ -76,6 +76,7 @@ import {
76 SyncLane,
77 InputDiscreteHydrationLane,
78 SelectiveHydrationLane,
79 + NoTimestamp,
80 getHighestPriorityPendingLanes,
81 higherPriorityLane,
82 } from './ReactFiberLane';
@@ -307,7 +308,7 @@ export function updateContainer(
308 }
309
310 enqueueUpdate(current, update);
310 - scheduleUpdateOnFiber(current, lane);
311 + scheduleUpdateOnFiber(current, lane, eventTime);
312
313 return lane;
314 }
@@ -352,7 +353,8 @@ export function attemptSynchronousHydration(fiber: Fiber): void {
353 }
354 break;
355 case SuspenseComponent:
355 - flushSync(() => scheduleUpdateOnFiber(fiber, SyncLane));
356 + const eventTime = requestEventTime();
357 + flushSync(() => scheduleUpdateOnFiber(fiber, SyncLane, eventTime));
358 // If we're still blocked after this, we need to increase
359 // the priority of any promises resolving within this
360 // boundary so that they next attempt also has higher pri.
@@ -389,8 +391,9 @@ export function attemptUserBlockingHydration(fiber: Fiber): void {
391 // Suspense.
392 return;
393 }
394 + const eventTime = requestEventTime();
395 const lane = InputDiscreteHydrationLane;
393 - scheduleUpdateOnFiber(fiber, lane);
396 + scheduleUpdateOnFiber(fiber, lane, eventTime);
397 markRetryLaneIfNotHydrated(fiber, lane);
398 }
399
@@ -402,8 +405,9 @@ export function attemptContinuousHydration(fiber: Fiber): void {
405 // Suspense.
406 return;
407 }
408 + const eventTime = requestEventTime();
409 const lane = SelectiveHydrationLane;
406 - scheduleUpdateOnFiber(fiber, lane);
410 + scheduleUpdateOnFiber(fiber, lane, eventTime);
411 markRetryLaneIfNotHydrated(fiber, lane);
412 }
413
@@ -413,8 +417,9 @@ export function attemptHydrationAtCurrentPriority(fiber: Fiber): void {
417 // their priority other than synchronously flush it.
418 return;
419 }
420 + const eventTime = requestEventTime();
421 const lane = requestUpdateLane(fiber, null);
417 - scheduleUpdateOnFiber(fiber, lane);
422 + scheduleUpdateOnFiber(fiber, lane, eventTime);
423 markRetryLaneIfNotHydrated(fiber, lane);
424 }
425
@@ -497,7 +502,7 @@ if (__DEV__) {
502 // Shallow cloning props works as a workaround for now to bypass the bailout check.
503 fiber.memoizedProps = {...fiber.memoizedProps};
504
500 - scheduleUpdateOnFiber(fiber, SyncLane);
505 + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
506 }
507 };
508
@@ -507,11 +512,11 @@ if (__DEV__) {
512 if (fiber.alternate) {
513 fiber.alternate.pendingProps = fiber.pendingProps;
514 }
510 - scheduleUpdateOnFiber(fiber, SyncLane);
515 + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
516 };
517
518 scheduleUpdate = (fiber: Fiber) => {
514 - scheduleUpdateOnFiber(fiber, SyncLane);
519 + scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
520 };
521
522 setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => {
packages/react-reconciler/src/ReactFiberRoot.new.js
+8 -3
@@ -12,7 +12,12 @@ import type {RootTag} from './ReactRootTags';
12
13 import {noTimeout} from './ReactFiberHostConfig';
14 import {createHostRootFiber} from './ReactFiber.new';
15 -import {NoLanes} from './ReactFiberLane';
15 +import {
16 + NoLanes,
17 + NoLanePriority,
18 + NoTimestamp,
19 + createLaneMap,
20 +} from './ReactFiberLane';
21 import {
22 enableSchedulerTracing,
23 enableSuspenseCallback,
@@ -33,8 +38,8 @@ function FiberRootNode(containerInfo, tag, hydrate) {
38 this.hydrate = hydrate;
39 this.callbackNode = null;
40 this.callbackId = NoLanes;
36 - this.callbackIsSync = false;
37 - this.expiresAt = -1;
41 + this.callbackPriority_new = NoLanePriority;
42 + this.expirationTimes = createLaneMap(NoTimestamp);
43
44 this.pendingLanes = NoLanes;
45 this.suspendedLanes = NoLanes;
packages/react-reconciler/src/ReactFiberRoot.old.js
+1 -1
@@ -36,7 +36,7 @@ function FiberRootNode(containerInfo, tag, hydrate) {
36 this.pendingContext = null;
37 this.hydrate = hydrate;
38 this.callbackNode = null;
39 - this.callbackPriority = NoPriority;
39 + this.callbackPriority_old = NoPriority;
40 this.firstPendingTime = NoWork;
41 this.lastPendingTime = NoWork;
42 this.firstSuspendedTime = NoWork;
packages/react-reconciler/src/ReactFiberThrow.new.js
+4 -3
@@ -56,6 +56,7 @@ import {logCapturedError} from './ReactFiberErrorLogger';
56
57 import {
58 SyncLane,
59 + NoTimestamp,
60 includesSomeLane,
61 mergeLanes,
62 pickArbitraryLane,
@@ -68,7 +69,7 @@ function createRootErrorUpdate(
69 errorInfo: CapturedValue<mixed>,
70 lane: Lane,
71 ): Update<mixed> {
71 - const update = createUpdate(-1, lane, null);
72 + const update = createUpdate(NoTimestamp, lane, null);
73 // Unmount the root by rendering null.
74 update.tag = CaptureUpdate;
75 // Caution: React DevTools currently depends on this property
@@ -87,7 +88,7 @@ function createClassErrorUpdate(
88 errorInfo: CapturedValue<mixed>,
89 lane: Lane,
90 ): Update<mixed> {
90 - const update = createUpdate(-1, lane, null);
91 + const update = createUpdate(NoTimestamp, lane, null);
92 update.tag = CaptureUpdate;
93 const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
94 if (typeof getDerivedStateFromError === 'function') {
@@ -254,7 +255,7 @@ function throwException(
255 // When we try rendering again, we should not reuse the current fiber,
256 // since it's known to be in an inconsistent state. Use a force update to
257 // prevent a bail out.
257 - const update = createUpdate(-1, SyncLane, null);
258 + const update = createUpdate(NoTimestamp, SyncLane, null);
259 update.tag = ForceUpdate;
260 enqueueUpdate(sourceFiber, update);
261 }
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+64 -70
@@ -109,6 +109,7 @@ import {
109 HydratingAndUpdate,
110 } from './ReactSideEffectTags';
111 import {
112 + NoLanePriority,
113 SyncLanePriority,
114 InputDiscreteLanePriority,
115 TransitionShortLanePriority,
@@ -118,6 +119,7 @@ import {
119 SyncLane,
120 SyncBatchedLane,
121 OffscreenLane,
122 + NoTimestamp,
123 findUpdateLane,
124 findTransitionLane,
125 includesSomeLane,
@@ -129,6 +131,7 @@ import {
131 hasUpdatePriority,
132 getNextLanes,
133 returnNextLanesPriority,
134 + markStarvedLanesAsExpired,
135 getLanesToRetrySynchronouslyOnError,
136 markRootUpdated,
137 markRootSuspended as markRootSuspended_dontCallThisOneDirectly,
@@ -253,8 +256,8 @@ let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
256 // A fatal error, if one is thrown
257 let workInProgressRootFatalError: mixed = null;
258 // Most recent event time among processed updates during this render.
256 -let workInProgressRootLatestProcessedEventTime: number = -1;
257 -let workInProgressRootLatestSuspenseTimeout: number = -1;
259 +let workInProgressRootLatestProcessedEventTime: number = NoTimestamp;
260 +let workInProgressRootLatestSuspenseTimeout: number = NoTimestamp;
261 let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null;
262 // "Included" lanes refer to lanes that were worked on during this render. It's
263 // slightly different than `renderLanes` because `renderLanes` can change as you
@@ -310,7 +313,7 @@ let spawnedWorkDuringRender: null | Array<Lane | Lanes> = null;
313 // If two updates are scheduled within the same event, we should treat their
314 // event times as simultaneous, even if the actual clock time has advanced
315 // between the first and second call.
313 -let currentEventTime: number = -1;
316 +let currentEventTime: number = NoTimestamp;
317 let currentEventWipLanes: Lanes = NoLanes;
318 let currentEventPendingLanes: Lanes = NoLanes;
319
@@ -331,7 +334,7 @@ export function requestEventTime() {
334 return now();
335 }
336 // We're not inside React, so we may be in the middle of a browser event.
334 - if (currentEventTime !== -1) {
337 + if (currentEventTime !== NoTimestamp) {
338 // Use the same start time for all updates until we enter React again.
339 return currentEventTime;
340 }
@@ -434,7 +437,11 @@ export function requestUpdateLane(
437 return lane;
438 }
439
437 -export function scheduleUpdateOnFiber(fiber: Fiber, lane: Lane) {
440 +export function scheduleUpdateOnFiber(
441 + fiber: Fiber,
442 + lane: Lane,
443 + eventTime: number,
444 +) {
445 checkForNestedUpdates();
446 warnAboutRenderPhaseUpdatesInDEV(fiber);
447
@@ -463,7 +470,7 @@ export function scheduleUpdateOnFiber(fiber: Fiber, lane: Lane) {
470 // should be deferred until the end of the batch.
471 performSyncWorkOnRoot(root);
472 } else {
466 - ensureRootIsScheduled(root);
473 + ensureRootIsScheduled(root, eventTime);
474 schedulePendingInteractions(root, lane);
475 if (executionContext === NoContext) {
476 // Flush the synchronous work now, unless we're already working or inside
@@ -492,7 +499,7 @@ export function scheduleUpdateOnFiber(fiber: Fiber, lane: Lane) {
499 }
500 }
501 // Schedule other updates after in case the callback is sync.
495 - ensureRootIsScheduled(root);
502 + ensureRootIsScheduled(root, eventTime);
503 schedulePendingInteractions(root, lane);
504 }
505
@@ -592,40 +599,39 @@ function markUpdateLaneFromFiberToRoot(
599 // expiration time of the existing task is the same as the expiration time of
600 // the next level that the root has work on. This function is called on every
601 // update, and right before exiting a task.
595 -function ensureRootIsScheduled(root: FiberRoot) {
602 +function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
603 const existingCallbackNode = root.callbackNode;
604
605 + // Check if any lanes are being starved by other work. If so, mark them as
606 + // expired so we know to work on those next.
607 + markStarvedLanesAsExpired(root, currentTime);
608 +
609 + // Determine the next lanes to work on, and their priority.
610 const newCallbackId = getNextLanes(
611 root,
612 root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
613 );
614 // This returns the priority level computed during the `getNextLanes` call.
603 - const newCallbackPriorityLevel = returnNextLanesPriority();
615 + const newCallbackPriority = returnNextLanesPriority();
616
617 if (newCallbackId === NoLanes) {
618 // Special case: There's nothing to work on.
619 if (existingCallbackNode !== null) {
620 cancelCallback(existingCallbackNode);
609 - root.expiresAt = -1;
621 root.callbackNode = null;
611 - root.callbackIsSync = false;
622 + root.callbackPriority_new = NoLanePriority;
623 root.callbackId = NoLanes;
624 }
625 return;
626 }
627
617 - const newTaskIsSync = newCallbackPriorityLevel === SyncLanePriority;
618 -
628 // Check if there's an existing task. We may be able to reuse it.
620 - const existingTaskId = root.callbackId;
621 - const existingCallbackIsSync = root.callbackIsSync;
622 - if (existingTaskId !== NoLanes) {
623 - if (newCallbackId === existingTaskId) {
629 + const existingCallbackId = root.callbackId;
630 + const existingCallbackPriority = root.callbackPriority_new;
631 + if (existingCallbackId !== NoLanes) {
632 + if (newCallbackId === existingCallbackId) {
633 // This task is already scheduled. Let's check its priority.
625 - if (
626 - (newTaskIsSync && existingCallbackIsSync) ||
627 - (!newTaskIsSync && !existingCallbackIsSync)
628 - ) {
634 + if (existingCallbackPriority === newCallbackPriority) {
635 // The priority hasn't changed. Exit.
636 return;
637 }
@@ -637,7 +643,7 @@ function ensureRootIsScheduled(root: FiberRoot) {
643
644 // Schedule a new callback.
645 let newCallbackNode;
640 - if (newTaskIsSync) {
646 + if (newCallbackPriority === SyncLanePriority) {
647 // Special case: Sync React callbacks are scheduled on a special
648 // internal queue
649 newCallbackNode = scheduleSyncCallback(
@@ -645,7 +651,7 @@ function ensureRootIsScheduled(root: FiberRoot) {
651 );
652 } else {
653 const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
648 - newCallbackPriorityLevel,
654 + newCallbackPriority,
655 );
656 newCallbackNode = scheduleCallback(
657 schedulerPriorityLevel,
@@ -654,8 +660,8 @@ function ensureRootIsScheduled(root: FiberRoot) {
660 }
661
662 root.callbackId = newCallbackId;
663 + root.callbackPriority_new = newCallbackPriority;
664 root.callbackNode = newCallbackNode;
658 - root.callbackIsSync = newTaskIsSync;
665 }
666
667 // This is the entry point for every concurrent task, i.e. anything that
@@ -663,7 +669,7 @@ function ensureRootIsScheduled(root: FiberRoot) {
669 function performConcurrentWorkOnRoot(root, didTimeout) {
670 // Since we know we're in a React event, we can clear the current
671 // event time. The next update will compute a new event time.
666 - currentEventTime = -1;
672 + currentEventTime = NoTimestamp;
673 currentEventWipLanes = NoLanes;
674 currentEventPendingLanes = NoLanes;
675
@@ -677,29 +683,16 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
683 return null;
684 }
685
680 - // Check if any work has expired.
681 - const rootExpiresAt = root.expiresAt;
682 - if (rootExpiresAt !== -1 && rootExpiresAt < now()) {
686 + // TODO: We only check `didTimeout` defensively, to account for a Scheduler
687 + // bug where `shouldYield` sometimes returns `true` even if `didTimeout` is
688 + // true, which leads to an infinite loop. Once the bug in Scheduler is
689 + // fixed, we can remove this, since we track expiration ourselves.
690 + if (didTimeout) {
691 // Something expired. Flush synchronously until there's no expired
692 // work left.
685 - // TODO: Should flush only the lanes that have expired, and maybe any lanes
686 - // that are higher priority than that.
693 markRootExpired(root, lanes);
694 // This will schedule a synchronous callback.
689 - ensureRootIsScheduled(root);
690 - return null;
691 - }
692 - // Similar branch, but for Scheduler.
693 - // TODO: This is only here to account for a Scheduler bug where `shouldYield`
694 - // sometimes returns `true` even if `didTimeout` is true, which leads to
695 - // an infinite loop. Once the bug in Scheduler is fixed, we can remove this,
696 - // since we track expiration times ourselves.
697 - if (didTimeout) {
698 - // The Scheduler task took too long to complete. Mark the root as expired to
699 - // prevent yielding to other tasks until this one finishes.
700 - markRootExpired(root, lanes);
701 - // This will schedule a synchronous callback.
702 - ensureRootIsScheduled(root);
695 + ensureRootIsScheduled(root, now());
696 return null;
697 }
698
@@ -742,7 +735,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
735 const fatalError = workInProgressRootFatalError;
736 prepareFreshStack(root, NoLanes);
737 markRootSuspended(root, lanes);
745 - ensureRootIsScheduled(root);
738 + ensureRootIsScheduled(root, now());
739 throw fatalError;
740 }
741
@@ -754,7 +747,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) {
747 finishConcurrentRender(root, finishedWork, exitStatus, lanes);
748 }
749
757 - ensureRootIsScheduled(root);
750 + ensureRootIsScheduled(root, now());
751 if (root.callbackNode === originalCallbackNode) {
752 // The task node scheduled for this root is the same one that's
753 // currently executed. Need to return a continuation.
@@ -788,7 +781,7 @@ function finishConcurrentRender(root, finishedWork, exitStatus, lanes) {
781 // have a new loading state ready. We want to ensure that we commit
782 // that as soon as possible.
783 const hasNotProcessedNewUpdates =
791 - workInProgressRootLatestProcessedEventTime === -1;
784 + workInProgressRootLatestProcessedEventTime === NoTimestamp;
785 if (
786 hasNotProcessedNewUpdates &&
787 // do not delay if we're inside an act() scope
@@ -815,7 +808,8 @@ function finishConcurrentRender(root, finishedWork, exitStatus, lanes) {
808 // suspended level. Ping the last suspended level to try
809 // rendering it again.
810 // FIXME: What if the suspended lanes are Idle? Should not restart.
818 - markRootPinged(root, suspendedLanes);
811 + const eventTime = requestEventTime();
812 + markRootPinged(root, suspendedLanes, eventTime);
813 break;
814 }
815
@@ -853,16 +847,17 @@ function finishConcurrentRender(root, finishedWork, exitStatus, lanes) {
847 // suspended level. Ping the last suspended level to try
848 // rendering it again.
849 // FIXME: What if the suspended lanes are Idle? Should not restart.
856 - markRootPinged(root, suspendedLanes);
850 + const eventTime = requestEventTime();
851 + markRootPinged(root, suspendedLanes, eventTime);
852 break;
853 }
854
855 let msUntilTimeout;
861 - if (workInProgressRootLatestSuspenseTimeout !== -1) {
856 + if (workInProgressRootLatestSuspenseTimeout !== NoTimestamp) {
857 // We have processed a suspense config whose expiration time we
858 // can use as the timeout.
859 msUntilTimeout = workInProgressRootLatestSuspenseTimeout - now();
865 - } else if (workInProgressRootLatestProcessedEventTime === -1) {
860 + } else if (workInProgressRootLatestProcessedEventTime === NoTimestamp) {
861 // This should never normally happen because only new updates
862 // cause delayed states, so we should have processed something.
863 // However, this could also happen in an offscreen tree.
@@ -896,7 +891,7 @@ function finishConcurrentRender(root, finishedWork, exitStatus, lanes) {
891 if (
892 // do not delay if we're inside an act() scope
893 !shouldForceFlushFallbacksInDEV() &&
899 - workInProgressRootLatestProcessedEventTime !== -1 &&
894 + workInProgressRootLatestProcessedEventTime !== NoTimestamp &&
895 workInProgressRootCanSuspendUsingConfig !== null
896 ) {
897 // If we have exceeded the minimum loading delay, which probably
@@ -992,7 +987,7 @@ function performSyncWorkOnRoot(root) {
987 const fatalError = workInProgressRootFatalError;
988 prepareFreshStack(root, NoLanes);
989 markRootSuspended(root, lanes);
995 - ensureRootIsScheduled(root);
990 + ensureRootIsScheduled(root, now());
991 throw fatalError;
992 }
993
@@ -1005,14 +1000,14 @@ function performSyncWorkOnRoot(root) {
1000
1001 // Before exiting, make sure there's a callback scheduled for the next
1002 // pending level.
1008 - ensureRootIsScheduled(root);
1003 + ensureRootIsScheduled(root, now());
1004
1005 return null;
1006 }
1007
1008 export function flushRoot(root: FiberRoot, lanes: Lanes) {
1009 markRootExpired(root, lanes);
1015 - ensureRootIsScheduled(root);
1010 + ensureRootIsScheduled(root, now());
1011 if ((executionContext & (RenderContext | CommitContext)) === NoContext) {
1012 flushSyncCallbackQueue();
1013 }
@@ -1059,7 +1054,7 @@ function flushPendingDiscreteUpdates() {
1054 rootsWithPendingDiscreteUpdates = null;
1055 roots.forEach(root => {
1056 markDiscreteUpdatesExpired(root);
1062 - ensureRootIsScheduled(root);
1057 + ensureRootIsScheduled(root, now());
1058 });
1059 }
1060 // Now flush the immediate queue.
@@ -1214,8 +1209,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
1209 workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes;
1210 workInProgressRootExitStatus = RootIncomplete;
1211 workInProgressRootFatalError = null;
1217 - workInProgressRootLatestProcessedEventTime = -1;
1218 - workInProgressRootLatestSuspenseTimeout = -1;
1212 + workInProgressRootLatestProcessedEventTime = NoTimestamp;
1213 + workInProgressRootLatestSuspenseTimeout = NoTimestamp;
1214 workInProgressRootCanSuspendUsingConfig = null;
1215 workInProgressRootSkippedLanes = NoLanes;
1216 workInProgressRootUpdatedLanes = NoLanes;
@@ -1785,11 +1780,6 @@ function commitRootImpl(root, renderPriorityLevel) {
1780 // So we can clear these now to allow a new callback to be scheduled.
1781 root.callbackNode = null;
1782 root.callbackId = NoLanes;
1788 - // TODO: Use LanePriority instead of SchedulerPriority
1789 - if (renderPriorityLevel < ImmediateSchedulerPriority) {
1790 - // If this was a concurrent render, we can reset the expiration time.
1791 - root.expiresAt = -1;
1792 - }
1783
1784 // Update the first and last pending times on this root. The new first
1785 // pending time is whatever is left on the root fiber.
@@ -2046,7 +2036,7 @@ function commitRootImpl(root, renderPriorityLevel) {
2036
2037 // Always call this before exiting `commitRoot`, to ensure that any
2038 // additional work on this root is scheduled.
2049 - ensureRootIsScheduled(root);
2039 + ensureRootIsScheduled(root, now());
2040
2041 if (hasUncaughtError) {
2042 hasUncaughtError = false;
@@ -2504,9 +2494,10 @@ function captureCommitPhaseErrorOnRoot(
2494 const errorInfo = createCapturedValue(error, sourceFiber);
2495 const update = createRootErrorUpdate(rootFiber, errorInfo, (SyncLane: Lane));
2496 enqueueUpdate(rootFiber, update);
2497 + const eventTime = requestEventTime();
2498 const root = markUpdateLaneFromFiberToRoot(rootFiber, (SyncLane: Lane));
2499 if (root !== null) {
2509 - ensureRootIsScheduled(root);
2500 + ensureRootIsScheduled(root, eventTime);
2501 schedulePendingInteractions(root, SyncLane);
2502 }
2503 }
@@ -2539,9 +2530,10 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
2530 (SyncLane: Lane),
2531 );
2532 enqueueUpdate(fiber, update);
2533 + const eventTime = requestEventTime();
2534 const root = markUpdateLaneFromFiberToRoot(fiber, (SyncLane: Lane));
2535 if (root !== null) {
2544 - ensureRootIsScheduled(root);
2536 + ensureRootIsScheduled(root, eventTime);
2537 schedulePendingInteractions(root, SyncLane);
2538 }
2539 return;
@@ -2563,7 +2555,8 @@ export function pingSuspendedRoot(
2555 pingCache.delete(wakeable);
2556 }
2557
2566 - markRootPinged(root, pingedLanes);
2558 + const eventTime = requestEventTime();
2559 + markRootPinged(root, pingedLanes, eventTime);
2560
2561 if (
2562 workInProgressRoot === root &&
@@ -2585,7 +2578,7 @@ export function pingSuspendedRoot(
2578 if (
2579 workInProgressRootExitStatus === RootSuspendedWithDelay ||
2580 (workInProgressRootExitStatus === RootSuspended &&
2588 - workInProgressRootLatestProcessedEventTime === -1 &&
2581 + workInProgressRootLatestProcessedEventTime === NoTimestamp &&
2582 now() - globalMostRecentFallbackTime < FALLBACK_THROTTLE_MS)
2583 ) {
2584 // Restart from the root.
@@ -2600,7 +2593,7 @@ export function pingSuspendedRoot(
2593 }
2594 }
2595
2603 - ensureRootIsScheduled(root);
2596 + ensureRootIsScheduled(root, eventTime);
2597 schedulePendingInteractions(root, pingedLanes);
2598 }
2599
@@ -2616,9 +2609,10 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) {
2609 retryLane = requestUpdateLane(boundaryFiber, suspenseConfig);
2610 }
2611 // TODO: Special case idle priority?
2612 + const eventTime = requestEventTime();
2613 const root = markUpdateLaneFromFiberToRoot(boundaryFiber, retryLane);
2614 if (root !== null) {
2621 - ensureRootIsScheduled(root);
2615 + ensureRootIsScheduled(root, eventTime);
2616 schedulePendingInteractions(root, retryLane);
2617 }
2618 }
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+5 -5
@@ -620,7 +620,7 @@ function ensureRootIsScheduled(root: FiberRoot) {
620 if (lastExpiredTime !== NoWork) {
621 // Special case: Expired work should flush synchronously.
622 root.callbackExpirationTime = Sync;
623 - root.callbackPriority = ImmediatePriority;
623 + root.callbackPriority_old = ImmediatePriority;
624 root.callbackNode = scheduleSyncCallback(
625 performSyncWorkOnRoot.bind(null, root),
626 );
@@ -634,7 +634,7 @@ function ensureRootIsScheduled(root: FiberRoot) {
634 if (existingCallbackNode !== null) {
635 root.callbackNode = null;
636 root.callbackExpirationTime = NoWork;
637 - root.callbackPriority = NoPriority;
637 + root.callbackPriority_old = NoPriority;
638 }
639 return;
640 }
@@ -650,7 +650,7 @@ function ensureRootIsScheduled(root: FiberRoot) {
650 // If there's an existing render task, confirm it has the correct priority and
651 // expiration time. Otherwise, we'll cancel it and schedule a new one.
652 if (existingCallbackNode !== null) {
653 - const existingCallbackPriority = root.callbackPriority;
653 + const existingCallbackPriority = root.callbackPriority_old;
654 const existingCallbackExpirationTime = root.callbackExpirationTime;
655 if (
656 // Callback must have the exact same expiration time.
@@ -668,7 +668,7 @@ function ensureRootIsScheduled(root: FiberRoot) {
668 }
669
670 root.callbackExpirationTime = expirationTime;
671 - root.callbackPriority = priorityLevel;
671 + root.callbackPriority_old = priorityLevel;
672
673 let callbackNode;
674 if (expirationTime === Sync) {
@@ -1878,7 +1878,7 @@ function commitRootImpl(root, renderPriorityLevel) {
1878 // So we can clear these now to allow a new callback to be scheduled.
1879 root.callbackNode = null;
1880 root.callbackExpirationTime = NoWork;
1881 - root.callbackPriority = NoPriority;
1881 + root.callbackPriority_old = NoPriority;
1882
1883 // Update the first and last pending times on this root. The new first
1884 // pending time is whatever is left on the root fiber.
packages/react-reconciler/src/ReactInternalTypes.js
+4 -13
@@ -23,7 +23,7 @@ import type {WorkTag} from './ReactWorkTags';
23 import type {TypeOfMode} from './ReactTypeOfMode';
24 import type {SideEffectTag} from './ReactSideEffectTags';
25 import type {ExpirationTime} from './ReactFiberExpirationTime.old';
26 -import type {Lane, Lanes} from './ReactFiberLane';
26 +import type {Lane, LanePriority, Lanes, LaneMap} from './ReactFiberLane';
27 import type {HookType} from './ReactFiberHooks.old';
28 import type {RootTag} from './ReactRootTags';
29 import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig';
@@ -226,7 +226,7 @@ type BaseFiberRootProperties = {|
226 // Expiration of the callback associated with this root
227 callbackExpirationTime: ExpirationTime,
228 // Priority of the callback associated with this root
229 - callbackPriority: ReactPriorityLevel,
229 + callbackPriority_old: ReactPriorityLevel,
230
231 finishedExpirationTime: ExpirationTime,
232 // The earliest pending expiration time that exists in the tree
@@ -252,17 +252,8 @@ type BaseFiberRootProperties = {|
252 // Represents the next task that the root should work on, or the current one
253 // if it's already working.
254 callbackId: Lanes,
255 - // Whether the currently scheduled task for this root is synchronous or
256 - // batched/concurrent. We have to track this because Scheduler does not
257 - // support synchronous tasks, so we put those on a separate queue. So you
258 - // could also think of this as "which queue is the callback scheduled with?"
259 - callbackIsSync: boolean,
260 - // Timestamp at which we will synchronously finish the current task to
261 - // prevent starvation.
262 - // TODO: There should be a separate expiration per lane.
263 - // NOTE: This is not an "ExpirationTime" as used by the old reconciler. It's a
264 - // timestamp, in milliseconds.
265 - expiresAt: number,
255 + callbackPriority_new: LanePriority,
256 + expirationTimes: LaneMap<number>,
257
258 pendingLanes: Lanes,
259 suspendedLanes: Lanes,
packages/react-reconciler/src/__tests__/ReactExpiration-test.js
+474
@@ -12,6 +12,8 @@
12 let React;
13 let ReactNoop;
14 let Scheduler;
15 +let readText;
16 +let resolveText;
17
18 describe('ReactExpiration', () => {
19 beforeEach(() => {
@@ -20,6 +22,53 @@ describe('ReactExpiration', () => {
22 React = require('react');
23 ReactNoop = require('react-noop-renderer');
24 Scheduler = require('scheduler');
25 +
26 + const textCache = new Map();
27 +
28 + readText = text => {
29 + const record = textCache.get(text);
30 + if (record !== undefined) {
31 + switch (record.status) {
32 + case 'pending':
33 + throw record.promise;
34 + case 'rejected':
35 + throw Error('Failed to load: ' + text);
36 + case 'resolved':
37 + return text;
38 + }
39 + } else {
40 + let ping;
41 + const promise = new Promise(resolve => (ping = resolve));
42 + const newRecord = {
43 + status: 'pending',
44 + ping: ping,
45 + promise,
46 + };
47 + textCache.set(text, newRecord);
48 + throw promise;
49 + }
50 + };
51 +
52 + resolveText = text => {
53 + const record = textCache.get(text);
54 + if (record !== undefined) {
55 + if (record.status === 'pending') {
56 + Scheduler.unstable_yieldValue(`Promise resolved [${text}]`);
57 + record.ping();
58 + record.ping = null;
59 + record.status = 'resolved';
60 + clearTimeout(record.promise._timer);
61 + record.promise = null;
62 + }
63 + } else {
64 + const newRecord = {
65 + ping: null,
66 + status: 'resolved',
67 + promise: null,
68 + };
69 + textCache.set(text, newRecord);
70 + }
71 + };
72 });
73
74 function Text(props) {
@@ -27,6 +76,27 @@ describe('ReactExpiration', () => {
76 return props.text;
77 }
78
79 + function AsyncText(props) {
80 + const text = props.text;
81 + try {
82 + readText(text);
83 + Scheduler.unstable_yieldValue(text);
84 + return text;
85 + } catch (promise) {
86 + if (typeof promise.then === 'function') {
87 + Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
88 + if (typeof props.ms === 'number' && promise._timer === undefined) {
89 + promise._timer = setTimeout(() => {
90 + resolveText(text);
91 + }, props.ms);
92 + }
93 + } else {
94 + Scheduler.unstable_yieldValue(`Error! [${text}]`);
95 + }
96 + throw promise;
97 + }
98 + }
99 +
100 function span(prop) {
101 return {type: 'span', children: [], prop, hidden: false};
102 }
@@ -348,4 +418,408 @@ describe('ReactExpiration', () => {
418 expect(Scheduler).toFlushExpired([]);
419 expect(ReactNoop).toMatchRenderedOutput('Hi');
420 });
421 +
422 + it('prevents starvation by high priority updates', async () => {
423 + const {useState} = React;
424 +
425 + let updateHighPri;
426 + let updateNormalPri;
427 + function App() {
428 + const [highPri, setHighPri] = useState(0);
429 + const [normalPri, setNormalPri] = useState(0);
430 + updateHighPri = () =>
431 + Scheduler.unstable_runWithPriority(
432 + Scheduler.unstable_UserBlockingPriority,
433 + () => setHighPri(n => n + 1),
434 + );
435 + updateNormalPri = () => setNormalPri(n => n + 1);
436 + return (
437 + <>
438 + <Text text={'High pri: ' + highPri} />
439 + {', '}
440 + <Text text={'Normal pri: ' + normalPri} />
441 + </>
442 + );
443 + }
444 +
445 + const root = ReactNoop.createRoot();
446 + await ReactNoop.act(async () => {
447 + root.render(<App />);
448 + });
449 + expect(Scheduler).toHaveYielded(['High pri: 0', 'Normal pri: 0']);
450 + expect(root).toMatchRenderedOutput('High pri: 0, Normal pri: 0');
451 +
452 + // First demonstrate what happens when there's no starvation
453 + await ReactNoop.act(async () => {
454 + updateNormalPri();
455 + expect(Scheduler).toFlushAndYieldThrough(['High pri: 0']);
456 + updateHighPri();
457 + });
458 + expect(Scheduler).toHaveYielded([
459 + // Interrupt high pri update to render sync update
460 + 'High pri: 1',
461 + 'Normal pri: 0',
462 + // Now render normal pri
463 + 'High pri: 1',
464 + 'Normal pri: 1',
465 + ]);
466 + expect(root).toMatchRenderedOutput('High pri: 1, Normal pri: 1');
467 +
468 + // Do the same thing, but starve the first update
469 + await ReactNoop.act(async () => {
470 + updateNormalPri();
471 + expect(Scheduler).toFlushAndYieldThrough(['High pri: 1']);
472 +
473 + // This time, a lot of time has elapsed since the normal pri update
474 + // started rendering. (This should advance time by some number that's
475 + // definitely bigger than the constant heuristic we use to detect
476 + // starvation of normal priority updates.)
477 + Scheduler.unstable_advanceTime(10000);
478 +
479 + // So when we get a high pri update, we shouldn't interrupt
480 + updateHighPri();
481 + });
482 + expect(Scheduler).toHaveYielded([
483 + // Finish normal pri update
484 + 'Normal pri: 2',
485 + // Then do high pri update
486 + 'High pri: 2',
487 + 'Normal pri: 2',
488 + ]);
489 + expect(root).toMatchRenderedOutput('High pri: 2, Normal pri: 2');
490 + });
491 +
492 + // @gate new
493 + it('prevents starvation by sync updates', async () => {
494 + const {useState} = React;
495 +
496 + let updateSyncPri;
497 + let updateHighPri;
498 + function App() {
499 + const [syncPri, setSyncPri] = useState(0);
500 + const [highPri, setHighPri] = useState(0);
501 + updateSyncPri = () => ReactNoop.flushSync(() => setSyncPri(n => n + 1));
502 + updateHighPri = () =>
503 + Scheduler.unstable_runWithPriority(
504 + Scheduler.unstable_UserBlockingPriority,
505 + () => setHighPri(n => n + 1),
506 + );
507 + return (
508 + <>
509 + <Text text={'Sync pri: ' + syncPri} />
510 + {', '}
511 + <Text text={'High pri: ' + highPri} />
512 + </>
513 + );
514 + }
515 +
516 + const root = ReactNoop.createRoot();
517 + await ReactNoop.act(async () => {
518 + root.render(<App />);
519 + });
520 + expect(Scheduler).toHaveYielded(['Sync pri: 0', 'High pri: 0']);
521 + expect(root).toMatchRenderedOutput('Sync pri: 0, High pri: 0');
522 +
523 + // First demonstrate what happens when there's no starvation
524 + await ReactNoop.act(async () => {
525 + updateHighPri();
526 + expect(Scheduler).toFlushAndYieldThrough(['Sync pri: 0']);
527 + updateSyncPri();
528 + });
529 + expect(Scheduler).toHaveYielded([
530 + // Interrupt high pri update to render sync update
531 + 'Sync pri: 1',
532 + 'High pri: 0',
533 + // Now render high pri
534 + 'Sync pri: 1',
535 + 'High pri: 1',
536 + ]);
537 + expect(root).toMatchRenderedOutput('Sync pri: 1, High pri: 1');
538 +
539 + // Do the same thing, but starve the first update
540 + await ReactNoop.act(async () => {
541 + updateHighPri();
542 + expect(Scheduler).toFlushAndYieldThrough(['Sync pri: 1']);
543 +
544 + // This time, a lot of time has elapsed since the high pri update started
545 + // rendering. (This should advance time by some number that's definitely
546 + // bigger than the constant heuristic we use to detect starvation of user
547 + // interactions, but not as high as the onse used for normal pri updates.)
548 + Scheduler.unstable_advanceTime(1500);
549 +
550 + // So when we get a sync update, we shouldn't interrupt
551 + updateSyncPri();
552 + });
553 + expect(Scheduler).toHaveYielded([
554 + // Finish high pri update
555 + 'High pri: 2',
556 + // Then do sync update
557 + 'Sync pri: 2',
558 + 'High pri: 2',
559 + ]);
560 + expect(root).toMatchRenderedOutput('Sync pri: 2, High pri: 2');
561 + });
562 +
563 + it('idle work never expires', async () => {
564 + const {useState} = React;
565 +
566 + let updateSyncPri;
567 + let updateIdlePri;
568 + function App() {
569 + const [syncPri, setSyncPri] = useState(0);
570 + const [highPri, setIdlePri] = useState(0);
571 + updateSyncPri = () => ReactNoop.flushSync(() => setSyncPri(n => n + 1));
572 + updateIdlePri = () =>
573 + Scheduler.unstable_runWithPriority(
574 + Scheduler.unstable_IdlePriority,
575 + () => setIdlePri(n => n + 1),
576 + );
577 + return (
578 + <>
579 + <Text text={'Sync pri: ' + syncPri} />
580 + {', '}
581 + <Text text={'Idle pri: ' + highPri} />
582 + </>
583 + );
584 + }
585 +
586 + const root = ReactNoop.createRoot();
587 + await ReactNoop.act(async () => {
588 + root.render(<App />);
589 + });
590 + expect(Scheduler).toHaveYielded(['Sync pri: 0', 'Idle pri: 0']);
591 + expect(root).toMatchRenderedOutput('Sync pri: 0, Idle pri: 0');
592 +
593 + // First demonstrate what happens when there's no starvation
594 + await ReactNoop.act(async () => {
595 + updateIdlePri();
596 + expect(Scheduler).toFlushAndYieldThrough(['Sync pri: 0']);
597 + updateSyncPri();
598 + });
599 + expect(Scheduler).toHaveYielded([
600 + // Interrupt idle update to render sync update
601 + 'Sync pri: 1',
602 + 'Idle pri: 0',
603 + // Now render idle
604 + 'Sync pri: 1',
605 + 'Idle pri: 1',
606 + ]);
607 + expect(root).toMatchRenderedOutput('Sync pri: 1, Idle pri: 1');
608 +
609 + // Do the same thing, but starve the first update
610 + await ReactNoop.act(async () => {
611 + updateIdlePri();
612 + expect(Scheduler).toFlushAndYieldThrough(['Sync pri: 1']);
613 +
614 + // Advance a ridiculously large amount of time to demonstrate that the
615 + // idle work never expires
616 + Scheduler.unstable_advanceTime(100000);
617 +
618 + updateSyncPri();
619 + });
620 + // Same thing should happen as last time
621 + expect(Scheduler).toHaveYielded([
622 + // Interrupt idle update to render sync update
623 + 'Sync pri: 2',
624 + 'Idle pri: 1',
625 + // Now render idle
626 + 'Sync pri: 2',
627 + 'Idle pri: 2',
628 + ]);
629 + expect(root).toMatchRenderedOutput('Sync pri: 2, Idle pri: 2');
630 + });
631 +
632 + // @gate new
633 + it('a single update can expire without forcing all other updates to expire', async () => {
634 + const {useState} = React;
635 +
636 + let updateHighPri;
637 + let updateNormalPri;
638 + function App() {
639 + const [highPri, setHighPri] = useState(0);
640 + const [normalPri, setNormalPri] = useState(0);
641 + updateHighPri = () =>
642 + Scheduler.unstable_runWithPriority(
643 + Scheduler.unstable_UserBlockingPriority,
644 + () => setHighPri(n => n + 1),
645 + );
646 + updateNormalPri = () => setNormalPri(n => n + 1);
647 + return (
648 + <>
649 + <Text text={'High pri: ' + highPri} />
650 + {', '}
651 + <Text text={'Normal pri: ' + normalPri} />
652 + {', '}
653 + <Text text="Sibling" />
654 + </>
655 + );
656 + }
657 +
658 + const root = ReactNoop.createRoot();
659 + await ReactNoop.act(async () => {
660 + root.render(<App />);
661 + });
662 + expect(Scheduler).toHaveYielded([
663 + 'High pri: 0',
664 + 'Normal pri: 0',
665 + 'Sibling',
666 + ]);
667 + expect(root).toMatchRenderedOutput('High pri: 0, Normal pri: 0, Sibling');
668 +
669 + await ReactNoop.act(async () => {
670 + // Partially render an update
671 + updateNormalPri();
672 + expect(Scheduler).toFlushAndYieldThrough(['High pri: 0']);
673 + // Some time goes by. In an interleaved event, schedule another update.
674 + // This will be placed into a separate batch.
675 + Scheduler.unstable_advanceTime(4000);
676 + updateNormalPri();
677 + // Keep rendering the first update
678 + expect(Scheduler).toFlushAndYieldThrough(['Normal pri: 1']);
679 + // More time goes by. Enough to expire the first batch, but not the
680 + // second one.
681 + Scheduler.unstable_advanceTime(1000);
682 + // Attempt to interrupt with a high pri update.
683 + updateHighPri();
684 +
685 + // The first update expired, so first will finish it without interrupting.
686 + // But not the second update, which hasn't expired yet.
687 + expect(Scheduler).toFlushExpired(['Sibling']);
688 + });
689 + expect(Scheduler).toHaveYielded([
690 + // Then render the high pri update
691 + 'High pri: 1',
692 + 'Normal pri: 1',
693 + 'Sibling',
694 + // Then the second normal pri update
695 + 'High pri: 1',
696 + 'Normal pri: 2',
697 + 'Sibling',
698 + ]);
699 + });
700 +
701 + it('detects starvation in multiple batches', async () => {
702 + const {useState} = React;
703 +
704 + let updateHighPri;
705 + let updateNormalPri;
706 + function App() {
707 + const [highPri, setHighPri] = useState(0);
708 + const [normalPri, setNormalPri] = useState(0);
709 + updateHighPri = () =>
710 + Scheduler.unstable_runWithPriority(
711 + Scheduler.unstable_UserBlockingPriority,
712 + () => setHighPri(n => n + 1),
713 + );
714 + updateNormalPri = () => setNormalPri(n => n + 1);
715 + return (
716 + <>
717 + <Text text={'High pri: ' + highPri} />
718 + {', '}
719 + <Text text={'Normal pri: ' + normalPri} />
720 + {', '}
721 + <Text text="Sibling" />
722 + </>
723 + );
724 + }
725 +
726 + const root = ReactNoop.createRoot();
727 + await ReactNoop.act(async () => {
728 + root.render(<App />);
729 + });
730 + expect(Scheduler).toHaveYielded([
731 + 'High pri: 0',
732 + 'Normal pri: 0',
733 + 'Sibling',
734 + ]);
735 + expect(root).toMatchRenderedOutput('High pri: 0, Normal pri: 0, Sibling');
736 +
737 + await ReactNoop.act(async () => {
738 + // Partially render an update
739 + updateNormalPri();
740 + expect(Scheduler).toFlushAndYieldThrough(['High pri: 0']);
741 + // Some time goes by. In an interleaved event, schedule another update.
742 + // This will be placed into a separate batch.
743 + Scheduler.unstable_advanceTime(4000);
744 + updateNormalPri();
745 + // Keep rendering the first update
746 + expect(Scheduler).toFlushAndYieldThrough(['Normal pri: 1']);
747 + // More time goes by. This expires both of the updates just scheduled.
748 + Scheduler.unstable_advanceTime(10000);
749 +
750 + // Attempt to interrupt with a high pri update.
751 + updateHighPri();
752 +
753 + // Both normal pri updates should have expired.
754 + expect(Scheduler).toFlushExpired([
755 + 'Sibling',
756 + // Note: we also flushed the high pri update here, because in the
757 + // current implementation, once we pick the next lanes to work on, we
758 + // entangle it with all pending at equal or higher priority. We could
759 + // feasibly change this heuristic so that the high pri update doesn't
760 + // render until after the expired updates have finished. But the
761 + // important thing in this test is that the normal updates expired.
762 + 'High pri: 1',
763 + 'Normal pri: 2',
764 + 'Sibling',
765 + ]);
766 + });
767 + });
768 +
769 + // @gate new
770 + it('updates do not expire while they are IO-bound', async () => {
771 + const {Suspense} = React;
772 +
773 + function App({text}) {
774 + return (
775 + <Suspense fallback={<Text text="Loading..." />}>
776 + <AsyncText text={text} />
777 + {', '}
778 + <Text text="Sibling" />
779 + </Suspense>
780 + );
781 + }
782 +
783 + const root = ReactNoop.createRoot();
784 + await ReactNoop.act(async () => {
785 + await resolveText('A');
786 + root.render(<App text="A" />);
787 + });
788 + expect(Scheduler).toHaveYielded(['A', 'Sibling']);
789 + expect(root).toMatchRenderedOutput('A, Sibling');
790 +
791 + await ReactNoop.act(async () => {
792 + root.render(<App text="B" />);
793 + expect(Scheduler).toFlushAndYield([
794 + 'Suspend! [B]',
795 + 'Sibling',
796 + 'Loading...',
797 + ]);
798 +
799 + // Lots of time elapses before the promise resolves
800 + Scheduler.unstable_advanceTime(10000);
801 + await resolveText('B');
802 + expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
803 +
804 + // But the update doesn't expire, because it was IO bound. So we can
805 + // partially rendering without finishing.
806 + expect(Scheduler).toFlushAndYieldThrough(['B']);
807 + expect(root).toMatchRenderedOutput('A, Sibling');
808 +
809 + // Lots more time elapses. We're CPU-bound now, so we should treat this
810 + // as starvation.
811 + Scheduler.unstable_advanceTime(10000);
812 +
813 + // Attempt to interrupt with a sync update.
814 + ReactNoop.flushSync(() => root.render(<App text="A" />));
815 + expect(Scheduler).toHaveYielded([
816 + // Because the previous update had already expired, we don't interrupt
817 + // it. Finish rendering it first.
818 + 'Sibling',
819 + // Then do the sync update.
820 + 'A',
821 + 'Sibling',
822 + ]);
823 + });
824 + });
825 });
packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.js
+2 -2
@@ -488,8 +488,8 @@ describe('ReactIncrementalUpdates', () => {
488 expect(ReactNoop.getChildren()).toEqual([span('derived state')]);
489 });
490
491 - // Note: This test doesn't really make sense in the new model, but we might
492 - // want to port it once lanes are implemented.
491 + // Note: This test doesn't really make sense in the new model. The
492 + // corresponding concept is tested in ReactExpiration-test.
493 // @gate old
494 it('flushes all expired updates in a single batch', () => {
495 const {useEffect} = React;
packages/react/src/__tests__/ReactProfiler-test.internal.js
+19 -6
@@ -295,13 +295,26 @@ describe('Profiler', () => {
295 );
296
297 // Should be called two times:
298 - // 2. To compute the update expiration time
299 - // 3. To record the commit time
298 + // 1. To compute the update expiration time
299 + // 2. To record the commit time
300 // No additional calls from ProfilerTimer are expected.
301 - expect(Scheduler).toHaveYielded([
302 - 'read current time',
303 - 'read current time',
304 - ]);
301 + expect(Scheduler).toHaveYielded(
302 + gate(flags =>
303 + flags.new
304 + ? [
305 + // The new reconciler reads the current time in more places,
306 + // to detect starvation. This is unrelated to the profiler,
307 + // which happens to use the same Scheduler method that we
308 + // mocked above. We should rewrite this test so that it's
309 + // less fragile.
310 + 'read current time',
311 + 'read current time',
312 + 'read current time',
313 + 'read current time',
314 + ]
315 + : ['read current time', 'read current time'],
316 + ),
317 + );
318
319 // Restore original mock
320 jest.mock('scheduler', () =>