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)