@samitouri / QOS-React-2 / commits / c7398f3396

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 });