Skip to content

Commit 7f31b6f

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 0131d0c commit 7f31b6f

File tree

5 files changed

+151
-21
lines changed

5 files changed

+151
-21
lines changed

packages/react-reconciler/src/ReactFiberCommitWork.js

+47-5
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 {
@@ -4064,21 +4065,62 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
40644065
resetCurrentDebugFiberInDEV();
40654066
}
40664067

4068+
// If we're inside a brand new tree, or a tree that was already visible, then we
4069+
// should only suspend host components that have a ShouldSuspendCommit flag.
4070+
// Components without it haven't changed since the last commit, so we can skip
4071+
// over those.
4072+
//
4073+
// When we enter a tree that is being revealed (going from hidden -> visible),
4074+
// we need to suspend _any_ component that _may_ suspend. Even if they're
4075+
// already in the "current" tree. Because their visibility has changed, the
4076+
// browser may not have prerendered them yet. So we check the MaySuspendCommit
4077+
// flag instead.
4078+
let suspenseyCommitFlag = ShouldSuspendCommit;
40674079
export function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void {
4068-
if (parentFiber.subtreeFlags & SuspenseyCommit) {
4080+
if (parentFiber.subtreeFlags & suspenseyCommitFlag) {
40694081
let child = parentFiber.child;
40704082
while (child !== null) {
4071-
recursivelyAccumulateSuspenseyCommit(child);
40724083
switch (child.tag) {
4084+
case OffscreenComponent: {
4085+
const isHidden =
4086+
(child.memoizedState: OffscreenState | null) !== null;
4087+
if (isHidden) {
4088+
// Don't suspend in hidden trees
4089+
} else {
4090+
const current = child.alternate;
4091+
const wasHidden =
4092+
current !== null &&
4093+
(current.memoizedState: OffscreenState | null) !== null;
4094+
if (wasHidden) {
4095+
// This tree is being revealed. Visit all newly visible suspensey
4096+
// instances, even if they're in the current tree.
4097+
const prevFlags = suspenseyCommitFlag;
4098+
suspenseyCommitFlag = MaySuspendCommit;
4099+
recursivelyAccumulateSuspenseyCommit(child);
4100+
suspenseyCommitFlag = prevFlags;
4101+
} else {
4102+
recursivelyAccumulateSuspenseyCommit(child);
4103+
}
4104+
}
4105+
break;
4106+
}
40734107
case HostComponent:
40744108
case HostHoistable: {
4075-
if (child.flags & SuspenseyCommit) {
4109+
recursivelyAccumulateSuspenseyCommit(child);
4110+
if (child.flags & suspenseyCommitFlag) {
40764111
const type = child.type;
40774112
const props = child.memoizedProps;
4078-
suspendInstance(type, props);
4113+
try {
4114+
suspendInstance(type, props);
4115+
} catch (error) {
4116+
captureCommitPhaseError(child, child.return, error);
4117+
}
40794118
}
40804119
break;
40814120
}
4121+
default: {
4122+
recursivelyAccumulateSuspenseyCommit(child);
4123+
}
40824124
}
40834125
child = child.sibling;
40844126
}

packages/react-reconciler/src/ReactFiberCompleteWork.js

+19-10
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ import {
8888
Incomplete,
8989
ShouldCapture,
9090
ForceClientRender,
91-
SuspenseyCommit,
91+
MaySuspendCommit,
9292
ScheduleRetry,
93+
ShouldSuspendCommit,
9394
} from './ReactFiberFlags';
9495

9596
import {
@@ -153,6 +154,7 @@ import {
153154
getRenderTargetTime,
154155
getWorkInProgressTransitions,
155156
shouldRemainOnPreviousScreen,
157+
getWorkInProgressRootRenderLanes,
156158
} from './ReactFiberWorkLoop';
157159
import {
158160
OffscreenLane,
@@ -529,26 +531,31 @@ function preloadInstanceAndSuspendIfNeeded(
529531
// safest thing to do is for maySuspendCommit to always return true, but
530532
// if the renderer is reasonably confident that the underlying resource
531533
// won't be evicted, it can return false as a performance optimization.
532-
workInProgress.flags &= ~SuspenseyCommit;
534+
workInProgress.flags &= ~MaySuspendCommit;
533535
return;
534536
}
535537

536-
// Mark this fiber with a flag. We use this right before the commit phase to
537-
// find all the fibers that might need to suspend the commit. In the future
538-
// we'll also use it when revealing a hidden tree. It gets set even if we
539-
// don't end up suspending this particular commit, because if this tree ever
540-
// becomes hidden, we might want to suspend before revealing it again.
541-
workInProgress.flags |= SuspenseyCommit;
538+
// Mark this fiber with a flag. This gets set on all host components that
539+
// might possibly suspend, even if they don't need to suspend currently. We
540+
// use this when revealing a prerendered tree, because even though the tree
541+
// has "mounted", its resources might not have loaded yet.
542+
workInProgress.flags |= MaySuspendCommit;
542543

543544
// Check if we're rendering at a "non-urgent" priority. This is the same
544545
// check that `useDeferredValue` does to determine whether it needs to
545546
// defer. This is partly for gradual adoption purposes (i.e. shouldn't start
546547
// suspending until you opt in with startTransition or Suspense) but it
547548
// also happens to be the desired behavior for the concrete use cases we've
548549
// thought of so far, like CSS loading, fonts, images, etc.
550+
//
551+
// We check the "root" render lanes here rather than the "subtree" render
552+
// because during a retry or offscreen prerender, the "subtree" render
553+
// lanes may include additional "base" lanes that were deferred during
554+
// a previous render.
549555
// TODO: We may decide to expose a way to force a fallback even during a
550556
// sync update.
551-
if (!includesOnlyNonUrgentLanes(renderLanes)) {
557+
const rootRenderLanes = getWorkInProgressRootRenderLanes();
558+
if (!includesOnlyNonUrgentLanes(rootRenderLanes)) {
552559
// This is an urgent render. Don't suspend or show a fallback. Also,
553560
// there's no need to preload, because we're going to commit this
554561
// synchronously anyway.
@@ -562,7 +569,9 @@ function preloadInstanceAndSuspendIfNeeded(
562569
const isReady = preloadInstance(type, props);
563570
if (!isReady) {
564571
if (shouldRemainOnPreviousScreen()) {
565-
// It's OK to suspend. Continue rendering.
572+
// It's OK to suspend. Mark the fiber so we know to suspend before the
573+
// commit phase. Then continue rendering.
574+
workInProgress.flags |= ShouldSuspendCommit;
566575
} else {
567576
// Trigger a fallback rather than block the render.
568577
suspendCommit();

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

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

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

+67-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ let ReactNoop;
44
let resolveSuspenseyThing;
55
let getSuspenseyThingStatus;
66
let Suspense;
7+
let Offscreen;
78
let SuspenseList;
9+
let useMemo;
810
let Scheduler;
911
let act;
1012
let assertLog;
@@ -18,10 +20,11 @@ describe('ReactSuspenseyCommitPhase', () => {
1820
ReactNoop = require('react-noop-renderer');
1921
Scheduler = require('scheduler');
2022
Suspense = React.Suspense;
21-
SuspenseList = React.SuspenseList;
2223
if (gate(flags => flags.enableSuspenseList)) {
2324
SuspenseList = React.SuspenseList;
2425
}
26+
Offscreen = React.unstable_Offscreen;
27+
useMemo = React.useMemo;
2528
startTransition = React.startTransition;
2629
resolveSuspenseyThing = ReactNoop.resolveSuspenseyThing;
2730
getSuspenseyThingStatus = ReactNoop.getSuspenseyThingStatus;
@@ -279,4 +282,67 @@ describe('ReactSuspenseyCommitPhase', () => {
279282
</>,
280283
);
281284
});
285+
286+
// @gate enableOffscreen
287+
test("host instances don't suspend during prerendering, but do suspend when they are revealed", async () => {
288+
function More() {
289+
Scheduler.log('More');
290+
return <SuspenseyImage src="More" />;
291+
}
292+
293+
function Details({showMore}) {
294+
Scheduler.log('Details');
295+
const more = useMemo(() => <More />, []);
296+
return (
297+
<>
298+
<div>Main Content</div>
299+
<Offscreen mode={showMore ? 'visible' : 'hidden'}>{more}</Offscreen>
300+
</>
301+
);
302+
}
303+
304+
const root = ReactNoop.createRoot();
305+
await act(async () => {
306+
root.render(<Details showMore={false} />);
307+
// First render the outer component, without the hidden content
308+
await waitForPaint(['Details']);
309+
expect(root).toMatchRenderedOutput(<div>Main Content</div>);
310+
});
311+
// Then prerender the hidden content.
312+
assertLog(['More', 'Image requested [More]']);
313+
// The prerender should commit even though the image is still loading,
314+
// because it's hidden.
315+
expect(root).toMatchRenderedOutput(
316+
<>
317+
<div>Main Content</div>
318+
<suspensey-thing hidden={true} src="More" />
319+
</>,
320+
);
321+
322+
// Reveal the prerendered content. This update should suspend, because the
323+
// image that is being revealed still hasn't loaded.
324+
await act(() => {
325+
startTransition(() => {
326+
root.render(<Details showMore={true} />);
327+
});
328+
});
329+
// The More component should not render again, because it was memoized,
330+
// and it already prerendered.
331+
assertLog(['Details']);
332+
expect(root).toMatchRenderedOutput(
333+
<>
334+
<div>Main Content</div>
335+
<suspensey-thing hidden={true} src="More" />
336+
</>,
337+
);
338+
339+
// Now resolve the image. The transition should complete.
340+
resolveSuspenseyThing('More');
341+
expect(root).toMatchRenderedOutput(
342+
<>
343+
<div>Main Content</div>
344+
<suspensey-thing src="More" />
345+
</>,
346+
);
347+
});
282348
});

0 commit comments

Comments
 (0)