Skip to content

Commit

Permalink
Schedule prerender after something suspends
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite committed Sep 4, 2024
1 parent 8b4c54c commit f9275aa
Show file tree
Hide file tree
Showing 24 changed files with 1,080 additions and 135 deletions.
25 changes: 19 additions & 6 deletions packages/react-cache/src/__tests__/ReactCacheOld-test.internal.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,15 @@ describe('ReactCache', () => {
error = e;
}
expect(error.message).toMatch('Failed to load: Hi');
assertLog(['Promise rejected [Hi]', 'Error! [Hi]', 'Error! [Hi]']);
assertLog([
'Promise rejected [Hi]',
'Error! [Hi]',
'Error! [Hi]',

...(gate('enableSiblingPrerendering')
? ['Error! [Hi]', 'Error! [Hi]']
: []),
]);

// Should throw again on a subsequent read
root.render(<App />);
Expand Down Expand Up @@ -191,6 +199,7 @@ describe('ReactCache', () => {
}
});

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

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

jest.advanceTimersByTime(100);
assertLog(['Promise resolved [2]']);
await waitForAll([1, 2, 'Suspend! [3]']);
assertLog(['Promise resolved [2]', 'Promise resolved [3]']);
await waitForAll([1, 2, 3]);

await act(() => jest.advanceTimersByTime(100));
assertLog(['Promise resolved [3]', 1, 2, 3]);

expect(root).toMatchRenderedOutput('123');

