Skip to content

Commit 100945b

Browse files
committed
Track "pending" and "suspended" ranges
A FiberRoot can have pending work at many distinct priorities. (Note: we refer to these levels as "expiration times" to distinguish the concept from Scheduler's notion of priority levels, which represent broad categories of work. React expiration times are more granualar. They're more like a concurrent thread ID, which also happens to correspond to a moment on a timeline. It's an overloaded concept and I'm handwaving over some of the details.) Given a root, there's no convenient way to read all the pending levels in the entire tree, i.e. there's no single queue-like structure that tracks all the levels, because that granularity of information is not needed by our algorithms. Instead we track the subset of information that we actually need — most importantly, the highest priority level that exists in the entire tree. Aside from that, the other information we track includes the range of pending levels that are known to be suspended, and therefore should not be worked on. This is a refactor of how that information is tracked, and what each field represents: - A *pending* level is work that is unfinished, or not yet committed. This includes work that is suspended from committing. `firstPendingTime` and `lastPendingTime` represent the range of pending work. (Previously, "pending" was the same as "not suspended.") - A *suspended* level is work that did not complete because data was missing. `firstSuspendedTime` and `lastSuspendedTime` represent the range of suspended work. It is a subset of the pending range. (These fields are new to this commit.) - `nextAfterSuspendedTime` represents the next known level that comes after the suspended range. This commit doesn't change much in terms of observable behavior. The one change is that, when a level is suspended, React will continue working on the next known level instead of jumping straight to the last pending level. Subsequent commits will use this new structure for a more substantial refactor for how tasks are scheduled per root.
1 parent 9ce8711 commit 100945b

File tree

3 files changed

+206
-42
lines changed

3 files changed

+206
-42
lines changed

packages/react-reconciler/src/ReactFiberRoot.js

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,15 @@ type BaseFiberRootProperties = {|
7373
firstPendingTime: ExpirationTime,
7474
// The latest pending expiration time that exists in the tree
7575
lastPendingTime: ExpirationTime,
76-
// The time at which a suspended component pinged the root to render again
77-
pingTime: ExpirationTime,
76+
// The earliest suspended expiration time that exists in the tree
77+
firstSuspendedTime: ExpirationTime,
78+
// The latest suspended expiration time that exists in the tree
79+
lastSuspendedTime: ExpirationTime,
80+
// The next known expiration time after the suspended range
81+
nextAfterSuspendedTime: ExpirationTime,
82+
// The latest time at which a suspended component pinged the root to
83+
// render again
84+
lastPingedTime: ExpirationTime,
7885
|};
7986

