Add Suspense Boundary Context (and unstable_avoidThisFallback) (#15578)
* Avoidable suspense boundaries * Move the context out of SuspenseComponent * Use setDefaultShallowSuspenseContext instead of passing 0
Sebastian Markbåge committed
May 7, 2019 at 18:08 UTC
c7398f33966c4fedcba2c48e915b379e8f334607
6 files changed
+280
-17
packages/react-reconciler/src/ReactFiberBeginWork.js
+71
-10
@@ -12,6 +12,7 @@ import type {Fiber} from './ReactFiber';
12
import type {FiberRoot} from './ReactFiberRoot';
13
import type {ExpirationTime} from './ReactFiberExpirationTime';
14
import type {SuspenseState} from './ReactFiberSuspenseComponent';
15
+import type {SuspenseContext} from './ReactFiberSuspenseContext';
16
17
import checkPropTypes from 'prop-types/checkPropTypes';
18
@@ -104,6 +105,16 @@ import {
105
pushHostContextForEventComponent,
106
pushHostContextForEventTarget,
107
} from './ReactFiberHostContext';
108
+import {
109
+ suspenseStackCursor,
110
+ pushSuspenseContext,
111
+ popSuspenseContext,
112
+ InvisibleParentSuspenseContext,
113
+ ForceSuspenseFallback,
114
+ hasSuspenseContext,
115
+ setDefaultShallowSuspenseContext,
116
+ addSubtreeSuspenseContext,
117
+} from './ReactFiberSuspenseContext';
118
import {
119
pushProvider,
120
propagateContextChange,
@@ -1394,32 +1405,62 @@ function updateSuspenseComponent(
1405
const mode = workInProgress.mode;
1406
const nextProps = workInProgress.pendingProps;
1407
1408
+ // This is used by DevTools to force a boundary to suspend.
1409
if (__DEV__) {
1410
if (shouldSuspend(workInProgress)) {
1411
workInProgress.effectTag |= DidCapture;
1412
}
1413
}
1414
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;
1416
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;
1430
// Something in this boundary's subtree already suspended. Switch to
1431
// rendering the fallback children.
1432
nextState = {
1433
fallbackExpirationTime:
1417
- nextState !== null ? nextState.fallbackExpirationTime : NoWork,
1434
+ attemptedState !== null
1435
+ ? attemptedState.fallbackExpirationTime
1436
+ : NoWork,
1437
};
1438
nextDidTimeout = true;
1439
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
+ }
1458
}
1459
1460
+ suspenseContext = setDefaultShallowSuspenseContext(suspenseContext);
1461
+
1462
+ pushSuspenseContext(workInProgress, suspenseContext);
1463
+
1464
if (__DEV__) {
1465
if ('maxDuration' in nextProps) {
1466
if (!didWarnAboutMaxDuration) {
@@ -1472,6 +1513,7 @@ function updateSuspenseComponent(
1513
tryToClaimNextHydratableInstance(workInProgress);
1514
// This could've changed the tag if this was a dehydrated suspense component.
1515
if (workInProgress.tag === DehydratedSuspenseComponent) {
1516
+ popSuspenseContext(workInProgress);
1517
return updateDehydratedSuspenseComponent(
1518
null,
1519
workInProgress,
@@ -1713,6 +1755,8 @@ function retrySuspenseComponentWithoutHydrating(
1755
current.nextEffect = null;
1756
current.effectTag = Deletion;
1757
1758
+ popSuspenseContext(workInProgress);
1759
+
1760
// Upgrade this work in progress to a real Suspense component.
1761
workInProgress.tag = SuspenseComponent;
1762
workInProgress.stateNode = null;
@@ -1728,6 +1772,10 @@ function updateDehydratedSuspenseComponent(
1772
workInProgress: Fiber,
1773
renderExpirationTime: ExpirationTime,
1774
) {
1775
+ pushSuspenseContext(
1776
+ workInProgress,
1777
+ setDefaultShallowSuspenseContext(suspenseStackCursor.current),
1778
+ );
1779
const suspenseInstance = (workInProgress.stateNode: SuspenseInstance);
1780
if (current === null) {
1781
// During the first pass, we'll bail out and not drill into the children.
@@ -2131,6 +2179,10 @@ function beginWork(
2179
renderExpirationTime,
2180
);
2181
} else {
2182
+ pushSuspenseContext(
2183
+ workInProgress,
2184
+ setDefaultShallowSuspenseContext(suspenseStackCursor.current),
2185
+ );
2186
// The primary children do not have pending work with sufficient
2187
// priority. Bailout.
2188
const child = bailoutOnAlreadyFinishedWork(
@@ -2146,11 +2198,20 @@ function beginWork(
2198
return null;
2199
}
2200
}
2201
+ } else {
2202
+ pushSuspenseContext(
2203
+ workInProgress,
2204
+ setDefaultShallowSuspenseContext(suspenseStackCursor.current),
2205
+ );
2206
}
2207
break;
2208
}
2209
case DehydratedSuspenseComponent: {
2210
if (enableSuspenseServerRenderer) {
2211
+ pushSuspenseContext(
2212
+ workInProgress,
2213
+ setDefaultShallowSuspenseContext(suspenseStackCursor.current),
2214
+ );
2215
// We know that this component will suspend again because if it has
2216
// been unsuspended it has committed as a regular Suspense component.
2217
// If it needs to be retried, it should have work scheduled on it.
packages/react-reconciler/src/ReactFiberCompleteWork.js
+3
@@ -77,6 +77,7 @@ import {
77
getHostContext,
78
popHostContainer,
79
} from './ReactFiberHostContext';
80
+import {popSuspenseContext} from './ReactFiberSuspenseContext';
81
import {
82
isContextProvider as isLegacyContextProvider,
83
popContext as popLegacyContext,
@@ -667,6 +668,7 @@ function completeWork(
668
case ForwardRef:
669
break;
670
case SuspenseComponent: {
671
+ popSuspenseContext(workInProgress);
672
const nextState: null | SuspenseState = workInProgress.memoizedState;
673
if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
674
// Something suspended. Re-render with the fallback children.
@@ -777,6 +779,7 @@ function completeWork(
779
}
780
case DehydratedSuspenseComponent: {
781
if (enableSuspenseServerRenderer) {
782
+ popSuspenseContext(workInProgress);
783
if (current === null) {
784
let wasHydrated = popHydrationState(workInProgress);
785
invariant(
packages/react-reconciler/src/ReactFiberSuspenseComponent.js
+23
-6
@@ -14,13 +14,30 @@ export type SuspenseState = {|
14
fallbackExpirationTime: ExpirationTime,
15
|};
16
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 {
21
// If it was the primary children that just suspended, capture and render the
22
// fallback. Otherwise, don't capture and bubble to the next boundary.
23
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;
43
}
packages/react-reconciler/src/ReactFiberSuspenseContext.js
new
+83
@@ -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
+31
-1
@@ -13,6 +13,7 @@ import type {ExpirationTime} from './ReactFiberExpirationTime';
13
import type {CapturedValue} from './ReactCapturedValue';
14
import type {Update} from './ReactUpdateQueue';
15
import type {Thenable} from './ReactFiberScheduler';
16
+import type {SuspenseContext} from './ReactFiberSuspenseContext';
17
18
import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
19
import getComponentName from 'shared/getComponentName';
@@ -55,6 +56,12 @@ import {
56
import {logError} from './ReactFiberCommitWork';
57
import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
58
import {popHostContainer, popHostContext} from './ReactFiberHostContext';
59
+import {
60
+ suspenseStackCursor,
61
+ InvisibleParentSuspenseContext,
62
+ hasSuspenseContext,
63
+ popSuspenseContext,
64
+} from './ReactFiberSuspenseContext';
65
import {
66
isContextProvider as isLegacyContextProvider,
67
popContext as popLegacyContext,
@@ -206,12 +213,17 @@ function throwException(
213
214
checkForWrongSuspensePriorityInDEV(sourceFiber);
215
216
+ let hasInvisibleParentBoundary = hasSuspenseContext(
217
+ suspenseStackCursor.current,
218
+ (InvisibleParentSuspenseContext: SuspenseContext),
219
+ );
220
+
221
// Schedule the nearest Suspense to re-render the timed out view.
222
let workInProgress = returnFiber;
223
do {
224
if (
225
workInProgress.tag === SuspenseComponent &&
214
- shouldCaptureSuspense(workInProgress)
226
+ shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary)
227
) {
228
// Found the nearest boundary.
229
@@ -274,6 +286,13 @@ function throwException(
286
287
workInProgress.effectTag |= ShouldCapture;
288
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
+
296
return;
297
} else if (
298
enableSuspenseServerRenderer &&
@@ -408,6 +427,7 @@ function unwindWork(
427
return null;
428
}
429
case SuspenseComponent: {
430
+ popSuspenseContext(workInProgress);
431
const effectTag = workInProgress.effectTag;
432
if (effectTag & ShouldCapture) {
433
workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
@@ -419,6 +439,7 @@ function unwindWork(
439
case DehydratedSuspenseComponent: {
440
if (enableSuspenseServerRenderer) {
441
// TODO: popHydrationState
442
+ popSuspenseContext(workInProgress);
443
const effectTag = workInProgress.effectTag;
444
if (effectTag & ShouldCapture) {
445
workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
@@ -466,6 +487,15 @@ function unwindInterruptedWork(interruptedWork: Fiber) {
487
case HostPortal:
488
popHostContainer(interruptedWork);
489
break;
490
+ case SuspenseComponent:
491
+ popSuspenseContext(interruptedWork);
492
+ break;
493
+ case DehydratedSuspenseComponent:
494
+ if (enableSuspenseServerRenderer) {
495
+ // TODO: popHydrationState
496
+ popSuspenseContext(interruptedWork);
497
+ }
498
+ break;
499
case ContextProvider:
500
popProvider(interruptedWork);
501
break;
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+69
@@ -56,6 +56,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
56
return {type: 'span', children: [], prop, hidden: false};
57
}
58
59
+ function hiddenSpan(prop) {
60
+ return {type: 'span', children: [], prop, hidden: true};
61
+ }
62
+
63
function advanceTimers(ms) {
64
// Note: This advances Jest's virtual time but not React's. Use
65
// ReactNoop.expire for that.
@@ -1772,4 +1776,69 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1776
{withoutStack: true},
1777
);
1778
});
1779
+
1780
+ it('shows the parent boundary if the inner boundary should be avoided', async () => {
1781
+ function Foo({showC}) {
1782
+ Scheduler.yieldValue('Foo');
1783
+ return (
1784
+ <Suspense fallback={<Text text="Initial load..." />}>
1785
+ <Suspense
1786
+ unstable_avoidThisFallback={true}
1787
+ fallback={<Text text="Updating..." />}>
1788
+ <AsyncText text="A" ms={5000} />
1789
+ {showC ? <AsyncText text="C" ms={5000} /> : null}
1790
+ </Suspense>
1791
+ <Text text="B" />
1792
+ </Suspense>
1793
+ );
1794
+ }
1795
+
1796
+ ReactNoop.render(<Foo />);
1797
+ expect(Scheduler).toFlushAndYield([
1798
+ 'Foo',
1799
+ 'Suspend! [A]',
1800
+ 'B',
1801
+ 'Initial load...',
1802
+ ]);
1803
+ // We're still suspended.
1804
+ expect(ReactNoop.getChildren()).toEqual([]);
1805
+ // Flush to skip suspended time.
1806
+ Scheduler.advanceTime(600);
1807
+ await advanceTimers(600);
1808
+ expect(ReactNoop.getChildren()).toEqual([span('Initial load...')]);
1809
+
1810
+ // Eventually we resolve and show the data.
1811
+ Scheduler.advanceTime(5000);
1812
+ await advanceTimers(5000);
1813
+ expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
1814
+ expect(Scheduler).toFlushAndYield(['A', 'B']);
1815
+ expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
1816
+
1817
+ // Update to show C
1818
+ ReactNoop.render(<Foo showC={true} />);
1819
+ expect(Scheduler).toFlushAndYield([
1820
+ 'Foo',
1821
+ 'A',
1822
+ 'Suspend! [C]',
1823
+ 'Updating...',
1824
+ 'B',
1825
+ ]);
1826
+ // Flush to skip suspended time.
1827
+ Scheduler.advanceTime(600);
1828
+ await advanceTimers(600);
1829
+ // Since the optional suspense boundary is already showing its content,
1830
+ // we have to use the inner fallback instead.
1831
+ expect(ReactNoop.getChildren()).toEqual([
1832
+ hiddenSpan('A'),
1833
+ span('Updating...'),
1834
+ span('B'),
1835
+ ]);
1836
+
1837
+ // Later we load the data.
1838
+ Scheduler.advanceTime(5000);
1839
+ await advanceTimers(5000);
1840
+ expect(Scheduler).toHaveYielded(['Promise resolved [C]']);
1841
+ expect(Scheduler).toFlushAndYield(['A', 'C']);
1842
+ expect(ReactNoop.getChildren()).toEqual([span('A'), span('C'), span('B')]);
1843
+ });
1844
});