Skip to content

Commit c7398f3

Browse files
authored
Add Suspense Boundary Context (and unstable_avoidThisFallback) (#15578)
* Avoidable suspense boundaries * Move the context out of SuspenseComponent * Use setDefaultShallowSuspenseContext instead of passing 0
1 parent f9e60c8 commit c7398f3

File tree

6 files changed

+280
-17
lines changed

6 files changed

+280
-17
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {Fiber} from './ReactFiber';
1212
import type {FiberRoot} from './ReactFiberRoot';
1313
import type {ExpirationTime} from './ReactFiberExpirationTime';
1414
import type {SuspenseState} from './ReactFiberSuspenseComponent';
15+
import type {SuspenseContext} from './ReactFiberSuspenseContext';
1516

1617
import checkPropTypes from 'prop-types/checkPropTypes';
1718

@@ -104,6 +105,16 @@ import {
104105
pushHostContextForEventComponent,
105106
pushHostContextForEventTarget,
106107
} from './ReactFiberHostContext';
108+
import {
109+
suspenseStackCursor,
110+
pushSuspenseContext,
111+
popSuspenseContext,
112+
InvisibleParentSuspenseContext,
113+
ForceSuspenseFallback,
114+
hasSuspenseContext,
115+
setDefaultShallowSuspenseContext,
116+
addSubtreeSuspenseContext,
117+
} from './ReactFiberSuspenseContext';
107118
import {
108119
pushProvider,
109120
propagateContextChange,
@@ -1394,32 +1405,62 @@ function updateSuspenseComponent(
13941405
const mode = workInProgress.mode;
13951406
const nextProps = workInProgress.pendingProps;
13961407

1408+
// This is used by DevTools to force a boundary to suspend.
13971409
if (__DEV__) {
13981410
if (shouldSuspend(workInProgress)) {
13991411
workInProgress.effectTag |= DidCapture;
14001412
}
14011413
}
14021414

1403-
// We should attempt to render the primary children unless this boundary
1404-
// already suspended during this render (`alreadyCaptured` is true).
1405-
let nextState: SuspenseState | null = workInProgress.memoizedState;
1415+
let suspenseContext: SuspenseContext = suspenseStackCursor.current;
14061416

1407-
let nextDidTimeout;
1408-
if ((workInProgress.effectTag & DidCapture) === NoEffect) {
1409-
// This is the first attempt.
1410-
nextState = null;
1411-
nextDidTimeout = false;
1412-
} else {
1417+
let nextState = null;
1418+
let nextDidTimeout = false;
1419+
1420+
if (
1421+
(workInProgress.effectTag & DidCapture) !== NoEffect ||
1422+
hasSuspenseContext(
1423+
suspenseContext,
1424+
(ForceSuspenseFallback: SuspenseContext),
1425+
)
1426+
) {
1427+
// This either already captured or is a new mount that was forced into its fallback
1428+
// state by a parent.
1429+
const attemptedState: SuspenseState | null = workInProgress.memoizedState;
14131430
// Something in this boundary's subtree already suspended. Switch to
14141431
// rendering the fallback children.
14151432
nextState = {
14161433
fallbackExpirationTime:
1417-
nextState !== null ? nextState.fallbackExpirationTime : NoWork,
1434+
attemptedState !== null
1435+
? attemptedState.fallbackExpirationTime
1436+
: NoWork,
14181437
};
14191438
nextDidTimeout = true;
14201439
workInProgress.effectTag &= ~DidCapture;
1440+
} else {
1441+
// Attempting the main content
1442+
if (current === null || current.memoizedState !== null) {
1443+
// This is a new mount or this boundary is already showing a fallback state.
1444+
// Mark this subtree context as having at least one invisible parent that could
1445+
// handle the fallback state.
1446+
// Boundaries without fallbacks or should be avoided are not considered since
1447+
// they cannot handle preferred fallback states.
1448+
if (
1449+
nextProps.fallback !== undefined &&
1450+
nextProps.unstable_avoidThisFallback !== true
1451+
) {
1452+
suspenseContext = addSubtreeSuspenseContext(
1453+
suspenseContext,
1454+
InvisibleParentSuspenseContext,
1455+
);
1456+
}
1457+
}
14211458
}
14221459

1460+
suspenseContext = setDefaultShallowSuspenseContext(suspenseContext);
1461+
1462+
pushSuspenseContext(workInProgress, suspenseContext);
1463+
14231464
if (__DEV__) {
14241465
if ('maxDuration' in nextProps) {
14251466
if (!didWarnAboutMaxDuration) {
@@ -1472,6 +1513,7 @@ function updateSuspenseComponent(
14721513
tryToClaimNextHydratableInstance(workInProgress);
14731514
// This could've changed the tag if this was a dehydrated suspense component.
14741515
if (workInProgress.tag === DehydratedSuspenseComponent) {
1516+
popSuspenseContext(workInProgress);
14751517
return updateDehydratedSuspenseComponent(
14761518
null,
14771519
workInProgress,
@@ -1713,6 +1755,8 @@ function retrySuspenseComponentWithoutHydrating(
17131755
current.nextEffect = null;
17141756
current.effectTag = Deletion;
17151757

1758+
popSuspenseContext(workInProgress);
1759+
17161760
// Upgrade this work in progress to a real Suspense component.
17171761
workInProgress.tag = SuspenseComponent;
17181762
workInProgress.stateNode = null;
@@ -1728,6 +1772,10 @@ function updateDehydratedSuspenseComponent(
17281772
workInProgress: Fiber,
17291773
renderExpirationTime: ExpirationTime,
17301774
) {
1775+
pushSuspenseContext(
1776+
workInProgress,
1777+
setDefaultShallowSuspenseContext(suspenseStackCursor.current),
1778+
);
17311779
const suspenseInstance = (workInProgress.stateNode: SuspenseInstance);
17321780
if (current === null) {
17331781
// During the first pass, we'll bail out and not drill into the children.
@@ -2131,6 +2179,10 @@ function beginWork(
21312179
renderExpirationTime,
21322180
);
21332181
} else {
2182+
pushSuspenseContext(
2183+
workInProgress,
2184+
setDefaultShallowSuspenseContext(suspenseStackCursor.current),
2185+
);
21342186
// The primary children do not have pending work with sufficient
21352187
// priority. Bailout.
21362188
const child = bailoutOnAlreadyFinishedWork(
@@ -2146,11 +2198,20 @@ function beginWork(
21462198
return null;
21472199
}
21482200
}
2201+
} else {
2202+
pushSuspenseContext(
2203+
workInProgress,
2204+
setDefaultShallowSuspenseContext(suspenseStackCursor.current),
2205+
);
21492206
}
21502207
break;
21512208
}
21522209
case DehydratedSuspenseComponent: {
21532210
if (enableSuspenseServerRenderer) {
2211+
pushSuspenseContext(
2212+
workInProgress,
2213+
setDefaultShallowSuspenseContext(suspenseStackCursor.current),
2214+
);
21542215
// We know that this component will suspend again because if it has
21552216
// been unsuspended it has committed as a regular Suspense component.
21562217
// If it needs to be retried, it should have work scheduled on it.

packages/react-reconciler/src/ReactFiberCompleteWork.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import {
7777
getHostContext,
7878
popHostContainer,
7979
} from './ReactFiberHostContext';
80+
import {popSuspenseContext} from './ReactFiberSuspenseContext';
8081
import {
8182
isContextProvider as isLegacyContextProvider,
8283
popContext as popLegacyContext,
@@ -667,6 +668,7 @@ function completeWork(
667668
case ForwardRef:
668669
break;
669670
case SuspenseComponent: {
671+
popSuspenseContext(workInProgress);
670672
const nextState: null | SuspenseState = workInProgress.memoizedState;
671673
if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
672674
// Something suspended. Re-render with the fallback children.
@@ -777,6 +779,7 @@ function completeWork(
777779
}
778780
case DehydratedSuspenseComponent: {
779781
if (enableSuspenseServerRenderer) {
782+
popSuspenseContext(workInProgress);
780783
if (current === null) {
781784
let wasHydrated = popHydrationState(workInProgress);
782785
invariant(

packages/react-reconciler/src/ReactFiberSuspenseComponent.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,30 @@ export type SuspenseState = {|
1414
fallbackExpirationTime: ExpirationTime,
1515
|};
1616

17-
export function shouldCaptureSuspense(workInProgress: Fiber): boolean {
18-
// In order to capture, the Suspense component must have a fallback prop.
19-
if (workInProgress.memoizedProps.fallback === undefined) {
20-
return false;
21-
}
17+
export function shouldCaptureSuspense(
18+
workInProgress: Fiber,
19+
hasInvisibleParent: boolean,
20+
): boolean {
2221
// If it was the primary children that just suspended, capture and render the
2322
// fallback. Otherwise, don't capture and bubble to the next boundary.
2423
const nextState: SuspenseState | null = workInProgress.memoizedState;
25-
return nextState === null;
24+
if (nextState !== null) {
25+
return false;
26+
}
27+
const props = workInProgress.memoizedProps;
28+
// In order to capture, the Suspense component must have a fallback prop.
29+
if (props.fallback === undefined) {
30+
return false;
31+
}
32+
// Regular boundaries always capture.
33+
if (props.unstable_avoidThisFallback !== true) {
34+
return true;
35+
}
36+
// If it's a boundary we should avoid, then we prefer to bubble up to the
37+
// parent boundary if it is currently invisible.
38+
if (hasInvisibleParent) {
39+
return false;
40+
}
41+
// If the parent is not able to handle it, we must handle it.
42+
return true;
2643
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {Fiber} from './ReactFiber';
11+
import type {StackCursor} from './ReactFiberStack';
12+
13+
import {createCursor, push, pop} from './ReactFiberStack';
14+
15+
export opaque type SuspenseContext = number;
16+
export opaque type SubtreeSuspenseContext: SuspenseContext = number;
17+
export opaque type ShallowSuspenseContext: SuspenseContext = number;
18+
19+
const DefaultSuspenseContext: SuspenseContext = 0b00;
20+
21+
// The Suspense Context is split into two parts. The lower bits is
22+
// inherited deeply down the subtree. The upper bits only affect
23+
// this immediate suspense boundary and gets reset each new
24+
// boundary or suspense list.
25+
const SubtreeSuspenseContextMask: SuspenseContext = 0b01;
26+
27+
// Subtree Flags:
28+
29+
// InvisibleParentSuspenseContext indicates that one of our parent Suspense
30+
// boundaries is not currently showing visible main content.
31+
// Either because it is already showing a fallback or is not mounted at all.
32+
// We can use this to determine if it is desirable to trigger a fallback at
33+
// the parent. If not, then we might need to trigger undesirable boundaries
34+
// and/or suspend the commit to avoid hiding the parent content.
35+
export const InvisibleParentSuspenseContext: SubtreeSuspenseContext = 0b01;
36+
37+
// Shallow Flags:
38+
39+
// ForceSuspenseFallback can be used by SuspenseList to force newly added
40+
// items into their fallback state during one of the render passes.
41+
export const ForceSuspenseFallback: ShallowSuspenseContext = 0b10;
42+
43+
export const suspenseStackCursor: StackCursor<SuspenseContext> = createCursor(
44+
DefaultSuspenseContext,
45+
);
46+
47+
export function hasSuspenseContext(
48+
parentContext: SuspenseContext,
49+
flag: SuspenseContext,
50+
): boolean {
51+
return (parentContext & flag) !== 0;
52+
}
53+
54+
export function setDefaultShallowSuspenseContext(
55+
parentContext: SuspenseContext,
56+
): SuspenseContext {
57+
return parentContext & SubtreeSuspenseContextMask;
58+
}
59+
60+
export function setShallowSuspenseContext(
61+
parentContext: SuspenseContext,
62+
shallowContext: ShallowSuspenseContext,
63+
): SuspenseContext {
64+
return (parentContext & SubtreeSuspenseContextMask) | shallowContext;
65+
}
66+
67+
export function addSubtreeSuspenseContext(
68+
parentContext: SuspenseContext,
69+
subtreeContext: SubtreeSuspenseContext,
70+
): SuspenseContext {
71+
return parentContext | subtreeContext;
72+
}
73+
74+
export function pushSuspenseContext(
75+
fiber: Fiber,
76+
newContext: SuspenseContext,
77+
): void {
78+
push(suspenseStackCursor, newContext, fiber);
79+
}
80+
81+
export function popSuspenseContext(fiber: Fiber): void {
82+
pop(suspenseStackCursor, fiber);
83+
}

packages/react-reconciler/src/ReactFiberUnwindWork.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {ExpirationTime} from './ReactFiberExpirationTime';
1313
import type {CapturedValue} from './ReactCapturedValue';
1414
import type {Update} from './ReactUpdateQueue';
1515
import type {Thenable} from './ReactFiberScheduler';
16+
import type {SuspenseContext} from './ReactFiberSuspenseContext';
1617

1718
import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
1819
import getComponentName from 'shared/getComponentName';
@@ -55,6 +56,12 @@ import {
5556
import {logError} from './ReactFiberCommitWork';
5657
import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
5758
import {popHostContainer, popHostContext} from './ReactFiberHostContext';
59+
import {
60+
suspenseStackCursor,
61+
InvisibleParentSuspenseContext,
62+
hasSuspenseContext,
63+
popSuspenseContext,
64+
} from './ReactFiberSuspenseContext';
5865
import {
5966
isContextProvider as isLegacyContextProvider,
6067
popContext as popLegacyContext,
@@ -206,12 +213,17 @@ function throwException(
206213

207214
checkForWrongSuspensePriorityInDEV(sourceFiber);
208215

216+
let hasInvisibleParentBoundary = hasSuspenseContext(
217+
suspenseStackCursor.current,
218+
(InvisibleParentSuspenseContext: SuspenseContext),
219+
);
220+
209221
// Schedule the nearest Suspense to re-render the timed out view.
210222
let workInProgress = returnFiber;
211223
do {
212224
if (
213225
workInProgress.tag === SuspenseComponent &&
214-
shouldCaptureSuspense(workInProgress)
226+
shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary)
215227
) {
216228
// Found the nearest boundary.
217229

@@ -274,6 +286,13 @@ function throwException(
274286

275287
workInProgress.effectTag |= ShouldCapture;
276288
workInProgress.expirationTime = renderExpirationTime;
289+
290+
if (!hasInvisibleParentBoundary) {
291+
// TODO: If we're not in an invisible subtree, then we need to mark this render
292+
// pass as needing to suspend for longer to avoid showing this fallback state.
293+
// We could do it here or when we render the fallback.
294+
}
295+
277296
return;
278297
} else if (
279298
enableSuspenseServerRenderer &&
@@ -408,6 +427,7 @@ function unwindWork(
408427
return null;
409428
}
410429
case SuspenseComponent: {
430+
popSuspenseContext(workInProgress);
411431
const effectTag = workInProgress.effectTag;
412432
if (effectTag & ShouldCapture) {
413433
workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
@@ -419,6 +439,7 @@ function unwindWork(
419439
case DehydratedSuspenseComponent: {
420440
if (enableSuspenseServerRenderer) {
421441
// TODO: popHydrationState
442+
popSuspenseContext(workInProgress);
422443
const effectTag = workInProgress.effectTag;
423444
if (effectTag & ShouldCapture) {
424445
workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
@@ -466,6 +487,15 @@ function unwindInterruptedWork(interruptedWork: Fiber) {
466487
case HostPortal:
467488
popHostContainer(interruptedWork);
468489
break;
490+
case SuspenseComponent:
491+
popSuspenseContext(interruptedWork);
492+
break;
493+
case DehydratedSuspenseComponent:
494+
if (enableSuspenseServerRenderer) {
495+
// TODO: popHydrationState
496+
popSuspenseContext(interruptedWork);
497+
}
498+
break;
469499
case ContextProvider:
470500
popProvider(interruptedWork);
471501
break;

0 commit comments

Comments
 (0)