mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
Add Suspense Boundary Context (and unstable_avoidThisFallback) (#15578)
* Avoidable suspense boundaries * Move the context out of SuspenseComponent * Use setDefaultShallowSuspenseContext instead of passing 0
This commit is contained in:
+71
-10
@@ -12,6 +12,7 @@ import type {Fiber} from './ReactFiber';
|
||||
import type {FiberRoot} from './ReactFiberRoot';
|
||||
import type {ExpirationTime} from './ReactFiberExpirationTime';
|
||||
import type {SuspenseState} from './ReactFiberSuspenseComponent';
|
||||
import type {SuspenseContext} from './ReactFiberSuspenseContext';
|
||||
|
||||
import checkPropTypes from 'prop-types/checkPropTypes';
|
||||
|
||||
@@ -104,6 +105,16 @@ import {
|
||||
pushHostContextForEventComponent,
|
||||
pushHostContextForEventTarget,
|
||||
} from './ReactFiberHostContext';
|
||||
import {
|
||||
suspenseStackCursor,
|
||||
pushSuspenseContext,
|
||||
popSuspenseContext,
|
||||
InvisibleParentSuspenseContext,
|
||||
ForceSuspenseFallback,
|
||||
hasSuspenseContext,
|
||||
setDefaultShallowSuspenseContext,
|
||||
addSubtreeSuspenseContext,
|
||||
} from './ReactFiberSuspenseContext';
|
||||
import {
|
||||
pushProvider,
|
||||
propagateContextChange,
|
||||
@@ -1394,32 +1405,62 @@ function updateSuspenseComponent(
|
||||
const mode = workInProgress.mode;
|
||||
const nextProps = workInProgress.pendingProps;
|
||||
|
||||
// This is used by DevTools to force a boundary to suspend.
|
||||
if (__DEV__) {
|
||||
if (shouldSuspend(workInProgress)) {
|
||||
workInProgress.effectTag |= DidCapture;
|
||||
}
|
||||
}
|
||||
|
||||
// We should attempt to render the primary children unless this boundary
|
||||
// already suspended during this render (`alreadyCaptured` is true).
|
||||
let nextState: SuspenseState | null = workInProgress.memoizedState;
|
||||
let suspenseContext: SuspenseContext = suspenseStackCursor.current;
|
||||
|
||||
let nextDidTimeout;
|
||||
if ((workInProgress.effectTag & DidCapture) === NoEffect) {
|
||||
// This is the first attempt.
|
||||
nextState = null;
|
||||
nextDidTimeout = false;
|
||||
} else {
|
||||
let nextState = null;
|
||||
let nextDidTimeout = false;
|
||||
|
||||
if (
|
||||
(workInProgress.effectTag & DidCapture) !== NoEffect ||
|
||||
hasSuspenseContext(
|
||||
suspenseContext,
|
||||
(ForceSuspenseFallback: SuspenseContext),
|
||||
)
|
||||
) {
|
||||
// This either already captured or is a new mount that was forced into its fallback
|
||||
// state by a parent.
|
||||
const attemptedState: SuspenseState | null = workInProgress.memoizedState;
|
||||
// Something in this boundary's subtree already suspended. Switch to
|
||||
// rendering the fallback children.
|
||||
nextState = {
|
||||
fallbackExpirationTime:
|
||||
nextState !== null ? nextState.fallbackExpirationTime : NoWork,
|
||||
attemptedState !== null
|
||||
? attemptedState.fallbackExpirationTime
|
||||
: NoWork,
|
||||
};
|
||||
nextDidTimeout = true;
|
||||
workInProgress.effectTag &= ~DidCapture;
|
||||
} else {
|
||||
// Attempting the main content
|
||||
if (current === null || current.memoizedState !== null) {
|
||||
// This is a new mount or this boundary is already showing a fallback state.
|
||||
// Mark this subtree context as having at least one invisible parent that could
|
||||
// handle the fallback state.
|
||||
// Boundaries without fallbacks or should be avoided are not considered since
|
||||
// they cannot handle preferred fallback states.
|
||||
if (
|
||||
nextProps.fallback !== undefined &&
|
||||
nextProps.unstable_avoidThisFallback !== true
|
||||
) {
|
||||
suspenseContext = addSubtreeSuspenseContext(
|
||||
suspenseContext,
|
||||
InvisibleParentSuspenseContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspenseContext = setDefaultShallowSuspenseContext(suspenseContext);
|
||||
|
||||
pushSuspenseContext(workInProgress, suspenseContext);
|
||||
|
||||
if (__DEV__) {
|
||||
if ('maxDuration' in nextProps) {
|
||||
if (!didWarnAboutMaxDuration) {
|
||||
@@ -1472,6 +1513,7 @@ function updateSuspenseComponent(
|
||||
tryToClaimNextHydratableInstance(workInProgress);
|
||||
// This could've changed the tag if this was a dehydrated suspense component.
|
||||
if (workInProgress.tag === DehydratedSuspenseComponent) {
|
||||
popSuspenseContext(workInProgress);
|
||||
return updateDehydratedSuspenseComponent(
|
||||
null,
|
||||
workInProgress,
|
||||
@@ -1713,6 +1755,8 @@ function retrySuspenseComponentWithoutHydrating(
|
||||
current.nextEffect = null;
|
||||
current.effectTag = Deletion;
|
||||
|
||||
popSuspenseContext(workInProgress);
|
||||
|
||||
// Upgrade this work in progress to a real Suspense component.
|
||||
workInProgress.tag = SuspenseComponent;
|
||||
workInProgress.stateNode = null;
|
||||
@@ -1728,6 +1772,10 @@ function updateDehydratedSuspenseComponent(
|
||||
workInProgress: Fiber,
|
||||
renderExpirationTime: ExpirationTime,
|
||||
) {
|
||||
pushSuspenseContext(
|
||||
workInProgress,
|
||||
setDefaultShallowSuspenseContext(suspenseStackCursor.current),
|
||||
);
|
||||
const suspenseInstance = (workInProgress.stateNode: SuspenseInstance);
|
||||
if (current === null) {
|
||||
// During the first pass, we'll bail out and not drill into the children.
|
||||
@@ -2131,6 +2179,10 @@ function beginWork(
|
||||
renderExpirationTime,
|
||||
);
|
||||
} else {
|
||||
pushSuspenseContext(
|
||||
workInProgress,
|
||||
setDefaultShallowSuspenseContext(suspenseStackCursor.current),
|
||||
);
|
||||
// The primary children do not have pending work with sufficient
|
||||
// priority. Bailout.
|
||||
const child = bailoutOnAlreadyFinishedWork(
|
||||
@@ -2146,11 +2198,20 @@ function beginWork(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pushSuspenseContext(
|
||||
workInProgress,
|
||||
setDefaultShallowSuspenseContext(suspenseStackCursor.current),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DehydratedSuspenseComponent: {
|
||||
if (enableSuspenseServerRenderer) {
|
||||
pushSuspenseContext(
|
||||
workInProgress,
|
||||
setDefaultShallowSuspenseContext(suspenseStackCursor.current),
|
||||
);
|
||||
// We know that this component will suspend again because if it has
|
||||
// been unsuspended it has committed as a regular Suspense component.
|
||||
// If it needs to be retried, it should have work scheduled on it.
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
getHostContext,
|
||||
popHostContainer,
|
||||
} from './ReactFiberHostContext';
|
||||
import {popSuspenseContext} from './ReactFiberSuspenseContext';
|
||||
import {
|
||||
isContextProvider as isLegacyContextProvider,
|
||||
popContext as popLegacyContext,
|
||||
@@ -667,6 +668,7 @@ function completeWork(
|
||||
case ForwardRef:
|
||||
break;
|
||||
case SuspenseComponent: {
|
||||
popSuspenseContext(workInProgress);
|
||||
const nextState: null | SuspenseState = workInProgress.memoizedState;
|
||||
if ((workInProgress.effectTag & DidCapture) !== NoEffect) {
|
||||
// Something suspended. Re-render with the fallback children.
|
||||
@@ -777,6 +779,7 @@ function completeWork(
|
||||
}
|
||||
case DehydratedSuspenseComponent: {
|
||||
if (enableSuspenseServerRenderer) {
|
||||
popSuspenseContext(workInProgress);
|
||||
if (current === null) {
|
||||
let wasHydrated = popHydrationState(workInProgress);
|
||||
invariant(
|
||||
|
||||
@@ -14,13 +14,30 @@ export type SuspenseState = {|
|
||||
fallbackExpirationTime: ExpirationTime,
|
||||
|};
|
||||
|
||||
export function shouldCaptureSuspense(workInProgress: Fiber): boolean {
|
||||
// In order to capture, the Suspense component must have a fallback prop.
|
||||
if (workInProgress.memoizedProps.fallback === undefined) {
|
||||
return false;
|
||||
}
|
||||
export function shouldCaptureSuspense(
|
||||
workInProgress: Fiber,
|
||||
hasInvisibleParent: boolean,
|
||||
): boolean {
|
||||
// If it was the primary children that just suspended, capture and render the
|
||||
// fallback. Otherwise, don't capture and bubble to the next boundary.
|
||||
const nextState: SuspenseState | null = workInProgress.memoizedState;
|
||||
return nextState === null;
|
||||
if (nextState !== null) {
|
||||
return false;
|
||||
}
|
||||
const props = workInProgress.memoizedProps;
|
||||
// In order to capture, the Suspense component must have a fallback prop.
|
||||
if (props.fallback === undefined) {
|
||||
return false;
|
||||
}
|
||||
// Regular boundaries always capture.
|
||||
if (props.unstable_avoidThisFallback !== true) {
|
||||
return true;
|
||||
}
|
||||
// If it's a boundary we should avoid, then we prefer to bubble up to the
|
||||
// parent boundary if it is currently invisible.
|
||||
if (hasInvisibleParent) {
|
||||
return false;
|
||||
}
|
||||
// If the parent is not able to handle it, we must handle it.
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
import type {Fiber} from './ReactFiber';
|
||||
import type {StackCursor} from './ReactFiberStack';
|
||||
|
||||
import {createCursor, push, pop} from './ReactFiberStack';
|
||||
|
||||
export opaque type SuspenseContext = number;
|
||||
export opaque type SubtreeSuspenseContext: SuspenseContext = number;
|
||||
export opaque type ShallowSuspenseContext: SuspenseContext = number;
|
||||
|
||||
const DefaultSuspenseContext: SuspenseContext = 0b00;
|
||||
|
||||
// The Suspense Context is split into two parts. The lower bits is
|
||||
// inherited deeply down the subtree. The upper bits only affect
|
||||
// this immediate suspense boundary and gets reset each new
|
||||
// boundary or suspense list.
|
||||
const SubtreeSuspenseContextMask: SuspenseContext = 0b01;
|
||||
|
||||
// Subtree Flags:
|
||||
|
||||
// InvisibleParentSuspenseContext indicates that one of our parent Suspense
|
||||
// boundaries is not currently showing visible main content.
|
||||
// Either because it is already showing a fallback or is not mounted at all.
|
||||
// We can use this to determine if it is desirable to trigger a fallback at
|
||||
// the parent. If not, then we might need to trigger undesirable boundaries
|
||||
// and/or suspend the commit to avoid hiding the parent content.
|
||||
export const InvisibleParentSuspenseContext: SubtreeSuspenseContext = 0b01;
|
||||
|
||||
// Shallow Flags:
|
||||
|
||||
// ForceSuspenseFallback can be used by SuspenseList to force newly added
|
||||
// items into their fallback state during one of the render passes.
|
||||
export const ForceSuspenseFallback: ShallowSuspenseContext = 0b10;
|
||||
|
||||
export const suspenseStackCursor: StackCursor<SuspenseContext> = createCursor(
|
||||
DefaultSuspenseContext,
|
||||
);
|
||||
|
||||
export function hasSuspenseContext(
|
||||
parentContext: SuspenseContext,
|
||||
flag: SuspenseContext,
|
||||
): boolean {
|
||||
return (parentContext & flag) !== 0;
|
||||
}
|
||||
|
||||
export function setDefaultShallowSuspenseContext(
|
||||
parentContext: SuspenseContext,
|
||||
): SuspenseContext {
|
||||
return parentContext & SubtreeSuspenseContextMask;
|
||||
}
|
||||
|
||||
export function setShallowSuspenseContext(
|
||||
parentContext: SuspenseContext,
|
||||
shallowContext: ShallowSuspenseContext,
|
||||
): SuspenseContext {
|
||||
return (parentContext & SubtreeSuspenseContextMask) | shallowContext;
|
||||
}
|
||||
|
||||
export function addSubtreeSuspenseContext(
|
||||
parentContext: SuspenseContext,
|
||||
subtreeContext: SubtreeSuspenseContext,
|
||||
): SuspenseContext {
|
||||
return parentContext | subtreeContext;
|
||||
}
|
||||
|
||||
export function pushSuspenseContext(
|
||||
fiber: Fiber,
|
||||
newContext: SuspenseContext,
|
||||
): void {
|
||||
push(suspenseStackCursor, newContext, fiber);
|
||||
}
|
||||
|
||||
export function popSuspenseContext(fiber: Fiber): void {
|
||||
pop(suspenseStackCursor, fiber);
|
||||
}
|
||||
+31
-1
@@ -13,6 +13,7 @@ import type {ExpirationTime} from './ReactFiberExpirationTime';
|
||||
import type {CapturedValue} from './ReactCapturedValue';
|
||||
import type {Update} from './ReactUpdateQueue';
|
||||
import type {Thenable} from './ReactFiberScheduler';
|
||||
import type {SuspenseContext} from './ReactFiberSuspenseContext';
|
||||
|
||||
import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
|
||||
import getComponentName from 'shared/getComponentName';
|
||||
@@ -55,6 +56,12 @@ import {
|
||||
import {logError} from './ReactFiberCommitWork';
|
||||
import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
|
||||
import {popHostContainer, popHostContext} from './ReactFiberHostContext';
|
||||
import {
|
||||
suspenseStackCursor,
|
||||
InvisibleParentSuspenseContext,
|
||||
hasSuspenseContext,
|
||||
popSuspenseContext,
|
||||
} from './ReactFiberSuspenseContext';
|
||||
import {
|
||||
isContextProvider as isLegacyContextProvider,
|
||||
popContext as popLegacyContext,
|
||||
@@ -206,12 +213,17 @@ function throwException(
|
||||
|
||||
checkForWrongSuspensePriorityInDEV(sourceFiber);
|
||||
|
||||
let hasInvisibleParentBoundary = hasSuspenseContext(
|
||||
suspenseStackCursor.current,
|
||||
(InvisibleParentSuspenseContext: SuspenseContext),
|
||||
);
|
||||
|
||||
// Schedule the nearest Suspense to re-render the timed out view.
|
||||
let workInProgress = returnFiber;
|
||||
do {
|
||||
if (
|
||||
workInProgress.tag === SuspenseComponent &&
|
||||
shouldCaptureSuspense(workInProgress)
|
||||
shouldCaptureSuspense(workInProgress, hasInvisibleParentBoundary)
|
||||
) {
|
||||
// Found the nearest boundary.
|
||||
|
||||
@@ -274,6 +286,13 @@ function throwException(
|
||||
|
||||
workInProgress.effectTag |= ShouldCapture;
|
||||
workInProgress.expirationTime = renderExpirationTime;
|
||||
|
||||
if (!hasInvisibleParentBoundary) {
|
||||
// TODO: If we're not in an invisible subtree, then we need to mark this render
|
||||
// pass as needing to suspend for longer to avoid showing this fallback state.
|
||||
// We could do it here or when we render the fallback.
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (
|
||||
enableSuspenseServerRenderer &&
|
||||
@@ -408,6 +427,7 @@ function unwindWork(
|
||||
return null;
|
||||
}
|
||||
case SuspenseComponent: {
|
||||
popSuspenseContext(workInProgress);
|
||||
const effectTag = workInProgress.effectTag;
|
||||
if (effectTag & ShouldCapture) {
|
||||
workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
|
||||
@@ -419,6 +439,7 @@ function unwindWork(
|
||||
case DehydratedSuspenseComponent: {
|
||||
if (enableSuspenseServerRenderer) {
|
||||
// TODO: popHydrationState
|
||||
popSuspenseContext(workInProgress);
|
||||
const effectTag = workInProgress.effectTag;
|
||||
if (effectTag & ShouldCapture) {
|
||||
workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
|
||||
@@ -466,6 +487,15 @@ function unwindInterruptedWork(interruptedWork: Fiber) {
|
||||
case HostPortal:
|
||||
popHostContainer(interruptedWork);
|
||||
break;
|
||||
case SuspenseComponent:
|
||||
popSuspenseContext(interruptedWork);
|
||||
break;
|
||||
case DehydratedSuspenseComponent:
|
||||
if (enableSuspenseServerRenderer) {
|
||||
// TODO: popHydrationState
|
||||
popSuspenseContext(interruptedWork);
|
||||
}
|
||||
break;
|
||||
case ContextProvider:
|
||||
popProvider(interruptedWork);
|
||||
break;
|
||||
|
||||
+69
@@ -56,6 +56,10 @@ describe('ReactSuspenseWithNoopRenderer', () => {
|
||||
return {type: 'span', children: [], prop, hidden: false};
|
||||
}
|
||||
|
||||
function hiddenSpan(prop) {
|
||||
return {type: 'span', children: [], prop, hidden: true};
|
||||
}
|
||||
|
||||
function advanceTimers(ms) {
|
||||
// Note: This advances Jest's virtual time but not React's. Use
|
||||
// ReactNoop.expire for that.
|
||||
@@ -1772,4 +1776,69 @@ describe('ReactSuspenseWithNoopRenderer', () => {
|
||||
{withoutStack: true},
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the parent boundary if the inner boundary should be avoided', async () => {
|
||||
function Foo({showC}) {
|
||||
Scheduler.yieldValue('Foo');
|
||||
return (
|
||||
<Suspense fallback={<Text text="Initial load..." />}>
|
||||
<Suspense
|
||||
unstable_avoidThisFallback={true}
|
||||
fallback={<Text text="Updating..." />}>
|
||||
<AsyncText text="A" ms={5000} />
|
||||
{showC ? <AsyncText text="C" ms={5000} /> : null}
|
||||
</Suspense>
|
||||
<Text text="B" />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
ReactNoop.render(<Foo />);
|
||||
expect(Scheduler).toFlushAndYield([
|
||||
'Foo',
|
||||
'Suspend! [A]',
|
||||
'B',
|
||||
'Initial load...',
|
||||
]);
|
||||
// We're still suspended.
|
||||
expect(ReactNoop.getChildren()).toEqual([]);
|
||||
// Flush to skip suspended time.
|
||||
Scheduler.advanceTime(600);
|
||||
await advanceTimers(600);
|
||||
expect(ReactNoop.getChildren()).toEqual([span('Initial load...')]);
|
||||
|
||||
// Eventually we resolve and show the data.
|
||||
Scheduler.advanceTime(5000);
|
||||
await advanceTimers(5000);
|
||||
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
|
||||
expect(Scheduler).toFlushAndYield(['A', 'B']);
|
||||
expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
|
||||
|
||||
// Update to show C
|
||||
ReactNoop.render(<Foo showC={true} />);
|
||||
expect(Scheduler).toFlushAndYield([
|
||||
'Foo',
|
||||
'A',
|
||||
'Suspend! [C]',
|
||||
'Updating...',
|
||||
'B',
|
||||
]);
|
||||
// Flush to skip suspended time.
|
||||
Scheduler.advanceTime(600);
|
||||
await advanceTimers(600);
|
||||
// Since the optional suspense boundary is already showing its content,
|
||||
// we have to use the inner fallback instead.
|
||||
expect(ReactNoop.getChildren()).toEqual([
|
||||
hiddenSpan('A'),
|
||||
span('Updating...'),
|
||||
span('B'),
|
||||
]);
|
||||
|
||||
// Later we load the data.
|
||||
Scheduler.advanceTime(5000);
|
||||
await advanceTimers(5000);
|
||||
expect(Scheduler).toHaveYielded(['Promise resolved [C]']);
|
||||
expect(Scheduler).toFlushAndYield(['A', 'C']);
|
||||
expect(ReactNoop.getChildren()).toEqual([span('A'), span('C'), span('B')]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user