Skip to content

Commit 06f075d

Browse files
committed
Unify use and renderDidSuspendWithDelay impl
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 computataion, but also introduces a risk that the logic will accidentally diverage. 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 2d72714 commit 06f075d

File tree

4 files changed

+273
-138
lines changed

4 files changed

+273
-138
lines changed

packages/react-reconciler/src/ReactFiberSuspenseContext.js

Lines changed: 80 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
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';
@@ -22,86 +23,72 @@ 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-
// TODO: This is function is only used by the `use` implementation. There's
66-
// identical logic in `throwException`, and also in the begin phase of the
67-
// Suspense component. Since we're already computing this in the begin phase,
68-
// track it on stack instead of re-computing it on demand. Less code, less
69-
// duplicated logic, less computation.
70-
if (current !== null) {
71-
const prevState: SuspenseState = current.memoizedState;
72-
const isShowingFallback = prevState !== null;
73-
if (!isShowingFallback && !isCurrentTreeHidden()) {
74-
// It's bad to switch to a fallback if content is already visible
75-
return true;
76-
}
77-
}
78-
79-
if (
80-
enableSuspenseAvoidThisFallback &&
81-
nextProps.unstable_avoidThisFallback === true
82-
) {
83-
// Experimental: Some fallbacks are always bad
84-
return true;
85-
}
86-
87-
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;
8842
}
8943

9044
export function pushPrimaryTreeSuspenseHandler(handler: Fiber): void {
91-
const props = handler.pendingProps;
92-
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.
9353
if (
9454
enableSuspenseAvoidThisFallback &&
9555
props.unstable_avoidThisFallback === true &&
96-
handlerOnStack !== null &&
97-
!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())
9859
) {
99-
// This boundary should not capture if something suspends. Reuse the
100-
// existing handler on the stack.
101-
push(suspenseHandlerStackCursor, handlerOnStack, handler);
102-
} else {
103-
// Push this handler onto the stack.
104-
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+
}
10592
}
10693
}
10794

