Skip to content

Commit a990e45

Browse files
committed
Schedule prerender after something suspends
Adds the concept of a "prerender". These special renders are spawned whenever something suspends (and we're not already prerendering). The purpose is to move speculative rendering work into a separate phase that does not block the UI from updating. For example, during a transition, if something suspends, we should not speculatively prerender siblings that will be replaced by a fallback in the UI until *after* the fallback has been shown to the user.
1 parent 8b4c54c commit a990e45

24 files changed

+1043
-130
lines changed

packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,15 @@ describe('ReactCache', () => {
148148
error = e;
149149
}
150150
expect(error.message).toMatch('Failed to load: Hi');
151-
assertLog(['Promise rejected [Hi]', 'Error! [Hi]', 'Error! [Hi]']);
151+
assertLog([
152+
'Promise rejected [Hi]',
153+
'Error! [Hi]',
154+
'Error! [Hi]',
155+
156+
...(gate('enableSiblingPrerendering')
157+
? ['Error! [Hi]', 'Error! [Hi]']
158+
: []),
159+
]);
152160

153161
// Should throw again on a subsequent read
154162
root.render(<App />);
@@ -191,6 +199,7 @@ describe('ReactCache', () => {
191199
}
192200
});
193201

202+
// @gate enableSiblingPrerendering
194203
it('evicts least recently used values', async () => {
195204
ReactCache.unstable_setGlobalCacheLimit(3);
196205

@@ -206,15 +215,13 @@ describe('ReactCache', () => {
206215
await waitForAll(['Suspend! [1]', 'Loading...']);
207216
jest.advanceTimersByTime(100);
208217
assertLog(['Promise resolved [1]']);
209-
await waitForAll([1, 'Suspend! [2]']);
218+
await waitForAll([1, 'Suspend! [2]', 1, 'Suspend! [2]', 'Suspend! [3]']);
210219

211220
jest.advanceTimersByTime(100);
212-
assertLog(['Promise resolved [2]']);
213-
await waitForAll([1, 2, 'Suspend! [3]']);
221+
assertLog(['Promise resolved [2]', 'Promise resolved [3]']);
222+
await waitForAll([1, 2, 3]);
214223

215224
await act(() => jest.advanceTimersByTime(100));
216-
assertLog(['Promise resolved [3]', 1, 2, 3]);
217-
218225
expect(root).toMatchRenderedOutput('123');
219226

220227
// Render 1, 4, 5
@@ -234,6 +241,9 @@ describe('ReactCache', () => {
234241
1,
235242
4,
236243
'Suspend! [5]',
244+
1,
245+
4,
246+
'Suspend! [5]',
237247
'Promise resolved [5]',
238248
1,
239249
4,
@@ -267,6 +277,9 @@ describe('ReactCache', () => {
267277
1,
268278
2,
269279
'Suspend! [3]',
280+
1,
281+
2,
282+
'Suspend! [3]',
270283
'Promise resolved [3]',
271284
1,
272285
2,

packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,7 @@ describe('ReactDOMFiberAsync', () => {
744744
// Because it suspended, it remains on the current path
745745
expect(div.textContent).toBe('/path/a');
746746
});
747-
assertLog([]);
747+
assertLog(gate('enableSiblingPrerendering') ? ['Suspend! [/path/b]'] : []);
748748

749749
await act(async () => {
750750
resolvePromise();

packages/react-dom/src/__tests__/ReactDOMForm-test.js

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,15 @@ describe('ReactDOMForm', () => {
699699
// This should suspend because form actions are implicitly wrapped
700700
// in startTransition.
701701
await submit(formRef.current);
702-
assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']);
702+
assertLog([
703+
'Pending...',
704+
'Suspend! [Updated]',
705+
'Loading...',
706+
707+
...(gate('enableSiblingPrerendering')
708+
? ['Suspend! [Updated]', 'Loading...']
709+
: []),
710+
]);
703711
expect(container.textContent).toBe('Pending...Initial');
704712

705713
await act(() => resolveText('Updated'));
@@ -736,7 +744,15 @@ describe('ReactDOMForm', () => {
736744

737745
// Update
738746
await submit(formRef.current);
739-
assertLog(['Pending...', 'Suspend! [Count: 1]', 'Loading...']);
747+
assertLog([
748+
'Pending...',
749+
'Suspend! [Count: 1]',
750+
'Loading...',
751+
752+
...(gate('enableSiblingPrerendering')
753+
? ['Suspend! [Count: 1]', 'Loading...']
754+
: []),
755+
]);
740756
expect(container.textContent).toBe('Pending...Count: 0');
741757

742758
await act(() => resolveText('Count: 1'));
@@ -745,7 +761,15 @@ describe('ReactDOMForm', () => {
745761

746762
// Update again
747763
await submit(formRef.current);
748-
assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']);
764+
assertLog([
765+
'Pending...',
766+
'Suspend! [Count: 2]',
767+
'Loading...',
768+
769+
...(gate('enableSiblingPrerendering')
770+
? ['Suspend! [Count: 2]', 'Loading...']
771+
: []),
772+
]);
749773
expect(container.textContent).toBe('Pending...Count: 1');
750774

751775
await act(() => resolveText('Count: 2'));
@@ -789,7 +813,14 @@ describe('ReactDOMForm', () => {
789813
assertLog(['Async action started', 'Pending...']);
790814

791815
await act(() => resolveText('Wait'));
792-
assertLog(['Suspend! [Updated]', 'Loading...']);
816+
assertLog([
817+
'Suspend! [Updated]',
818+
'Loading...',
819+
820+
...(gate('enableSiblingPrerendering')
821+
? ['Suspend! [Updated]', 'Loading...']
822+
: []),
823+
]);
793824
expect(container.textContent).toBe('Pending...Initial');
794825

795826
await act(() => resolveText('Updated'));
@@ -1475,7 +1506,15 @@ describe('ReactDOMForm', () => {
14751506
// Now dispatch inside of a transition. This one does not trigger a
14761507
// loading state.
14771508
await act(() => startTransition(() => dispatch()));
1478-
assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']);
1509+
assertLog([
1510+
'Count: 1',
1511+
'Suspend! [Count: 2]',
1512+
'Loading...',
1513+
1514+
...(gate('enableSiblingPrerendering')
1515+
? ['Suspend! [Count: 2]', 'Loading...']
1516+
: []),
1517+
]);
14791518
expect(container.textContent).toBe('Count: 1');
14801519

14811520
await act(() => resolveText('Count: 2'));
@@ -1495,7 +1534,11 @@ describe('ReactDOMForm', () => {
14951534

14961535
const root = ReactDOMClient.createRoot(container);
14971536
await act(() => root.render(<App />));
1498-
assertLog(['Suspend! [Count: 0]']);
1537+
assertLog([
1538+
'Suspend! [Count: 0]',
1539+
1540+
...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 0]'] : []),
1541+
]);
14991542
await act(() => resolveText('Count: 0'));
15001543
assertLog(['Count: 0']);
15011544

@@ -1508,7 +1551,11 @@ describe('ReactDOMForm', () => {
15081551
{withoutStack: true},
15091552
],
15101553
]);
1511-
assertLog(['Suspend! [Count: 1]']);
1554+
assertLog([
1555+
'Suspend! [Count: 1]',
1556+
1557+
...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 1]'] : []),
1558+
]);
15121559
expect(container.textContent).toBe('Count: 0');
15131560
});
15141561

packages/react-reconciler/src/ReactFiberLane.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,28 +229,49 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {
229229

230230
const suspendedLanes = root.suspendedLanes;
231231
const pingedLanes = root.pingedLanes;
232+
const warmLanes = root.warmLanes;
232233

233234
// Do not work on any idle work until all the non-idle work has finished,
234235
// even if the work is suspended.
235236
const nonIdlePendingLanes = pendingLanes & NonIdleLanes;
236237
if (nonIdlePendingLanes !== NoLanes) {
238+
// First check for fresh updates.
237239
const nonIdleUnblockedLanes = nonIdlePendingLanes & ~suspendedLanes;
238240
if (nonIdleUnblockedLanes !== NoLanes) {
239241
nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes);
240242
} else {
243+
// No fresh updates. Check if suspended work has been pinged.
241244
const nonIdlePingedLanes = nonIdlePendingLanes & pingedLanes;
242245
if (nonIdlePingedLanes !== NoLanes) {
243246
nextLanes = getHighestPriorityLanes(nonIdlePingedLanes);
247+
} else {
248+
// Nothing has been pinged. Check for lanes that need to be prewarmed.
249+
const lanesToPrewarm = nonIdlePendingLanes & ~warmLanes;
250+
if (lanesToPrewarm !== NoLanes) {
251+
nextLanes = getHighestPriorityLanes(lanesToPrewarm);
252+
}
244253
}
245254
}
246255
} else {
247256
// The only remaining work is Idle.
257+
// TODO: Idle isn't really used anywhere, and the thinking around
258+
// speculative rendering has evolved since this was implemented. Consider
259+
// removing until we've thought about this again.
260+
261+
// First check for fresh updates.
248262
const unblockedLanes = pendingLanes & ~suspendedLanes;
249263
if (unblockedLanes !== NoLanes) {
250264
nextLanes = getHighestPriorityLanes(unblockedLanes);
251265
} else {
266+
// No fresh updates. Check if suspended work has been pinged.
252267
if (pingedLanes !== NoLanes) {
253268
nextLanes = getHighestPriorityLanes(pingedLanes);
269+
} else {
270+
// Nothing has been pinged. Check for lanes that need to be prewarmed.
271+
const lanesToPrewarm = pendingLanes & ~warmLanes;
272+
if (lanesToPrewarm !== NoLanes) {
273+
nextLanes = getHighestPriorityLanes(lanesToPrewarm);
274+
}
254275
}
255276
}
256277
}
@@ -335,6 +356,21 @@ export function getNextLanesToFlushSync(
335356
return NoLanes;
336357
}
337358

359+
export function checkIfRootIsPrerendering(
360+
root: FiberRoot,
361+
renderLanes: Lanes,
362+
): boolean {
363+
const pendingLanes = root.pendingLanes;
364+
const suspendedLanes = root.suspendedLanes;
365+
const pingedLanes = root.pingedLanes;
366+
// Remove lanes that are suspended (but not pinged)
367+
const unblockedLanes = pendingLanes & ~(suspendedLanes & ~pingedLanes);
368+
369+
// If there are no unsuspended or pinged lanes, that implies that we're
370+
// performing a prerender.
371+
return (unblockedLanes & renderLanes) === 0;
372+
}
373+
338374
export function getEntangledLanes(root: FiberRoot, renderLanes: Lanes): Lanes {
339375
let entangledLanes = renderLanes;
340376

@@ -670,17 +706,27 @@ export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
670706
if (updateLane !== IdleLane) {
671707
root.suspendedLanes = NoLanes;
672708
root.pingedLanes = NoLanes;
709+
root.warmLanes = NoLanes;
673710
}
674711
}
675712

676713
export function markRootSuspended(
677714
root: FiberRoot,
678715
suspendedLanes: Lanes,
679716
spawnedLane: Lane,
717+
didSkipSuspendedSiblings: boolean,
680718
) {
681719
root.suspendedLanes |= suspendedLanes;
682720
root.pingedLanes &= ~suspendedLanes;
683721

722+
if (!didSkipSuspendedSiblings) {
723+
// Mark these lanes as warm so we know there's nothing else to work on.
724+
root.warmLanes |= suspendedLanes;
725+
} else {
726+
// Render unwound without attempting all the siblings. Do no mark the lanes
727+
// as warm. This will cause a prewarm render to be scheduled.
728+
}
729+
684730
// The suspended lanes are no longer CPU-bound. Clear their expiration times.
685731
const expirationTimes = root.expirationTimes;
686732
let lanes = suspendedLanes;
@@ -700,6 +746,9 @@ export function markRootSuspended(
700746

701747
export function markRootPinged(root: FiberRoot, pingedLanes: Lanes) {
702748
root.pingedLanes |= root.suspendedLanes & pingedLanes;
749+
// The data that just resolved could have unblocked additional children, which
750+
// will also need to be prewarmed if something suspends again.
751+
root.warmLanes &= ~pingedLanes;
703752
}
704753

705754
export function markRootFinished(
@@ -714,6 +763,7 @@ export function markRootFinished(
714763
// Let's try everything again
715764
root.suspendedLanes = NoLanes;
716765
root.pingedLanes = NoLanes;
766+
root.warmLanes = NoLanes;
717767

718768
root.expiredLanes &= remainingLanes;
719769

packages/react-reconciler/src/ReactFiberRoot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ function FiberRootNode(
7575
this.pendingLanes = NoLanes;
7676
this.suspendedLanes = NoLanes;
7777
this.pingedLanes = NoLanes;
78+
this.warmLanes = NoLanes;
7879
this.expiredLanes = NoLanes;
7980
this.finishedLanes = NoLanes;
8081
this.errorRecoveryDisabledLanes = NoLanes;

0 commit comments

Comments
 (0)