@samitouri / QOS-React-2 / commits / 137ea783b1

Re-enable risky work loop changes (#16771)

The stack of PRs in #16743 was reverted. This adds them back.

Andrew Clark committed Sep 13, 2019 at 09:33 UTC 137ea783b1073f7b94a0bf252b6344174c65754b
10 files changed +1137 -466
packages/react-reconciler/src/ReactFiberBeginWork.js
+6
@@ -177,6 +177,7 @@ import {
177 retryDehydratedSuspenseBoundary,
178 scheduleWork,
179 renderDidSuspendDelayIfPossible,
180 + markUnprocessedUpdateTime,
181 } from './ReactFiberWorkLoop';
182
183 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
@@ -2709,6 +2710,11 @@ function bailoutOnAlreadyFinishedWork(
2710 stopProfilerTimerIfRunning(workInProgress);
2711 }
2712
2713 + const updateExpirationTime = workInProgress.expirationTime;
2714 + if (updateExpirationTime !== NoWork) {
2715 + markUnprocessedUpdateTime(updateExpirationTime);
2716 + }
2717 +
2718 // Check if the children have any pending work.
2719 const childExpirationTime = workInProgress.childExpirationTime;
2720 if (childExpirationTime < renderExpirationTime) {
packages/react-reconciler/src/ReactFiberExpirationTime.js
+3
@@ -21,7 +21,10 @@ import {
21 export type ExpirationTime = number;
22
23 export const NoWork = 0;
24 +// TODO: Think of a better name for Never.
25 export const Never = 1;
26 +// TODO: Use the Idle expiration time for idle state updates
27 +export const Idle = 2;
28 export const Sync = MAX_SIGNED_31_BIT_INT;
29 export const Batched = Sync - 1;
30
packages/react-reconciler/src/ReactFiberHooks.js
+3
@@ -43,6 +43,7 @@ import {
43 warnIfNotCurrentlyActingUpdatesInDev,
44 warnIfNotScopedWithMatchingAct,
45 markRenderEventTimeAndConfig,
46 + markUnprocessedUpdateTime,
47 } from './ReactFiberWorkLoop';
48
49 import invariant from 'shared/invariant';
@@ -531,6 +532,7 @@ export function resetHooks(): void {
532 // This is used to reset the state of this module when a component throws.
533 // It's also called inside mountIndeterminateComponent if we determine the
534 // component is a module-style component.
535 +
536 renderExpirationTime = NoWork;
537 currentlyRenderingFiber = null;
538
@@ -755,6 +757,7 @@ function updateReducer<S, I, A>(
757 // Update the remaining priority in the queue.
758 if (updateExpirationTime > remainingExpirationTime) {
759 remainingExpirationTime = updateExpirationTime;
760 + markUnprocessedUpdateTime(remainingExpirationTime);
761 }
762 } else {
763 // This update does have sufficient priority.
packages/react-reconciler/src/ReactFiberRoot.js
+125 -7
@@ -14,6 +14,7 @@ import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig';
14 import type {Thenable} from './ReactFiberWorkLoop';
15 import type {Interaction} from 'scheduler/src/Tracing';
16 import type {SuspenseHydrationCallbacks} from './ReactFiberSuspenseComponent';
17 +import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';
18
19 import {noTimeout} from './ReactFiberHostConfig';
20 import {createHostRootFiber} from './ReactFiber';
@@ -23,6 +24,7 @@ import {
24 enableSuspenseCallback,
25 } from 'shared/ReactFeatureFlags';
26 import {unstable_getThreadID} from 'scheduler/tracing';
27 +import {NoPriority} from './SchedulerWithReactIntegration';
28
29 // TODO: This should be lifted into the renderer.
30 export type Batch = {
@@ -69,12 +71,20 @@ type BaseFiberRootProperties = {|
71 callbackNode: *,
72 // Expiration of the callback associated with this root
73 callbackExpirationTime: ExpirationTime,
74 + // Priority of the callback associated with this root
75 + callbackPriority: ReactPriorityLevel,
76 // The earliest pending expiration time that exists in the tree
77 firstPendingTime: ExpirationTime,
74 - // The latest pending expiration time that exists in the tree
75 - lastPendingTime: ExpirationTime,
76 - // The time at which a suspended component pinged the root to render again
77 - pingTime: ExpirationTime,
78 + // The earliest suspended expiration time that exists in the tree
79 + firstSuspendedTime: ExpirationTime,
80 + // The latest suspended expiration time that exists in the tree
81 + lastSuspendedTime: ExpirationTime,
82 + // The next known expiration time after the suspended range
83 + nextKnownPendingLevel: ExpirationTime,
84 + // The latest time at which a suspended component pinged the root to
85 + // render again
86 + lastPingedTime: ExpirationTime,
87 + lastExpiredTime: ExpirationTime,
88 |};
89
90 // The following attributes are only used by interaction tracing builds.
@@ -117,10 +127,13 @@ function FiberRootNode(containerInfo, tag, hydrate) {
127 this.hydrate = hydrate;
128 this.firstBatch = null;
129 this.callbackNode = null;
120 - this.callbackExpirationTime = NoWork;
130 + this.callbackPriority = NoPriority;
131 this.firstPendingTime = NoWork;
122 - this.lastPendingTime = NoWork;
123 - this.pingTime = NoWork;
132 + this.firstSuspendedTime = NoWork;
133 + this.lastSuspendedTime = NoWork;
134 + this.nextKnownPendingLevel = NoWork;
135 + this.lastPingedTime = NoWork;
136 + this.lastExpiredTime = NoWork;
137
138 if (enableSchedulerTracing) {
139 this.interactionThreadID = unstable_getThreadID();
@@ -151,3 +164,108 @@ export function createFiberRoot(
164
165 return root;
166 }
167 +
168 +export function isRootSuspendedAtTime(
169 + root: FiberRoot,
170 + expirationTime: ExpirationTime,
171 +): boolean {
172 + const firstSuspendedTime = root.firstSuspendedTime;
173 + const lastSuspendedTime = root.lastSuspendedTime;
174 + return (
175 + firstSuspendedTime !== NoWork &&
176 + (firstSuspendedTime >= expirationTime &&
177 + lastSuspendedTime <= expirationTime)
178 + );
179 +}
180 +
181 +export function markRootSuspendedAtTime(
182 + root: FiberRoot,
183 + expirationTime: ExpirationTime,
184 +): void {
185 + const firstSuspendedTime = root.firstSuspendedTime;
186 + const lastSuspendedTime = root.lastSuspendedTime;
187 + if (firstSuspendedTime < expirationTime) {
188 + root.firstSuspendedTime = expirationTime;
189 + }
190 + if (lastSuspendedTime > expirationTime || firstSuspendedTime === NoWork) {
191 + root.lastSuspendedTime = expirationTime;
192 + }
193 +
194 + if (expirationTime <= root.lastPingedTime) {
195 + root.lastPingedTime = NoWork;
196 + }
197 +
198 + if (expirationTime <= root.lastExpiredTime) {
199 + root.lastExpiredTime = NoWork;
200 + }
201 +}
202 +
203 +export function markRootUpdatedAtTime(
204 + root: FiberRoot,
205 + expirationTime: ExpirationTime,
206 +): void {
207 + // Update the range of pending times
208 + const firstPendingTime = root.firstPendingTime;
209 + if (expirationTime > firstPendingTime) {
210 + root.firstPendingTime = expirationTime;
211 + }
212 +
213 + // Update the range of suspended times. Treat everything lower priority or
214 + // equal to this update as unsuspended.
215 + const firstSuspendedTime = root.firstSuspendedTime;
216 + if (firstSuspendedTime !== NoWork) {
217 + if (expirationTime >= firstSuspendedTime) {
218 + // The entire suspended range is now unsuspended.
219 + root.firstSuspendedTime = root.lastSuspendedTime = root.nextKnownPendingLevel = NoWork;
220 + } else if (expirationTime >= root.lastSuspendedTime) {
221 + root.lastSuspendedTime = expirationTime + 1;
222 + }
223 +
224 + // This is a pending level. Check if it's higher priority than the next
225 + // known pending level.
226 + if (expirationTime > root.nextKnownPendingLevel) {
227 + root.nextKnownPendingLevel = expirationTime;
228 + }
229 + }
230 +}
231 +
232 +export function markRootFinishedAtTime(
233 + root: FiberRoot,
234 + finishedExpirationTime: ExpirationTime,
235 + remainingExpirationTime: ExpirationTime,
236 +): void {
237 + // Update the range of pending times
238 + root.firstPendingTime = remainingExpirationTime;
239 +
240 + // Update the range of suspended times. Treat everything higher priority or
241 + // equal to this update as unsuspended.
242 + if (finishedExpirationTime <= root.lastSuspendedTime) {
243 + // The entire suspended range is now unsuspended.
244 + root.firstSuspendedTime = root.lastSuspendedTime = root.nextKnownPendingLevel = NoWork;
245 + } else if (finishedExpirationTime <= root.firstSuspendedTime) {
246 + // Part of the suspended range is now unsuspended. Narrow the range to
247 + // include everything between the unsuspended time (non-inclusive) and the
248 + // last suspended time.
249 + root.firstSuspendedTime = finishedExpirationTime - 1;
250 + }
251 +
252 + if (finishedExpirationTime <= root.lastPingedTime) {
253 + // Clear the pinged time
254 + root.lastPingedTime = NoWork;
255 + }
256 +
257 + if (finishedExpirationTime <= root.lastExpiredTime) {
258 + // Clear the expired time
259 + root.lastExpiredTime = NoWork;
260 + }
261 +}
262 +
263 +export function markRootExpiredAtTime(
264 + root: FiberRoot,
265 + expirationTime: ExpirationTime,
266 +): void {
267 + const lastExpiredTime = root.lastExpiredTime;
268 + if (lastExpiredTime === NoWork || lastExpiredTime > expirationTime) {
269 + root.lastExpiredTime = expirationTime;
270 + }
271 +}
packages/react-reconciler/src/ReactFiberWorkLoop.js
+581 -411
@@ -10,10 +10,7 @@
10 import type {Fiber} from './ReactFiber';
11 import type {FiberRoot} from './ReactFiberRoot';
12 import type {ExpirationTime} from './ReactFiberExpirationTime';
13 -import type {
14 - ReactPriorityLevel,
15 - SchedulerCallback,
16 -} from './SchedulerWithReactIntegration';
13 +import type {ReactPriorityLevel} from './SchedulerWithReactIntegration';
14 import type {Interaction} from 'scheduler/src/Tracing';
15 import type {SuspenseConfig} from './ReactFiberSuspenseConfig';
16 import type {SuspenseState} from './ReactFiberSuspenseComponent';
@@ -66,6 +63,13 @@ import {
63 } from './ReactFiberHostConfig';
64
65 import {createWorkInProgress, assignFiberPropertiesInDEV} from './ReactFiber';
66 +import {
67 + isRootSuspendedAtTime,
68 + markRootSuspendedAtTime,
69 + markRootFinishedAtTime,
70 + markRootUpdatedAtTime,
71 + markRootExpiredAtTime,
72 +} from './ReactFiberRoot';
73 import {
74 NoMode,
75 StrictMode,
@@ -112,6 +116,7 @@ import {
116 inferPriorityFromExpirationTime,
117 LOW_PRIORITY_EXPIRATION,
118 Batched,
119 + Idle,
120 } from './ReactFiberExpirationTime';
121 import {beginWork as originalBeginWork} from './ReactFiberBeginWork';
122 import {completeWork} from './ReactFiberCompleteWork';
@@ -195,12 +200,13 @@ const LegacyUnbatchedContext = /* */ 0b001000;
200 const RenderContext = /* */ 0b010000;
201 const CommitContext = /* */ 0b100000;
202
198 -type RootExitStatus = 0 | 1 | 2 | 3 | 4;
203 +type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5;
204 const RootIncomplete = 0;
205 const RootErrored = 1;
206 const RootSuspended = 2;
207 const RootSuspendedWithDelay = 3;
208 const RootCompleted = 4;
209 +const RootLocked = 5;
210
211 export type Thenable = {
212 then(resolve: () => mixed, reject?: () => mixed): Thenable | void,
@@ -226,6 +232,10 @@ let workInProgressRootExitStatus: RootExitStatus = RootIncomplete;
232 let workInProgressRootLatestProcessedExpirationTime: ExpirationTime = Sync;
233 let workInProgressRootLatestSuspenseTimeout: ExpirationTime = Sync;
234 let workInProgressRootCanSuspendUsingConfig: null | SuspenseConfig = null;
235 +// The work left over by components that were visited during this render. Only
236 +// includes unprocessed updates, not work in bailed out children.
237 +let workInProgressRootNextUnprocessedUpdateTime: ExpirationTime = NoWork;
238 +
239 // If we're pinged while rendering we don't always restart immediately.
240 // This flag determines if it might be worthwhile to restart if an opportunity
241 // happens latere.
@@ -381,8 +391,6 @@ export function scheduleUpdateOnFiber(
391 return;
392 }
393
384 - root.pingTime = NoWork;
385 -
394 checkForInterruption(fiber, expirationTime);
395 recordScheduleUpdate();
396
@@ -403,12 +411,10 @@ export function scheduleUpdateOnFiber(
411 // This is a legacy edge case. The initial mount of a ReactDOM.render-ed
412 // root inside of batchedUpdates should be synchronous, but layout updates
413 // should be deferred until the end of the batch.
406 - let callback = renderRoot(root, Sync, true);
407 - while (callback !== null) {
408 - callback = callback(true);
409 - }
414 + performSyncWorkOnRoot(root);
415 } else {
411 - scheduleCallbackForRoot(root, ImmediatePriority, Sync);
416 + ensureRootIsScheduled(root);
417 + schedulePendingInteractions(root, expirationTime);
418 if (executionContext === NoContext) {
419 // Flush the synchronous work now, unless we're already working or inside
420 // a batch. This is intentionally inside scheduleUpdateOnFiber instead of
@@ -419,7 +425,8 @@ export function scheduleUpdateOnFiber(
425 }
426 }
427 } else {
422 - scheduleCallbackForRoot(root, priorityLevel, expirationTime);
428 + ensureRootIsScheduled(root);
429 + schedulePendingInteractions(root, expirationTime);
430 }
431
432 if (
@@ -487,97 +494,511 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) {
494 }
495
496 if (root !== null) {
490 - // Update the first and last pending expiration times in this root
491 - const firstPendingTime = root.firstPendingTime;
492 - if (expirationTime > firstPendingTime) {
493 - root.firstPendingTime = expirationTime;
494 - }
495 - const lastPendingTime = root.lastPendingTime;
496 - if (lastPendingTime === NoWork || expirationTime < lastPendingTime) {
497 - root.lastPendingTime = expirationTime;
497 + if (workInProgressRoot === root) {
498 + // Received an update to a tree that's in the middle of rendering. Mark
499 + // that's unprocessed work on this root.
500 + markUnprocessedUpdateTime(expirationTime);
501 +
502 + if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
503 + // The root already suspended with a delay, which means this render
504 + // definitely won't finish. Since we have a new update, let's mark it as
505 + // suspended now, right before marking the incoming update. This has the
506 + // effect of interrupting the current render and switching to the update.
507 + // TODO: This happens to work when receiving an update during the render
508 + // phase, because of the trick inside computeExpirationForFiber to
509 + // subtract 1 from `renderExpirationTime` to move it into a
510 + // separate bucket. But we should probably model it with an exception,
511 + // using the same mechanism we use to force hydration of a subtree.
512 + // TODO: This does not account for low pri updates that were already
513 + // scheduled before the root started rendering. Need to track the next
514 + // pending expiration time (perhaps by backtracking the return path) and
515 + // then trigger a restart in the `renderDidSuspendDelayIfPossible` path.
516 + markRootSuspendedAtTime(root, renderExpirationTime);
517 + }
518 }
519 + // Mark that the root has a pending update.
520 + markRootUpdatedAtTime(root, expirationTime);
521 }
522
523 return root;
524 }
525
504 -// Use this function, along with runRootCallback, to ensure that only a single
505 -// callback per root is scheduled. It's still possible to call renderRoot
506 -// directly, but scheduling via this function helps avoid excessive callbacks.
507 -// It works by storing the callback node and expiration time on the root. When a
508 -// new callback comes in, it compares the expiration time to determine if it
509 -// should cancel the previous one. It also relies on commitRoot scheduling a
510 -// callback to render the next level, because that means we don't need a
511 -// separate callback per expiration time.
512 -function scheduleCallbackForRoot(
513 - root: FiberRoot,
514 - priorityLevel: ReactPriorityLevel,
515 - expirationTime: ExpirationTime,
516 -) {
517 - const existingCallbackExpirationTime = root.callbackExpirationTime;
518 - if (existingCallbackExpirationTime < expirationTime) {
519 - // New callback has higher priority than the existing one.
520 - const existingCallbackNode = root.callbackNode;
526 +function getNextRootExpirationTimeToWorkOn(root: FiberRoot): ExpirationTime {
527 + // Determines the next expiration time that the root should render, taking
528 + // into account levels that may be suspended, or levels that may have
529 + // received a ping.
530 +
531 + const lastExpiredTime = root.lastExpiredTime;
532 + if (lastExpiredTime !== NoWork) {
533 + return lastExpiredTime;
534 + }
535 +
536 + // "Pending" refers to any update that hasn't committed yet, including if it
537 + // suspended. The "suspended" range is therefore a subset.
538 + const firstPendingTime = root.firstPendingTime;
539 + if (!isRootSuspendedAtTime(root, firstPendingTime)) {
540 + // The highest priority pending time is not suspended. Let's work on that.
541 + return firstPendingTime;
542 + }
543 +
544 + // If the first pending time is suspended, check if there's a lower priority
545 + // pending level that we know about. Or check if we received a ping. Work
546 + // on whichever is higher priority.
547 + const lastPingedTime = root.lastPingedTime;
548 + const nextKnownPendingLevel = root.nextKnownPendingLevel;
549 + return lastPingedTime > nextKnownPendingLevel
550 + ? lastPingedTime
551 + : nextKnownPendingLevel;
552 +}
553 +
554 +// Use this function to schedule a task for a root. There's only one task per
555 +// root; if a task was already scheduled, we'll check to make sure the
556 +// expiration time of the existing task is the same as the expiration time of
557 +// the next level that the root has work on. This function is called on every
558 +// update, and right before exiting a task.
559 +function ensureRootIsScheduled(root: FiberRoot) {
560 + const lastExpiredTime = root.lastExpiredTime;
561 + if (lastExpiredTime !== NoWork) {
562 + // Special case: Expired work should flush synchronously.
563 + root.callbackExpirationTime = Sync;
564 + root.callbackPriority = ImmediatePriority;
565 + root.callbackNode = scheduleSyncCallback(
566 + performSyncWorkOnRoot.bind(null, root),
567 + );
568 + return;
569 + }
570 +
571 + const expirationTime = getNextRootExpirationTimeToWorkOn(root);
572 + const existingCallbackNode = root.callbackNode;
573 + if (expirationTime === NoWork) {
574 + // There's nothing to work on.
575 if (existingCallbackNode !== null) {
522 - cancelCallback(existingCallbackNode);
576 + root.callbackNode = null;
577 + root.callbackExpirationTime = NoWork;
578 + root.callbackPriority = NoPriority;
579 }
524 - root.callbackExpirationTime = expirationTime;
580 + return;
581 + }
582
526 - if (expirationTime === Sync) {
527 - // Sync React callbacks are scheduled on a special internal queue
528 - root.callbackNode = scheduleSyncCallback(
529 - runRootCallback.bind(
530 - null,
531 - root,
532 - renderRoot.bind(null, root, expirationTime),
533 - ),
534 - );
535 - } else {
536 - let options = null;
537 - if (
538 - !disableSchedulerTimeoutBasedOnReactExpirationTime &&
539 - expirationTime !== Never
540 - ) {
541 - let timeout = expirationTimeToMs(expirationTime) - now();
542 - options = {timeout};
543 - }
583 + // TODO: If this is an update, we already read the current time. Pass the
584 + // time as an argument.
585 + const currentTime = requestCurrentTime();
586 + const priorityLevel = inferPriorityFromExpirationTime(
587 + currentTime,
588 + expirationTime,
589 + );
590
545 - root.callbackNode = scheduleCallback(
546 - priorityLevel,
547 - runRootCallback.bind(
548 - null,
549 - root,
550 - renderRoot.bind(null, root, expirationTime),
551 - ),
552 - options,
553 - );
591 + // If there's an existing render task, confirm it has the correct priority and
592 + // expiration time. Otherwise, we'll cancel it and schedule a new one.
593 + if (existingCallbackNode !== null) {
594 + const existingCallbackPriority = root.callbackPriority;
595 + const existingCallbackExpirationTime = root.callbackExpirationTime;
596 + if (
597 + // Callback must have the exact same expiration time.
598 + existingCallbackExpirationTime === expirationTime &&
599 + // Callback must have greater or equal priority.
600 + existingCallbackPriority >= priorityLevel
601 + ) {
602 + // Existing callback is sufficient.
603 + return;
604 }
605 + // Need to schedule a new task.
606 + // TODO: Instead of scheduling a new task, we should be able to change the
607 + // priority of the existing one.
608 + cancelCallback(existingCallbackNode);
609 + }
610 +
611 + root.callbackExpirationTime = expirationTime;
612 + root.callbackPriority = priorityLevel;
613 +
614 + let callbackNode;
615 + if (expirationTime === Sync) {
616 + // Sync React callbacks are scheduled on a special internal queue
617 + callbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
618 + } else if (disableSchedulerTimeoutBasedOnReactExpirationTime) {
619 + callbackNode = scheduleCallback(
620 + priorityLevel,
621 + performConcurrentWorkOnRoot.bind(null, root),
622 + );
623 + } else {
624 + callbackNode = scheduleCallback(
625 + priorityLevel,
626 + performConcurrentWorkOnRoot.bind(null, root),
627 + // Compute a task timeout based on the expiration time. This also affects
628 + // ordering because tasks are processed in timeout order.
629 + {timeout: expirationTimeToMs(expirationTime) - now()},
630 + );
631 }
632
557 - // Associate the current interactions with this new root+priority.
558 - schedulePendingInteractions(root, expirationTime);
633 + root.callbackNode = callbackNode;
634 }
635
561 -function runRootCallback(root, callback, isSync) {
562 - const prevCallbackNode = root.callbackNode;
563 - let continuation = null;
636 +// This is the entry point for every concurrent task, i.e. anything that
637 +// goes through Scheduler.
638 +function performConcurrentWorkOnRoot(root, didTimeout) {
639 + // Since we know we're in a React event, we can clear the current
640 + // event time. The next update will compute a new event time.
641 + currentEventTime = NoWork;
642 +
643 + if (didTimeout) {
644 + // The render task took too long to complete. Mark the current time as
645 + // expired to synchronously render all expired work in a single batch.
646 + const currentTime = requestCurrentTime();
647 + markRootExpiredAtTime(root, currentTime);
648 + // This will schedule a synchronous callback.
649 + ensureRootIsScheduled(root);
650 + return null;
651 + }
652 +
653 + // Determine the next expiration time to work on, using the fields stored
654 + // on the root.
655 + const expirationTime = getNextRootExpirationTimeToWorkOn(root);
656 + if (expirationTime !== NoWork) {
657 + const originalCallbackNode = root.callbackNode;
658 + try {
659 + renderRoot(root, expirationTime, false);
660 + if (workInProgress !== null) {
661 + // There's still work left over. Exit without committing.
662 + stopInterruptedWorkLoopTimer();
663 + } else {
664 + // We now have a consistent tree. The next step is either to commit it,
665 + // or, if something suspended, wait to commit it after a timeout.
666 + stopFinishedWorkLoopTimer();
667 +
668 + const finishedWork: Fiber = ((root.finishedWork =
669 + root.current.alternate): any);
670 + root.finishedExpirationTime = expirationTime;
671 +
672 + resolveLocksOnRoot(root, expirationTime);
673 +
674 + // Set this to null to indicate there's no in-progress render.
675 + workInProgressRoot = null;
676 +
677 + switch (workInProgressRootExitStatus) {
678 + case RootIncomplete: {
679 + invariant(false, 'Should have a work-in-progress.');
680 + }
681 + // Flow knows about invariant, so it complains if I add a break
682 + // statement, but eslint doesn't know about invariant, so it complains
683 + // if I do. eslint-disable-next-line no-fallthrough
684 + case RootErrored: {
685 + if (expirationTime !== Idle) {
686 + // If this was an async render, the error may have happened due to
687 + // a mutation in a concurrent event. Try rendering one more time,
688 + // synchronously, to see if the error goes away. If there are
689 + // lower priority updates, let's include those, too, in case they
690 + // fix the inconsistency. Render at Idle to include all updates.
691 + markRootExpiredAtTime(root, Idle);
692 + break;
693 + }
694 + // Commit the root in its errored state.
695 + commitRoot(root);
696 + break;
697 + }
698 + case RootSuspended: {
699 + markRootSuspendedAtTime(root, expirationTime);
700 + const lastSuspendedTime = root.lastSuspendedTime;
701 + if (expirationTime === lastSuspendedTime) {
702 + root.nextKnownPendingLevel = getRemainingExpirationTime(
703 + finishedWork,
704 + );
705 + }
706 + flushSuspensePriorityWarningInDEV();
707 +
708 + // We have an acceptable loading state. We need to figure out if we
709 + // should immediately commit it or wait a bit.
710 +
711 + // If we have processed new updates during this render, we may now
712 + // have a new loading state ready. We want to ensure that we commit
713 + // that as soon as possible.
714 + const hasNotProcessedNewUpdates =
715 + workInProgressRootLatestProcessedExpirationTime === Sync;
716 + if (
717 + hasNotProcessedNewUpdates &&
718 + // do not delay if we're inside an act() scope
719 + !(
720 + __DEV__ &&
721 + flushSuspenseFallbacksInTests &&
722 + IsThisRendererActing.current
723 + )
724 + ) {
725 + // If we have not processed any new updates during this pass, then
726 + // this is either a retry of an existing fallback state or a
727 + // hidden tree. Hidden trees shouldn't be batched with other work
728 + // and after that's fixed it can only be a retry. We're going to
729 + // throttle committing retries so that we don't show too many
730 + // loading states too quickly.
731 + let msUntilTimeout =
732 + globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now();
733 + // Don't bother with a very short suspense time.
734 + if (msUntilTimeout > 10) {
735 + if (workInProgressRootHasPendingPing) {
736 + const lastPingedTime = root.lastPingedTime;
737 + if (
738 + lastPingedTime === NoWork ||
739 + lastPingedTime >= expirationTime
740 + ) {
741 + // This render was pinged but we didn't get to restart
742 + // earlier so try restarting now instead.
743 + root.lastPingedTime = expirationTime;
744 + prepareFreshStack(root, expirationTime);
745 + break;
746 + }
747 + }
748 +
749 + const nextTime = getNextRootExpirationTimeToWorkOn(root);
750 + if (nextTime !== NoWork && nextTime !== expirationTime) {
751 + // There's additional work on this root.
752 + break;
753 + }
754 + if (
755 + lastSuspendedTime !== NoWork &&
756 + lastSuspendedTime !== expirationTime
757 + ) {
758 + // We should prefer to render the fallback of at the last
759 + // suspended level. Ping the last suspended level to try
760 + // rendering it again.
761 + root.lastPingedTime = lastSuspendedTime;
762 + break;
763 + }
764 +
765 + // The render is suspended, it hasn't timed out, and there's no
766 + // lower priority work to do. Instead of committing the fallback
767 + // immediately, wait for more data to arrive.
768 + root.timeoutHandle = scheduleTimeout(
769 + commitRoot.bind(null, root),
770 + msUntilTimeout,
771 + );
772 + break;
773 + }
774 + }
775 + // The work expired. Commit immediately.
776 + commitRoot(root);
777 + break;
778 + }
779 + case RootSuspendedWithDelay: {
780 + markRootSuspendedAtTime(root, expirationTime);
781 + const lastSuspendedTime = root.lastSuspendedTime;
782 + if (expirationTime === lastSuspendedTime) {
783 + root.nextKnownPendingLevel = getRemainingExpirationTime(
784 + finishedWork,
785 + );
786 + }
787 + flushSuspensePriorityWarningInDEV();
788 +
789 + if (
790 + // do not delay if we're inside an act() scope
791 + !(
792 + __DEV__ &&
793 + flushSuspenseFallbacksInTests &&
794 + IsThisRendererActing.current
795 + )
796 + ) {
797 + // We're suspended in a state that should be avoided. We'll try to
798 + // avoid committing it for as long as the timeouts let us.
799 + if (workInProgressRootHasPendingPing) {
800 + const lastPingedTime = root.lastPingedTime;
801 + if (
802 + lastPingedTime === NoWork ||
803 + lastPingedTime >= expirationTime
804 + ) {
805 + // This render was pinged but we didn't get to restart earlier
806 + // so try restarting now instead.
807 + root.lastPingedTime = expirationTime;
808 + prepareFreshStack(root, expirationTime);
809 + break;
810 + }
811 + }
812 +
813 + const nextTime = getNextRootExpirationTimeToWorkOn(root);
814 + if (nextTime !== NoWork && nextTime !== expirationTime) {
815 + // There's additional work on this root.
816 + break;
817 + }
818 + if (
819 + lastSuspendedTime !== NoWork &&
820 + lastSuspendedTime !== expirationTime
821 + ) {
822 + // We should prefer to render the fallback of at the last
823 + // suspended level. Ping the last suspended level to try
824 + // rendering it again.
825 + root.lastPingedTime = lastSuspendedTime;
826 + break;
827 + }
828 +
829 + let msUntilTimeout;
830 + if (workInProgressRootLatestSuspenseTimeout !== Sync) {
831 + // We have processed a suspense config whose expiration time we
832 + // can use as the timeout.
833 + msUntilTimeout =
834 + expirationTimeToMs(workInProgressRootLatestSuspenseTimeout) -
835 + now();
836 + } else if (
837 + workInProgressRootLatestProcessedExpirationTime === Sync
838 + ) {
839 + // This should never normally happen because only new updates
840 + // cause delayed states, so we should have processed something.
841 + // However, this could also happen in an offscreen tree.
842 + msUntilTimeout = 0;
843 + } else {
844 + // If we don't have a suspense config, we're going to use a
845 + // heuristic to determine how long we can suspend.
846 + const eventTimeMs: number = inferTimeFromExpirationTime(
847 + workInProgressRootLatestProcessedExpirationTime,
848 + );
849 + const currentTimeMs = now();
850 + const timeUntilExpirationMs =
851 + expirationTimeToMs(expirationTime) - currentTimeMs;
852 + let timeElapsed = currentTimeMs - eventTimeMs;
853 + if (timeElapsed < 0) {
854 + // We get this wrong some time since we estimate the time.
855 + timeElapsed = 0;
856 + }
857 +
858 + msUntilTimeout = jnd(timeElapsed) - timeElapsed;
859 +
860 + // Clamp the timeout to the expiration time. TODO: Once the
861 + // event time is exact instead of inferred from expiration time
862 + // we don't need this.
863 + if (timeUntilExpirationMs < msUntilTimeout) {
864 + msUntilTimeout = timeUntilExpirationMs;
865 + }
866 + }
867 +
868 + // Don't bother with a very short suspense time.
869 + if (msUntilTimeout > 10) {
870 + // The render is suspended, it hasn't timed out, and there's no
871 + // lower priority work to do. Instead of committing the fallback
872 + // immediately, wait for more data to arrive.
873 + root.timeoutHandle = scheduleTimeout(
874 + commitRoot.bind(null, root),
875 + msUntilTimeout,
876 + );
877 + break;
878 + }
879 + }
880 + // The work expired. Commit immediately.
881 + commitRoot(root);
882 + break;
883 + }
884 + case RootCompleted: {
885 + // The work completed. Ready to commit.
886 + if (
887 + // do not delay if we're inside an act() scope
888 + !(
889 + __DEV__ &&
890 + flushSuspenseFallbacksInTests &&
891 + IsThisRendererActing.current
892 + ) &&
893 + workInProgressRootLatestProcessedExpirationTime !== Sync &&
894 + workInProgressRootCanSuspendUsingConfig !== null
895 + ) {
896 + // If we have exceeded the minimum loading delay, which probably
897 + // means we have shown a spinner already, we might have to suspend
898 + // a bit longer to ensure that the spinner is shown for
899 + // enough time.
900 + const msUntilTimeout = computeMsUntilSuspenseLoadingDelay(
901 + workInProgressRootLatestProcessedExpirationTime,
902 + expirationTime,
903 + workInProgressRootCanSuspendUsingConfig,
904 + );
905 + if (msUntilTimeout > 10) {
906 + markRootSuspendedAtTime(root, expirationTime);
907 + root.timeoutHandle = scheduleTimeout(
908 + commitRoot.bind(null, root),
909 + msUntilTimeout,
910 + );
911 + break;
912 + }
913 + }
914 + commitRoot(root);
915 + break;
916 + }
917 + case RootLocked: {
918 + // This root has a lock that prevents it from committing. Exit. If
919 + // we begin work on the root again, without any intervening updates,
920 + // it will finish without doing additional work.
921 + markRootSuspendedAtTime(root, expirationTime);
922 + break;
923 + }
924 + default: {
925 + invariant(false, 'Unknown root exit status.');
926 + }
927 + }
928 + }
929 + // Before exiting, make sure there's a callback scheduled for the
930 + // pending level. This is intentionally duplicated in the `catch` block,
931 + // instead of using `finally`, because it needs to happen before we
932 + // possibly return a continuation, and we can't return in the `finally`
933 + // block without suppressing a potential error.
934 + ensureRootIsScheduled(root);
935 + if (root.callbackNode === originalCallbackNode) {
936 + // The task node scheduled for this root is the same one that's
937 + // currently executed. Need to return a continuation.
938 + return performConcurrentWorkOnRoot.bind(null, root);
939 + }
940 + } catch (error) {
941 + ensureRootIsScheduled(root);
942 + throw error;
943 + }
944 + }
945 + return null;
946 +}
947 +
948 +// This is the entry point for synchronous tasks that don't go
949 +// through Scheduler
950 +function performSyncWorkOnRoot(root) {
951 + // Check if there's expired work on this root. Otherwise, render at Sync.
952 + const lastExpiredTime = root.lastExpiredTime;
953 + const expirationTime = lastExpiredTime !== NoWork ? lastExpiredTime : Sync;
954 try {
565 - continuation = callback(isSync);
566 - if (continuation !== null) {
567 - return runRootCallback.bind(null, root, continuation);
955 + if (root.finishedExpirationTime === expirationTime) {
956 + // There's already a pending commit at this expiration time.
957 + // TODO: This is poorly factored. This case only exists for the
958 + // batch.commit() API.
959 + commitRoot(root);
960 } else {
569 - return null;
961 + renderRoot(root, expirationTime, true);
962 + invariant(
963 + workInProgressRootExitStatus !== RootIncomplete,
964 + 'Cannot commit an incomplete root. This error is likely caused by a ' +
965 + 'bug in React. Please file an issue.',
966 + );
967 +
968 + // We now have a consistent tree. The next step is either to commit it,
969 + // or, if something suspended, wait to commit it after a timeout.
970 + stopFinishedWorkLoopTimer();
971 +
972 + root.finishedWork = ((root.current.alternate: any): Fiber);
973 + root.finishedExpirationTime = expirationTime;
974 +
975 + resolveLocksOnRoot(root, expirationTime);
976 + if (workInProgressRootExitStatus === RootLocked) {
977 + // This root has a lock that prevents it from committing. Exit. If we
978 + // begin work on the root again, without any intervening updates, it
979 + // will finish without doing additional work.
980 + markRootSuspendedAtTime(root, expirationTime);
981 + } else {
982 + // Set this to null to indicate there's no in-progress render.
983 + workInProgressRoot = null;
984 +
985 + if (__DEV__) {
986 + if (
987 + workInProgressRootExitStatus === RootSuspended ||
988 + workInProgressRootExitStatus === RootSuspendedWithDelay
989 + ) {
990 + flushSuspensePriorityWarningInDEV();
991 + }
992 + }
993 + commitRoot(root);
994 + }
995 }
996 } finally {
572 - // If the callback exits without returning a continuation, remove the
573 - // corresponding callback node from the root. Unless the callback node
574 - // has changed, which implies that it was already cancelled by a high
575 - // priority update.
576 - if (continuation === null && prevCallbackNode === root.callbackNode) {
577 - root.callbackNode = null;
578 - root.callbackExpirationTime = NoWork;
579 - }
997 + // Before exiting, make sure there's a callback scheduled for the
998 + // pending level.
999 + ensureRootIsScheduled(root);
1000 }
1001 + return null;
1002 }
1003
1004 export function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) {
@@ -588,7 +1009,8 @@ export function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) {
1009 'means you attempted to commit from inside a lifecycle method.',
1010 );
1011 }
591 - scheduleSyncCallback(renderRoot.bind(null, root, expirationTime));
1012 + markRootExpiredAtTime(root, expirationTime);
1013 + ensureRootIsScheduled(root);
1014 flushSyncCallbackQueue();
1015 }
1016
@@ -630,9 +1052,7 @@ function resolveLocksOnRoot(root: FiberRoot, expirationTime: ExpirationTime) {
1052 firstBatch._onComplete();
1053 return null;
1054 });
633 - return true;
634 - } else {
635 - return false;
1055 + workInProgressRootExitStatus = RootLocked;
1056 }
1057 }
1058
@@ -657,7 +1077,8 @@ function flushPendingDiscreteUpdates() {
1077 const roots = rootsWithPendingDiscreteUpdates;
1078 rootsWithPendingDiscreteUpdates = null;
1079 roots.forEach((expirationTime, root) => {
660 - scheduleSyncCallback(renderRoot.bind(null, root, expirationTime));
1080 + markRootExpiredAtTime(root, expirationTime);
1081 + ensureRootIsScheduled(root);
1082 });
1083 // Now flush the immediate queue.
1084 flushSyncCallbackQueue();
@@ -789,6 +1210,7 @@ function prepareFreshStack(root, expirationTime) {
1210 workInProgressRootLatestProcessedExpirationTime = Sync;
1211 workInProgressRootLatestSuspenseTimeout = Sync;
1212 workInProgressRootCanSuspendUsingConfig = null;
1213 + workInProgressRootNextUnprocessedUpdateTime = NoWork;
1214 workInProgressRootHasPendingPing = false;
1215
1216 if (enableSchedulerTracing) {
@@ -801,30 +1223,18 @@ function prepareFreshStack(root, expirationTime) {
1223 }
1224 }
1225
1226 +// renderRoot should only be called from inside either
1227 +// `performConcurrentWorkOnRoot` or `performSyncWorkOnRoot`.
1228 function renderRoot(
1229 root: FiberRoot,
1230 expirationTime: ExpirationTime,
1231 isSync: boolean,
808 -): SchedulerCallback | null {
1232 +): void {
1233 invariant(
1234 (executionContext & (RenderContext | CommitContext)) === NoContext,
1235 'Should not already be working.',
1236 );
1237
814 - if (root.firstPendingTime < expirationTime) {
815 - // If there's no work left at this expiration time, exit immediately. This
816 - // happens when multiple callbacks are scheduled for a single root, but an
817 - // earlier callback flushes the work of a later one.
818 - return null;
819 - }
820 -
821 - if (isSync && root.finishedExpirationTime === expirationTime) {
822 - // There's already a pending commit at this expiration time.
823 - // TODO: This is poorly factored. This case only exists for the
824 - // batch.commit() API.
825 - return commitRoot.bind(null, root);
826 - }
827 -
1238 flushPassiveEffects();
1239
1240 // If the root or expiration time have changed, throw out the existing stack
@@ -832,26 +1242,6 @@ function renderRoot(
1242 if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) {
1243 prepareFreshStack(root, expirationTime);
1244 startWorkOnPendingInteractions(root, expirationTime);
835 - } else if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
836 - // We could've received an update at a lower priority while we yielded.
837 - // We're suspended in a delayed state. Once we complete this render we're
838 - // just going to try to recover at the last pending time anyway so we might
839 - // as well start doing that eagerly.
840 - // Ideally we should be able to do this even for retries but we don't yet
841 - // know if we're going to process an update which wants to commit earlier,
842 - // and this path happens very early so it would happen too often. Instead,
843 - // for that case, we'll wait until we complete.
844 - if (workInProgressRootHasPendingPing) {
845 - // We have a ping at this expiration. Let's restart to see if we get unblocked.
846 - prepareFreshStack(root, expirationTime);
847 - } else {
848 - const lastPendingTime = root.lastPendingTime;
849 - if (lastPendingTime < expirationTime) {
850 - // There's lower priority work. It might be unsuspended. Try rendering
851 - // at that level immediately, while preserving the position in the queue.
852 - return renderRoot.bind(null, root, lastPendingTime);
853 - }
854 - }
1245 }
1246
1247 // If we have a work-in-progress fiber, it means there's still work to do
@@ -875,34 +1265,10 @@ function renderRoot(
1265
1266 startWorkLoopTimer(workInProgress);
1267
878 - // TODO: Fork renderRoot into renderRootSync and renderRootAsync
879 - if (isSync) {
880 - if (expirationTime !== Sync) {
881 - // An async update expired. There may be other expired updates on
882 - // this root. We should render all the expired work in a
883 - // single batch.
884 - const currentTime = requestCurrentTime();
885 - if (currentTime < expirationTime) {
886 - // Restart at the current time.
887 - executionContext = prevExecutionContext;
888 - resetContextDependencies();
889 - ReactCurrentDispatcher.current = prevDispatcher;
890 - if (enableSchedulerTracing) {
891 - __interactionsRef.current = ((prevInteractions: any): Set<
892 - Interaction,
893 - >);
894 - }
895 - return renderRoot.bind(null, root, currentTime);
896 - }
897 - }
898 - } else {
899 - // Since we know we're in a React event, we can clear the current
900 - // event time. The next update will compute a new event time.
901 - currentEventTime = NoWork;
902 - }
903 -
1268 do {
1269 try {
1270 + // TODO: This is now the only place that `isSync` is used. Consider
1271 + // outlining the contents of `renderRoot`.
1272 if (isSync) {
1273 workLoopSync();
1274 } else {
@@ -922,6 +1288,7 @@ function renderRoot(
1288 // boundary.
1289 prepareFreshStack(root, expirationTime);
1290 executionContext = prevExecutionContext;
1291 + markRootSuspendedAtTime(root, expirationTime);
1292 throw thrownValue;
1293 }
1294
@@ -940,6 +1307,8 @@ function renderRoot(
1307 thrownValue,
1308 renderExpirationTime,
1309 );
1310 + // TODO: This is not wrapped in a try-catch, so if the complete phase
1311 + // throws, we won't capture it.
1312 workInProgress = completeUnitOfWork(sourceFiber);
1313 }
1314 } while (true);
@@ -950,229 +1319,6 @@ function renderRoot(
1319 if (enableSchedulerTracing) {
1320 __interactionsRef.current = ((prevInteractions: any): Set<Interaction>);
1321 }
953 -
954 - if (workInProgress !== null) {
955 - // There's still work left over. Return a continuation.
956 - stopInterruptedWorkLoopTimer();
957 - return renderRoot.bind(null, root, expirationTime);
958 - }
959 - }
960 -
961 - // We now have a consistent tree. The next step is either to commit it, or, if
962 - // something suspended, wait to commit it after a timeout.
963 - stopFinishedWorkLoopTimer();
964 -
965 - root.finishedWork = root.current.alternate;
966 - root.finishedExpirationTime = expirationTime;
967 -
968 - const isLocked = resolveLocksOnRoot(root, expirationTime);
969 - if (isLocked) {
970 - // This root has a lock that prevents it from committing. Exit. If we begin
971 - // work on the root again, without any intervening updates, it will finish
972 - // without doing additional work.
973 - return null;
974 - }
975 -
976 - // Set this to null to indicate there's no in-progress render.
977 - workInProgressRoot = null;
978 -
979 - switch (workInProgressRootExitStatus) {
980 - case RootIncomplete: {
981 - invariant(false, 'Should have a work-in-progress.');
982 - }
983 - // Flow knows about invariant, so it complains if I add a break statement,
984 - // but eslint doesn't know about invariant, so it complains if I do.
985 - // eslint-disable-next-line no-fallthrough
986 - case RootErrored: {
987 - // An error was thrown. First check if there is lower priority work
988 - // scheduled on this root.
989 - const lastPendingTime = root.lastPendingTime;
990 - if (lastPendingTime < expirationTime) {
991 - // There's lower priority work. Before raising the error, try rendering
992 - // at the lower priority to see if it fixes it. Use a continuation to
993 - // maintain the existing priority and position in the queue.
994 - return renderRoot.bind(null, root, lastPendingTime);
995 - }
996 - if (!isSync) {
997 - // If we're rendering asynchronously, it's possible the error was
998 - // caused by tearing due to a mutation during an event. Try rendering
999 - // one more time without yiedling to events.
1000 - prepareFreshStack(root, expirationTime);
1001 - scheduleSyncCallback(renderRoot.bind(null, root, expirationTime));
1002 - return null;
1003 - }
1004 - // If we're already rendering synchronously, commit the root in its
1005 - // errored state.
1006 - return commitRoot.bind(null, root);
1007 - }
1008 - case RootSuspended: {
1009 - flushSuspensePriorityWarningInDEV();
1010 -
1011 - // We have an acceptable loading state. We need to figure out if we should
1012 - // immediately commit it or wait a bit.
1013 -
1014 - // If we have processed new updates during this render, we may now have a
1015 - // new loading state ready. We want to ensure that we commit that as soon as
1016 - // possible.
1017 - const hasNotProcessedNewUpdates =
1018 - workInProgressRootLatestProcessedExpirationTime === Sync;
1019 - if (
1020 - hasNotProcessedNewUpdates &&
1021 - !isSync &&
1022 - // do not delay if we're inside an act() scope
1023 - !(
1024 - __DEV__ &&
1025 - flushSuspenseFallbacksInTests &&
1026 - IsThisRendererActing.current
1027 - )
1028 - ) {
1029 - // If we have not processed any new updates during this pass, then this is
1030 - // either a retry of an existing fallback state or a hidden tree.
1031 - // Hidden trees shouldn't be batched with other work and after that's
1032 - // fixed it can only be a retry.
1033 - // We're going to throttle committing retries so that we don't show too
1034 - // many loading states too quickly.
1035 - let msUntilTimeout =
1036 - globalMostRecentFallbackTime + FALLBACK_THROTTLE_MS - now();
1037 - // Don't bother with a very short suspense time.
1038 - if (msUntilTimeout > 10) {
1039 - if (workInProgressRootHasPendingPing) {
1040 - // This render was pinged but we didn't get to restart earlier so try
1041 - // restarting now instead.
1042 - prepareFreshStack(root, expirationTime);
1043 - return renderRoot.bind(null, root, expirationTime);
1044 - }
1045 - const lastPendingTime = root.lastPendingTime;
1046 - if (lastPendingTime < expirationTime) {
1047 - // There's lower priority work. It might be unsuspended. Try rendering
1048 - // at that level.
1049 - return renderRoot.bind(null, root, lastPendingTime);
1050 - }
1051 - // The render is suspended, it hasn't timed out, and there's no lower
1052 - // priority work to do. Instead of committing the fallback
1053 - // immediately, wait for more data to arrive.
1054 - root.timeoutHandle = scheduleTimeout(
1055 - commitRoot.bind(null, root),
1056 - msUntilTimeout,
1057 - );
1058 - return null;
1059 - }
1060 - }
1061 - // The work expired. Commit immediately.
1062 - return commitRoot.bind(null, root);
1063 - }
1064 - case RootSuspendedWithDelay: {
1065 - flushSuspensePriorityWarningInDEV();
1066 -
1067 - if (
1068 - !isSync &&
1069 - // do not delay if we're inside an act() scope
1070 - !(
1071 - __DEV__ &&
1072 - flushSuspenseFallbacksInTests &&
1073 - IsThisRendererActing.current
1074 - )
1075 - ) {
1076 - // We're suspended in a state that should be avoided. We'll try to avoid committing
1077 - // it for as long as the timeouts let us.
1078 - if (workInProgressRootHasPendingPing) {
1079 - // This render was pinged but we didn't get to restart earlier so try
1080 - // restarting now instead.
1081 - prepareFreshStack(root, expirationTime);
1082 - return renderRoot.bind(null, root, expirationTime);
1083 - }
1084 - const lastPendingTime = root.lastPendingTime;
1085 - if (lastPendingTime < expirationTime) {
1086 - // There's lower priority work. It might be unsuspended. Try rendering
1087 - // at that level immediately.
1088 - return renderRoot.bind(null, root, lastPendingTime);
1089 - }
1090 -
1091 - let msUntilTimeout;
1092 - if (workInProgressRootLatestSuspenseTimeout !== Sync) {
1093 - // We have processed a suspense config whose expiration time we can use as
1094 - // the timeout.
1095 - msUntilTimeout =
1096 - expirationTimeToMs(workInProgressRootLatestSuspenseTimeout) - now();
1097 - } else if (workInProgressRootLatestProcessedExpirationTime === Sync) {
1098 - // This should never normally happen because only new updates cause
1099 - // delayed states, so we should have processed something. However,
1100 - // this could also happen in an offscreen tree.
1101 - msUntilTimeout = 0;
1102 - } else {
1103 - // If we don't have a suspense config, we're going to use a heuristic to
1104 - // determine how long we can suspend.
1105 - const eventTimeMs: number = inferTimeFromExpirationTime(
1106 - workInProgressRootLatestProcessedExpirationTime,
1107 - );
1108 - const currentTimeMs = now();
1109 - const timeUntilExpirationMs =
1110 - expirationTimeToMs(expirationTime) - currentTimeMs;
1111 - let timeElapsed = currentTimeMs - eventTimeMs;
1112 - if (timeElapsed < 0) {
1113 - // We get this wrong some time since we estimate the time.
1114 - timeElapsed = 0;
1115 - }
1116 -
1117 - msUntilTimeout = jnd(timeElapsed) - timeElapsed;
1118 -
1119 - // Clamp the timeout to the expiration time.
1120 - // TODO: Once the event time is exact instead of inferred from expiration time
1121 - // we don't need this.
1122 - if (timeUntilExpirationMs < msUntilTimeout) {
1123 - msUntilTimeout = timeUntilExpirationMs;
1124 - }
1125 - }
1126 -
1127 - // Don't bother with a very short suspense time.
1128 - if (msUntilTimeout > 10) {
1129 - // The render is suspended, it hasn't timed out, and there's no lower
1130 - // priority work to do. Instead of committing the fallback
1131 - // immediately, wait for more data to arrive.
1132 - root.timeoutHandle = scheduleTimeout(
1133 - commitRoot.bind(null, root),
1134 - msUntilTimeout,
1135 - );
1136 - return null;
1137 - }
1138 - }
1139 - // The work expired. Commit immediately.
1140 - return commitRoot.bind(null, root);
1141 - }
1142 - case RootCompleted: {
1143 - // The work completed. Ready to commit.
1144 - if (
1145 - !isSync &&
1146 - // do not delay if we're inside an act() scope
1147 - !(
1148 - __DEV__ &&
1149 - flushSuspenseFallbacksInTests &&
1150 - IsThisRendererActing.current
1151 - ) &&
1152 - workInProgressRootLatestProcessedExpirationTime !== Sync &&
1153 - workInProgressRootCanSuspendUsingConfig !== null
1154 - ) {
1155 - // If we have exceeded the minimum loading delay, which probably
1156 - // means we have shown a spinner already, we might have to suspend
1157 - // a bit longer to ensure that the spinner is shown for enough time.
1158 - const msUntilTimeout = computeMsUntilSuspenseLoadingDelay(
1159 - workInProgressRootLatestProcessedExpirationTime,
1160 - expirationTime,
1161 - workInProgressRootCanSuspendUsingConfig,
1162 - );
1163 - if (msUntilTimeout > 10) {
1164 - root.timeoutHandle = scheduleTimeout(
1165 - commitRoot.bind(null, root),
1166 - msUntilTimeout,
1167 - );
1168 - return null;
1169 - }
1170 - }
1171 - return commitRoot.bind(null, root);
1172 - }
1173 - default: {
1174 - invariant(false, 'Unknown root exit status.');
1175 - }
1322 }
1323 }
1324
@@ -1202,6 +1348,14 @@ export function markRenderEventTimeAndConfig(
1348 }
1349 }
1350
1351 +export function markUnprocessedUpdateTime(
1352 + expirationTime: ExpirationTime,
1353 +): void {
1354 + if (expirationTime > workInProgressRootNextUnprocessedUpdateTime) {
1355 + workInProgressRootNextUnprocessedUpdateTime = expirationTime;
1356 + }
1357 +}
1358 +
1359 export function renderDidSuspend(): void {
1360 if (workInProgressRootExitStatus === RootIncomplete) {
1361 workInProgressRootExitStatus = RootSuspended;
@@ -1215,6 +1369,22 @@ export function renderDidSuspendDelayIfPossible(): void {
1369 ) {
1370 workInProgressRootExitStatus = RootSuspendedWithDelay;
1371 }
1372 +
1373 + // Check if there's a lower priority update somewhere else in the tree.
1374 + if (
1375 + workInProgressRootNextUnprocessedUpdateTime !== NoWork &&
1376 + workInProgressRoot !== null
1377 + ) {
1378 + // Mark the current render as suspended, and then mark that there's a
1379 + // pending update.
1380 + // TODO: This should immediately interrupt the current render, instead
1381 + // of waiting until the next time we yield.
1382 + markRootSuspendedAtTime(workInProgressRoot, renderExpirationTime);
1383 + markRootUpdatedAtTime(
1384 + workInProgressRoot,
1385 + workInProgressRootNextUnprocessedUpdateTime,
1386 + );
1387 + }
1388 }
1389
1390 export function renderDidError() {
@@ -1429,6 +1599,14 @@ function completeUnitOfWork(unitOfWork: Fiber): Fiber | null {
1599 return null;
1600 }
1601
1602 +function getRemainingExpirationTime(fiber: Fiber) {
1603 + const updateExpirationTime = fiber.expirationTime;
1604 + const childExpirationTime = fiber.childExpirationTime;
1605 + return updateExpirationTime > childExpirationTime
1606 + ? updateExpirationTime
1607 + : childExpirationTime;
1608 +}
1609 +
1610 function resetChildExpirationTime(completedWork: Fiber) {
1611 if (
1612 renderExpirationTime !== Never &&
@@ -1531,23 +1709,21 @@ function commitRootImpl(root, renderPriorityLevel) {
1709 // So we can clear these now to allow a new callback to be scheduled.
1710 root.callbackNode = null;
1711 root.callbackExpirationTime = NoWork;
1712 + root.callbackPriority = NoPriority;
1713 + root.nextKnownPendingLevel = NoWork;
1714
1715 startCommitTimer();
1716
1717 // Update the first and last pending times on this root. The new first
1718 // pending time is whatever is left on the root fiber.
1539 - const updateExpirationTimeBeforeCommit = finishedWork.expirationTime;
1540 - const childExpirationTimeBeforeCommit = finishedWork.childExpirationTime;
1541 - const firstPendingTimeBeforeCommit =
1542 - childExpirationTimeBeforeCommit > updateExpirationTimeBeforeCommit
1543 - ? childExpirationTimeBeforeCommit
1544 - : updateExpirationTimeBeforeCommit;
1545 - root.firstPendingTime = firstPendingTimeBeforeCommit;
1546 - if (firstPendingTimeBeforeCommit < root.lastPendingTime) {
1547 - // This usually means we've finished all the work, but it can also happen
1548 - // when something gets downprioritized during render, like a hidden tree.
1549 - root.lastPendingTime = firstPendingTimeBeforeCommit;
1550 - }
1719 + const remainingExpirationTimeBeforeCommit = getRemainingExpirationTime(
1720 + finishedWork,
1721 + );
1722 + markRootFinishedAtTime(
1723 + root,
1724 + expirationTime,
1725 + remainingExpirationTimeBeforeCommit,
1726 + );
1727
1728 if (root === workInProgressRoot) {
1729 // We can reset these now that they are finished.
@@ -1749,12 +1925,6 @@ function commitRootImpl(root, renderPriorityLevel) {
1925 // Check if there's remaining work on this root
1926 const remainingExpirationTime = root.firstPendingTime;
1927 if (remainingExpirationTime !== NoWork) {
1752 - const currentTime = requestCurrentTime();
1753 - const priorityLevel = inferPriorityFromExpirationTime(
1754 - currentTime,
1755 - remainingExpirationTime,
1756 - );
1757 -
1928 if (enableSchedulerTracing) {
1929 if (spawnedWorkDuringRender !== null) {
1930 const expirationTimes = spawnedWorkDuringRender;
@@ -1767,9 +1937,8 @@ function commitRootImpl(root, renderPriorityLevel) {
1937 );
1938 }
1939 }
1940 + schedulePendingInteractions(root, remainingExpirationTime);
1941 }
1771 -
1772 - scheduleCallbackForRoot(root, priorityLevel, remainingExpirationTime);
1942 } else {
1943 // If there's no remaining work, we can clear the set of already failed
1944 // error boundaries.
@@ -1786,8 +1955,6 @@ function commitRootImpl(root, renderPriorityLevel) {
1955 }
1956 }
1957
1789 - onCommitRoot(finishedWork.stateNode, expirationTime);
1790 -
1958 if (remainingExpirationTime === Sync) {
1959 // Count the number of times the root synchronously re-renders without
1960 // finishing. If there are too many, it indicates an infinite update loop.
@@ -1801,6 +1968,12 @@ function commitRootImpl(root, renderPriorityLevel) {
1968 nestedUpdateCount = 0;
1969 }
1970
1971 + onCommitRoot(finishedWork.stateNode, expirationTime);
1972 +
1973 + // Always call this before exiting `commitRoot`, to ensure that any
1974 + // additional work on this root is scheduled.
1975 + ensureRootIsScheduled(root);
1976 +
1977 if (hasUncaughtError) {
1978 hasUncaughtError = false;
1979 const error = firstUncaughtError;
@@ -2065,7 +2238,8 @@ function captureCommitPhaseErrorOnRoot(
2238 enqueueUpdate(rootFiber, update);
2239 const root = markUpdateTimeFromFiberToRoot(rootFiber, Sync);
2240 if (root !== null) {
2068 - scheduleCallbackForRoot(root, ImmediatePriority, Sync);
2241 + ensureRootIsScheduled(root);
2242 + schedulePendingInteractions(root, Sync);
2243 }
2244 }
2245
@@ -2100,7 +2274,8 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
2274 enqueueUpdate(fiber, update);
2275 const root = markUpdateTimeFromFiberToRoot(fiber, Sync);
2276 if (root !== null) {
2103 - scheduleCallbackForRoot(root, ImmediatePriority, Sync);
2277 + ensureRootIsScheduled(root);
2278 + schedulePendingInteractions(root, Sync);
2279 }
2280 return;
2281 }
@@ -2152,20 +2327,19 @@ export function pingSuspendedRoot(
2327 return;
2328 }
2329
2155 - const lastPendingTime = root.lastPendingTime;
2156 - if (lastPendingTime < suspendedTime) {
2330 + if (!isRootSuspendedAtTime(root, suspendedTime)) {
2331 // The root is no longer suspended at this time.
2332 return;
2333 }
2334
2161 - const pingTime = root.pingTime;
2162 - if (pingTime !== NoWork && pingTime < suspendedTime) {
2335 + const lastPingedTime = root.lastPingedTime;
2336 + if (lastPingedTime !== NoWork && lastPingedTime < suspendedTime) {
2337 // There's already a lower priority ping scheduled.
2338 return;
2339 }
2340
2341 // Mark the time at which this ping was scheduled.
2168 - root.pingTime = suspendedTime;
2342 + root.lastPingedTime = suspendedTime;
2343
2344 if (root.finishedExpirationTime === suspendedTime) {
2345 // If there's a pending fallback waiting to commit, throw it away.
@@ -2173,12 +2347,8 @@ export function pingSuspendedRoot(
2347 root.finishedWork = null;
2348 }
2349
2176 - const currentTime = requestCurrentTime();
2177 - const priorityLevel = inferPriorityFromExpirationTime(
2178 - currentTime,
2179 - suspendedTime,
2180 - );
2181 - scheduleCallbackForRoot(root, priorityLevel, suspendedTime);
2350 + ensureRootIsScheduled(root);
2351 + schedulePendingInteractions(root, suspendedTime);
2352 }
2353
2354 function retryTimedOutBoundary(
@@ -2189,9 +2359,9 @@ function retryTimedOutBoundary(
2359 // previously was rendered in its fallback state. One of the promises that
2360 // suspended it has resolved, which means at least part of the tree was
2361 // likely unblocked. Try rendering again, at a new expiration time.
2192 - const currentTime = requestCurrentTime();
2362 if (retryTime === Never) {
2363 const suspenseConfig = null; // Retries don't carry over the already committed update.
2364 + const currentTime = requestCurrentTime();
2365 retryTime = computeExpirationForFiber(
2366 currentTime,
2367 boundaryFiber,
@@ -2199,10 +2369,10 @@ function retryTimedOutBoundary(
2369 );
2370 }
2371 // TODO: Special case idle priority?
2202 - const priorityLevel = inferPriorityFromExpirationTime(currentTime, retryTime);
2372 const root = markUpdateTimeFromFiberToRoot(boundaryFiber, retryTime);
2373 if (root !== null) {
2205 - scheduleCallbackForRoot(root, priorityLevel, retryTime);
2374 + ensureRootIsScheduled(root);
2375 + schedulePendingInteractions(root, retryTime);
2376 }
2377 }
2378
packages/react-reconciler/src/ReactUpdateQueue.js
+5 -1
@@ -103,7 +103,10 @@ import {
103 } from 'shared/ReactFeatureFlags';
104
105 import {StrictMode} from './ReactTypeOfMode';
106 -import {markRenderEventTimeAndConfig} from './ReactFiberWorkLoop';
106 +import {
107 + markRenderEventTimeAndConfig,
108 + markUnprocessedUpdateTime,
109 +} from './ReactFiberWorkLoop';
110
111 import invariant from 'shared/invariant';
112 import warningWithoutStack from 'shared/warningWithoutStack';
@@ -580,6 +583,7 @@ export function processUpdateQueue<State>(
583 // dealt with the props. Context in components that specify
584 // shouldComponentUpdate is tricky; but we'll have to account for
585 // that regardless.
586 + markUnprocessedUpdateTime(newExpirationTime);
587 workInProgress.expirationTime = newExpirationTime;
588 workInProgress.memoizedState = resultState;
589
packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js
+76 -45
@@ -243,11 +243,20 @@ describe('ReactIncrementalErrorHandling', () => {
243 // This update is in a separate batch
244 ReactNoop.render(<App isBroken={false} />, onCommit);
245
246 - expect(Scheduler).toFlushAndYield([
246 + expect(Scheduler).toFlushAndYieldThrough([
247 // The first render fails. But because there's a lower priority pending
248 // update, it doesn't throw.
249 'error',
250 - // Now we retry at the lower priority. This time it succeeds.
250 + ]);
251 +
252 + // React will try to recover by rendering all the pending updates in a
253 + // single batch, synchronously. This time it succeeds.
254 + //
255 + // This tells Scheduler to render a single unit of work. Because the render
256 + // to recover from the error is synchronous, this should be enough to
257 + // finish the rest of the work.
258 + Scheduler.unstable_flushNumberOfYields(1);
259 + expect(Scheduler).toHaveYielded([
260 'success',
261 // Nothing commits until the second update completes.
262 'commit',
@@ -256,54 +265,80 @@ describe('ReactIncrementalErrorHandling', () => {
265 expect(ReactNoop.getChildren()).toEqual([span('Everything is fine.')]);
266 });
267
259 - it('on error, retries at a lower priority using the expiration of higher priority', () => {
260 - class Parent extends React.Component {
261 - state = {hideChild: false};
262 - componentDidUpdate() {
263 - Scheduler.unstable_yieldValue('commit: ' + this.state.hideChild);
264 - }
265 - render() {
266 - if (this.state.hideChild) {
267 - Scheduler.unstable_yieldValue('(empty)');
268 - return <span prop="(empty)" />;
269 - }
270 - return <Child isBroken={this.props.childIsBroken} />;
268 + it('does not include offscreen work when retrying after an error', () => {
269 + function App(props) {
270 + if (props.isBroken) {
271 + Scheduler.unstable_yieldValue('error');
272 + throw new Error('Oops!');
273 }
274 + Scheduler.unstable_yieldValue('success');
275 + return (
276 + <>
277 + Everything is fine
278 + <div hidden={true}>
279 + <div>Offscreen content</div>
280 + </div>
281 + </>
282 + );
283 }
284
274 - function Child(props) {
275 - if (props.isBroken) {
276 - Scheduler.unstable_yieldValue('Error!');
277 - throw new Error('Error!');
278 - }
279 - Scheduler.unstable_yieldValue('Child');
280 - return <span prop="Child" />;
285 + function onCommit() {
286 + Scheduler.unstable_yieldValue('commit');
287 + }
288 +
289 + function interrupt() {
290 + ReactNoop.flushSync(() => {
291 + ReactNoop.renderToRootWithID(null, 'other-root');
292 + });
293 }
294
283 - // Initial mount
284 - const parent = React.createRef(null);
285 - ReactNoop.render(<Parent ref={parent} childIsBroken={false} />);
286 - expect(Scheduler).toFlushAndYield(['Child']);
287 - expect(ReactNoop.getChildren()).toEqual([span('Child')]);
295 + ReactNoop.render(<App isBroken={true} />, onCommit);
296 + Scheduler.unstable_advanceTime(1000);
297 + expect(Scheduler).toFlushAndYieldThrough(['error']);
298 + interrupt();
299
289 - // Schedule a low priority update to hide the child
290 - parent.current.setState({hideChild: true});
300 + expect(ReactNoop).toMatchRenderedOutput(null);
301
292 - // Before the low priority update is flushed, synchronously trigger an
293 - // error in the child.
294 - ReactNoop.flushSync(() => {
295 - ReactNoop.render(<Parent ref={parent} childIsBroken={true} />);
296 - });
302 + // This update is in a separate batch
303 + ReactNoop.render(<App isBroken={false} />, onCommit);
304 +
305 + expect(Scheduler).toFlushAndYieldThrough([
306 + // The first render fails. But because there's a lower priority pending
307 + // update, it doesn't throw.
308 + 'error',
309 + ]);
310 +
311 + // React will try to recover by rendering all the pending updates in a
312 + // single batch, synchronously. This time it succeeds.
313 + //
314 + // This tells Scheduler to render a single unit of work. Because the render
315 + // to recover from the error is synchronous, this should be enough to
316 + // finish the rest of the work.
317 + Scheduler.unstable_flushNumberOfYields(1);
318 expect(Scheduler).toHaveYielded([
298 - // First the sync update triggers an error
299 - 'Error!',
300 - // Because there's a pending low priority update, we restart at the
301 - // lower priority. This hides the children, suppressing the error.
302 - '(empty)',
303 - // Now the tree can commit.
304 - 'commit: true',
319 + 'success',
320 + // Nothing commits until the second update completes.
321 + 'commit',
322 + 'commit',
323 ]);
306 - expect(ReactNoop.getChildren()).toEqual([span('(empty)')]);
324 + // This should not include the offscreen content
325 + expect(ReactNoop).toMatchRenderedOutput(
326 + <>
327 + Everything is fine
328 + <div hidden={true} />
329 + </>,
330 + );
331 +
332 + // The offscreen content finishes in a subsequent render
333 + expect(Scheduler).toFlushAndYield([]);
334 + expect(ReactNoop).toMatchRenderedOutput(
335 + <>
336 + Everything is fine
337 + <div hidden={true}>
338 + <div>Offscreen content</div>
339 + </div>
340 + </>,
341 + );
342 });
343
344 it('retries one more time before handling error', () => {
@@ -346,10 +381,6 @@ describe('ReactIncrementalErrorHandling', () => {
381 expect(ReactNoop.getChildren()).toEqual([]);
382 });
383
349 - // TODO: This is currently unobservable, but will be once we lift renderRoot
350 - // and commitRoot into the renderer.
351 - // it("does not retry synchronously if there's an update between complete and commit");
352 -
384 it('calls componentDidCatch multiple times for multiple errors', () => {
385 let id = 0;
386 class BadMount extends React.Component {
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+268
@@ -263,6 +263,274 @@ describe('ReactSuspense', () => {
263 expect(root).toMatchRenderedOutput('AsyncAfter SuspenseSibling');
264 });
265
266 + it(
267 + 'interrupts current render if something already suspended with a ' +
268 + "delay, and then subsequently there's a lower priority update",
269 + () => {
270 + const root = ReactTestRenderer.create(
271 + <>
272 + <Suspense fallback={<Text text="Loading..." />} />
273 + <Text text="Initial" />
274 + </>,
275 + {
276 + unstable_isConcurrent: true,
277 + },
278 + );
279 + expect(Scheduler).toFlushAndYield(['Initial']);
280 + expect(root).toMatchRenderedOutput('Initial');
281 +
282 + // The update will suspend.
283 + root.update(
284 + <>
285 + <Suspense fallback={<Text text="Loading..." />}>
286 + <AsyncText text="Async" ms={2000} />
287 + </Suspense>
288 + <Text text="After Suspense" />
289 + <Text text="Sibling" />
290 + </>,
291 + );
292 +
293 + // Yield past the Suspense boundary but don't complete the last sibling.
294 + expect(Scheduler).toFlushAndYieldThrough([
295 + 'Suspend! [Async]',
296 + 'Loading...',
297 + 'After Suspense',
298 + ]);
299 +
300 + // Receives a lower priority update before the current render phase
301 + // has completed.
302 + Scheduler.unstable_advanceTime(1000);
303 + root.update(
304 + <>
305 + <Suspense fallback={<Text text="Loading..." />} />
306 + <Text text="Updated" />
307 + </>,
308 + );
309 + expect(Scheduler).toHaveYielded([]);
310 + expect(root).toMatchRenderedOutput('Initial');
311 +
312 + // Render the update, instead of continuing
313 + expect(Scheduler).toFlushAndYield(['Updated']);
314 + expect(root).toMatchRenderedOutput('Updated');
315 + },
316 + );
317 +
318 + it(
319 + 'interrupts current render when something suspends with a ' +
320 + "delay and we've already skipped over a lower priority update in " +
321 + 'a parent',
322 + () => {
323 + function interrupt() {
324 + // React has a heuristic to batch all updates that occur within the same
325 + // event. This is a trick to circumvent that heuristic.
326 + ReactTestRenderer.create('whatever');
327 + }
328 +
329 + function App({shouldSuspend, step}) {
330 + return (
331 + <>
332 + <Text text={`A${step}`} />
333 + <Suspense fallback={<Text text="Loading..." />}>
334 + {shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
335 + </Suspense>
336 + <Text text={`B${step}`} />
337 + <Text text={`C${step}`} />
338 + </>
339 + );
340 + }
341 +
342 + const root = ReactTestRenderer.create(null, {
343 + unstable_isConcurrent: true,
344 + });
345 +
346 + root.update(<App shouldSuspend={false} step={0} />);
347 + expect(Scheduler).toFlushAndYield(['A0', 'B0', 'C0']);
348 + expect(root).toMatchRenderedOutput('A0B0C0');
349 +
350 + // This update will suspend.
351 + root.update(<App shouldSuspend={true} step={1} />);
352 +
353 + // Need to move into the next async bucket.
354 + Scheduler.unstable_advanceTime(1000);
355 + // Do a bit of work, then interrupt to trigger a restart.
356 + expect(Scheduler).toFlushAndYieldThrough(['A1']);
357 + interrupt();
358 +
359 + // Schedule another update. This will have lower priority because of
360 + // the interrupt trick above.
361 + root.update(<App shouldSuspend={false} step={2} />);
362 +
363 + expect(Scheduler).toFlushAndYieldThrough([
364 + // Should have restarted the first update, because of the interruption
365 + 'A1',
366 + 'Suspend! [Async]',
367 + 'Loading...',
368 + 'B1',
369 + ]);
370 +
371 + // Should not have committed loading state
372 + expect(root).toMatchRenderedOutput('A0B0C0');
373 +
374 + // After suspending, should abort the first update and switch to the
375 + // second update. So, C1 should not appear in the log.
376 + // TODO: This should work even if React does not yield to the main
377 + // thread. Should use same mechanism as selective hydration to interrupt
378 + // the render before the end of the current slice of work.
379 + expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
380 +
381 + expect(root).toMatchRenderedOutput('A2B2C2');
382 + },
383 + );
384 +
385 + it(
386 + 'interrupts current render when something suspends with a ' +
387 + "delay and we've already bailed out lower priority update in " +
388 + 'a parent',
389 + async () => {
390 + // This is similar to the previous test case, except this covers when
391 + // React completely bails out on the parent component, without processing
392 + // the update queue.
393 +
394 + const {useState} = React;
395 +
396 + function interrupt() {
397 + // React has a heuristic to batch all updates that occur within the same
398 + // event. This is a trick to circumvent that heuristic.
399 + ReactTestRenderer.create('whatever');
400 + }
401 +
402 + let setShouldSuspend;
403 + function Async() {
404 + const [shouldSuspend, _setShouldSuspend] = useState(false);
405 + setShouldSuspend = _setShouldSuspend;
406 + return (
407 + <>
408 + <Text text="A" />
409 + <Suspense fallback={<Text text="Loading..." />}>
410 + {shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
411 + </Suspense>
412 + <Text text="B" />
413 + <Text text="C" />
414 + </>
415 + );
416 + }
417 +
418 + let setShouldHideInParent;
419 + function App() {
420 + const [shouldHideInParent, _setShouldHideInParent] = useState(false);
421 + setShouldHideInParent = _setShouldHideInParent;
422 + Scheduler.unstable_yieldValue(
423 + 'shouldHideInParent: ' + shouldHideInParent,
424 + );
425 + return shouldHideInParent ? <Text text="(empty)" /> : <Async />;
426 + }
427 +
428 + const root = ReactTestRenderer.create(null, {
429 + unstable_isConcurrent: true,
430 + });
431 +
432 + await ReactTestRenderer.act(async () => {
433 + root.update(<App />);
434 + expect(Scheduler).toFlushAndYield([
435 + 'shouldHideInParent: false',
436 + 'A',
437 + 'B',
438 + 'C',
439 + ]);
440 + expect(root).toMatchRenderedOutput('ABC');
441 +
442 + // This update will suspend.
443 + setShouldSuspend(true);
444 +
445 + // Need to move into the next async bucket.
446 + Scheduler.unstable_advanceTime(1000);
447 + // Do a bit of work, then interrupt to trigger a restart.
448 + expect(Scheduler).toFlushAndYieldThrough(['A']);
449 + interrupt();
450 + // Should not have committed loading state
451 + expect(root).toMatchRenderedOutput('ABC');
452 +
453 + // Schedule another update. This will have lower priority because of
454 + // the interrupt trick above.
455 + setShouldHideInParent(true);
456 +
457 + expect(Scheduler).toFlushAndYieldThrough([
458 + // Should have restarted the first update, because of the interruption
459 + 'A',
460 + 'Suspend! [Async]',
461 + 'Loading...',
462 + 'B',
463 + ]);
464 +
465 + // Should not have committed loading state
466 + expect(root).toMatchRenderedOutput('ABC');
467 +
468 + // After suspending, should abort the first update and switch to the
469 + // second update.
470 + expect(Scheduler).toFlushAndYield([
471 + 'shouldHideInParent: true',
472 + '(empty)',
473 + ]);
474 +
475 + expect(root).toMatchRenderedOutput('(empty)');
476 + });
477 + },
478 + );
479 +
480 + it(
481 + 'interrupts current render when something suspends with a ' +
482 + 'delay, and a parent received an update after it completed',
483 + () => {
484 + function App({shouldSuspend, step}) {
485 + return (
486 + <>
487 + <Text text={`A${step}`} />
488 + <Suspense fallback={<Text text="Loading..." />}>
489 + {shouldSuspend ? <AsyncText text="Async" ms={2000} /> : null}
490 + </Suspense>
491 + <Text text={`B${step}`} />
492 + <Text text={`C${step}`} />
493 + </>
494 + );
495 + }
496 +
497 + const root = ReactTestRenderer.create(null, {
498 + unstable_isConcurrent: true,
499 + });
500 +
501 + root.update(<App shouldSuspend={false} step={0} />);
502 + expect(Scheduler).toFlushAndYield(['A0', 'B0', 'C0']);
503 + expect(root).toMatchRenderedOutput('A0B0C0');
504 +
505 + // This update will suspend.
506 + root.update(<App shouldSuspend={true} step={1} />);
507 + // Flush past the root, but stop before the async component.
508 + expect(Scheduler).toFlushAndYieldThrough(['A1']);
509 +
510 + // Schedule an update on the root, which already completed.
511 + root.update(<App shouldSuspend={false} step={2} />);
512 + // We'll keep working on the existing update.
513 + expect(Scheduler).toFlushAndYieldThrough([
514 + // Now the async component suspends
515 + 'Suspend! [Async]',
516 + 'Loading...',
517 + 'B1',
518 + ]);
519 +
520 + // Should not have committed loading state
521 + expect(root).toMatchRenderedOutput('A0B0C0');
522 +
523 + // After suspending, should abort the first update and switch to the
524 + // second update. So, C1 should not appear in the log.
525 + // TODO: This should work even if React does not yield to the main
526 + // thread. Should use same mechanism as selective hydration to interrupt
527 + // the render before the end of the current slice of work.
528 + expect(Scheduler).toFlushAndYield(['A2', 'B2', 'C2']);
529 +
530 + expect(root).toMatchRenderedOutput('A2B2C2');
531 + },
532 + );
533 +
534 it('mounts a lazy class component in non-concurrent mode', async () => {
535 class Class extends React.Component {
536 componentDidMount() {
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+66 -1
@@ -494,7 +494,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
494 return <Text text="(empty)" />;
495 }
496 return (
497 - <Suspense>
497 + <Suspense fallback="Loading...">
498 <AsyncText ms={2000} text="Async" />
499 </Suspense>
500 );
@@ -518,6 +518,71 @@ describe('ReactSuspenseWithNoopRenderer', () => {
518 expect(ReactNoop.getChildren()).toEqual([span('(empty)')]);
519 });
520
521 + it('tries each subsequent level after suspending', async () => {
522 + const root = ReactNoop.createRoot();
523 +
524 + function App({step, shouldSuspend}) {
525 + return (
526 + <Suspense fallback="Loading...">
527 + <Text text="Sibling" />
528 + {shouldSuspend ? (
529 + <AsyncText ms={10000} text={'Step ' + step} />
530 + ) : (
531 + <Text text={'Step ' + step} />
532 + )}
533 + </Suspense>
534 + );
535 + }
536 +
537 + function interrupt() {
538 + // React has a heuristic to batch all updates that occur within the same
539 + // event. This is a trick to circumvent that heuristic.
540 + ReactNoop.flushSync(() => {
541 + ReactNoop.renderToRootWithID(null, 'other-root');
542 + });
543 + }
544 +
545 + // Mount the Suspense boundary without suspending, so that the subsequent
546 + // updates suspend with a delay.
547 + await ReactNoop.act(async () => {
548 + root.render(<App step={0} shouldSuspend={false} />);
549 + });
550 + await advanceTimers(1000);
551 + expect(Scheduler).toHaveYielded(['Sibling', 'Step 0']);
552 +
553 + // Schedule an update at several distinct expiration times
554 + await ReactNoop.act(async () => {
555 + root.render(<App step={1} shouldSuspend={true} />);
556 + Scheduler.unstable_advanceTime(1000);
557 + expect(Scheduler).toFlushAndYieldThrough(['Sibling']);
558 + interrupt();
559 +
560 + root.render(<App step={2} shouldSuspend={true} />);
561 + Scheduler.unstable_advanceTime(1000);
562 + expect(Scheduler).toFlushAndYieldThrough(['Sibling']);
563 + interrupt();
564 +
565 + root.render(<App step={3} shouldSuspend={true} />);
566 + Scheduler.unstable_advanceTime(1000);
567 + expect(Scheduler).toFlushAndYieldThrough(['Sibling']);
568 + interrupt();
569 +
570 + root.render(<App step={4} shouldSuspend={false} />);
571 + });
572 +
573 + // Should suspend at each distinct level
574 + expect(Scheduler).toHaveYielded([
575 + 'Sibling',
576 + 'Suspend! [Step 1]',
577 + 'Sibling',
578 + 'Suspend! [Step 2]',
579 + 'Sibling',
580 + 'Suspend! [Step 3]',
581 + 'Sibling',
582 + 'Step 4',
583 + ]);
584 + });
585 +
586 it('forces an expiration after an update times out', async () => {
587 ReactNoop.render(
588 <Fragment>
packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap
+4 -1
@@ -422,7 +422,10 @@ exports[`ReactDebugFiberPerf warns if an in-progress update is interrupted 1`] =
422 `;
423
424 exports[`ReactDebugFiberPerf warns if async work expires (starvation) 1`] = `
425 -"⚛ (Committing Changes)
425 +"⚛ (React Tree Reconciliation: Completed Root)
426 + ⚛ Foo [mount]
427 +
428 +⚛ (Committing Changes)
429 ⚛ (Committing Snapshot Effects: 0 Total)
430 ⚛ (Committing Host Effects: 1 Total)
431 ⚛ (Calling Lifecycle Methods: 0 Total)