Skip to content

Commit 965d08c

Browse files
authored
Add dedicated LanePriority for Suspense retries (facebook#19287)
A "retry" is a special type of update that attempts to flip a Suspense boundary from its placeholder state back to its primary/resolved state. Currently, retries are given default priority, using the same algorithm as normal updates and occupying range of lanes. This adds a new range of lanes dedicated specifically to retries, and gives them lower priority than normal updates. A couple of existing tests were affected because retries are no longer batched with normal updates; they commit in separate batches. Not totally satisfied with this design, but I think things will snap more into place once the rest of the Lanes changes (like how we handle parallel Suspense transitions) have settled.
1 parent 766af59 commit 965d08c

File tree

6 files changed

+181
-35
lines changed

6 files changed

+181
-35
lines changed

packages/react-reconciler/src/ReactFiberLane.js

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ export opaque type LanePriority =
2626
| 13
2727
| 14
2828
| 15
29-
| 16;
29+
| 16
30+
| 17;
3031
export opaque type Lanes = number;
3132
export opaque type Lane = number;
3233
export opaque type LaneMap<T> = Array<T>;
@@ -42,23 +43,25 @@ import {
4243
NoPriority as NoSchedulerPriority,
4344
} from './SchedulerWithReactIntegration.new';
4445

45-
export const SyncLanePriority: LanePriority = 16;
46-
const SyncBatchedLanePriority: LanePriority = 15;
46+
export const SyncLanePriority: LanePriority = 17;
47+
const SyncBatchedLanePriority: LanePriority = 16;
4748

48-
const InputDiscreteHydrationLanePriority: LanePriority = 14;
49-
export const InputDiscreteLanePriority: LanePriority = 13;
49+
const InputDiscreteHydrationLanePriority: LanePriority = 15;
50+
export const InputDiscreteLanePriority: LanePriority = 14;
5051

51-
const InputContinuousHydrationLanePriority: LanePriority = 12;
52-
export const InputContinuousLanePriority: LanePriority = 11;
52+
const InputContinuousHydrationLanePriority: LanePriority = 13;
53+
export const InputContinuousLanePriority: LanePriority = 12;
5354

54-
const DefaultHydrationLanePriority: LanePriority = 10;
55-
export const DefaultLanePriority: LanePriority = 9;
55+
const DefaultHydrationLanePriority: LanePriority = 11;
56+
export const DefaultLanePriority: LanePriority = 10;
5657

57-
const TransitionShortHydrationLanePriority: LanePriority = 8;
58-
export const TransitionShortLanePriority: LanePriority = 7;
58+
const TransitionShortHydrationLanePriority: LanePriority = 9;
59+
export const TransitionShortLanePriority: LanePriority = 8;
5960

60-
const TransitionLongHydrationLanePriority: LanePriority = 6;
61-
export const TransitionLongLanePriority: LanePriority = 5;
61+
const TransitionLongHydrationLanePriority: LanePriority = 7;
62+
export const TransitionLongLanePriority: LanePriority = 6;
63+
64+
const RetryLanePriority: LanePriority = 5;
6265

6366
const SelectiveHydrationLanePriority: LanePriority = 4;
6467

@@ -90,25 +93,30 @@ const InputContinuousUpdateRangeStart = 6;
9093
const InputContinuousUpdateRangeEnd = 8;
9194

9295
export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000100000000;
93-
const DefaultLanes: Lanes = /* */ 0b0000000000000000011111100000000;
96+
export const DefaultLanes: Lanes = /* */ 0b0000000000000000000111100000000;
9497
const DefaultUpdateRangeStart = 9;
95-
const DefaultUpdateRangeEnd = 14;
98+
const DefaultUpdateRangeEnd = 12;
99+
100+
const TransitionShortHydrationLane: Lane = /* */ 0b0000000000000000001000000000000;
101+
const TransitionShortLanes: Lanes = /* */ 0b0000000000000011111000000000000;
102+
const TransitionShortUpdateRangeStart = 13;
103+
const TransitionShortUpdateRangeEnd = 17;
96104

97-
const TransitionShortHydrationLane: Lane = /* */ 0b0000000000000000100000000000000;
98-
const TransitionShortLanes: Lanes = /* */ 0b0000000000011111100000000000000;
99-
const TransitionShortUpdateRangeStart = 15;
100-
const TransitionShortUpdateRangeEnd = 20;
105+
const TransitionLongHydrationLane: Lane = /* */ 0b0000000000000100000000000000000;
106+
const TransitionLongLanes: Lanes = /* */ 0b0000000001111100000000000000000;
107+
const TransitionLongUpdateRangeStart = 18;
108+
const TransitionLongUpdateRangeEnd = 22;
101109

102-
const TransitionLongHydrationLane: Lane = /* */ 0b0000000000100000000000000000000;
103-
const TransitionLongLanes: Lanes = /* */ 0b0000011111100000000000000000000;
104-
const TransitionLongUpdateRangeStart = 21;
105-
const TransitionLongUpdateRangeEnd = 26;
110+
// Includes all updates. Except Idle updates, which have special semantics.
111+
const UpdateRangeEnd = TransitionLongUpdateRangeEnd;
106112

107-
export const SelectiveHydrationLane: Lane = /* */ 0b0000110000000000000000000000000;
113+
const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
114+
const RetryRangeStart = 22;
115+
const RetryRangeEnd = 26;
116+
117+
export const SelectiveHydrationLane: Lane = /* */ 0b0000100000000000000000000000000;
108118
const SelectiveHydrationRangeEnd = 27;
109119

110-
// Includes all non-Idle updates
111-
const UpdateRangeEnd = 27;
112120
const NonIdleLanes = /* */ 0b0000111111111111111111111111111;
113121

114122
export const IdleHydrationLane: Lane = /* */ 0b0001000000000000000000000000000;
@@ -206,6 +214,12 @@ function getHighestPriorityLanes(lanes: Lanes | Lane): Lanes {
206214
return transitionLongLanes;
207215
}
208216
}
217+
const retryLanes = RetryLanes & lanes;
218+
if (retryLanes !== NoLanes) {
219+
return_highestLanePriority = RetryLanePriority;
220+
return_updateRangeEnd = RetryRangeEnd;
221+
return retryLanes;
222+
}
209223
if (lanes & SelectiveHydrationLane) {
210224
return_highestLanePriority = SelectiveHydrationLanePriority;
211225
return_updateRangeEnd = SelectiveHydrationRangeEnd;
@@ -275,6 +289,7 @@ export function lanePriorityToSchedulerPriority(
275289
case TransitionLongHydrationLanePriority:
276290
case TransitionLongLanePriority:
277291
case SelectiveHydrationLanePriority:
292+
case RetryLanePriority:
278293
return NormalSchedulerPriority;
279294
case IdleHydrationLanePriority:
280295
case IdleLanePriority:
@@ -537,6 +552,9 @@ export function findUpdateLane(
537552
case TransitionLongLanePriority:
538553
// Should be handled by findTransitionLane instead
539554
break;
555+
case RetryLanePriority:
556+
// Should be handled by findRetryLane instead
557+
break;
540558
case IdleLanePriority:
541559
let lane = findLane(IdleUpdateRangeStart, IdleUpdateRangeEnd, wipLanes);
542560
if (lane === NoLane) {
@@ -604,6 +622,19 @@ export function findTransitionLane(
604622
);
605623
}
606624

625+
// To ensure consistency across multiple updates in the same event, this should
626+
// be pure function, so that it always returns the same lane for given inputs.
627+
export function findRetryLane(wipLanes: Lanes): Lane {
628+
// This is a fork of `findUpdateLane` designed specifically for Suspense
629+
// "retries" — a special update that attempts to flip a Suspense boundary
630+
// from its placeholder state to its primary/resolved state.
631+
let lane = findLane(RetryRangeStart, RetryRangeEnd, wipLanes);
632+
if (lane === NoLane) {
633+
lane = pickArbitraryLane(RetryLanes);
634+
}
635+
return lane;
636+
}
637+
607638
function findLane(start, end, skipLanes) {
608639
// This finds the first bit between the `start` and `end` positions that isn't
609640
// in `skipLanes`.
@@ -808,6 +839,11 @@ export function getBumpedLaneForHydration(
808839
case TransitionLongLanePriority:
809840
lane = TransitionLongHydrationLane;
810841
break;
842+
case RetryLanePriority:
843+
// Shouldn't be reachable under normal circumstances, so there's no
844+
// dedicated lane for retry priority. Use the one for long transitions.
845+
lane = TransitionLongHydrationLane;
846+
break;
811847
case SelectiveHydrationLanePriority:
812848
lane = SelectiveHydrationLane;
813849
break;

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
NoTimestamp,
126126
findUpdateLane,
127127
findTransitionLane,
128+
findRetryLane,
128129
includesSomeLane,
129130
isSubsetOfLanes,
130131
mergeLanes,
@@ -472,6 +473,28 @@ export function requestUpdateLane(
472473
return lane;
473474
}
474475

476+
function requestRetryLane(fiber: Fiber) {
477+
// This is a fork of `requestUpdateLane` designed specifically for Suspense
478+
// "retries" — a special update that attempts to flip a Suspense boundary
479+
// from its placeholder state to its primary/resolved state.
480+
481+
// Special cases
482+
const mode = fiber.mode;
483+
if ((mode & BlockingMode) === NoMode) {
484+
return (SyncLane: Lane);
485+
} else if ((mode & ConcurrentMode) === NoMode) {
486+
return getCurrentPriorityLevel() === ImmediateSchedulerPriority
487+
? (SyncLane: Lane)
488+
: (SyncBatchedLane: Lane);
489+
}
490+
491+
// See `requestUpdateLane` for explanation of `currentEventWipLanes`
492+
if (currentEventWipLanes === NoLanes) {
493+
currentEventWipLanes = workInProgressRootIncludedLanes;
494+
}
495+
return findRetryLane(currentEventWipLanes);
496+
}
497+
475498
export function scheduleUpdateOnFiber(
476499
fiber: Fiber,
477500
lane: Lane,
@@ -2708,10 +2731,7 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) {
27082731
// suspended it has resolved, which means at least part of the tree was
27092732
// likely unblocked. Try rendering again, at a new expiration time.
27102733
if (retryLane === NoLane) {
2711-
const suspenseConfig = null; // Retries don't carry over the already committed update.
2712-
// TODO: Should retries get their own lane? Maybe it could share with
2713-
// transitions.
2714-
retryLane = requestUpdateLane(boundaryFiber, suspenseConfig);
2734+
retryLane = requestRetryLane(boundaryFiber);
27152735
}
27162736
// TODO: Special case idle priority?
27172737
const eventTime = requestEventTime();

packages/react-reconciler/src/ReactFiberWorkLoop.old.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ import {
148148
NoTimestamp,
149149
findUpdateLane,
150150
findTransitionLane,
151+
findRetryLane,
151152
includesSomeLane,
152153
isSubsetOfLanes,
153154
mergeLanes,
@@ -495,6 +496,28 @@ export function requestUpdateLane(
495496
return lane;
496497
}
497498

499+
function requestRetryLane(fiber: Fiber) {
500+
// This is a fork of `requestUpdateLane` designed specifically for Suspense
501+
// "retries" — a special update that attempts to flip a Suspense boundary
502+
// from its placeholder state to its primary/resolved state.
503+
504+
// Special cases
505+
const mode = fiber.mode;
506+
if ((mode & BlockingMode) === NoMode) {
507+
return (SyncLane: Lane);
508+
} else if ((mode & ConcurrentMode) === NoMode) {
509+
return getCurrentPriorityLevel() === ImmediateSchedulerPriority
510+
? (SyncLane: Lane)
511+
: (SyncBatchedLane: Lane);
512+
}
513+
514+
// See `requestUpdateLane` for explanation of `currentEventWipLanes`
515+
if (currentEventWipLanes === NoLanes) {
516+
currentEventWipLanes = workInProgressRootIncludedLanes;
517+
}
518+
return findRetryLane(currentEventWipLanes);
519+
}
520+
498521
export function scheduleUpdateOnFiber(
499522
fiber: Fiber,
500523
lane: Lane,
@@ -2855,10 +2878,7 @@ function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) {
28552878
// suspended it has resolved, which means at least part of the tree was
28562879
// likely unblocked. Try rendering again, at a new expiration time.
28572880
if (retryLane === NoLane) {
2858-
const suspenseConfig = null; // Retries don't carry over the already committed update.
2859-
// TODO: Should retries get their own lane? Maybe it could share with
2860-
// transitions.
2861-
retryLane = requestUpdateLane(boundaryFiber, suspenseConfig);
2881+
retryLane = requestRetryLane(boundaryFiber);
28622882
}
28632883
// TODO: Special case idle priority?
28642884
const eventTime = requestEventTime();

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,11 @@ describe('ReactSuspense', () => {
219219
await resolve1();
220220
ReactNoop.render(element);
221221
expect(Scheduler).toFlushWithoutYielding();
222+
223+
// Force fallback to commit.
224+
// TODO: Should be able to use `act` here.
225+
jest.runAllTimers();
226+
222227
expect(ReactNoop.getChildren()).toEqual([
223228
text('Waiting Tier 2'),
224229
text('Done'),

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,17 @@ describe('ReactSuspenseWithNoopRenderer', () => {
623623
// The new reconciler batches everything together, so it finishes without
624624
// suspending again.
625625
'Sibling',
626+
627+
// NOTE: The final of the update got pushed into a lower priority range of
628+
// lanes, leading to the extra intermediate render. This is because when
629+
// we schedule the fourth update, we're already in the middle of rendering
630+
// the three others. Since there are only three lanes in the default
631+
// range, the fourth lane is shifted to slightly lower priority. This
632+
// could easily change when we tweak our batching heuristics. Ideally,
633+
// they'd all have default priority and render in a single batch.
634+
'Suspend! [Step 3]',
635+
'Sibling',
636+
626637
'Step 4',
627638
]);
628639
});
@@ -3896,4 +3907,59 @@ describe('ReactSuspenseWithNoopRenderer', () => {
38963907
expect(Scheduler).toHaveYielded(['Promise resolved [B]', 'B']);
38973908
expect(root).toMatchRenderedOutput(<span prop="B" />);
38983909
});
3910+
3911+
it('retries have lower priority than normal updates', async () => {
3912+
const {useState} = React;
3913+
3914+
let setText;
3915+
function UpdatingText() {
3916+
const [text, _setText] = useState('A');
3917+
setText = _setText;
3918+
return <Text text={text} />;
3919+
}
3920+
3921+
const root = ReactNoop.createRoot();
3922+
await ReactNoop.act(async () => {
3923+
root.render(
3924+
<>
3925+
<UpdatingText />
3926+
<Suspense fallback={<Text text="Loading..." />}>
3927+
<AsyncText text="Async" />
3928+
</Suspense>
3929+
</>,
3930+
);
3931+
});
3932+
expect(Scheduler).toHaveYielded(['A', 'Suspend! [Async]', 'Loading...']);
3933+
expect(root).toMatchRenderedOutput(
3934+
<>
3935+
<span prop="A" />
3936+
<span prop="Loading..." />
3937+
</>,
3938+
);
3939+
3940+
await ReactNoop.act(async () => {
3941+
// Resolve the promise. This will trigger a retry.
3942+
await resolveText('Async');
3943+
expect(Scheduler).toHaveYielded(['Promise resolved [Async]']);
3944+
// Before the retry happens, schedule a new update.
3945+
setText('B');
3946+
3947+
// The update should be allowed to finish before the retry is attempted.
3948+
expect(Scheduler).toFlushUntilNextPaint(['B']);
3949+
expect(root).toMatchRenderedOutput(
3950+
<>
3951+
<span prop="B" />
3952+
<span prop="Loading..." />
3953+
</>,
3954+
);
3955+
});
3956+
// Then do the retry.
3957+
expect(Scheduler).toHaveYielded(['Async']);
3958+
expect(root).toMatchRenderedOutput(
3959+
<>
3960+
<span prop="B" />
3961+
<span prop="Async" />
3962+
</>,
3963+
);
3964+
});
38993965
});

packages/react/src/__tests__/ReactProfiler-test.internal.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3923,7 +3923,6 @@ describe('Profiler', () => {
39233923

39243924
expect(onPostCommit).toHaveBeenCalledTimes(1);
39253925
expect(onPostCommit.mock.calls[0][4]).toMatchInteractions([
3926-
initialRenderInteraction,
39273926
highPriUpdateInteraction,
39283927
]);
39293928

0 commit comments

Comments
 (0)