8087
// The following attributes are only used by interaction tracing builds.
@@ -120,7 +127,10 @@ function FiberRootNode(containerInfo, tag, hydrate) {
120127
this.callbackExpirationTime = NoWork;
121128
this.firstPendingTime = NoWork;
122129
this.lastPendingTime = NoWork;
123-
this.pingTime = NoWork;
130+
this.firstSuspendedTime = NoWork;
131+
this.lastSuspendedTime = NoWork;
132+
this.nextAfterSuspendedTime = NoWork;
133+
this.lastPingedTime = NoWork;
124134

125135
if (enableSchedulerTracing) {
126136
this.interactionThreadID = unstable_getThreadID();
@@ -151,3 +161,53 @@ export function createFiberRoot(
151161

152162
return root;
153163
}
164+
165+
export function isRootSuspendedAtTime(
166+
root: FiberRoot,
167+
expirationTime: ExpirationTime,
168+
): boolean {
169+
const firstSuspendedTime = root.firstSuspendedTime;
170+
const lastSuspendedTime = root.lastSuspendedTime;
171+
return (
172+
firstSuspendedTime !== NoWork &&
173+
(firstSuspendedTime >= expirationTime &&
174+
lastSuspendedTime <= expirationTime)
175+
);
176+
}
177+
178+
export function markRootSuspendedAtTime(
179+
root: FiberRoot,
180+
expirationTime: ExpirationTime,
181+
): void {
182+
const firstSuspendedTime = root.firstSuspendedTime;
183+
const lastSuspendedTime = root.lastSuspendedTime;
184+
if (firstSuspendedTime < expirationTime) {
185+
root.firstSuspendedTime = expirationTime;
186+
}
187+
if (lastSuspendedTime > expirationTime || firstSuspendedTime === NoWork) {
188+
root.lastSuspendedTime = expirationTime;
189+
}
190+
191+
if (expirationTime <= root.lastPingedTime) {
192+
root.lastPingedTime = NoWork;
193+
}
194+
}
195+
196+
export function markRootUnsuspendedAtTime(
197+
root: FiberRoot,
198+
expirationTime: ExpirationTime,
199+
): void {
200+
if (expirationTime <= root.lastSuspendedTime) {
201+
// The entire suspended range is now unsuspended.
202+
root.firstSuspendedTime = root.lastSuspendedTime = root.nextAfterSuspendedTime = NoWork;
203+
} else if (expirationTime <= root.firstSuspendedTime) {
204+
// Part of the suspended range is now unsuspended. Narrow the range to
205+
// include everything between the unsuspended time (non-inclusive) and the
206+
// last suspended time.
207+
root.firstSuspendedTime = expirationTime - 1;
208+
}
209+
210+
if (expirationTime <= root.lastPingedTime) {
211+
root.lastPingedTime = NoWork;
212+
}
213+
}

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 78 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ import {
6666
} from './ReactFiberHostConfig';
6767

6868
import {createWorkInProgress, assignFiberPropertiesInDEV} from './ReactFiber';
69+
import {
70+
isRootSuspendedAtTime,
71+
markRootSuspendedAtTime,
72+
markRootUnsuspendedAtTime,
73+
} from './ReactFiberRoot';
6974
import {
7075
NoMode,
7176
StrictMode,
@@ -377,8 +382,6 @@ export function scheduleUpdateOnFiber(
377382
return;
378383
}
379384

380-
root.pingTime = NoWork;
381-
382385
checkForInterruption(fiber, expirationTime);
383386
recordScheduleUpdate();
384387

@@ -492,6 +495,9 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) {
492495
if (lastPendingTime === NoWork || expirationTime < lastPendingTime) {
493496
root.lastPendingTime = expirationTime;
494497
}
498+
499+
// Mark that the root is no longer suspended at this time.
500+
markRootUnsuspendedAtTime(root, expirationTime);
495501
}
496502

497503
return root;
@@ -807,13 +813,6 @@ function renderRoot(
807813
'Should not already be working.',
808814
);
809815

810-
if (root.firstPendingTime < expirationTime) {
811-
// If there's no work left at this expiration time, exit immediately. This
812-
// happens when multiple callbacks are scheduled for a single root, but an
813-
// earlier callback flushes the work of a later one.
814-
return null;
815-
}
816-
817816
if (isSync && root.finishedExpirationTime === expirationTime) {
818817
// There's already a pending commit at this expiration time.
819818
// TODO: This is poorly factored. This case only exists for the
@@ -831,21 +830,25 @@ function renderRoot(
831830
} else if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
832831
// We could've received an update at a lower priority while we yielded.
833832
// We're suspended in a delayed state. Once we complete this render we're
834-
// just going to try to recover at the last pending time anyway so we might
835-
// as well start doing that eagerly.
833+
// just going to try to recover at the pending time anyway so we might as
834+
// well start doing that eagerly.
835+
//
836836
// Ideally we should be able to do this even for retries but we don't yet
837837
// know if we're going to process an update which wants to commit earlier,
838838
// and this path happens very early so it would happen too often. Instead,
839839
// for that case, we'll wait until we complete.
840840
if (workInProgressRootHasPendingPing) {
841841
// We have a ping at this expiration. Let's restart to see if we get unblocked.
842842
prepareFreshStack(root, expirationTime);
843-
} else {
844-
const lastPendingTime = root.lastPendingTime;
845-
if (lastPendingTime < expirationTime) {
846-
// There's lower priority work. It might be unsuspended. Try rendering
847-
// at that level immediately, while preserving the position in the queue.
848-
return renderRoot.bind(null, root, lastPendingTime);
843+
} else if (!isSync) {
844+
// Check if there's work that isn't in the suspended range
845+
const firstPendingTime = root.firstPendingTime;
846+
if (!isRootSuspendedAtTime(root, firstPendingTime)) {
847+
// There's a pending update that falls outside the range of
848+
// suspended work.
849+
if (firstPendingTime > expirationTime) {
850+
return renderRoot.bind(null, root, firstPendingTime);
851+
}
849852
}
850853
}
851854
}
@@ -958,7 +961,8 @@ function renderRoot(
958961
// something suspended, wait to commit it after a timeout.
959962
stopFinishedWorkLoopTimer();
960963

961-
root.finishedWork = root.current.alternate;
964+
const finishedWork: Fiber = ((root.finishedWork =
965+
root.current.alternate): any);
962966
root.finishedExpirationTime = expirationTime;
963967

964968
const isLocked = resolveLocksOnRoot(root, expirationTime);
@@ -1002,6 +1006,11 @@ function renderRoot(
10021006
return commitRoot.bind(null, root);
10031007
}
10041008
case RootSuspended: {
1009+
markRootSuspendedAtTime(root, expirationTime);
1010+
const lastSuspendedTime = root.lastSuspendedTime;
1011+
if (expirationTime === lastSuspendedTime) {
1012+
root.nextAfterSuspendedTime = getRemainingExpirationTime(finishedWork);
1013+
}
10051014
flushSuspensePriorityWarningInDEV();
10061015

10071016
// We have an acceptable loading state. We need to figure out if we should
@@ -1038,11 +1047,20 @@ function renderRoot(
10381047
prepareFreshStack(root, expirationTime);
10391048
return renderRoot.bind(null, root, expirationTime);
10401049
}
1041-
const lastPendingTime = root.lastPendingTime;
1042-
if (lastPendingTime < expirationTime) {
1050+
1051+
const nextAfterSuspendedTime = root.nextAfterSuspendedTime;
1052+
if (nextAfterSuspendedTime !== NoWork) {
10431053
// There's lower priority work. It might be unsuspended. Try rendering
10441054
// at that level.
1045-
return renderRoot.bind(null, root, lastPendingTime);
1055+
return renderRoot.bind(null, root, nextAfterSuspendedTime);
1056+
}
1057+
if (
1058+
lastSuspendedTime !== NoWork &&
1059+
lastSuspendedTime !== expirationTime
1060+
) {
1061+
// We should prefer to render the fallback of at the last
1062+
// suspended level.
1063+
return renderRoot.bind(null, root, lastSuspendedTime);
10461064
}
10471065
// The render is suspended, it hasn't timed out, and there's no lower
10481066
// priority work to do. Instead of committing the fallback
@@ -1058,6 +1076,11 @@ function renderRoot(
10581076
return commitRoot.bind(null, root);
10591077
}
10601078
case RootSuspendedWithDelay: {
1079+
markRootSuspendedAtTime(root, expirationTime);
1080+
const lastSuspendedTime = root.lastSuspendedTime;
1081+
if (expirationTime === lastSuspendedTime) {
1082+
root.nextAfterSuspendedTime = getRemainingExpirationTime(finishedWork);
1083+
}
10611084
flushSuspensePriorityWarningInDEV();
10621085

10631086
if (
@@ -1077,11 +1100,20 @@ function renderRoot(
10771100
prepareFreshStack(root, expirationTime);
10781101
return renderRoot.bind(null, root, expirationTime);
10791102
}
1080-
const lastPendingTime = root.lastPendingTime;
1081-
if (lastPendingTime < expirationTime) {
1103+
1104+
const nextAfterSuspendedTime = root.nextAfterSuspendedTime;
1105+
if (nextAfterSuspendedTime !== NoWork) {
10821106
// There's lower priority work. It might be unsuspended. Try rendering
1083-
// at that level immediately.
1084-
return renderRoot.bind(null, root, lastPendingTime);
1107+
// at that level.
1108+
return renderRoot.bind(null, root, nextAfterSuspendedTime);
1109+
}
1110+
if (
1111+
lastSuspendedTime !== NoWork &&
1112+
lastSuspendedTime !== expirationTime
1113+
) {
1114+
// We should prefer to render the fallback of at the last
1115+
// suspended level.
1116+
return renderRoot.bind(null, root, lastSuspendedTime);
10851117
}
10861118

10871119
let msUntilTimeout;
@@ -1425,6 +1457,14 @@ function completeUnitOfWork(unitOfWork: Fiber): Fiber | null {
14251457
return null;
14261458
}
14271459

1460+
function getRemainingExpirationTime(fiber: Fiber) {
1461+
const updateExpirationTime = fiber.expirationTime;
1462+
const childExpirationTime = fiber.childExpirationTime;
1463+
return updateExpirationTime > childExpirationTime
1464+
? updateExpirationTime
1465+
: childExpirationTime;
1466+
}
1467+
14281468
function resetChildExpirationTime(completedWork: Fiber) {
14291469
if (
14301470
renderExpirationTime !== Never &&
@@ -1540,19 +1580,19 @@ function commitRootImpl(root, renderPriorityLevel) {
15401580

15411581
// Update the first and last pending times on this root. The new first
15421582
// pending time is whatever is left on the root fiber.
1543-
const updateExpirationTimeBeforeCommit = finishedWork.expirationTime;
1544-
const childExpirationTimeBeforeCommit = finishedWork.childExpirationTime;
1545-
const firstPendingTimeBeforeCommit =
1546-
childExpirationTimeBeforeCommit > updateExpirationTimeBeforeCommit
1547-
? childExpirationTimeBeforeCommit
1548-
: updateExpirationTimeBeforeCommit;
1549-
root.firstPendingTime = firstPendingTimeBeforeCommit;
1550-
if (firstPendingTimeBeforeCommit < root.lastPendingTime) {
1583+
const remainingExpirationTimeBeforeCommit = getRemainingExpirationTime(
1584+
finishedWork,
1585+
);
1586+
root.firstPendingTime = remainingExpirationTimeBeforeCommit;
1587+
if (remainingExpirationTimeBeforeCommit < root.lastPendingTime) {
15511588
// This usually means we've finished all the work, but it can also happen
15521589
// when something gets downprioritized during render, like a hidden tree.
1553-
root.lastPendingTime = firstPendingTimeBeforeCommit;
1590+
root.lastPendingTime = remainingExpirationTimeBeforeCommit;
15541591
}
15551592

1593+
// Mark that the root is no longer suspended at the finished time
1594+
markRootUnsuspendedAtTime(root, expirationTime);
1595+
15561596
if (root === workInProgressRoot) {
15571597
// We can reset these now that they are finished.
15581598
workInProgressRoot = null;
@@ -2148,20 +2188,19 @@ export function pingSuspendedRoot(
21482188
return;
21492189
}
21502190

2151-
const lastPendingTime = root.lastPendingTime;
2152-
if (lastPendingTime < suspendedTime) {
2191+
if (!isRootSuspendedAtTime(root, suspendedTime)) {
21532192
// The root is no longer suspended at this time.
21542193
return;
21552194
}
21562195

2157-
const pingTime = root.pingTime;
2158-
if (pingTime !== NoWork && pingTime < suspendedTime) {
2196+
const lastPingedTime = root.lastPingedTime;
2197+
if (lastPingedTime !== NoWork && lastPingedTime < suspendedTime) {
21592198
// There's already a lower priority ping scheduled.
21602199
return;
21612200
}
21622201

21632202
// Mark the time at which this ping was scheduled.
2164-
root.pingTime = suspendedTime;
2203+
root.lastPingedTime = suspendedTime;
21652204

21662205
if (root.finishedExpirationTime === suspendedTime) {
21672206
// If there's a pending fallback waiting to commit, throw it away.

packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,71 @@ describe('ReactSuspenseWithNoopRenderer', () => {
518518
expect(ReactNoop.getChildren()).toEqual([span('(empty)')]);
519519
});
520520

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+
521586
it('forces an expiration after an update times out', async () => {
522587
ReactNoop.render(
523588
<Fragment>

0 commit comments

Comments
 (0)