Skip to content

Commit c2d6552

Browse files
authored
Unify use and renderDidSuspendDelayIfPossible implementations (#25922)
When unwrapping a promise with `use`, we sometimes suspend the work loop from rendering anything else until the data has resolved. This is different from how Suspense works in the old throw-a-promise world, where rather than suspend rendering midway through the render phase, we prepare a fallback and block the commit at the end, if necessary; however, the logic for determining whether it's OK to block is the same. The implementation is only incidentally different because it happens in two different parts of the code. This means for `use`, we end up doing the same checks twice, which is wasteful in terms of computation, but also introduces a risk that the logic will accidentally diverge. This unifies the implementation by moving it into the SuspenseContext module. Most of the logic for deciding whether to suspend is already performed in the begin phase of SuspenseComponent, so it makes sense to store that information on the stack rather than recompute it on demand. The way I've chosen to model this is to track whether the work loop is rendering inside the "shell" of the tree. The shell is defined as the part of the tree that's visible in the current UI. Once we enter a new Suspense boundary (or a hidden Offscreen boundary, which acts a Suspense boundary), we're no longer in the shell. This is already how Suspense behavior was modeled in terms of UX, so using this concept directly in the implementation turns out to result in less code than before. For the most part, this is purely an internal refactor, though it does fix a bug in the `use` implementation related to nested Suspense boundaries. I wouldn't be surprised if it happens to fix other bugs that we haven't yet discovered, especially around Offscreen. I'll add more tests as I think of them.
1 parent 48274a4 commit c2d6552

6 files changed

+297
-132
lines changed

packages/react-reconciler/src/ReactFiberCompleteWork.js

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,6 @@ import {
126126
setShallowSuspenseListContext,
127127
ForceSuspenseFallback,
128128
setDefaultShallowSuspenseListContext,
129-
isBadSuspenseFallback,
130129
} from './ReactFiberSuspenseContext';
131130
import {popHiddenContext} from './ReactFiberHiddenContext';
132131
import {findFirstSuspended} from './ReactFiberSuspenseComponent';
@@ -148,8 +147,6 @@ import {
148147
upgradeHydrationErrorsToRecoverable,
149148
} from './ReactFiberHydrationContext';
150149
import {
151-
renderDidSuspend,
152-
renderDidSuspendDelayIfPossible,
153150
renderHasNotSuspendedYet,
154151
getRenderTargetTime,
155152
getWorkInProgressTransitions,
@@ -1257,24 +1254,6 @@ function completeWork(
12571254
if (nextDidTimeout) {
12581255
const offscreenFiber: Fiber = (workInProgress.child: any);
12591256
offscreenFiber.flags |= Visibility;
1260-
1261-
// TODO: This will still suspend a synchronous tree if anything
1262-
// in the concurrent tree already suspended during this render.
1263-
// This is a known bug.
1264-
if ((workInProgress.mode & ConcurrentMode) !== NoMode) {
1265-
// TODO: Move this back to throwException because this is too late
1266-
// if this is a large tree which is common for initial loads. We
1267-
// don't know if we should restart a render or not until we get
1268-
// this marker, and this is too late.
1269-
// If this render already had a ping or lower pri updates,
1270-
// and this is the first time we know we're going to suspend we
1271-
// should be able to immediately restart from within throwException.
1272-
if (isBadSuspenseFallback(current, newProps)) {
1273-
renderDidSuspendDelayIfPossible();
1274-
} else {
1275-
renderDidSuspend();
1276-
}
1277-
}
12781257
}
12791258
}
12801259

packages/react-reconciler/src/ReactFiberSuspenseContext.js

Lines changed: 80 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -9,94 +9,86 @@
99

1010
import type {Fiber} from './ReactInternalTypes';
1111
import type {StackCursor} from './ReactFiberStack';
12-
import type {SuspenseState, SuspenseProps} from './ReactFiberSuspenseComponent';
12+
import type {SuspenseProps, SuspenseState} from './ReactFiberSuspenseComponent';
13+
import type {OffscreenState} from './ReactFiberOffscreenComponent';
1314

1415
import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags';
1516
import {createCursor, push, pop} from './ReactFiberStack';
1617
import {isCurrentTreeHidden} from './ReactFiberHiddenContext';
17-
import {SuspenseComponent, OffscreenComponent} from './ReactWorkTags';
18+
import {OffscreenComponent} from './ReactWorkTags';
1819

1920
// The Suspense handler is the boundary that should capture if something
2021
// suspends, i.e. it's the nearest `catch` block on the stack.
2122
const suspenseHandlerStackCursor: StackCursor<Fiber | null> = createCursor(
2223
null,
2324
);
2425

25-
function shouldAvoidedBoundaryCapture(
26-
workInProgress: Fiber,
27-
handlerOnStack: Fiber,
28-
props: any,
29-
): boolean {
30-
if (enableSuspenseAvoidThisFallback) {
31-
// If the parent is already showing content, and we're not inside a hidden
32-
// tree, then we should show the avoided fallback.
33-
if (handlerOnStack.alternate !== null && !isCurrentTreeHidden()) {
34-
return true;
35-
}
36-
37-
// If the handler on the stack is also an avoided boundary, then we should
38-
// favor this inner one.
39-
if (
40-
handlerOnStack.tag === SuspenseComponent &&
41-
handlerOnStack.memoizedProps.unstable_avoidThisFallback === true
42-
) {
43-
return true;
44-
}
45-
46-
// If this avoided boundary is dehydrated, then it should capture.
47-
const suspenseState: SuspenseState | null = workInProgress.memoizedState;
48-
if (suspenseState !== null && suspenseState.dehydrated !== null) {
49-
return true;
50-
}
51-
}
52-
53-
// If none of those cases apply, then we should avoid this fallback and show
54-
// the outer one instead.
55-
return false;
56-
}
57-
58-
export function isBadSuspenseFallback(
59-
current: Fiber | null,
60-
nextProps: SuspenseProps,
61-
): boolean {
62-
// Check if this is a "bad" fallback state or a good one. A bad fallback state
63-
// is one that we only show as a last resort; if this is a transition, we'll
64-
// block it from displaying, and wait for more data to arrive.
65-
if (current !== null) {
66-
const prevState: SuspenseState = current.memoizedState;
67-
const isShowingFallback = prevState !== null;
68-
if (!isShowingFallback && !isCurrentTreeHidden()) {
69-
// It's bad to switch to a fallback if content is already visible
70-
return true;
71-
}
72-
}
73-
74-
if (
75-
enableSuspenseAvoidThisFallback &&
76-
nextProps.unstable_avoidThisFallback === true
77-
) {
78-
// Experimental: Some fallbacks are always bad
79-
return true;
80-
}
81-
82-
return false;
26+
// Represents the outermost boundary that is not visible in the current tree.
27+
// Everything above this is the "shell". When this is null, it means we're
28+
// rendering in the shell of the app. If it's non-null, it means we're rendering
29+
// deeper than the shell, inside a new tree that wasn't already visible.
30+
//
31+
// The main way we use this concept is to determine whether showing a fallback
32+
// would result in a desirable or undesirable loading state. Activing a fallback
33+
// in the shell is considered an undersirable loading state, because it would
34+
// mean hiding visible (albeit stale) content in the current tree — we prefer to
35+
// show the stale content, rather than switch to a fallback. But showing a
36+
// fallback in a new tree is fine, because there's no stale content to
37+
// prefer instead.
38+
let shellBoundary: Fiber | null = null;
39+
40+
export function getShellBoundary(): Fiber | null {
41+
return shellBoundary;
8342
}
8443

8544
export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void {
86-
const props = handler.pendingProps;
87-
const handlerOnStack = suspenseHandlerStackCursor.current;
45+
// TODO: Pass as argument
46+
const current = handler.alternate;
47+
const props: SuspenseProps = handler.pendingProps;
48+
49+
// Experimental feature: Some Suspense boundaries are marked as having an
50+
// undesirable fallback state. These have special behavior where we only
51+
// activate the fallback if there's no other boundary on the stack that we can
52+
// use instead.
8853
if (
8954
enableSuspenseAvoidThisFallback &&
9055
props.unstable_avoidThisFallback === true &&
91-
handlerOnStack !== null &&
92-
!shouldAvoidedBoundaryCapture(handler, handlerOnStack, props)
56+
// If an avoided boundary is already visible, it behaves identically to
57+
// a regular Suspense boundary.
58+
(current === null || isCurrentTreeHidden())
9359
) {
94-
// This boundary should not capture if something suspends. Reuse the
95-
// existing handler on the stack.
96-
push(suspenseHandlerStackCursor, handlerOnStack, handler);
97-
} else {
98-
// Push this handler onto the stack.
99-
push(suspenseHandlerStackCursor, handler, handler);
60+
if (shellBoundary === null) {
61+
// We're rendering in the shell. There's no parent Suspense boundary that
62+
// can provide a desirable fallback state. We'll use this boundary.
63+
push(suspenseHandlerStackCursor, handler, handler);
64+
65+
// However, because this is not a desirable fallback, the children are
66+
// still considered part of the shell. So we intentionally don't assign
67+
// to `shellBoundary`.
68+
} else {
69+
// There's already a parent Suspense boundary that can provide a desirable
70+
// fallback state. Prefer that one.
71+
const handlerOnStack = suspenseHandlerStackCursor.current;
72+
push(suspenseHandlerStackCursor, handlerOnStack, handler);
73+
}
74+
return;
75+
}
76+
77+
// TODO: If the parent Suspense handler already suspended, there's no reason
78+
// to push a nested Suspense handler, because it will get replaced by the
79+
// outer fallback, anyway. Consider this as a future optimization.
80+
push(suspenseHandlerStackCursor, handler, handler);
81+
if (shellBoundary === null) {
82+
if (current === null || isCurrentTreeHidden()) {
83+
// This boundary is not visible in the current UI.
84+
shellBoundary = handler;
85+
} else {
86+
const prevState: SuspenseState = current.memoizedState;
87+
if (prevState !== null) {
88+
// This boundary is showing a fallback in the current UI.
89+
shellBoundary = handler;
90+
}
91+
}
10092
}
10193
}
10294

@@ -110,6 +102,20 @@ export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void {
110102
export function pushOffscreenSuspenseHandler(fiber: Fiber): void {
111103
if (fiber.tag === OffscreenComponent) {
112104
push(suspenseHandlerStackCursor, fiber, fiber);
105+
if (shellBoundary !== null) {
106+
// A parent boundary is showing a fallback, so we've already rendered
107+
// deeper than the shell.
108+
} else {
109+
const current = fiber.alternate;
110+
if (current !== null) {
111+
const prevState: OffscreenState = current.memoizedState;
112+
if (prevState !== null) {
113+
// This is the first boundary in the stack that's already showing
114+
// a fallback. So everything outside is considered the shell.
115+
shellBoundary = fiber;
116+
}
117+
}
118+
}
113119
} else {
114120
// This is a LegacyHidden component.
115121
reuseSuspenseHandlerOnStack(fiber);
@@ -126,6 +132,10 @@ export function getSuspenseHandler(): Fiber | null {
126132

127133
export function popSuspenseHandler(fiber: Fiber): void {
128134
pop(suspenseHandlerStackCursor, fiber);
135+
if (shellBoundary === fiber) {
136+
// Popping back into the shell.
137+
shellBoundary = null;
138+
}
129139
}
130140

131141
// SuspenseList context

packages/react-reconciler/src/ReactFiberThrow.js

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ import {
4949
enqueueUpdate,
5050
} from './ReactFiberClassUpdateQueue';
5151
import {markFailedErrorBoundaryForHotReloading} from './ReactFiberHotReloading';
52-
import {getSuspenseHandler} from './ReactFiberSuspenseContext';
52+
import {
53+
getShellBoundary,
54+
getSuspenseHandler,
55+
} from './ReactFiberSuspenseContext';
5356
import {
5457
renderDidError,
5558
renderDidSuspendDelayIfPossible,
@@ -58,6 +61,7 @@ import {
5861
isAlreadyFailedLegacyErrorBoundary,
5962
attachPingListener,
6063
restorePendingUpdaters,
64+
renderDidSuspend,
6165
} from './ReactFiberWorkLoop';
6266
import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext';
6367
import {logCapturedError} from './ReactFiberErrorLogger';
@@ -349,11 +353,46 @@ function throwException(
349353
}
350354
}
351355

352-
// Schedule the nearest Suspense to re-render the timed out view.
356+
// Mark the nearest Suspense boundary to switch to rendering a fallback.
353357
const suspenseBoundary = getSuspenseHandler();
354358
if (suspenseBoundary !== null) {
355359
switch (suspenseBoundary.tag) {
356360
case SuspenseComponent: {
361+
// If this suspense boundary is not already showing a fallback, mark
362+
// the in-progress render as suspended. We try to perform this logic
363+
// as soon as soon as possible during the render phase, so the work
364+
// loop can know things like whether it's OK to switch to other tasks,
365+
// or whether it can wait for data to resolve before continuing.
366+
// TODO: Most of these checks are already performed when entering a
367+
// Suspense boundary. We should track the information on the stack so
368+
// we don't have to recompute it on demand. This would also allow us
369+
// to unify with `use` which needs to perform this logic even sooner,
370+
// before `throwException` is called.
371+
if (sourceFiber.mode & ConcurrentMode) {
372+
if (getShellBoundary() === null) {
373+
// Suspended in the "shell" of the app. This is an undesirable
374+
// loading state. We should avoid committing this tree.
375+
renderDidSuspendDelayIfPossible();
376+
} else {
377+
// If we suspended deeper than the shell, we don't need to delay
378+
// the commmit. However, we still call renderDidSuspend if this is
379+
// a new boundary, to tell the work loop that a new fallback has
380+
// appeared during this render.
381+
// TODO: Theoretically we should be able to delete this branch.
382+
// It's currently used for two things: 1) to throttle the
383+
// appearance of successive loading states, and 2) in
384+
// SuspenseList, to determine whether the children include any
385+
// pending fallbacks. For 1, we should apply throttling to all
386+
// retries, not just ones that render an additional fallback. For
387+
// 2, we should check subtreeFlags instead. Then we can delete
388+
// this branch.
389+
const current = suspenseBoundary.alternate;
390+
if (current === null) {
391+
renderDidSuspend();
392+
}
393+
}
394+
}
395+
357396
suspenseBoundary.flags &= ~ForceClientRender;
358397
markSuspenseBoundaryShouldCapture(
359398
suspenseBoundary,

0 commit comments

Comments
 (0)