Skip to content

Commit c4af3b5

Browse files
committed
Suspensey commits in prerendered trees
Prerendering a tree (i.e. with Offscreen) should not suspend the commit phase, because the content is not yet visible. However, when revealing a prerendered tree, we should suspend the commit phase if resources in the prerendered tree haven't finished loading yet. To do this properly, we need to visit all the visible nodes in the tree that might possibly suspend. This includes nodes in the current tree, because even though they were already "mounted", the resources might not have loaded yet, because we didn't suspend when it was prerendered. We will need to add this capability to the Offscreen component's "manual" mode, too. Something like a `ready()` method that returns a promise that resolves when the tree has fully loaded.
1 parent 0191007 commit c4af3b5

File tree

6 files changed

+160
-27
lines changed

6 files changed

+160
-27
lines changed

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

+18-5
Original file line numberDiff line numberDiff line change
@@ -2939,7 +2939,6 @@ body {
29392939
);
29402940
});
29412941

2942-
// @gate TODO
29432942
it('can interrupt a suspended commit with a new update', async () => {
29442943
function App({children}) {
29452944
return (
@@ -2949,9 +2948,13 @@ body {
29492948
);
29502949
}
29512950
const root = ReactDOMClient.createRoot(document);
2951+
2952+
// Do an initial render. This means subsequent insertions will suspend,
2953+
// unless they are wrapped inside a fresh Suspense boundary.
29522954
root.render(<App />);
29532955
await waitForAll([]);
29542956

2957+
// Insert a stylesheet. This will suspend because it's a transition.
29552958
React.startTransition(() => {
29562959
root.render(
29572960
<App>
@@ -2961,6 +2964,7 @@ body {
29612964
);
29622965
});
29632966
await waitForAll([]);
2967+
// Although the commit suspended, a preload was inserted.
29642968
expect(getMeaningfulChildren(document)).toEqual(
29652969
<html>
29662970
<head>
@@ -2970,6 +2974,9 @@ body {
29702974
</html>,
29712975
);
29722976

2977+
// Before the stylesheet has loaded, do an urgent update. This will insert a
2978+
// different stylesheet, and cancel the first one. This stylesheet will not
2979+
// suspend, even though it hasn't loaded, because it's an urgent update.
29732980
root.render(
29742981
<App>
29752982
hello2
@@ -2978,6 +2985,9 @@ body {
29782985
</App>,
29792986
);
29802987
await waitForAll([]);
2988+
2989+
// The bar stylesheet was inserted. There's still a "foo" preload, even
2990+
// though that update was superseded.
29812991
expect(getMeaningfulChildren(document)).toEqual(
29822992
<html>
29832993
<head>
@@ -2989,9 +2999,10 @@ body {
29892999
</html>,
29903000
);
29913001

2992-
// Even though foo was preloaded we don't see the stylesheet insert because the commit was cancelled.
2993-
// If we do a followup render that tries to recommit that resource it will insert right away because
2994-
// the preload is already loaded
3002+
// When "foo" finishes loading, nothing happens, because "foo" was not
3003+
// included in the last root update. However, if we insert "foo" again
3004+
// later, it should immediately commit without suspending, because it's
3005+
// been preloaded.
29953006
loadPreloads(['foo']);
29963007
assertLog(['load preload: foo']);
29973008
expect(getMeaningfulChildren(document)).toEqual(
@@ -3005,6 +3016,7 @@ body {
30053016
</html>,
30063017
);
30073018

3019+
// Now insert "foo" again.
30083020
React.startTransition(() => {
30093021
root.render(
30103022
<App>
@@ -3015,6 +3027,7 @@ body {
30153027
);
30163028
});
30173029
await waitForAll([]);
3030+
// Commits without suspending because "foo" was preloaded.
30183031
expect(getMeaningfulChildren(document)).toEqual(
30193032
<html>
30203033
<head>
@@ -3023,7 +3036,7 @@ body {
30233036
<link rel="preload" href="foo" as="style" />
30243037
<link rel="preload" href="bar" as="style" />
30253038
</head>
3026-
<body>hello2</body>
3039+
<body>hello3</body>
30273040
</html>,
30283041
);
30293042

packages/react-reconciler/src/ReactFiberCommitWork.js

+41-6
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ import {
9494
LayoutMask,
9595
PassiveMask,
9696
Visibility,
97-
SuspenseyCommit,
97+
ShouldSuspendCommit,
98+
MaySuspendCommit,
9899
} from './ReactFiberFlags';
99100
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
100101
import {
@@ -4065,12 +4066,23 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
40654066
resetCurrentDebugFiberInDEV();
40664067
}
40674068

4069+
// If we're inside a brand new tree, or a tree that was already visible, then we
4070+
// should only suspend host components that have a ShouldSuspendCommit flag.
4071+
// Components without it haven't changed since the last commit, so we can skip
4072+
// over those.
4073+
//
4074+
// When we enter a tree that is being revealed (going from hidden -> visible),
4075+
// we need to suspend _any_ component that _may_ suspend. Even if they're
4076+
// already in the "current" tree. Because their visibility has changed, the
4077+
// browser may not have prerendered them yet. So we check the MaySuspendCommit
4078+
// flag instead.
4079+
let suspenseyCommitFlag = ShouldSuspendCommit;
40684080
export function accumulateSuspenseyCommit(finishedWork: Fiber): void {
40694081
accumulateSuspenseyCommitOnFiber(finishedWork);
40704082
}
40714083

40724084
function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void {
4073-
if (parentFiber.subtreeFlags & SuspenseyCommit) {
4085+
if (parentFiber.subtreeFlags & suspenseyCommitFlag) {
40744086
let child = parentFiber.child;
40754087
while (child !== null) {
40764088
accumulateSuspenseyCommitOnFiber(child);
@@ -4083,7 +4095,7 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
40834095
switch (fiber.tag) {
40844096
case HostHoistable: {
40854097
recursivelyAccumulateSuspenseyCommit(fiber);
4086-
if (fiber.flags & SuspenseyCommit) {
4098+
if (fiber.flags & suspenseyCommitFlag) {
40874099
if (fiber.memoizedState !== null) {
40884100
suspendResource(
40894101
// This should always be set by visiting HostRoot first
@@ -4101,7 +4113,7 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
41014113
}
41024114
case HostComponent: {
41034115
recursivelyAccumulateSuspenseyCommit(fiber);
4104-
if (fiber.flags & SuspenseyCommit) {
4116+
if (fiber.flags & suspenseyCommitFlag) {
41054117
const type = fiber.type;
41064118
const props = fiber.memoizedProps;
41074119
suspendInstance(type, props);
@@ -4117,10 +4129,33 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
41174129

41184130
recursivelyAccumulateSuspenseyCommit(fiber);
41194131
currentHoistableRoot = previousHoistableRoot;
4120-
break;
4132+
} else {
4133+
recursivelyAccumulateSuspenseyCommit(fiber);
41214134
}
4135+
break;
4136+
}
4137+
case OffscreenComponent: {
4138+
const isHidden = (fiber.memoizedState: OffscreenState | null) !== null;
4139+
if (isHidden) {
4140+
// Don't suspend in hidden trees
4141+
} else {
4142+
const current = fiber.alternate;
4143+
const wasHidden =
4144+
current !== null &&
4145+
(current.memoizedState: OffscreenState | null) !== null;
4146+
if (wasHidden) {
4147+
// This tree is being revealed. Visit all newly visible suspensey
4148+
// instances, even if they're in the current tree.
4149+
const prevFlags = suspenseyCommitFlag;
4150+
suspenseyCommitFlag = MaySuspendCommit;
4151+
recursivelyAccumulateSuspenseyCommit(fiber);
4152+
suspenseyCommitFlag = prevFlags;
4153+
} else {
4154+
recursivelyAccumulateSuspenseyCommit(fiber);
4155+
}
4156+
}
4157+
break;
41224158
}
4123-
// eslint-disable-next-line-no-fallthrough
41244159
default: {
41254160
recursivelyAccumulateSuspenseyCommit(fiber);
41264161
}

packages/react-reconciler/src/ReactFiberCompleteWork.js

+16-10
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ import {
8787
MutationMask,
8888
Passive,
8989
ForceClientRender,
90-
SuspenseyCommit,
90+
MaySuspendCommit,
9191
ScheduleRetry,
92+
ShouldSuspendCommit,
9293
} from './ReactFiberFlags';
9394

9495
import {
@@ -154,6 +155,7 @@ import {
154155
getRenderTargetTime,
155156
getWorkInProgressTransitions,
156157
shouldRemainOnPreviousScreen,
158+
getWorkInProgressRootRenderLanes,
157159
} from './ReactFiberWorkLoop';
158160
import {
159161
OffscreenLane,
@@ -534,7 +536,7 @@ function preloadInstanceAndSuspendIfNeeded(
534536
// return true, but if the renderer is reasonably confident that the
535537
// underlying resource won't be evicted, it can return false as a
536538
// performance optimization.
537-
workInProgress.flags &= ~SuspenseyCommit;
539+
workInProgress.flags &= ~MaySuspendCommit;
538540
return;
539541
}
540542

@@ -543,7 +545,7 @@ function preloadInstanceAndSuspendIfNeeded(
543545
// currently. We use this when revealing a prerendered tree, because
544546
// even though the tree has "mounted", its resources might not have
545547
// loaded yet.
546-
workInProgress.flags |= SuspenseyCommit;
548+
workInProgress.flags |= MaySuspendCommit;
547549

548550
// Check if we're rendering at a "non-urgent" priority. This is the same
549551
// check that `useDeferredValue` does to determine whether it needs to
@@ -558,7 +560,8 @@ function preloadInstanceAndSuspendIfNeeded(
558560
// a previous render.
559561
// TODO: We may decide to expose a way to force a fallback even during a
560562
// sync update.
561-
if (!includesOnlyNonUrgentLanes(renderLanes)) {
563+
const rootRenderLanes = getWorkInProgressRootRenderLanes();
564+
if (!includesOnlyNonUrgentLanes(rootRenderLanes)) {
562565
// This is an urgent render. Don't suspend or show a fallback. Also,
563566
// there's no need to preload, because we're going to commit this
564567
// synchronously anyway.
@@ -572,7 +575,9 @@ function preloadInstanceAndSuspendIfNeeded(
572575
const isReady = preloadInstance(type, props);
573576
if (!isReady) {
574577
if (shouldRemainOnPreviousScreen()) {
575-
// It's OK to suspend. Continue rendering.
578+
// It's OK to suspend. Mark the fiber so we know to suspend before the
579+
// commit phase. Then continue rendering.
580+
workInProgress.flags |= ShouldSuspendCommit;
576581
} else {
577582
// Trigger a fallback rather than block the render.
578583
suspendCommit();
@@ -590,19 +595,20 @@ function preloadResourceAndSuspendIfNeeded(
590595
) {
591596
// This is a fork of preloadInstanceAndSuspendIfNeeded, but for resources.
592597
if (!mayResourceSuspendCommit(resource)) {
593-
workInProgress.flags &= ~SuspenseyCommit;
598+
workInProgress.flags &= ~MaySuspendCommit;
594599
return;
595600
}
596601

597-
workInProgress.flags |= SuspenseyCommit;
602+
workInProgress.flags |= MaySuspendCommit;
598603

599-
if (!includesOnlyNonUrgentLanes(renderLanes)) {
604+
const rootRenderLanes = getWorkInProgressRootRenderLanes();
605+
if (!includesOnlyNonUrgentLanes(rootRenderLanes)) {
600606
// This is an urgent render. Don't suspend or show a fallback.
601607
} else {
602608
const isReady = preloadResource(resource);
603609
if (!isReady) {
604610
if (shouldRemainOnPreviousScreen()) {
605-
// It's OK to suspend. Continue rendering.
611+
workInProgress.flags |= ShouldSuspendCommit;
606612
} else {
607613
suspendCommit();
608614
}
@@ -1129,7 +1135,7 @@ function completeWork(
11291135

11301136
bubbleProperties(workInProgress);
11311137
if (nextResource === currentResource) {
1132-
workInProgress.flags &= ~SuspenseyCommit;
1138+
workInProgress.flags &= ~MaySuspendCommit;
11331139
} else {
11341140
preloadResourceAndSuspendIfNeeded(
11351141
workInProgress,

packages/react-reconciler/src/ReactFiberFlags.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ export const Passive = /* */ 0b0000000000000000100000000000
3636
export const Visibility = /* */ 0b0000000000000010000000000000;
3737
export const StoreConsistency = /* */ 0b0000000000000100000000000000;
3838

39-
// It's OK to reuse this bit because these flags are mutually exclusive for
39+
// It's OK to reuse these bits because these flags are mutually exclusive for
4040
// different fiber types. We should really be doing this for as many flags as
4141
// possible, because we're about to run out of bits.
4242
export const ScheduleRetry = StoreConsistency;
43+
export const ShouldSuspendCommit = Visibility;
4344

4445
export const LifecycleEffectMask =
4546
Passive | Update | Callback | Ref | Snapshot | StoreConsistency;
@@ -63,7 +64,7 @@ export const Forked = /* */ 0b0000000100000000000000000000
6364
export const RefStatic = /* */ 0b0000001000000000000000000000;
6465
export const LayoutStatic = /* */ 0b0000010000000000000000000000;
6566
export const PassiveStatic = /* */ 0b0000100000000000000000000000;
66-
export const SuspenseyCommit = /* */ 0b0001000000000000000000000000;
67+
export const MaySuspendCommit = /* */ 0b0001000000000000000000000000;
6768

6869
// Flag used to identify newly inserted fibers. It isn't reset after commit unlike `Placement`.
6970
export const PlacementDEV = /* */ 0b0010000000000000000000000000;
@@ -103,4 +104,4 @@ export const PassiveMask = Passive | Visibility | ChildDeletion;
103104
// This allows certain concepts to persist without recalculating them,
104105
// e.g. whether a subtree contains passive effects or portals.
105106
export const StaticMask =
106-
LayoutStatic | PassiveStatic | RefStatic | SuspenseyCommit;
107+
LayoutStatic | PassiveStatic | RefStatic | MaySuspendCommit;

packages/react-reconciler/src/ReactFiberWorkLoop.js

+14-2
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ import {
169169
addTransitionToLanesMap,
170170
getTransitionsForLanes,
171171
includesOnlyNonUrgentLanes,
172+
includesSomeLane,
173+
OffscreenLane,
172174
} from './ReactFiberLane';
173175
import {
174176
DiscreteEventPriority,
@@ -1997,17 +1999,27 @@ export function shouldRemainOnPreviousScreen(): boolean {
19971999
// parent Suspense boundary, even outside a transition. Somehow. Otherwise,
19982000
// an uncached promise can fall into an infinite loop.
19992001
} else {
2000-
if (includesOnlyRetries(workInProgressRootRenderLanes)) {
2002+
if (
2003+
includesOnlyRetries(workInProgressRootRenderLanes) ||
2004+
// In this context, an OffscreenLane counts as a Retry
2005+
// TODO: It's become increasingly clear that Retries and Offscreen are
2006+
// deeply connected. They probably can be unified further.
2007+
includesSomeLane(workInProgressRootRenderLanes, OffscreenLane)
2008+
) {
20012009
// During a retry, we can suspend rendering if the nearest Suspense boundary
20022010
// is the boundary of the "shell", because we're guaranteed not to block
20032011
// any new content from appearing.
2012+
//
2013+
// The reason we must check if this is a retry is because it guarantees
2014+
// that suspending the work loop won't block an actual update, because
2015+
// retries don't "update" anything; they fill in fallbacks that were left
2016+
// behind by a previous transition.
20042017
return handler === getShellBoundary();
20052018
}
20062019
}
20072020

20082021
// For all other Lanes besides Transitions and Retries, we should not wait
20092022
// for the data to load.
2010-
// TODO: We should wait during Offscreen prerendering, too.
20112023
return false;
20122024
}
20132025

0 commit comments

Comments
 (0)