@@ -115,6 +102,21 @@ export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void {
115102
export function pushOffscreenSuspenseHandler(fiber: Fiber): void {
116103
if (fiber.tag === OffscreenComponent) {
117104
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 nextState: OffscreenState = fiber.memoizedState;
110+
const current = fiber.alternate;
111+
if (current !== null) {
112+
const prevState: OffscreenState = current.memoizedState;
113+
if (prevState !== null) {
114+
// This is the first boundary in the stack that's already showing
115+
// a fallback. So everything outside is considered the shell.
116+
shellBoundary = fiber;
117+
}
118+
}
119+
}
118120
} else {
119121
// This is a LegacyHidden component.
120122
reuseSuspenseHandlerOnStack(fiber);
@@ -131,6 +133,10 @@ export function getSuspenseHandler(): Fiber | null {
131133

132134
export function popSuspenseHandler(fiber: Fiber): void {
133135
pop(suspenseHandlerStackCursor, fiber);
136+
if (shellBoundary === fiber) {
137+
// Popping back into the shell.
138+
shellBoundary = null;
139+
}
134140
}
135141

136142
// SuspenseList context

packages/react-reconciler/src/ReactFiberThrow.js

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {CapturedValue} from './ReactCapturedValue';
1313
import type {Update} from './ReactFiberClassUpdateQueue';
1414
import type {Wakeable} from 'shared/ReactTypes';
1515
import type {OffscreenQueue} from './ReactFiberOffscreenComponent';
16-
import type {SuspenseState} from './ReactFiberSuspenseComponent';
16+
import type {SuspenseProps, SuspenseState} from './ReactFiberSuspenseComponent';
1717

1818
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
1919
import {
@@ -51,7 +51,10 @@ import {
5151
enqueueUpdate,
5252
} from './ReactFiberClassUpdateQueue';
5353
import {markFailedErrorBoundaryForHotReloading} from './ReactFiberHotReloading';
54-
import {getSuspenseHandler} from './ReactFiberSuspenseContext';
54+
import {
55+
getShellBoundary,
56+
getSuspenseHandler,
57+
} from './ReactFiberSuspenseContext';
5558
import {
5659
renderDidError,
5760
renderDidSuspendDelayIfPossible,
@@ -368,40 +371,26 @@ function throwException(
368371
// to unify with `use` which needs to perform this logic even sooner,
369372
// before `throwException` is called.
370373
if (sourceFiber.mode & ConcurrentMode) {
371-
if (getIsHydrating()) {
372-
// A dehydrated boundary is considered a fallback state. We don't
373-
// have to suspend.
374+
if (getShellBoundary() === null) {
375+
// Suspended in the "shell" of the app. This is an undesirable
376+
// loading state. We should avoid committing this tree.
377+
renderDidSuspendDelayIfPossible();
374378
} else {
379+
// If we suspended deeper than the shell, we don't need to delay
380+
// the commmit. However, we still call renderDidSuspend if this is
381+
// a new boundary, to tell the work loop that a new fallback has
382+
// appeared during this render.
383+
// TODO: Theoretically we should be able to delete this branch.
384+
// It's currently used for two things: 1) to throttle the
385+
// appearance of successive loading states, and 2) in
386+
// SuspenseList, to determine whether the children include any
387+
// pending fallbacks. For 1, we should apply throttling to all
388+
// retries, not just ones that render an additional fallback. For
389+
// 2, we should check subtreeFlags instead. Then we can delete
390+
// this branch.
375391
const current = suspenseBoundary.alternate;
376392
if (current === null) {
377-
// This is a new mount. Unless this is an "avoided" fallback
378-
// (experimental feature) this should not delay the tree
379-
// from appearing.
380-
const nextProps = suspenseBoundary.pendingProps;
381-
if (
382-
enableSuspenseAvoidThisFallback &&
383-
nextProps.unstable_avoidThisFallback === true
384-
) {
385-
// Experimental feature: Some fallbacks are always bad
386-
renderDidSuspendDelayIfPossible();
387-
} else {
388-
// Show a fallback without delaying. The only reason we mark
389-
// this case at all is so we can throttle the appearance of
390-
// new fallbacks. If we did nothing here, all other behavior
391-
// would be the same, except it wouldn't throttle.
392-
renderDidSuspend();
393-
}
394-
} else {
395-
const prevState: SuspenseState = current.memoizedState;
396-
if (prevState !== null) {
397-
// This boundary is currently showing a fallback. Don't need
398-
// to suspend.
399-
} else {
400-
// This boundary is currently showing content. Switching to a
401-
// fallback will cause that content to disappear. Tell the
402-
// work loop to delay the commit, if possible.
403-
renderDidSuspendDelayIfPossible();
404-
}
393+
renderDidSuspend();
405394
}
406395
}
407396
}

packages/react-reconciler/src/ReactFiberWorkLoop.js

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ import {
276276
import {schedulePostPaintCallback} from './ReactPostPaintCallback';
277277
import {
278278
getSuspenseHandler,
279-
isBadSuspenseFallback,
279+
getShellBoundary,
280280
} from './ReactFiberSuspenseContext';
281281
import {resolveDefaultProps} from './ReactFiberLazyComponent';
282282

@@ -1859,19 +1859,11 @@ function handleThrow(root, thrownValue): void {
18591859
}
18601860

18611861
function shouldAttemptToSuspendUntilDataResolves() {
1862-
// TODO: This function needs to have parity with
1863-
// renderDidSuspendDelayIfPossible, but it currently doesn't. (It only affects
1864-
// the `use` API.) Fix by unifying the logic here with the equivalent checks
1865-
// in `throwException` and in the begin phase of Suspense.
1866-
1867-
if (includesOnlyRetries(workInProgressRootRenderLanes)) {
1868-
// We can always wait during a retry.
1869-
return true;
1870-
}
1871-
18721862
// Check if there are other pending updates that might possibly unblock this
18731863
// component from suspending. This mirrors the check in
18741864
// renderDidSuspendDelayIfPossible. We should attempt to unify them somehow.
1865+
// TODO: Consider unwinding immediately, using the
1866+
// SuspendedOnHydration mechanism.
18751867
if (
18761868
includesNonIdleWork(workInProgressRootSkippedLanes) ||
18771869
includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
@@ -1884,27 +1876,24 @@ function shouldAttemptToSuspendUntilDataResolves() {
18841876
// TODO: We should be able to remove the equivalent check in
18851877
// finishConcurrentRender, and rely just on this one.
18861878
if (includesOnlyTransitions(workInProgressRootRenderLanes)) {
1887-
const suspenseHandler = getSuspenseHandler();
1888-
if (suspenseHandler !== null && suspenseHandler.tag === SuspenseComponent) {
1889-
const currentSuspenseHandler = suspenseHandler.alternate;
1890-
const nextProps: SuspenseProps = suspenseHandler.memoizedProps;
1891-
if (isBadSuspenseFallback(currentSuspenseHandler, nextProps)) {
1892-
// The nearest Suspense boundary is already showing content. We should
1893-
// avoid replacing it with a fallback, and instead wait until the
1894-
// data finishes loading.
1895-
return true;
1896-
} else {
1897-
// This is not a bad fallback condition. We should show a fallback
1898-
// immediately instead of waiting for the data to resolve. This includes
1899-
// when suspending inside new trees.
1900-
return false;
1901-
}
1902-
}
1879+
// If we're rendering inside the "shell" of the app, it's better to suspend
1880+
// rendering and wait for the data to resolve. Otherwise, we should switch
1881+
// to a fallback and continue rendering.
1882+
return getShellBoundary() === null;
1883+
}
19031884

1904-
// During a transition, if there is no Suspense boundary (i.e. suspending in
1905-
// the "shell" of an application), or if we're inside a hidden tree, then
1906-
// we should wait until the data finishes loading.
1907-
return true;
1885+
const handler = getSuspenseHandler();
1886+
if (handler === null) {
1887+
// TODO: We should support suspending in the case where there's no
1888+
// parent Suspense boundary, even outside a transition. Somehow. Otherwise,
1889+
// an uncached promise can fall into an infinite loop.
1890+
} else {
1891+
if (includesOnlyRetries(workInProgressRootRenderLanes)) {
1892+
// During a retry, we can suspend rendering if the nearest Suspense boundary
1893+
// is the boundary of the "shell", because we're guaranteed not to block
1894+
// any new content from appearing.
1895+
return handler === getShellBoundary();
1896+
}
19081897
}
19091898

19101899
// For all other Lanes besides Transitions and Retries, we should not wait
@@ -1982,6 +1971,8 @@ export function renderDidSuspendDelayIfPossible(): void {
19821971
// (inside this function), since by suspending at the end of the render
19831972
// phase introduces a potential mistake where we suspend lanes that were
19841973
// pinged or updated while we were rendering.
1974+
// TODO: Consider unwinding immediately, using the
1975+
// SuspendedOnHydration mechanism.
19851976
markRootSuspended(workInProgressRoot, workInProgressRootRenderLanes);
19861977
}
19871978
}
@@ -2199,6 +2190,10 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
21992190
}
22002191
// The work loop is suspended on data. We should wait for it to
22012192
// resolve before continuing to render.
2193+
// TODO: Handle the case where the promise resolves synchronously.
2194+
// Usually this is handled when we instrument the promise to add a
2195+
// `status` field, but if the promise already has a status, we won't
2196+
// have added a listener until right here.
22022197
const onResolution = () => {
22032198
ensureRootIsScheduled(root, now());
22042199
};

0 commit comments

Comments
 (0)