// Render 1, 4, 5
Expand All @@ -234,6 +241,9 @@ describe('ReactCache', () => {
1,
4,
'Suspend! [5]',
1,
4,
'Suspend! [5]',
'Promise resolved [5]',
1,
4,
Expand Down Expand Up @@ -267,6 +277,9 @@ describe('ReactCache', () => {
1,
2,
'Suspend! [3]',
1,
2,
'Suspend! [3]',
'Promise resolved [3]',
1,
2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ describe('ReactDOMFiberAsync', () => {
// Because it suspended, it remains on the current path
expect(div.textContent).toBe('/path/a');
});
assertLog([]);
assertLog(gate('enableSiblingPrerendering') ? ['Suspend! [/path/b]'] : []);

await act(async () => {
resolvePromise();
Expand Down
61 changes: 54 additions & 7 deletions packages/react-dom/src/__tests__/ReactDOMForm-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,15 @@ describe('ReactDOMForm', () => {
// This should suspend because form actions are implicitly wrapped
// in startTransition.
await submit(formRef.current);
assertLog(['Pending...', 'Suspend! [Updated]', 'Loading...']);
assertLog([
'Pending...',
'Suspend! [Updated]',
'Loading...',

...(gate('enableSiblingPrerendering')
? ['Suspend! [Updated]', 'Loading...']
: []),
]);
expect(container.textContent).toBe('Pending...Initial');

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

// Update
await submit(formRef.current);
assertLog(['Pending...', 'Suspend! [Count: 1]', 'Loading...']);
assertLog([
'Pending...',
'Suspend! [Count: 1]',
'Loading...',

...(gate('enableSiblingPrerendering')
? ['Suspend! [Count: 1]', 'Loading...']
: []),
]);
expect(container.textContent).toBe('Pending...Count: 0');

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

// Update again
await submit(formRef.current);
assertLog(['Pending...', 'Suspend! [Count: 2]', 'Loading...']);
assertLog([
'Pending...',
'Suspend! [Count: 2]',
'Loading...',

...(gate('enableSiblingPrerendering')
? ['Suspend! [Count: 2]', 'Loading...']
: []),
]);
expect(container.textContent).toBe('Pending...Count: 1');

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

await act(() => resolveText('Wait'));
assertLog(['Suspend! [Updated]', 'Loading...']);
assertLog([
'Suspend! [Updated]',
'Loading...',

...(gate('enableSiblingPrerendering')
? ['Suspend! [Updated]', 'Loading...']
: []),
]);
expect(container.textContent).toBe('Pending...Initial');

await act(() => resolveText('Updated'));
Expand Down Expand Up @@ -1475,7 +1506,15 @@ describe('ReactDOMForm', () => {
// Now dispatch inside of a transition. This one does not trigger a
// loading state.
await act(() => startTransition(() => dispatch()));
assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']);
assertLog([
'Count: 1',
'Suspend! [Count: 2]',
'Loading...',

...(gate('enableSiblingPrerendering')
? ['Suspend! [Count: 2]', 'Loading...']
: []),
]);
expect(container.textContent).toBe('Count: 1');

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

const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog(['Suspend! [Count: 0]']);
assertLog([
'Suspend! [Count: 0]',

...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 0]'] : []),
]);
await act(() => resolveText('Count: 0'));
assertLog(['Count: 0']);

Expand All @@ -1508,7 +1551,11 @@ describe('ReactDOMForm', () => {
{withoutStack: true},
],
]);
assertLog(['Suspend! [Count: 1]']);
assertLog([
'Suspend! [Count: 1]',

...(gate('enableSiblingPrerendering') ? ['Suspend! [Count: 1]'] : []),
]);
expect(container.textContent).toBe('Count: 0');
});

Expand Down
50 changes: 50 additions & 0 deletions packages/react-reconciler/src/ReactFiberLane.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,28 +229,49 @@ export function getNextLanes(root: FiberRoot, wipLanes: Lanes): Lanes {

const suspendedLanes = root.suspendedLanes;
const pingedLanes = root.pingedLanes;
const warmLanes = root.warmLanes;

// Do not work on any idle work until all the non-idle work has finished,
// even if the work is suspended.
const nonIdlePendingLanes = pendingLanes & NonIdleLanes;
if (nonIdlePendingLanes !== NoLanes) {
// First check for fresh updates.
const nonIdleUnblockedLanes = nonIdlePendingLanes & ~suspendedLanes;
if (nonIdleUnblockedLanes !== NoLanes) {
nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes);
} else {
// No fresh updates. Check if suspended work has been pinged.
const nonIdlePingedLanes = nonIdlePendingLanes & pingedLanes;
if (nonIdlePingedLanes !== NoLanes) {
nextLanes = getHighestPriorityLanes(nonIdlePingedLanes);
} else {
// Nothing has been pinged. Check for lanes that need to be prewarmed.
const lanesToPrewarm = nonIdlePendingLanes & ~warmLanes;
if (lanesToPrewarm !== NoLanes) {
nextLanes = getHighestPriorityLanes(lanesToPrewarm);
}
}
}
} else {
// The only remaining work is Idle.
// TODO: Idle isn't really used anywhere, and the thinking around
// speculative rendering has evolved since this was implemented. Consider
// removing until we've thought about this again.

// First check for fresh updates.
const unblockedLanes = pendingLanes & ~suspendedLanes;
if (unblockedLanes !== NoLanes) {
nextLanes = getHighestPriorityLanes(unblockedLanes);
} else {
// No fresh updates. Check if suspended work has been pinged.
if (pingedLanes !== NoLanes) {
nextLanes = getHighestPriorityLanes(pingedLanes);
} else {
// Nothing has been pinged. Check for lanes that need to be prewarmed.
const lanesToPrewarm = pendingLanes & ~warmLanes;
if (lanesToPrewarm !== NoLanes) {
nextLanes = getHighestPriorityLanes(lanesToPrewarm);
}
}
}
}
Expand Down Expand Up @@ -335,6 +356,21 @@ export function getNextLanesToFlushSync(
return NoLanes;
}

export function checkIfRootIsPrerendering(
root: FiberRoot,
renderLanes: Lanes,
): boolean {
const pendingLanes = root.pendingLanes;
const suspendedLanes = root.suspendedLanes;
const pingedLanes = root.pingedLanes;
// Remove lanes that are suspended (but not pinged)
const unblockedLanes = pendingLanes & ~(suspendedLanes & ~pingedLanes);

// If there are no unsuspended or pinged lanes, that implies that we're
// performing a prerender.
return (unblockedLanes & renderLanes) === 0;
}

export function getEntangledLanes(root: FiberRoot, renderLanes: Lanes): Lanes {
let entangledLanes = renderLanes;

Expand Down Expand Up @@ -670,17 +706,27 @@ export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
if (updateLane !== IdleLane) {
root.suspendedLanes = NoLanes;
root.pingedLanes = NoLanes;
root.warmLanes = NoLanes;
}
}

export function markRootSuspended(
root: FiberRoot,
suspendedLanes: Lanes,
spawnedLane: Lane,
didSkipSuspendedSiblings: boolean,
) {
root.suspendedLanes |= suspendedLanes;
root.pingedLanes &= ~suspendedLanes;

if (!didSkipSuspendedSiblings) {
// Mark these lanes as warm so we know there's nothing else to work on.
root.warmLanes |= suspendedLanes;
} else {
// Render unwound without attempting all the siblings. Do no mark the lanes
// as warm. This will cause a prewarm render to be scheduled.
}

// The suspended lanes are no longer CPU-bound. Clear their expiration times.
const expirationTimes = root.expirationTimes;
let lanes = suspendedLanes;
Expand All @@ -700,6 +746,9 @@ export function markRootSuspended(

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

export function markRootFinished(
Expand All @@ -714,6 +763,7 @@ export function markRootFinished(
// Let's try everything again
root.suspendedLanes = NoLanes;
root.pingedLanes = NoLanes;
root.warmLanes = NoLanes;

root.expiredLanes &= remainingLanes;

Expand Down
1 change: 1 addition & 0 deletions packages/react-reconciler/src/ReactFiberRoot.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function FiberRootNode(
this.pendingLanes = NoLanes;
this.suspendedLanes = NoLanes;
this.pingedLanes = NoLanes;
this.warmLanes = NoLanes;
this.expiredLanes = NoLanes;
this.finishedLanes = NoLanes;
this.errorRecoveryDisabledLanes = NoLanes;
Expand Down
Loading

0 comments on commit f9275aa

Please sign in to comment.