Suspending inside a hidden tree should not cause fallbacks to appear (#24699)
* [FORKED] Hidden trees should capture Suspense If something suspends inside a hidden tree, it should not affect anything in the visible part of the UI. This means that Offscreen acts like a Suspense boundary whenever it's in its hidden state. * Add previous commit to forked revisions
This commit is contained in:
parent
c1f5884ffe
commit
82e9e99098
|
@ -719,6 +719,7 @@ export function createFiberFromOffscreen(
|
|||
const primaryChildInstance: OffscreenInstance = {
|
||||
isHidden: false,
|
||||
pendingMarkers: null,
|
||||
retryCache: null,
|
||||
transitions: null,
|
||||
};
|
||||
fiber.stateNode = primaryChildInstance;
|
||||
|
@ -740,6 +741,7 @@ export function createFiberFromLegacyHidden(
|
|||
isHidden: false,
|
||||
pendingMarkers: null,
|
||||
transitions: null,
|
||||
retryCache: null,
|
||||
};
|
||||
fiber.stateNode = instance;
|
||||
return fiber;
|
||||
|
|
|
@ -719,6 +719,7 @@ export function createFiberFromOffscreen(
|
|||
const primaryChildInstance: OffscreenInstance = {
|
||||
isHidden: false,
|
||||
pendingMarkers: null,
|
||||
retryCache: null,
|
||||
transitions: null,
|
||||
};
|
||||
fiber.stateNode = primaryChildInstance;
|
||||
|
@ -740,6 +741,7 @@ export function createFiberFromLegacyHidden(
|
|||
isHidden: false,
|
||||
pendingMarkers: null,
|
||||
transitions: null,
|
||||
retryCache: null,
|
||||
};
|
||||
fiber.stateNode = instance;
|
||||
return fiber;
|
||||
|
|
|
@ -174,6 +174,8 @@ import {
|
|||
setShallowSuspenseListContext,
|
||||
pushPrimaryTreeSuspenseHandler,
|
||||
pushFallbackTreeSuspenseHandler,
|
||||
pushOffscreenSuspenseHandler,
|
||||
reuseSuspenseHandlerOnStack,
|
||||
popSuspenseHandler,
|
||||
} from './ReactFiberSuspenseContext.new';
|
||||
import {
|
||||
|
@ -678,6 +680,52 @@ function updateOffscreenComponent(
|
|||
(enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding')
|
||||
) {
|
||||
// Rendering a hidden tree.
|
||||
|
||||
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
|
||||
if (didSuspend) {
|
||||
// Something suspended inside a hidden tree
|
||||
|
||||
// Include the base lanes from the last render
|
||||
const nextBaseLanes =
|
||||
prevState !== null
|
||||
? mergeLanes(prevState.baseLanes, renderLanes)
|
||||
: renderLanes;
|
||||
|
||||
if (current !== null) {
|
||||
// Reset to the current children
|
||||
let currentChild = (workInProgress.child = current.child);
|
||||
|
||||
// The current render suspended, but there may be other lanes with
|
||||
// pending work. We can't read `childLanes` from the current Offscreen
|
||||
// fiber because we reset it when it was deferred; however, we can read
|
||||
// the pending lanes from the child fibers.
|
||||
let currentChildLanes = NoLanes;
|
||||
while (currentChild !== null) {
|
||||
currentChildLanes = mergeLanes(
|
||||
mergeLanes(currentChildLanes, currentChild.lanes),
|
||||
currentChild.childLanes,
|
||||
);
|
||||
currentChild = currentChild.sibling;
|
||||
}
|
||||
const lanesWeJustAttempted = nextBaseLanes;
|
||||
const remainingChildLanes = removeLanes(
|
||||
currentChildLanes,
|
||||
lanesWeJustAttempted,
|
||||
);
|
||||
workInProgress.childLanes = remainingChildLanes;
|
||||
} else {
|
||||
workInProgress.childLanes = NoLanes;
|
||||
workInProgress.child = null;
|
||||
}
|
||||
|
||||
return deferHiddenOffscreenComponent(
|
||||
current,
|
||||
workInProgress,
|
||||
nextBaseLanes,
|
||||
renderLanes,
|
||||
);
|
||||
}
|
||||
|
||||
if ((workInProgress.mode & ConcurrentMode) === NoMode) {
|
||||
// In legacy sync mode, don't defer the subtree. Render it now.
|
||||
// TODO: Consider how Offscreen should work with transitions in the future
|
||||
|
@ -694,50 +742,28 @@ function updateOffscreenComponent(
|
|||
}
|
||||
}
|
||||
reuseHiddenContextOnStack(workInProgress);
|
||||
pushOffscreenSuspenseHandler(workInProgress);
|
||||
} else if (!includesSomeLane(renderLanes, (OffscreenLane: Lane))) {
|
||||
// We're hidden, and we're not rendering at Offscreen. We will bail out
|
||||
// and resume this tree later.
|
||||
let nextBaseLanes = renderLanes;
|
||||
if (prevState !== null) {
|
||||
// Include the base lanes from the last render
|
||||
nextBaseLanes = mergeLanes(nextBaseLanes, prevState.baseLanes);
|
||||
}
|
||||
|
||||
// Schedule this fiber to re-render at offscreen priority. Then bailout.
|
||||
// Schedule this fiber to re-render at Offscreen priority
|
||||
workInProgress.lanes = workInProgress.childLanes = laneToLanes(
|
||||
OffscreenLane,
|
||||
);
|
||||
const nextState: OffscreenState = {
|
||||
baseLanes: nextBaseLanes,
|
||||
// Save the cache pool so we can resume later.
|
||||
cachePool: enableCache ? getOffscreenDeferredCache() : null,
|
||||
};
|
||||
workInProgress.memoizedState = nextState;
|
||||
workInProgress.updateQueue = null;
|
||||
if (enableCache) {
|
||||
// push the cache pool even though we're going to bail out
|
||||
// because otherwise there'd be a context mismatch
|
||||
if (current !== null) {
|
||||
pushTransition(workInProgress, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
// We're about to bail out, but we need to push this to the stack anyway
|
||||
// to avoid a push/pop misalignment.
|
||||
reuseHiddenContextOnStack(workInProgress);
|
||||
// Include the base lanes from the last render
|
||||
const nextBaseLanes =
|
||||
prevState !== null
|
||||
? mergeLanes(prevState.baseLanes, renderLanes)
|
||||
: renderLanes;
|
||||
|
||||
if (enableLazyContextPropagation && current !== null) {
|
||||
// Since this tree will resume rendering in a separate render, we need
|
||||
// to propagate parent contexts now so we don't lose track of which
|
||||
// ones changed.
|
||||
propagateParentContextChangesToDeferredTree(
|
||||
current,
|
||||
workInProgress,
|
||||
renderLanes,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return deferHiddenOffscreenComponent(
|
||||
current,
|
||||
workInProgress,
|
||||
nextBaseLanes,
|
||||
renderLanes,
|
||||
);
|
||||
} else {
|
||||
// This is the second render. The surrounding visible content has already
|
||||
// committed. Now we resume rendering the hidden tree.
|
||||
|
@ -764,6 +790,7 @@ function updateOffscreenComponent(
|
|||
} else {
|
||||
reuseHiddenContextOnStack(workInProgress);
|
||||
}
|
||||
pushOffscreenSuspenseHandler(workInProgress);
|
||||
}
|
||||
} else {
|
||||
// Rendering a visible tree.
|
||||
|
@ -791,6 +818,7 @@ function updateOffscreenComponent(
|
|||
|
||||
// Push the lanes that were skipped when we bailed out.
|
||||
pushHiddenContext(workInProgress, prevState);
|
||||
reuseSuspenseHandlerOnStack(workInProgress);
|
||||
|
||||
// Since we're not hidden anymore, reset the state
|
||||
workInProgress.memoizedState = null;
|
||||
|
@ -811,6 +839,7 @@ function updateOffscreenComponent(
|
|||
// We're about to bail out, but we need to push this to the stack anyway
|
||||
// to avoid a push/pop misalignment.
|
||||
reuseHiddenContextOnStack(workInProgress);
|
||||
reuseSuspenseHandlerOnStack(workInProgress);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -818,6 +847,46 @@ function updateOffscreenComponent(
|
|||
return workInProgress.child;
|
||||
}
|
||||
|
||||
function deferHiddenOffscreenComponent(
|
||||
current: Fiber | null,
|
||||
workInProgress: Fiber,
|
||||
nextBaseLanes: Lanes,
|
||||
renderLanes: Lanes,
|
||||
) {
|
||||
const nextState: OffscreenState = {
|
||||
baseLanes: nextBaseLanes,
|
||||
// Save the cache pool so we can resume later.
|
||||
cachePool: enableCache ? getOffscreenDeferredCache() : null,
|
||||
};
|
||||
workInProgress.memoizedState = nextState;
|
||||
if (enableCache) {
|
||||
// push the cache pool even though we're going to bail out
|
||||
// because otherwise there'd be a context mismatch
|
||||
if (current !== null) {
|
||||
pushTransition(workInProgress, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
// We're about to bail out, but we need to push this to the stack anyway
|
||||
// to avoid a push/pop misalignment.
|
||||
reuseHiddenContextOnStack(workInProgress);
|
||||
|
||||
pushOffscreenSuspenseHandler(workInProgress);
|
||||
|
||||
if (enableLazyContextPropagation && current !== null) {
|
||||
// Since this tree will resume rendering in a separate render, we need
|
||||
// to propagate parent contexts now so we don't lose track of which
|
||||
// ones changed.
|
||||
propagateParentContextChangesToDeferredTree(
|
||||
current,
|
||||
workInProgress,
|
||||
renderLanes,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: These happen to have identical begin phases, for now. We shouldn't hold
|
||||
// ourselves to this constraint, though. If the behavior diverges, we should
|
||||
// fork the function.
|
||||
|
@ -2109,13 +2178,19 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
|
|||
if (enableTransitionTracing) {
|
||||
const currentTransitions = getPendingTransitions();
|
||||
if (currentTransitions !== null) {
|
||||
// If there are no transitions, we don't need to keep track of tracing markers
|
||||
const parentMarkerInstances = getMarkerInstances();
|
||||
const primaryChildUpdateQueue: OffscreenQueue = {
|
||||
transitions: currentTransitions,
|
||||
markerInstances: parentMarkerInstances,
|
||||
};
|
||||
primaryChildFragment.updateQueue = primaryChildUpdateQueue;
|
||||
const offscreenQueue: OffscreenQueue | null = (primaryChildFragment.updateQueue: any);
|
||||
if (offscreenQueue === null) {
|
||||
const newOffscreenQueue: OffscreenQueue = {
|
||||
transitions: currentTransitions,
|
||||
markerInstances: parentMarkerInstances,
|
||||
wakeables: null,
|
||||
};
|
||||
primaryChildFragment.updateQueue = newOffscreenQueue;
|
||||
} else {
|
||||
offscreenQueue.transitions = currentTransitions;
|
||||
offscreenQueue.markerInstances = parentMarkerInstances;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2140,6 +2215,8 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
|
|||
);
|
||||
workInProgress.memoizedState = SUSPENDED_MARKER;
|
||||
|
||||
// TODO: Transition Tracing is not yet implemented for CPU Suspense.
|
||||
|
||||
// Since nothing actually suspended, there will nothing to ping this to
|
||||
// get it started back up to attempt the next item. While in terms of
|
||||
// priority this work has the same priority as this current render, it's
|
||||
|
@ -2201,11 +2278,31 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
|
|||
const currentTransitions = getPendingTransitions();
|
||||
if (currentTransitions !== null) {
|
||||
const parentMarkerInstances = getMarkerInstances();
|
||||
const primaryChildUpdateQueue: OffscreenQueue = {
|
||||
transitions: currentTransitions,
|
||||
markerInstances: parentMarkerInstances,
|
||||
};
|
||||
primaryChildFragment.updateQueue = primaryChildUpdateQueue;
|
||||
const offscreenQueue: OffscreenQueue | null = (primaryChildFragment.updateQueue: any);
|
||||
const currentOffscreenQueue: OffscreenQueue | null = (current.updateQueue: any);
|
||||
if (offscreenQueue === null) {
|
||||
const newOffscreenQueue: OffscreenQueue = {
|
||||
transitions: currentTransitions,
|
||||
markerInstances: parentMarkerInstances,
|
||||
wakeables: null,
|
||||
};
|
||||
primaryChildFragment.updateQueue = newOffscreenQueue;
|
||||
} else if (offscreenQueue === currentOffscreenQueue) {
|
||||
// If the work-in-progress queue is the same object as current, we
|
||||
// can't modify it without cloning it first.
|
||||
const newOffscreenQueue: OffscreenQueue = {
|
||||
transitions: currentTransitions,
|
||||
markerInstances: parentMarkerInstances,
|
||||
wakeables:
|
||||
currentOffscreenQueue !== null
|
||||
? currentOffscreenQueue.wakeables
|
||||
: null,
|
||||
};
|
||||
primaryChildFragment.updateQueue = newOffscreenQueue;
|
||||
} else {
|
||||
offscreenQueue.transitions = currentTransitions;
|
||||
offscreenQueue.markerInstances = parentMarkerInstances;
|
||||
}
|
||||
}
|
||||
}
|
||||
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
|
||||
|
|
|
@ -2130,6 +2130,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
|
|||
const primaryChildUpdateQueue: OffscreenQueue = {
|
||||
transitions: currentTransitions,
|
||||
markerInstances: parentMarkerInstances,
|
||||
wakeables: null,
|
||||
};
|
||||
primaryChildFragment.updateQueue = primaryChildUpdateQueue;
|
||||
}
|
||||
|
@ -2216,6 +2217,7 @@ function updateSuspenseComponent(current, workInProgress, renderLanes) {
|
|||
const primaryChildUpdateQueue: OffscreenQueue = {
|
||||
transitions: currentTransitions,
|
||||
markerInstances: parentMarkerInstances,
|
||||
wakeables: null,
|
||||
};
|
||||
primaryChildFragment.updateQueue = primaryChildUpdateQueue;
|
||||
}
|
||||
|
|
|
@ -1953,40 +1953,65 @@ function commitSuspenseHydrationCallbacks(
|
|||
}
|
||||
}
|
||||
|
||||
function attachSuspenseRetryListeners(finishedWork: Fiber) {
|
||||
function getRetryCache(finishedWork) {
|
||||
// TODO: Unify the interface for the retry cache so we don't have to switch
|
||||
// on the tag like this.
|
||||
switch (finishedWork.tag) {
|
||||
case SuspenseComponent:
|
||||
case SuspenseListComponent: {
|
||||
let retryCache = finishedWork.stateNode;
|
||||
if (retryCache === null) {
|
||||
retryCache = finishedWork.stateNode = new PossiblyWeakSet();
|
||||
}
|
||||
return retryCache;
|
||||
}
|
||||
case OffscreenComponent: {
|
||||
const instance: OffscreenInstance = finishedWork.stateNode;
|
||||
let retryCache = instance.retryCache;
|
||||
if (retryCache === null) {
|
||||
retryCache = instance.retryCache = new PossiblyWeakSet();
|
||||
}
|
||||
return retryCache;
|
||||
}
|
||||
default: {
|
||||
throw new Error(
|
||||
`Unexpected Suspense handler tag (${finishedWork.tag}). This is a ` +
|
||||
'bug in React.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function attachSuspenseRetryListeners(
|
||||
finishedWork: Fiber,
|
||||
wakeables: Set<Wakeable>,
|
||||
) {
|
||||
// If this boundary just timed out, then it will have a set of wakeables.
|
||||
// For each wakeable, attach a listener so that when it resolves, React
|
||||
// attempts to re-render the boundary in the primary (pre-timeout) state.
|
||||
const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any);
|
||||
if (wakeables !== null) {
|
||||
finishedWork.updateQueue = null;
|
||||
let retryCache = finishedWork.stateNode;
|
||||
if (retryCache === null) {
|
||||
retryCache = finishedWork.stateNode = new PossiblyWeakSet();
|
||||
}
|
||||
wakeables.forEach(wakeable => {
|
||||
// Memoize using the boundary fiber to prevent redundant listeners.
|
||||
const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable);
|
||||
if (!retryCache.has(wakeable)) {
|
||||
retryCache.add(wakeable);
|
||||
const retryCache = getRetryCache(finishedWork);
|
||||
wakeables.forEach(wakeable => {
|
||||
// Memoize using the boundary fiber to prevent redundant listeners.
|
||||
const retry = resolveRetryWakeable.bind(null, finishedWork, wakeable);
|
||||
if (!retryCache.has(wakeable)) {
|
||||
retryCache.add(wakeable);
|
||||
|
||||
if (enableUpdaterTracking) {
|
||||
if (isDevToolsPresent) {
|
||||
if (inProgressLanes !== null && inProgressRoot !== null) {
|
||||
// If we have pending work still, associate the original updaters with it.
|
||||
restorePendingUpdaters(inProgressRoot, inProgressLanes);
|
||||
} else {
|
||||
throw Error(
|
||||
'Expected finished root and lanes to be set. This is a bug in React.',
|
||||
);
|
||||
}
|
||||
if (enableUpdaterTracking) {
|
||||
if (isDevToolsPresent) {
|
||||
if (inProgressLanes !== null && inProgressRoot !== null) {
|
||||
// If we have pending work still, associate the original updaters with it.
|
||||
restorePendingUpdaters(inProgressRoot, inProgressLanes);
|
||||
} else {
|
||||
throw Error(
|
||||
'Expected finished root and lanes to be set. This is a bug in React.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
wakeable.then(retry, retry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wakeable.then(retry, retry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// This function detects when a Suspense boundary goes from visible to hidden.
|
||||
|
@ -2307,7 +2332,11 @@ function commitMutationEffectsOnFiber(
|
|||
} catch (error) {
|
||||
captureCommitPhaseError(finishedWork, finishedWork.return, error);
|
||||
}
|
||||
attachSuspenseRetryListeners(finishedWork);
|
||||
const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any);
|
||||
if (wakeables !== null) {
|
||||
finishedWork.updateQueue = null;
|
||||
attachSuspenseRetryListeners(finishedWork, wakeables);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -2362,6 +2391,18 @@ function commitMutationEffectsOnFiber(
|
|||
hideOrUnhideAllChildren(offscreenBoundary, isHidden);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Move to passive phase
|
||||
if (flags & Update) {
|
||||
const offscreenQueue: OffscreenQueue | null = (finishedWork.updateQueue: any);
|
||||
if (offscreenQueue !== null) {
|
||||
const wakeables = offscreenQueue.wakeables;
|
||||
if (wakeables !== null) {
|
||||
offscreenQueue.wakeables = null;
|
||||
attachSuspenseRetryListeners(finishedWork, wakeables);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
case SuspenseListComponent: {
|
||||
|
@ -2369,7 +2410,11 @@ function commitMutationEffectsOnFiber(
|
|||
commitReconciliationEffects(finishedWork);
|
||||
|
||||
if (flags & Update) {
|
||||
attachSuspenseRetryListeners(finishedWork);
|
||||
const wakeables: Set<Wakeable> | null = (finishedWork.updateQueue: any);
|
||||
if (wakeables !== null) {
|
||||
finishedWork.updateQueue = null;
|
||||
attachSuspenseRetryListeners(finishedWork, wakeables);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -2878,7 +2923,7 @@ function commitPassiveMountOnFiber(
|
|||
|
||||
if (enableTransitionTracing) {
|
||||
const isFallback = finishedWork.memoizedState;
|
||||
const queue: OffscreenQueue = (finishedWork.updateQueue: any);
|
||||
const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
|
||||
const instance: OffscreenInstance = finishedWork.stateNode;
|
||||
|
||||
if (queue !== null) {
|
||||
|
|
|
@ -2878,7 +2878,7 @@ function commitPassiveMountOnFiber(
|
|||
|
||||
if (enableTransitionTracing) {
|
||||
const isFallback = finishedWork.memoizedState;
|
||||
const queue: OffscreenQueue = (finishedWork.updateQueue: any);
|
||||
const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
|
||||
const instance: OffscreenInstance = finishedWork.stateNode;
|
||||
|
||||
if (queue !== null) {
|
||||
|
|
|
@ -1508,6 +1508,7 @@ function completeWork(
|
|||
}
|
||||
case OffscreenComponent:
|
||||
case LegacyHiddenComponent: {
|
||||
popSuspenseHandler(workInProgress);
|
||||
popHiddenContext(workInProgress);
|
||||
const nextState: OffscreenState | null = workInProgress.memoizedState;
|
||||
const nextIsHidden = nextState !== null;
|
||||
|
@ -1529,7 +1530,11 @@ function completeWork(
|
|||
} else {
|
||||
// Don't bubble properties for hidden children unless we're rendering
|
||||
// at offscreen priority.
|
||||
if (includesSomeLane(renderLanes, (OffscreenLane: Lane))) {
|
||||
if (
|
||||
includesSomeLane(renderLanes, (OffscreenLane: Lane)) &&
|
||||
// Also don't bubble if the tree suspended
|
||||
(workInProgress.flags & DidCapture) === NoLanes
|
||||
) {
|
||||
bubbleProperties(workInProgress);
|
||||
// Check if there was an insertion or update in the hidden subtree.
|
||||
// If so, we need to hide those nodes in the commit phase, so
|
||||
|
@ -1544,6 +1549,12 @@ function completeWork(
|
|||
}
|
||||
}
|
||||
|
||||
if (workInProgress.updateQueue !== null) {
|
||||
// Schedule an effect to attach Suspense retry listeners
|
||||
// TODO: Move to passive phase
|
||||
workInProgress.flags |= Update;
|
||||
}
|
||||
|
||||
if (enableCache) {
|
||||
let previousCache: Cache | null = null;
|
||||
if (
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* @flow
|
||||
*/
|
||||
|
||||
import type {ReactNodeList, OffscreenMode} from 'shared/ReactTypes';
|
||||
import type {ReactNodeList, OffscreenMode, Wakeable} from 'shared/ReactTypes';
|
||||
import type {Lanes} from './ReactFiberLane.old';
|
||||
import type {SpawnedCachePool} from './ReactFiberCacheComponent.new';
|
||||
import type {
|
||||
|
@ -40,10 +40,12 @@ export type OffscreenState = {|
|
|||
export type OffscreenQueue = {|
|
||||
transitions: Array<Transition> | null,
|
||||
markerInstances: Array<TracingMarkerInstance> | null,
|
||||
|} | null;
|
||||
wakeables: Set<Wakeable> | null,
|
||||
|};
|
||||
|
||||
export type OffscreenInstance = {|
|
||||
isHidden: boolean,
|
||||
pendingMarkers: Set<PendingSuspenseBoundaries> | null,
|
||||
transitions: Set<Transition> | null,
|
||||
retryCache: WeakSet<Wakeable> | Set<Wakeable> | null,
|
||||
|};
|
||||
|
|
|
@ -14,7 +14,7 @@ import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
|
|||
import {enableSuspenseAvoidThisFallback} from 'shared/ReactFeatureFlags';
|
||||
import {createCursor, push, pop} from './ReactFiberStack.new';
|
||||
import {isCurrentTreeHidden} from './ReactFiberHiddenContext.new';
|
||||
import {SuspenseComponent} from './ReactWorkTags';
|
||||
import {SuspenseComponent, OffscreenComponent} from './ReactWorkTags';
|
||||
|
||||
// The Suspense handler is the boundary that should capture if something
|
||||
// suspends, i.e. it's the nearest `catch` block on the stack.
|
||||
|
@ -77,6 +77,19 @@ export function pushFallbackTreeSuspenseHandler(fiber: Fiber): void {
|
|||
// We're about to render the fallback. If something in the fallback suspends,
|
||||
// it's akin to throwing inside of a `catch` block. This boundary should not
|
||||
// capture. Reuse the existing handler on the stack.
|
||||
reuseSuspenseHandlerOnStack(fiber);
|
||||
}
|
||||
|
||||
export function pushOffscreenSuspenseHandler(fiber: Fiber): void {
|
||||
if (fiber.tag === OffscreenComponent) {
|
||||
push(suspenseHandlerStackCursor, fiber, fiber);
|
||||
} else {
|
||||
// This is a LegacyHidden component.
|
||||
reuseSuspenseHandlerOnStack(fiber);
|
||||
}
|
||||
}
|
||||
|
||||
export function reuseSuspenseHandlerOnStack(fiber: Fiber) {
|
||||
push(suspenseHandlerStackCursor, getSuspenseHandler(), fiber);
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import type {Lane, Lanes} from './ReactFiberLane.new';
|
|||
import type {CapturedValue} from './ReactCapturedValue';
|
||||
import type {Update} from './ReactFiberClassUpdateQueue.new';
|
||||
import type {Wakeable} from 'shared/ReactTypes';
|
||||
import type {OffscreenQueue} from './ReactFiberOffscreenComponent';
|
||||
|
||||
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
|
||||
import {
|
||||
|
@ -22,6 +23,8 @@ import {
|
|||
FunctionComponent,
|
||||
ForwardRef,
|
||||
SimpleMemoComponent,
|
||||
SuspenseComponent,
|
||||
OffscreenComponent,
|
||||
} from './ReactWorkTags';
|
||||
import {
|
||||
DidCapture,
|
||||
|
@ -196,33 +199,6 @@ function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
|
|||
}
|
||||
}
|
||||
|
||||
function attachRetryListener(
|
||||
suspenseBoundary: Fiber,
|
||||
root: FiberRoot,
|
||||
wakeable: Wakeable,
|
||||
lanes: Lanes,
|
||||
) {
|
||||
// Retry listener
|
||||
//
|
||||
// If the fallback does commit, we need to attach a different type of
|
||||
// listener. This one schedules an update on the Suspense boundary to turn
|
||||
// the fallback state off.
|
||||
//
|
||||
// Stash the wakeable on the boundary fiber so we can access it in the
|
||||
// commit phase.
|
||||
//
|
||||
// When the wakeable resolves, we'll attempt to render the boundary
|
||||
// again ("retry").
|
||||
const wakeables: Set<Wakeable> | null = (suspenseBoundary.updateQueue: any);
|
||||
if (wakeables === null) {
|
||||
const updateQueue = (new Set(): any);
|
||||
updateQueue.add(wakeable);
|
||||
suspenseBoundary.updateQueue = updateQueue;
|
||||
} else {
|
||||
wakeables.add(wakeable);
|
||||
}
|
||||
}
|
||||
|
||||
function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) {
|
||||
if (enableLazyContextPropagation) {
|
||||
const currentSourceFiber = sourceFiber.alternate;
|
||||
|
@ -419,20 +395,70 @@ function throwException(
|
|||
// Schedule the nearest Suspense to re-render the timed out view.
|
||||
const suspenseBoundary = getSuspenseHandler();
|
||||
if (suspenseBoundary !== null) {
|
||||
suspenseBoundary.flags &= ~ForceClientRender;
|
||||
markSuspenseBoundaryShouldCapture(
|
||||
suspenseBoundary,
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
switch (suspenseBoundary.tag) {
|
||||
case SuspenseComponent: {
|
||||
suspenseBoundary.flags &= ~ForceClientRender;
|
||||
markSuspenseBoundaryShouldCapture(
|
||||
suspenseBoundary,
|
||||
returnFiber,
|
||||
sourceFiber,
|
||||
root,
|
||||
rootRenderLanes,
|
||||
);
|
||||
// Retry listener
|
||||
//
|
||||
// If the fallback does commit, we need to attach a different type of
|
||||
// listener. This one schedules an update on the Suspense boundary to
|
||||
// turn the fallback state off.
|
||||
//
|
||||
// Stash the wakeable on the boundary fiber so we can access it in the
|
||||
// commit phase.
|
||||
//
|
||||
// When the wakeable resolves, we'll attempt to render the boundary
|
||||
// again ("retry").
|
||||
const wakeables: Set<Wakeable> | null = (suspenseBoundary.updateQueue: any);
|
||||
if (wakeables === null) {
|
||||
suspenseBoundary.updateQueue = new Set([wakeable]);
|
||||
} else {
|
||||
wakeables.add(wakeable);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case OffscreenComponent: {
|
||||
if (suspenseBoundary.mode & ConcurrentMode) {
|
||||
suspenseBoundary.flags |= ShouldCapture;
|
||||
const offscreenQueue: OffscreenQueue | null = (suspenseBoundary.updateQueue: any);
|
||||
if (offscreenQueue === null) {
|
||||
const newOffscreenQueue: OffscreenQueue = {
|
||||
transitions: null,
|
||||
markerInstances: null,
|
||||
wakeables: new Set([wakeable]),
|
||||
};
|
||||
suspenseBoundary.updateQueue = newOffscreenQueue;
|
||||
} else {
|
||||
const wakeables = offscreenQueue.wakeables;
|
||||
if (wakeables === null) {
|
||||
offscreenQueue.wakeables = new Set([wakeable]);
|
||||
} else {
|
||||
wakeables.add(wakeable);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
default: {
|
||||
throw new Error(
|
||||
`Unexpected Suspense handler tag (${suspenseBoundary.tag}). This ` +
|
||||
'is a bug in React.',
|
||||
);
|
||||
}
|
||||
}
|
||||
// We only attach ping listeners in concurrent mode. Legacy Suspense always
|
||||
// commits fallbacks synchronously, so there are no pings.
|
||||
if (suspenseBoundary.mode & ConcurrentMode) {
|
||||
attachPingListener(root, wakeable, rootRenderLanes);
|
||||
}
|
||||
attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes);
|
||||
return;
|
||||
} else {
|
||||
// No boundary was found. Unless this is a sync update, this is OK.
|
||||
|
|
|
@ -162,10 +162,24 @@ function unwindWork(
|
|||
popProvider(context, workInProgress);
|
||||
return null;
|
||||
case OffscreenComponent:
|
||||
case LegacyHiddenComponent:
|
||||
case LegacyHiddenComponent: {
|
||||
popSuspenseHandler(workInProgress);
|
||||
popHiddenContext(workInProgress);
|
||||
popTransition(workInProgress, current);
|
||||
const flags = workInProgress.flags;
|
||||
if (flags & ShouldCapture) {
|
||||
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
|
||||
// Captured a suspense effect. Re-render the boundary.
|
||||
if (
|
||||
enableProfilerTimer &&
|
||||
(workInProgress.mode & ProfileMode) !== NoMode
|
||||
) {
|
||||
transferActualDuration(workInProgress);
|
||||
}
|
||||
return workInProgress;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case CacheComponent:
|
||||
if (enableCache) {
|
||||
const cache: Cache = workInProgress.memoizedState.cache;
|
||||
|
@ -238,6 +252,7 @@ function unwindInterruptedWork(
|
|||
break;
|
||||
case OffscreenComponent:
|
||||
case LegacyHiddenComponent:
|
||||
popSuspenseHandler(interruptedWork);
|
||||
popHiddenContext(interruptedWork);
|
||||
popTransition(interruptedWork, current);
|
||||
break;
|
||||
|
|
|
@ -20,6 +20,7 @@ import type {
|
|||
MarkerTransitionObject,
|
||||
Transition,
|
||||
} from './ReactFiberTracingMarkerComponent.new';
|
||||
import type {OffscreenInstance} from './ReactFiberOffscreenComponent';
|
||||
|
||||
import {
|
||||
warnAboutDeprecatedLifecycles,
|
||||
|
@ -96,6 +97,7 @@ import {
|
|||
ClassComponent,
|
||||
SuspenseComponent,
|
||||
SuspenseListComponent,
|
||||
OffscreenComponent,
|
||||
FunctionComponent,
|
||||
ForwardRef,
|
||||
MemoComponent,
|
||||
|
@ -2748,6 +2750,11 @@ export function resolveRetryWakeable(boundaryFiber: Fiber, wakeable: Wakeable) {
|
|||
case SuspenseListComponent:
|
||||
retryCache = boundaryFiber.stateNode;
|
||||
break;
|
||||
case OffscreenComponent: {
|
||||
const instance: OffscreenInstance = boundaryFiber.stateNode;
|
||||
retryCache = instance.retryCache;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
'Pinged unknown suspense boundary type. ' +
|
||||
|
|
|
@ -2,10 +2,12 @@ let React;
|
|||
let ReactNoop;
|
||||
let Scheduler;
|
||||
let act;
|
||||
let LegacyHidden;
|
||||
let Offscreen;
|
||||
let Suspense;
|
||||
let useState;
|
||||
let useEffect;
|
||||
let startTransition;
|
||||
let textCache;
|
||||
|
||||
describe('ReactOffscreen', () => {
|
||||
|
@ -16,10 +18,12 @@ describe('ReactOffscreen', () => {
|
|||
ReactNoop = require('react-noop-renderer');
|
||||
Scheduler = require('scheduler');
|
||||
act = require('jest-react').act;
|
||||
LegacyHidden = React.unstable_LegacyHidden;
|
||||
Offscreen = React.unstable_Offscreen;
|
||||
Suspense = React.Suspense;
|
||||
useState = React.useState;
|
||||
useEffect = React.useEffect;
|
||||
startTransition = React.startTransition;
|
||||
|
||||
textCache = new Map();
|
||||
});
|
||||
|
@ -86,6 +90,328 @@ describe('ReactOffscreen', () => {
|
|||
return text;
|
||||
}
|
||||
|
||||
// Only works in new reconciler
|
||||
// @gate variant
|
||||
// @gate enableOffscreen
|
||||
test('basic example of suspending inside hidden tree', async () => {
|
||||
const root = ReactNoop.createRoot();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<span>
|
||||
<Text text="Visible" />
|
||||
</span>
|
||||
<Offscreen mode="hidden">
|
||||
<span>
|
||||
<AsyncText text="Hidden" />
|
||||
</span>
|
||||
</Offscreen>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// The hidden tree hasn't finished loading, but we should still be able to
|
||||
// show the surrounding contents. The outer Suspense boundary
|
||||
// isn't affected.
|
||||
await act(async () => {
|
||||
root.render(<App />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['Visible', 'Suspend! [Hidden]']);
|
||||
expect(root).toMatchRenderedOutput(<span>Visible</span>);
|
||||
|
||||
// When the data resolves, we should be able to finish prerendering
|
||||
// the hidden tree.
|
||||
await act(async () => {
|
||||
await resolveText('Hidden');
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['Hidden']);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Visible</span>
|
||||
<span hidden={true}>Hidden</span>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
// @gate www
|
||||
test('LegacyHidden does not handle suspense', async () => {
|
||||
const root = ReactNoop.createRoot();
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<span>
|
||||
<Text text="Visible" />
|
||||
</span>
|
||||
<LegacyHidden mode="hidden">
|
||||
<span>
|
||||
<AsyncText text="Hidden" />
|
||||
</span>
|
||||
</LegacyHidden>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Unlike Offscreen, LegacyHidden never captures if something suspends
|
||||
await act(async () => {
|
||||
root.render(<App />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
'Visible',
|
||||
'Suspend! [Hidden]',
|
||||
'Loading...',
|
||||
]);
|
||||
// Nearest Suspense boundary switches to a fallback even though the
|
||||
// suspended content is hidden.
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span hidden={true}>Visible</span>
|
||||
Loading...
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
// Only works in new reconciler
|
||||
// @gate variant
|
||||
// @gate experimental || www
|
||||
test("suspending inside currently hidden tree that's switching to visible", async () => {
|
||||
const root = ReactNoop.createRoot();
|
||||
|
||||
function Details({open, children}) {
|
||||
return (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<span>
|
||||
<Text text={open ? 'Open' : 'Closed'} />
|
||||
</span>
|
||||
<Offscreen mode={open ? 'visible' : 'hidden'}>
|
||||
<span>{children}</span>
|
||||
</Offscreen>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// The hidden tree hasn't finished loading, but we should still be able to
|
||||
// show the surrounding contents. It doesn't matter that there's no
|
||||
// Suspense boundary because the unfinished content isn't visible.
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Details open={false}>
|
||||
<AsyncText text="Async" />
|
||||
</Details>,
|
||||
);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['Closed', 'Suspend! [Async]']);
|
||||
expect(root).toMatchRenderedOutput(<span>Closed</span>);
|
||||
|
||||
// But when we switch the boundary from hidden to visible, it should
|
||||
// now bubble to the nearest Suspense boundary.
|
||||
await act(async () => {
|
||||
startTransition(() => {
|
||||
root.render(
|
||||
<Details open={true}>
|
||||
<AsyncText text="Async" />
|
||||
</Details>,
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['Open', 'Suspend! [Async]', 'Loading...']);
|
||||
// It should suspend with delay to prevent the already-visible Suspense
|
||||
// boundary from switching to a fallback
|
||||
expect(root).toMatchRenderedOutput(<span>Closed</span>);
|
||||
|
||||
// Resolve the data and finish rendering
|
||||
await act(async () => {
|
||||
await resolveText('Async');
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['Open', 'Async']);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Open</span>
|
||||
<span>Async</span>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
// Only works in new reconciler
|
||||
// @gate variant
|
||||
// @gate enableOffscreen
|
||||
test("suspending inside currently visible tree that's switching to hidden", async () => {
|
||||
const root = ReactNoop.createRoot();
|
||||
|
||||
function Details({open, children}) {
|
||||
return (
|
||||
<Suspense fallback={<Text text="Loading..." />}>
|
||||
<span>
|
||||
<Text text={open ? 'Open' : 'Closed'} />
|
||||
</span>
|
||||
<Offscreen mode={open ? 'visible' : 'hidden'}>
|
||||
<span>{children}</span>
|
||||
</Offscreen>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial mount. Nothing suspends
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Details open={true}>
|
||||
<Text text="(empty)" />
|
||||
</Details>,
|
||||
);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['Open', '(empty)']);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Open</span>
|
||||
<span>(empty)</span>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Update that suspends inside the currently visible tree
|
||||
await act(async () => {
|
||||
startTransition(() => {
|
||||
root.render(
|
||||
<Details open={true}>
|
||||
<AsyncText text="Async" />
|
||||
</Details>,
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['Open', 'Suspend! [Async]', 'Loading...']);
|
||||
// It should suspend with delay to prevent the already-visible Suspense
|
||||
// boundary from switching to a fallback
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Open</span>
|
||||
<span>(empty)</span>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Update that hides the suspended tree
|
||||
await act(async () => {
|
||||
startTransition(() => {
|
||||
root.render(
|
||||
<Details open={false}>
|
||||
<AsyncText text="Async" />
|
||||
</Details>,
|
||||
);
|
||||
});
|
||||
});
|
||||
// Now the visible part of the tree can commit without being blocked
|
||||
// by the suspended content, which is hidden.
|
||||
expect(Scheduler).toHaveYielded(['Closed', 'Suspend! [Async]']);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Closed</span>
|
||||
<span hidden={true}>(empty)</span>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Resolve the data and finish rendering
|
||||
await act(async () => {
|
||||
await resolveText('Async');
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['Async']);
|
||||
expect(root).toMatchRenderedOutput(
|
||||
<>
|
||||
<span>Closed</span>
|
||||
<span hidden={true}>Async</span>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
// @gate experimental || www
|
||||
test('update that suspends inside hidden tree', async () => {
|
||||
let setText;
|
||||
function Child() {
|
||||
const [text, _setText] = useState('A');
|
||||
setText = _setText;
|
||||
return <AsyncText text={text} />;
|
||||
}
|
||||
|
||||
function App({show}) {
|
||||
return (
|
||||
<Offscreen mode={show ? 'visible' : 'hidden'}>
|
||||
<span>
|
||||
<Child />
|
||||
</span>
|
||||
</Offscreen>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot();
|
||||
resolveText('A');
|
||||
await act(async () => {
|
||||
root.render(<App show={false} />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['A']);
|
||||
|
||||
await act(async () => {
|
||||
startTransition(() => {
|
||||
setText('B');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Only works in new reconciler
|
||||
// @gate variant
|
||||
// @gate experimental || www
|
||||
test('updates at multiple priorities that suspend inside hidden tree', async () => {
|
||||
let setText;
|
||||
let setStep;
|
||||
function Child() {
|
||||
const [text, _setText] = useState('A');
|
||||
setText = _setText;
|
||||
|
||||
const [step, _setStep] = useState(0);
|
||||
setStep = _setStep;
|
||||
|
||||
return <AsyncText text={text + step} />;
|
||||
}
|
||||
|
||||
function App({show}) {
|
||||
return (
|
||||
<Offscreen mode={show ? 'visible' : 'hidden'}>
|
||||
<span>
|
||||
<Child />
|
||||
</span>
|
||||
</Offscreen>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactNoop.createRoot();
|
||||
resolveText('A0');
|
||||
await act(async () => {
|
||||
root.render(<App show={false} />);
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['A0']);
|
||||
expect(root).toMatchRenderedOutput(<span hidden={true}>A0</span>);
|
||||
|
||||
await act(async () => {
|
||||
setStep(1);
|
||||
ReactNoop.flushSync(() => {
|
||||
setText('B');
|
||||
});
|
||||
});
|
||||
expect(Scheduler).toHaveYielded([
|
||||
// The high priority render suspends again
|
||||
'Suspend! [B0]',
|
||||
// There's still pending work in another lane, so we should attempt
|
||||
// that, too.
|
||||
'Suspend! [B1]',
|
||||
]);
|
||||
expect(root).toMatchRenderedOutput(<span hidden={true}>A0</span>);
|
||||
|
||||
// Resolve the data and finish rendering
|
||||
await act(async () => {
|
||||
resolveText('B1');
|
||||
});
|
||||
expect(Scheduler).toHaveYielded(['B1']);
|
||||
expect(root).toMatchRenderedOutput(<span hidden={true}>B1</span>);
|
||||
});
|
||||
|
||||
// Only works in new reconciler
|
||||
// @gate enableOffscreen
|
||||
test('detect updates to a hidden tree during a concurrent event', async () => {
|
||||
// This is a pretty complex test case. It relates to how we detect if an
|
||||
|
|
|
@ -419,5 +419,6 @@
|
|||
"431": "React elements are not allowed in ServerContext",
|
||||
"432": "The render was aborted by the server without a reason.",
|
||||
"433": "useId can only be used while React is rendering",
|
||||
"434": "`dangerouslySetInnerHTML` does not make sense on <title>."
|
||||
}
|
||||
"434": "`dangerouslySetInnerHTML` does not make sense on <title>.",
|
||||
"435": "Unexpected Suspense handler tag (%s). This is a bug in React."
|
||||
}
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
67de5e3fb09eecfab91321246246095058a708a9 [FORKED] Hidden trees should capture Suspense
|
||||
6ab05ee2e9c5b1f4c8dc1f7ae8906bf613788ba7 [FORKED] Track nearest Suspense handler on stack
|
||||
051ac55cb75f426b81f8f75b143f34255476b9bc [FORKED] Add HiddenContext to track if subtree is hidden
|
||||
051ac55cb75f426b81f8f75b143f34255476b9bc [FORKED] Add HiddenContext to track if subtree is hidden
|
||||
|
|
Loading…
Reference in New Issue