Skip to content

Commit d029d8c

Browse files
committed
Avoidable suspense boundaries
1 parent f9e60c8 commit d029d8c

File tree

6 files changed

+284
-15
lines changed

6 files changed

+284
-15
lines changed

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 87 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,17 @@ import {
104105
pushHostContextForEventComponent,
105106
pushHostContextForEventTarget,
106107
} from './ReactFiberHostContext';
108+
import {
109+
suspenseStackCursor,
110+
pushSuspenseContext,
111+
popSuspenseContext,
112+
DefaultShallowSuspenseContext,
113+
InvisibleParentSuspenseContext,
114+
ForceSuspenseFallback,
115+
hasSuspenseContext,
116+
setShallowSuspenseContext,
117+
addSubtreeSuspenseContext,
118+
} from './ReactFiberSuspenseContext';
107119
import {
108120
pushProvider,
109121
propagateContextChange,
@@ -1394,32 +1406,65 @@ function updateSuspenseComponent(
13941406
const mode = workInProgress.mode;
13951407
const nextProps = workInProgress.pendingProps;
13961408

1409+
// This is used by DevTools to force a boundary to suspend.
13971410
if (__DEV__) {
13981411
if (shouldSuspend(workInProgress)) {
13991412
workInProgress.effectTag |= DidCapture;
14001413
}
14011414
}
14021415

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;
1416+
let suspenseContext: SuspenseContext = suspenseStackCursor.current;
14061417

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

1461+
suspenseContext = setShallowSuspenseContext(
1462+
suspenseContext,
1463+
DefaultShallowSuspenseContext,
1464+
);
1465+
1466+
pushSuspenseContext(workInProgress, suspenseContext);
1467+
14231468
if (__DEV__) {
14241469
if ('maxDuration' in nextProps) {
14251470
if (!didWarnAboutMaxDuration) {
@@ -1472,6 +1517,7 @@ function updateSuspenseComponent(
14721517
tryToClaimNextHydratableInstance(workInProgress);
14731518
// This could've changed the tag if this was a dehydrated suspense component.
14741519
if (workInProgress.tag === DehydratedSuspenseComponent) {
1520+
popSuspenseContext(workInProgress);
14751521
return updateDehydratedSuspenseComponent(
14761522
null,
14771523
workInProgress,
@@ -1713,6 +1759,8 @@ function retrySuspenseComponentWithoutHydrating(
17131759
current.nextEffect = null;
17141760
current.effectTag = Deletion;
17151761

1762+
popSuspenseContext(workInProgress);
1763+
17161764
// Upgrade this work in progress to a real Suspense component.
17171765
workInProgress.tag = SuspenseComponent;
17181766
workInProgress.stateNode = null;
@@ -1728,6 +1776,13 @@ function updateDehydratedSuspenseComponent(
17281776
workInProgress: Fiber,
17291777
renderExpirationTime: ExpirationTime,
17301778
) {
1779+
pushSuspenseContext(
1780+
workInProgress,
1781+
setShallowSuspenseContext(
1782+
suspenseStackCursor.current,
1783+
DefaultShallowSuspenseContext,
1784+
),
1785+
);
17311786
const suspenseInstance = (workInProgress.stateNode: SuspenseInstance);
17321787
if (current === null) {
17331788
// During the first pass, we'll bail out and not drill into the children.
@@ -2131,6 +2186,13 @@ function beginWork(
21312186
renderExpirationTime,
21322187
);
21332188
} else {
2189+
pushSuspenseContext(
2190+
workInProgress,
2191+
setShallowSuspenseContext(
2192+
suspenseStackCursor.current,
2193+
DefaultShallowSuspenseContext,
2194+
),
2195+
);
21342196
// The primary children do not have pending work with sufficient
21352197
// priority. Bailout.
21362198
const child = bailoutOnAlreadyFinishedWork(
@@ -2146,11 +2208,26 @@ function beginWork(
21462208
return null;
21472209
}
21482210
}
2211+
} else {
2212+
pushSuspenseContext(
2213+
workInProgress,
2214+
setShallowSuspenseContext(
2215+
suspenseStackCursor.current,
2216+
DefaultShallowSuspenseContext,
2217+
),
2218+
);
21492219
}
21502220
break;
21512221
}
21522222
case DehydratedSuspenseComponent: {
21532223
if (enableSuspenseServerRenderer) {
2224+
pushSuspenseContext(
2225+
workInProgress,
2226+
setShallowSuspenseContext(
2227+
suspenseStackCursor.current,
2228+
DefaultShallowSuspenseContext,
2229+
),
2230+
);
21542231
// We know that this component will suspend again because if it has
21552232
// been unsuspended it has committed as a regular Suspense component.
21562233
// 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: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,43 @@
1010
import type {Fiber} from './ReactFiber';
1111
import type {ExpirationTime} from './ReactFiberExpirationTime';
1212

13+
import type {SuspenseContext} from './ReactFiberSuspenseContext';
14+
import {
15+
suspenseStackCursor,
16+
InvisibleParentSuspenseContext,
17+
hasSuspenseContext,
18+
} from './ReactFiberSuspenseContext';
19+
1320
export type SuspenseState = {|
1421
fallbackExpirationTime: ExpirationTime,
1522
|};
1623

1724
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-
}
2225
// If it was the primary children that just suspended, capture and render the
2326
// fallback. Otherwise, don't capture and bubble to the next boundary.
2427
const nextState: SuspenseState | null = workInProgress.memoizedState;
25-
return nextState === null;
28+
if (nextState !== null) {
29+
return false;
30+
}
31+
const props = workInProgress.memoizedProps;
32+
// In order to capture, the Suspense component must have a fallback prop.
33+
if (props.fallback === undefined) {
34+
return false;
35+
}
36+
// Regular boundaries always capture.
37+
if (props.unstable_avoidThisFallback !== true) {
38+
return true;
39+
}
40+
// If it's a boundary we should avoid, then we prefer to bubble up to the
41+
// parent boundary if it is currently invisible.
42+
if (
43+
hasSuspenseContext(
44+
suspenseStackCursor.current,
45+
(InvisibleParentSuspenseContext: SuspenseContext),
46+
)
47+
) {
48+
return false;
49+
}
50+
// If the parent is not able to handle it, we must handle it.
51+
return true;
2652
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
export const DefaultShallowSuspenseContext: ShallowSuspenseContext = 0b00;
40+
41+
// ForceSuspenseFallback can be used by SuspenseList to force newly added
42+
// items into their fallback state during one of the render passes.
43+
export const ForceSuspenseFallback: ShallowSuspenseContext = 0b10;
44+
45+
export const suspenseStackCursor: StackCursor<SuspenseContext> = createCursor(
46+
DefaultSuspenseContext,
47+
);
48+
49+
export function hasSuspenseContext(
50+
parentContext: SuspenseContext,
51+
flag: SuspenseContext,
52+
): boolean {
53+
return (parentContext & flag) !== 0;
54+
}
55+
56+
export function setShallowSuspenseContext(
57+
parentContext: SuspenseContext,
58+
shallowContext: ShallowSuspenseContext,
59+
): SuspenseContext {
60+
return (parentContext & SubtreeSuspenseContextMask) | shallowContext;
61+
}
62+
63+
export function addSubtreeSuspenseContext(
64+
parentContext: SuspenseContext,
65+
subtreeContext: SubtreeSuspenseContext,
66+
): SuspenseContext {
67+
return parentContext | subtreeContext;
68+
}
69+
70+
export function pushSuspenseContext(
71+
fiber: Fiber,
72+
newContext: SuspenseContext,
73+
): void {
74+
push(suspenseStackCursor, newContext, fiber);
75+
}
76+
77+
export function popSuspenseContext(fiber: Fiber): void {
78+
pop(suspenseStackCursor, fiber);
79+
}

packages/react-reconciler/src/ReactFiberUnwindWork.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
import {logError} from './ReactFiberCommitWork';
5656
import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
5757
import {popHostContainer, popHostContext} from './ReactFiberHostContext';
58+
import {popSuspenseContext} from './ReactFiberSuspenseContext';
5859
import {
5960
isContextProvider as isLegacyContextProvider,
6061
popContext as popLegacyContext,
@@ -206,6 +207,9 @@ function throwException(
206207

207208
checkForWrongSuspensePriorityInDEV(sourceFiber);
208209

210+
// TODO: If we're not in an invisible subtree, then we need to mark this render
211+
// as needing to suspend for longer to avoid showing this fallback state.
212+
209213
// Schedule the nearest Suspense to re-render the timed out view.
210214
let workInProgress = returnFiber;
211215
do {
@@ -408,6 +412,7 @@ function unwindWork(
408412
return null;
409413
}
410414
case SuspenseComponent: {
415+
popSuspenseContext(workInProgress);
411416
const effectTag = workInProgress.effectTag;
412417
if (effectTag & ShouldCapture) {
413418
workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
@@ -419,6 +424,7 @@ function unwindWork(
419424
case DehydratedSuspenseComponent: {
420425
if (enableSuspenseServerRenderer) {
421426
// TODO: popHydrationState
427+
popSuspenseContext(workInProgress);
422428
const effectTag = workInProgress.effectTag;
423429
if (effectTag & ShouldCapture) {
424430
workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
@@ -466,6 +472,15 @@ function unwindInterruptedWork(interruptedWork: Fiber) {
466472
case HostPortal:
467473
popHostContainer(interruptedWork);
468474
break;
475+
case SuspenseComponent:
476+
popSuspenseContext(interruptedWork);
477+
break;
478+
case DehydratedSuspenseComponent:
479+
if (enableSuspenseServerRenderer) {
480+
// TODO: popHydrationState
481+
popSuspenseContext(interruptedWork);
482+
}
483+
break;
469484
case ContextProvider:
470485
popProvider(interruptedWork);
471486
break;

0 commit comments

Comments
 (0)