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:
Andrew Clark 2022-07-05 17:51:27 -04:00 committed by GitHub
parent c1f5884ffe
commit 82e9e99098
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 671 additions and 121 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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(

View File

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

View File

@ -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) {

View File

@ -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) {

View File

@ -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 (

View File

@ -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,
|};

View File

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

View File

@ -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.

View File

@ -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;

View File

@ -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. ' +

View File

@ -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

View File

@ -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."
}

View File

@ -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