Skip to content

Commit b9755e7

Browse files
committed
[WIP] Schedule prerender after something suspends
Part of a series of PRs related to sibling prerendering. I have the implementation working locally, but still working on splitting it up into a reasonable sequence of steps so we can land it incrementally. There's likely to be some regressions due to the scope of the change, so I've also done my best to keep the change behind a feature flag. Opening this in draft mode while I continue to work on updating the test suite, which requires many changes. --- 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 62c8c03 commit b9755e7

24 files changed

+1027
-128
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 === 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)