Don't fire passive effects during initial mount of a hidden Offscreen tree (#24967)

* Change OffscreenInstance isHidden to bitmask

The isHidden field of OffscreenInstance is a boolean that represents
whether the tree is currently hidden. To implement resuable effects, we
need to also track whether the passive effects are currently connected.
So I've changed this field to a bitmask.

No other behavior has changed in this commit. I'll update the effects
behavior in the following steps.

* Extract passive mount effects to separate functions

I'm about to add a "reappear passive effects" function that will share
much of the same code as commitPassiveMountEffectOnFiber. To minimize
the duplicated code, I've extracted the shared parts into separate
functions, similar to what I did for commitLayoutEffectOnFiber and
reappearLayoutEffects.

This may not save much on code size because Closure will likely inline
some of it, anyway, but it makes it harder for the two paths to
accidentally diverge.

* Don't mount passive effects in a new hidden tree

This changes the behavior of Offscreen so that passive effects do not
fire when prerendering a brand new tree. Previously, Offscreen did not
affect passive effects at all — only layout effects, which mount or
unmount whenever the visibility of the tree changes.

When hiding an already visible tree, the behavior of passive effects is
unchanged, for now; unlike layout effects, the passive effects will not
get unmounted. Pre-rendered updates to a hidden tree in this state will
also fire normally. This is only temporary, though — the plan is for
passive effects to act more like layout effects, and unmount them when
the tree is hidden. Perhaps after a delay so that if the visibility
toggles quickly back and forth, the effects don't need to remount. I'll
implement this separately.

* "Atomic" passive commit effects must always fire

There are a few cases where commit phase logic always needs to fire even
inside a hidden tree. In general, we should try to design algorithms
that don't depend on a commit effect running during prerendering, but
there's at least one case where I think it makes sense.

The experimental Cache component uses reference counting to keep track
of the lifetime of a cache instance. This allows us to expose an
AbortSignal object that data frameworks can use to cancel aborted
requests. These cache objects are considered alive even inside a
prerendered tree.

To implement this I added an "atomic" passive effect traversal that runs
even when a tree is hidden. (As a follow up, we should add a special
subtree flag so that we can skip over nodes that don't have them. There
are a number of similar subtree flag optimizations that we have planned,
so I'll leave them for a later refactor.)

The only other feature that currently depends on this behavior is
Transition Tracing. I did not add a test for this because Transition
Tracing is still in development and doesn't yet work with Offscreen.
This commit is contained in:
Andrew Clark 2022-07-29 19:22:57 -04:00 committed by GitHub
parent 2c7dea7365
commit 4ea064eb09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1270 additions and 342 deletions

View File

@ -61,6 +61,7 @@ import {
CacheComponent,
TracingMarkerComponent,
} from './ReactWorkTags';
import {OffscreenVisible} from './ReactFiberOffscreenComponent';
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
import {isDevToolsPresent} from './ReactFiberDevToolsHook.new';
@ -717,7 +718,7 @@ export function createFiberFromOffscreen(
fiber.elementType = REACT_OFFSCREEN_TYPE;
fiber.lanes = lanes;
const primaryChildInstance: OffscreenInstance = {
isHidden: false,
visibility: OffscreenVisible,
pendingMarkers: null,
retryCache: null,
transitions: null,
@ -738,7 +739,7 @@ export function createFiberFromLegacyHidden(
// Adding a stateNode for legacy hidden because it's currently using
// the offscreen implementation, which depends on a state node
const instance: OffscreenInstance = {
isHidden: false,
visibility: OffscreenVisible,
pendingMarkers: null,
transitions: null,
retryCache: null,

View File

@ -61,6 +61,7 @@ import {
CacheComponent,
TracingMarkerComponent,
} from './ReactWorkTags';
import {OffscreenVisible} from './ReactFiberOffscreenComponent';
import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
import {isDevToolsPresent} from './ReactFiberDevToolsHook.old';
@ -717,7 +718,7 @@ export function createFiberFromOffscreen(
fiber.elementType = REACT_OFFSCREEN_TYPE;
fiber.lanes = lanes;
const primaryChildInstance: OffscreenInstance = {
isHidden: false,
visibility: OffscreenVisible,
pendingMarkers: null,
retryCache: null,
transitions: null,
@ -738,7 +739,7 @@ export function createFiberFromLegacyHidden(
// Adding a stateNode for legacy hidden because it's currently using
// the offscreen implementation, which depends on a state node
const instance: OffscreenInstance = {
isHidden: false,
visibility: OffscreenVisible,
pendingMarkers: null,
transitions: null,
retryCache: null,

View File

@ -173,6 +173,10 @@ import {
} from './ReactFiberDevToolsHook.new';
import {releaseCache, retainCache} from './ReactFiberCacheComponent.new';
import {clearTransitionsForLanes} from './ReactFiberLane.new';
import {
OffscreenVisible,
OffscreenPassiveEffectsConnected,
} from './ReactFiberOffscreenComponent';
let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
if (__DEV__) {
@ -2424,14 +2428,8 @@ function commitMutationEffectsOnFiber(
const offscreenFiber: Fiber = (finishedWork.child: any);
if (offscreenFiber.flags & Visibility) {
const offscreenInstance: OffscreenInstance = offscreenFiber.stateNode;
const newState: OffscreenState | null = offscreenFiber.memoizedState;
const isHidden = newState !== null;
// Track the current state on the Offscreen instance so we can
// read it during an event
offscreenInstance.isHidden = isHidden;
if (isHidden) {
const wasHidden =
offscreenFiber.alternate !== null &&
@ -2485,7 +2483,11 @@ function commitMutationEffectsOnFiber(
// Track the current state on the Offscreen instance so we can
// read it during an event
offscreenInstance.isHidden = isHidden;
if (isHidden) {
offscreenInstance.visibility &= ~OffscreenVisible;
} else {
offscreenInstance.visibility |= OffscreenVisible;
}
if (isHidden) {
if (!wasHidden) {
@ -2871,6 +2873,167 @@ function recursivelyTraverseReappearLayoutEffects(
setCurrentDebugFiberInDEV(prevDebugFiber);
}
function commitHookPassiveMountEffects(
finishedWork: Fiber,
hookFlags: HookFlags,
) {
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
finishedWork.mode & ProfileMode
) {
startPassiveEffectTimer();
try {
commitHookEffectListMount(hookFlags, finishedWork);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
recordPassiveEffectDuration(finishedWork);
} else {
try {
commitHookEffectListMount(hookFlags, finishedWork);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
}
function commitOffscreenPassiveMountEffects(
current: Fiber | null,
finishedWork: Fiber,
instance: OffscreenInstance,
) {
if (enableCache) {
let previousCache: Cache | null = null;
if (
current !== null &&
current.memoizedState !== null &&
current.memoizedState.cachePool !== null
) {
previousCache = current.memoizedState.cachePool.pool;
}
let nextCache: Cache | null = null;
if (
finishedWork.memoizedState !== null &&
finishedWork.memoizedState.cachePool !== null
) {
nextCache = finishedWork.memoizedState.cachePool.pool;
}
// Retain/release the cache used for pending (suspended) nodes.
// Note that this is only reached in the non-suspended/visible case:
// when the content is suspended/hidden, the retain/release occurs
// via the parent Suspense component (see case above).
if (nextCache !== previousCache) {
if (nextCache != null) {
retainCache(nextCache);
}
if (previousCache != null) {
releaseCache(previousCache);
}
}
}
if (enableTransitionTracing) {
// TODO: Pre-rendering should not be counted as part of a transition. We
// may add separate logs for pre-rendering, but it's not part of the
// primary metrics.
const offscreenState: OffscreenState = finishedWork.memoizedState;
const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
const isHidden = offscreenState !== null;
if (queue !== null) {
if (isHidden) {
const transitions = queue.transitions;
if (transitions !== null) {
transitions.forEach(transition => {
// Add all the transitions saved in the update queue during
// the render phase (ie the transitions associated with this boundary)
// into the transitions set.
if (instance.transitions === null) {
instance.transitions = new Set();
}
instance.transitions.add(transition);
});
}
const markerInstances = queue.markerInstances;
if (markerInstances !== null) {
markerInstances.forEach(markerInstance => {
const markerTransitions = markerInstance.transitions;
// There should only be a few tracing marker transitions because
// they should be only associated with the transition that
// caused them
if (markerTransitions !== null) {
markerTransitions.forEach(transition => {
if (instance.transitions === null) {
instance.transitions = new Set();
} else if (instance.transitions.has(transition)) {
if (markerInstance.pendingBoundaries === null) {
markerInstance.pendingBoundaries = new Map();
}
if (instance.pendingMarkers === null) {
instance.pendingMarkers = new Set();
}
instance.pendingMarkers.add(markerInstance);
}
});
}
});
}
}
finishedWork.updateQueue = null;
}
commitTransitionProgress(finishedWork);
}
}
function commitCachePassiveMountEffect(
current: Fiber | null,
finishedWork: Fiber,
) {
if (enableCache) {
let previousCache: Cache | null = null;
if (finishedWork.alternate !== null) {
previousCache = finishedWork.alternate.memoizedState.cache;
}
const nextCache = finishedWork.memoizedState.cache;
// Retain/release the cache. In theory the cache component
// could be "borrowing" a cache instance owned by some parent,
// in which case we could avoid retaining/releasing. But it
// is non-trivial to determine when that is the case, so we
// always retain/release.
if (nextCache !== previousCache) {
retainCache(nextCache);
if (previousCache != null) {
releaseCache(previousCache);
}
}
}
}
function commitTracingMarkerPassiveMountEffect(finishedWork: Fiber) {
// Get the transitions that were initiatized during the render
// and add a start transition callback for each of them
const instance = finishedWork.stateNode;
if (
instance.transitions !== null &&
(instance.pendingBoundaries === null ||
instance.pendingBoundaries.size === 0)
) {
instance.transitions.forEach(transition => {
addMarkerCompleteCallbackToPendingTransition(
finishedWork.memoizedProps.name,
instance.transitions,
);
});
instance.transitions = null;
instance.pendingBoundaries = null;
}
}
export function commitPassiveMountEffects(
root: FiberRoot,
finishedWork: Fiber,
@ -2916,6 +3079,9 @@ function commitPassiveMountOnFiber(
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
): void {
// When updating this function, also update reconnectPassiveEffects, which does
// most of the same things when an offscreen tree goes from hidden -> visible,
// or when toggling effects inside a hidden tree.
const flags = finishedWork.flags;
switch (finishedWork.tag) {
case FunctionComponent:
@ -2928,31 +3094,10 @@ function commitPassiveMountOnFiber(
committedTransitions,
);
if (flags & Passive) {
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
finishedWork.mode & ProfileMode
) {
startPassiveEffectTimer();
try {
commitHookEffectListMount(
HookPassive | HookHasEffect,
finishedWork,
);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
recordPassiveEffectDuration(finishedWork);
} else {
try {
commitHookEffectListMount(
HookPassive | HookHasEffect,
finishedWork,
);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
commitHookPassiveMountEffects(
finishedWork,
HookPassive | HookHasEffect,
);
}
break;
}
@ -3013,95 +3158,78 @@ function commitPassiveMountOnFiber(
}
case LegacyHiddenComponent:
case OffscreenComponent: {
recursivelyTraversePassiveMountEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
// TODO: Pass `current` as argument to this function
const instance: OffscreenInstance = finishedWork.stateNode;
const nextState: OffscreenState | null = finishedWork.memoizedState;
const isHidden = nextState !== null;
if (isHidden) {
if (instance.visibility & OffscreenPassiveEffectsConnected) {
// The effects are currently connected. Update them.
recursivelyTraversePassiveMountEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
} else {
if (finishedWork.mode & ConcurrentMode) {
// The effects are currently disconnected. Since the tree is hidden,
// don't connect them. This also applies to the initial render.
if (enableCache || enableTransitionTracing) {
// "Atomic" effects are ones that need to fire on every commit,
// even during pre-rendering. An example is updating the reference
// count on cache instances.
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
}
} else {
// Legacy Mode: Fire the effects even if the tree is hidden.
instance.visibility |= OffscreenPassiveEffectsConnected;
recursivelyTraversePassiveMountEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
}
}
} else {
// Tree is visible
if (instance.visibility & OffscreenPassiveEffectsConnected) {
// The effects are currently connected. Update them.
recursivelyTraversePassiveMountEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
} else {
// The effects are currently disconnected. Reconnect them, while also
// firing effects inside newly mounted trees. This also applies to
// the initial render.
instance.visibility |= OffscreenPassiveEffectsConnected;
const includeWorkInProgressEffects =
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags;
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
}
}
if (flags & Passive) {
if (enableCache) {
let previousCache: Cache | null = null;
if (
finishedWork.alternate !== null &&
finishedWork.alternate.memoizedState !== null &&
finishedWork.alternate.memoizedState.cachePool !== null
) {
previousCache = finishedWork.alternate.memoizedState.cachePool.pool;
}
let nextCache: Cache | null = null;
if (
finishedWork.memoizedState !== null &&
finishedWork.memoizedState.cachePool !== null
) {
nextCache = finishedWork.memoizedState.cachePool.pool;
}
// Retain/release the cache used for pending (suspended) nodes.
// Note that this is only reached in the non-suspended/visible case:
// when the content is suspended/hidden, the retain/release occurs
// via the parent Suspense component (see case above).
if (nextCache !== previousCache) {
if (nextCache != null) {
retainCache(nextCache);
}
if (previousCache != null) {
releaseCache(previousCache);
}
}
}
if (enableTransitionTracing) {
const isFallback = finishedWork.memoizedState;
const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
const instance: OffscreenInstance = finishedWork.stateNode;
if (queue !== null) {
if (isFallback) {
const transitions = queue.transitions;
if (transitions !== null) {
transitions.forEach(transition => {
// Add all the transitions saved in the update queue during
// the render phase (ie the transitions associated with this boundary)
// into the transitions set.
if (instance.transitions === null) {
instance.transitions = new Set();
}
instance.transitions.add(transition);
});
}
const markerInstances = queue.markerInstances;
if (markerInstances !== null) {
markerInstances.forEach(markerInstance => {
const markerTransitions = markerInstance.transitions;
// There should only be a few tracing marker transitions because
// they should be only associated with the transition that
// caused them
if (markerTransitions !== null) {
markerTransitions.forEach(transition => {
if (instance.transitions === null) {
instance.transitions = new Set();
} else if (instance.transitions.has(transition)) {
if (markerInstance.pendingBoundaries === null) {
markerInstance.pendingBoundaries = new Map();
}
if (instance.pendingMarkers === null) {
instance.pendingMarkers = new Set();
}
instance.pendingMarkers.add(markerInstance);
}
});
}
});
}
}
finishedWork.updateQueue = null;
}
commitTransitionProgress(finishedWork);
}
const current = finishedWork.alternate;
commitOffscreenPassiveMountEffects(current, finishedWork, instance);
}
break;
}
@ -3113,24 +3241,9 @@ function commitPassiveMountOnFiber(
committedTransitions,
);
if (flags & Passive) {
if (enableCache) {
let previousCache: Cache | null = null;
if (finishedWork.alternate !== null) {
previousCache = finishedWork.alternate.memoizedState.cache;
}
const nextCache = finishedWork.memoizedState.cache;
// Retain/release the cache. In theory the cache component
// could be "borrowing" a cache instance owned by some parent,
// in which case we could avoid retaining/releasing. But it
// is non-trivial to determine when that is the case, so we
// always retain/release.
if (nextCache !== previousCache) {
retainCache(nextCache);
if (previousCache != null) {
releaseCache(previousCache);
}
}
}
// TODO: Pass `current` as argument to this function
const current = finishedWork.alternate;
commitCachePassiveMountEffect(current, finishedWork);
}
break;
}
@ -3143,23 +3256,7 @@ function commitPassiveMountOnFiber(
committedTransitions,
);
if (flags & Passive) {
// Get the transitions that were initiatized during the render
// and add a start transition callback for each of them
const instance = finishedWork.stateNode;
if (
instance.transitions !== null &&
(instance.pendingBoundaries === null ||
instance.pendingBoundaries.size === 0)
) {
instance.transitions.forEach(transition => {
addMarkerCompleteCallbackToPendingTransition(
finishedWork.memoizedProps.name,
instance.transitions,
);
});
instance.transitions = null;
instance.pendingBoundaries = null;
}
commitTracingMarkerPassiveMountEffect(finishedWork);
}
break;
}
@ -3178,6 +3275,278 @@ function commitPassiveMountOnFiber(
}
}
function recursivelyTraverseReconnectPassiveEffects(
finishedRoot: FiberRoot,
parentFiber: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
includeWorkInProgressEffects: boolean,
) {
// This function visits both newly finished work and nodes that were re-used
// from a previously committed tree. We cannot check non-static flags if the
// node was reused.
const childShouldIncludeWorkInProgressEffects =
includeWorkInProgressEffects &&
(parentFiber.subtreeFlags & PassiveMask) !== NoFlags;
// TODO (Offscreen) Check: flags & (RefStatic | LayoutStatic)
const prevDebugFiber = getCurrentDebugFiberInDEV();
let child = parentFiber.child;
while (child !== null) {
reconnectPassiveEffects(
finishedRoot,
child,
committedLanes,
committedTransitions,
childShouldIncludeWorkInProgressEffects,
);
child = child.sibling;
}
setCurrentDebugFiberInDEV(prevDebugFiber);
}
function reconnectPassiveEffects(
finishedRoot: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
// This function visits both newly finished work and nodes that were re-used
// from a previously committed tree. We cannot check non-static flags if the
// node was reused.
includeWorkInProgressEffects: boolean,
) {
const flags = finishedWork.flags;
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
// TODO: Check for PassiveStatic flag
commitHookPassiveMountEffects(finishedWork, HookPassive);
break;
}
// Unlike commitPassiveMountOnFiber, we don't need to handle HostRoot
// because this function only visits nodes that are inside an
// Offscreen fiber.
// case HostRoot: {
// ...
// }
case LegacyHiddenComponent:
case OffscreenComponent: {
const instance: OffscreenInstance = finishedWork.stateNode;
const nextState: OffscreenState | null = finishedWork.memoizedState;
const isHidden = nextState !== null;
if (isHidden) {
if (instance.visibility & OffscreenPassiveEffectsConnected) {
// The effects are currently connected. Update them.
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
} else {
if (finishedWork.mode & ConcurrentMode) {
// The effects are currently disconnected. Since the tree is hidden,
// don't connect them. This also applies to the initial render.
if (enableCache || enableTransitionTracing) {
// "Atomic" effects are ones that need to fire on every commit,
// even during pre-rendering. An example is updating the reference
// count on cache instances.
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
}
} else {
// Legacy Mode: Fire the effects even if the tree is hidden.
instance.visibility |= OffscreenPassiveEffectsConnected;
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
}
}
} else {
// Tree is visible
// Since we're already inside a reconnecting tree, it doesn't matter
// whether the effects are currently connected. In either case, we'll
// continue traversing the tree and firing all the effects.
//
// We do need to set the "connected" flag on the instance, though.
instance.visibility |= OffscreenPassiveEffectsConnected;
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
}
if (includeWorkInProgressEffects && flags & Passive) {
// TODO: Pass `current` as argument to this function
const current: Fiber | null = finishedWork.alternate;
commitOffscreenPassiveMountEffects(current, finishedWork, instance);
}
break;
}
case CacheComponent: {
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
if (includeWorkInProgressEffects && flags & Passive) {
// TODO: Pass `current` as argument to this function
const current = finishedWork.alternate;
commitCachePassiveMountEffect(current, finishedWork);
}
break;
}
case TracingMarkerComponent: {
if (enableTransitionTracing) {
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
if (includeWorkInProgressEffects && flags & Passive) {
commitTracingMarkerPassiveMountEffect(finishedWork);
}
break;
}
// Intentional fallthrough to next branch
}
// eslint-disable-next-line-no-fallthrough
default: {
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
break;
}
}
}
function recursivelyTraverseAtomicPassiveEffects(
finishedRoot: FiberRoot,
parentFiber: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
) {
// "Atomic" effects are ones that need to fire on every commit, even during
// pre-rendering. We call this function when traversing a hidden tree whose
// regular effects are currently disconnected.
const prevDebugFiber = getCurrentDebugFiberInDEV();
// TODO: Add special flag for atomic effects
if (parentFiber.subtreeFlags & PassiveMask) {
let child = parentFiber.child;
while (child !== null) {
setCurrentDebugFiberInDEV(child);
commitAtomicPassiveEffects(
finishedRoot,
child,
committedLanes,
committedTransitions,
);
child = child.sibling;
}
}
setCurrentDebugFiberInDEV(prevDebugFiber);
}
function commitAtomicPassiveEffects(
finishedRoot: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
) {
// "Atomic" effects are ones that need to fire on every commit, even during
// pre-rendering. We call this function when traversing a hidden tree whose
// regular effects are currently disconnected.
const flags = finishedWork.flags;
switch (finishedWork.tag) {
case OffscreenComponent: {
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
if (flags & Passive) {
// TODO: Pass `current` as argument to this function
const current = finishedWork.alternate;
const instance: OffscreenInstance = finishedWork.stateNode;
commitOffscreenPassiveMountEffects(current, finishedWork, instance);
}
break;
}
case CacheComponent: {
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
if (flags & Passive) {
// TODO: Pass `current` as argument to this function
const current = finishedWork.alternate;
commitCachePassiveMountEffect(current, finishedWork);
}
break;
}
case TracingMarkerComponent: {
if (enableTransitionTracing) {
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
if (flags & Passive) {
commitTracingMarkerPassiveMountEffect(finishedWork);
}
break;
}
// Intentional fallthrough to next branch
}
// eslint-disable-next-line-no-fallthrough
default: {
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
break;
}
}
}
export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
setCurrentDebugFiberInDEV(finishedWork);
commitPassiveUnmountOnFiber(finishedWork);
@ -3275,6 +3644,11 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
}
break;
}
// TODO: Disconnect passive effects when a tree is hidden, perhaps after
// a delay.
// case OffscreenComponent: {
// ...
// }
default: {
recursivelyTraversePassiveUnmountEffects(finishedWork);
break;

View File

@ -173,6 +173,10 @@ import {
} from './ReactFiberDevToolsHook.old';
import {releaseCache, retainCache} from './ReactFiberCacheComponent.old';
import {clearTransitionsForLanes} from './ReactFiberLane.old';
import {
OffscreenVisible,
OffscreenPassiveEffectsConnected,
} from './ReactFiberOffscreenComponent';
let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
if (__DEV__) {
@ -2424,14 +2428,8 @@ function commitMutationEffectsOnFiber(
const offscreenFiber: Fiber = (finishedWork.child: any);
if (offscreenFiber.flags & Visibility) {
const offscreenInstance: OffscreenInstance = offscreenFiber.stateNode;
const newState: OffscreenState | null = offscreenFiber.memoizedState;
const isHidden = newState !== null;
// Track the current state on the Offscreen instance so we can
// read it during an event
offscreenInstance.isHidden = isHidden;
if (isHidden) {
const wasHidden =
offscreenFiber.alternate !== null &&
@ -2485,7 +2483,11 @@ function commitMutationEffectsOnFiber(
// Track the current state on the Offscreen instance so we can
// read it during an event
offscreenInstance.isHidden = isHidden;
if (isHidden) {
offscreenInstance.visibility &= ~OffscreenVisible;
} else {
offscreenInstance.visibility |= OffscreenVisible;
}
if (isHidden) {
if (!wasHidden) {
@ -2871,6 +2873,167 @@ function recursivelyTraverseReappearLayoutEffects(
setCurrentDebugFiberInDEV(prevDebugFiber);
}
function commitHookPassiveMountEffects(
finishedWork: Fiber,
hookFlags: HookFlags,
) {
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
finishedWork.mode & ProfileMode
) {
startPassiveEffectTimer();
try {
commitHookEffectListMount(hookFlags, finishedWork);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
recordPassiveEffectDuration(finishedWork);
} else {
try {
commitHookEffectListMount(hookFlags, finishedWork);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
}
function commitOffscreenPassiveMountEffects(
current: Fiber | null,
finishedWork: Fiber,
instance: OffscreenInstance,
) {
if (enableCache) {
let previousCache: Cache | null = null;
if (
current !== null &&
current.memoizedState !== null &&
current.memoizedState.cachePool !== null
) {
previousCache = current.memoizedState.cachePool.pool;
}
let nextCache: Cache | null = null;
if (
finishedWork.memoizedState !== null &&
finishedWork.memoizedState.cachePool !== null
) {
nextCache = finishedWork.memoizedState.cachePool.pool;
}
// Retain/release the cache used for pending (suspended) nodes.
// Note that this is only reached in the non-suspended/visible case:
// when the content is suspended/hidden, the retain/release occurs
// via the parent Suspense component (see case above).
if (nextCache !== previousCache) {
if (nextCache != null) {
retainCache(nextCache);
}
if (previousCache != null) {
releaseCache(previousCache);
}
}
}
if (enableTransitionTracing) {
// TODO: Pre-rendering should not be counted as part of a transition. We
// may add separate logs for pre-rendering, but it's not part of the
// primary metrics.
const offscreenState: OffscreenState = finishedWork.memoizedState;
const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
const isHidden = offscreenState !== null;
if (queue !== null) {
if (isHidden) {
const transitions = queue.transitions;
if (transitions !== null) {
transitions.forEach(transition => {
// Add all the transitions saved in the update queue during
// the render phase (ie the transitions associated with this boundary)
// into the transitions set.
if (instance.transitions === null) {
instance.transitions = new Set();
}
instance.transitions.add(transition);
});
}
const markerInstances = queue.markerInstances;
if (markerInstances !== null) {
markerInstances.forEach(markerInstance => {
const markerTransitions = markerInstance.transitions;
// There should only be a few tracing marker transitions because
// they should be only associated with the transition that
// caused them
if (markerTransitions !== null) {
markerTransitions.forEach(transition => {
if (instance.transitions === null) {
instance.transitions = new Set();
} else if (instance.transitions.has(transition)) {
if (markerInstance.pendingBoundaries === null) {
markerInstance.pendingBoundaries = new Map();
}
if (instance.pendingMarkers === null) {
instance.pendingMarkers = new Set();
}
instance.pendingMarkers.add(markerInstance);
}
});
}
});
}
}
finishedWork.updateQueue = null;
}
commitTransitionProgress(finishedWork);
}
}
function commitCachePassiveMountEffect(
current: Fiber | null,
finishedWork: Fiber,
) {
if (enableCache) {
let previousCache: Cache | null = null;
if (finishedWork.alternate !== null) {
previousCache = finishedWork.alternate.memoizedState.cache;
}
const nextCache = finishedWork.memoizedState.cache;
// Retain/release the cache. In theory the cache component
// could be "borrowing" a cache instance owned by some parent,
// in which case we could avoid retaining/releasing. But it
// is non-trivial to determine when that is the case, so we
// always retain/release.
if (nextCache !== previousCache) {
retainCache(nextCache);
if (previousCache != null) {
releaseCache(previousCache);
}
}
}
}
function commitTracingMarkerPassiveMountEffect(finishedWork: Fiber) {
// Get the transitions that were initiatized during the render
// and add a start transition callback for each of them
const instance = finishedWork.stateNode;
if (
instance.transitions !== null &&
(instance.pendingBoundaries === null ||
instance.pendingBoundaries.size === 0)
) {
instance.transitions.forEach(transition => {
addMarkerCompleteCallbackToPendingTransition(
finishedWork.memoizedProps.name,
instance.transitions,
);
});
instance.transitions = null;
instance.pendingBoundaries = null;
}
}
export function commitPassiveMountEffects(
root: FiberRoot,
finishedWork: Fiber,
@ -2916,6 +3079,9 @@ function commitPassiveMountOnFiber(
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
): void {
// When updating this function, also update reconnectPassiveEffects, which does
// most of the same things when an offscreen tree goes from hidden -> visible,
// or when toggling effects inside a hidden tree.
const flags = finishedWork.flags;
switch (finishedWork.tag) {
case FunctionComponent:
@ -2928,31 +3094,10 @@ function commitPassiveMountOnFiber(
committedTransitions,
);
if (flags & Passive) {
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
finishedWork.mode & ProfileMode
) {
startPassiveEffectTimer();
try {
commitHookEffectListMount(
HookPassive | HookHasEffect,
finishedWork,
);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
recordPassiveEffectDuration(finishedWork);
} else {
try {
commitHookEffectListMount(
HookPassive | HookHasEffect,
finishedWork,
);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
commitHookPassiveMountEffects(
finishedWork,
HookPassive | HookHasEffect,
);
}
break;
}
@ -3013,95 +3158,78 @@ function commitPassiveMountOnFiber(
}
case LegacyHiddenComponent:
case OffscreenComponent: {
recursivelyTraversePassiveMountEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
// TODO: Pass `current` as argument to this function
const instance: OffscreenInstance = finishedWork.stateNode;
const nextState: OffscreenState | null = finishedWork.memoizedState;
const isHidden = nextState !== null;
if (isHidden) {
if (instance.visibility & OffscreenPassiveEffectsConnected) {
// The effects are currently connected. Update them.
recursivelyTraversePassiveMountEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
} else {
if (finishedWork.mode & ConcurrentMode) {
// The effects are currently disconnected. Since the tree is hidden,
// don't connect them. This also applies to the initial render.
if (enableCache || enableTransitionTracing) {
// "Atomic" effects are ones that need to fire on every commit,
// even during pre-rendering. An example is updating the reference
// count on cache instances.
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
}
} else {
// Legacy Mode: Fire the effects even if the tree is hidden.
instance.visibility |= OffscreenPassiveEffectsConnected;
recursivelyTraversePassiveMountEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
}
}
} else {
// Tree is visible
if (instance.visibility & OffscreenPassiveEffectsConnected) {
// The effects are currently connected. Update them.
recursivelyTraversePassiveMountEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
} else {
// The effects are currently disconnected. Reconnect them, while also
// firing effects inside newly mounted trees. This also applies to
// the initial render.
instance.visibility |= OffscreenPassiveEffectsConnected;
const includeWorkInProgressEffects =
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags;
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
}
}
if (flags & Passive) {
if (enableCache) {
let previousCache: Cache | null = null;
if (
finishedWork.alternate !== null &&
finishedWork.alternate.memoizedState !== null &&
finishedWork.alternate.memoizedState.cachePool !== null
) {
previousCache = finishedWork.alternate.memoizedState.cachePool.pool;
}
let nextCache: Cache | null = null;
if (
finishedWork.memoizedState !== null &&
finishedWork.memoizedState.cachePool !== null
) {
nextCache = finishedWork.memoizedState.cachePool.pool;
}
// Retain/release the cache used for pending (suspended) nodes.
// Note that this is only reached in the non-suspended/visible case:
// when the content is suspended/hidden, the retain/release occurs
// via the parent Suspense component (see case above).
if (nextCache !== previousCache) {
if (nextCache != null) {
retainCache(nextCache);
}
if (previousCache != null) {
releaseCache(previousCache);
}
}
}
if (enableTransitionTracing) {
const isFallback = finishedWork.memoizedState;
const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
const instance: OffscreenInstance = finishedWork.stateNode;
if (queue !== null) {
if (isFallback) {
const transitions = queue.transitions;
if (transitions !== null) {
transitions.forEach(transition => {
// Add all the transitions saved in the update queue during
// the render phase (ie the transitions associated with this boundary)
// into the transitions set.
if (instance.transitions === null) {
instance.transitions = new Set();
}
instance.transitions.add(transition);
});
}
const markerInstances = queue.markerInstances;
if (markerInstances !== null) {
markerInstances.forEach(markerInstance => {
const markerTransitions = markerInstance.transitions;
// There should only be a few tracing marker transitions because
// they should be only associated with the transition that
// caused them
if (markerTransitions !== null) {
markerTransitions.forEach(transition => {
if (instance.transitions === null) {
instance.transitions = new Set();
} else if (instance.transitions.has(transition)) {
if (markerInstance.pendingBoundaries === null) {
markerInstance.pendingBoundaries = new Map();
}
if (instance.pendingMarkers === null) {
instance.pendingMarkers = new Set();
}
instance.pendingMarkers.add(markerInstance);
}
});
}
});
}
}
finishedWork.updateQueue = null;
}
commitTransitionProgress(finishedWork);
}
const current = finishedWork.alternate;
commitOffscreenPassiveMountEffects(current, finishedWork, instance);
}
break;
}
@ -3113,24 +3241,9 @@ function commitPassiveMountOnFiber(
committedTransitions,
);
if (flags & Passive) {
if (enableCache) {
let previousCache: Cache | null = null;
if (finishedWork.alternate !== null) {
previousCache = finishedWork.alternate.memoizedState.cache;
}
const nextCache = finishedWork.memoizedState.cache;
// Retain/release the cache. In theory the cache component
// could be "borrowing" a cache instance owned by some parent,
// in which case we could avoid retaining/releasing. But it
// is non-trivial to determine when that is the case, so we
// always retain/release.
if (nextCache !== previousCache) {
retainCache(nextCache);
if (previousCache != null) {
releaseCache(previousCache);
}
}
}
// TODO: Pass `current` as argument to this function
const current = finishedWork.alternate;
commitCachePassiveMountEffect(current, finishedWork);
}
break;
}
@ -3143,23 +3256,7 @@ function commitPassiveMountOnFiber(
committedTransitions,
);
if (flags & Passive) {
// Get the transitions that were initiatized during the render
// and add a start transition callback for each of them
const instance = finishedWork.stateNode;
if (
instance.transitions !== null &&
(instance.pendingBoundaries === null ||
instance.pendingBoundaries.size === 0)
) {
instance.transitions.forEach(transition => {
addMarkerCompleteCallbackToPendingTransition(
finishedWork.memoizedProps.name,
instance.transitions,
);
});
instance.transitions = null;
instance.pendingBoundaries = null;
}
commitTracingMarkerPassiveMountEffect(finishedWork);
}
break;
}
@ -3178,6 +3275,278 @@ function commitPassiveMountOnFiber(
}
}
function recursivelyTraverseReconnectPassiveEffects(
finishedRoot: FiberRoot,
parentFiber: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
includeWorkInProgressEffects: boolean,
) {
// This function visits both newly finished work and nodes that were re-used
// from a previously committed tree. We cannot check non-static flags if the
// node was reused.
const childShouldIncludeWorkInProgressEffects =
includeWorkInProgressEffects &&
(parentFiber.subtreeFlags & PassiveMask) !== NoFlags;
// TODO (Offscreen) Check: flags & (RefStatic | LayoutStatic)
const prevDebugFiber = getCurrentDebugFiberInDEV();
let child = parentFiber.child;
while (child !== null) {
reconnectPassiveEffects(
finishedRoot,
child,
committedLanes,
committedTransitions,
childShouldIncludeWorkInProgressEffects,
);
child = child.sibling;
}
setCurrentDebugFiberInDEV(prevDebugFiber);
}
function reconnectPassiveEffects(
finishedRoot: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
// This function visits both newly finished work and nodes that were re-used
// from a previously committed tree. We cannot check non-static flags if the
// node was reused.
includeWorkInProgressEffects: boolean,
) {
const flags = finishedWork.flags;
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
// TODO: Check for PassiveStatic flag
commitHookPassiveMountEffects(finishedWork, HookPassive);
break;
}
// Unlike commitPassiveMountOnFiber, we don't need to handle HostRoot
// because this function only visits nodes that are inside an
// Offscreen fiber.
// case HostRoot: {
// ...
// }
case LegacyHiddenComponent:
case OffscreenComponent: {
const instance: OffscreenInstance = finishedWork.stateNode;
const nextState: OffscreenState | null = finishedWork.memoizedState;
const isHidden = nextState !== null;
if (isHidden) {
if (instance.visibility & OffscreenPassiveEffectsConnected) {
// The effects are currently connected. Update them.
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
} else {
if (finishedWork.mode & ConcurrentMode) {
// The effects are currently disconnected. Since the tree is hidden,
// don't connect them. This also applies to the initial render.
if (enableCache || enableTransitionTracing) {
// "Atomic" effects are ones that need to fire on every commit,
// even during pre-rendering. An example is updating the reference
// count on cache instances.
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
}
} else {
// Legacy Mode: Fire the effects even if the tree is hidden.
instance.visibility |= OffscreenPassiveEffectsConnected;
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
}
}
} else {
// Tree is visible
// Since we're already inside a reconnecting tree, it doesn't matter
// whether the effects are currently connected. In either case, we'll
// continue traversing the tree and firing all the effects.
//
// We do need to set the "connected" flag on the instance, though.
instance.visibility |= OffscreenPassiveEffectsConnected;
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
}
if (includeWorkInProgressEffects && flags & Passive) {
// TODO: Pass `current` as argument to this function
const current: Fiber | null = finishedWork.alternate;
commitOffscreenPassiveMountEffects(current, finishedWork, instance);
}
break;
}
case CacheComponent: {
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
if (includeWorkInProgressEffects && flags & Passive) {
// TODO: Pass `current` as argument to this function
const current = finishedWork.alternate;
commitCachePassiveMountEffect(current, finishedWork);
}
break;
}
case TracingMarkerComponent: {
if (enableTransitionTracing) {
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
if (includeWorkInProgressEffects && flags & Passive) {
commitTracingMarkerPassiveMountEffect(finishedWork);
}
break;
}
// Intentional fallthrough to next branch
}
// eslint-disable-next-line-no-fallthrough
default: {
recursivelyTraverseReconnectPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
includeWorkInProgressEffects,
);
break;
}
}
}
function recursivelyTraverseAtomicPassiveEffects(
finishedRoot: FiberRoot,
parentFiber: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
) {
// "Atomic" effects are ones that need to fire on every commit, even during
// pre-rendering. We call this function when traversing a hidden tree whose
// regular effects are currently disconnected.
const prevDebugFiber = getCurrentDebugFiberInDEV();
// TODO: Add special flag for atomic effects
if (parentFiber.subtreeFlags & PassiveMask) {
let child = parentFiber.child;
while (child !== null) {
setCurrentDebugFiberInDEV(child);
commitAtomicPassiveEffects(
finishedRoot,
child,
committedLanes,
committedTransitions,
);
child = child.sibling;
}
}
setCurrentDebugFiberInDEV(prevDebugFiber);
}
function commitAtomicPassiveEffects(
finishedRoot: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
) {
// "Atomic" effects are ones that need to fire on every commit, even during
// pre-rendering. We call this function when traversing a hidden tree whose
// regular effects are currently disconnected.
const flags = finishedWork.flags;
switch (finishedWork.tag) {
case OffscreenComponent: {
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
if (flags & Passive) {
// TODO: Pass `current` as argument to this function
const current = finishedWork.alternate;
const instance: OffscreenInstance = finishedWork.stateNode;
commitOffscreenPassiveMountEffects(current, finishedWork, instance);
}
break;
}
case CacheComponent: {
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
if (flags & Passive) {
// TODO: Pass `current` as argument to this function
const current = finishedWork.alternate;
commitCachePassiveMountEffect(current, finishedWork);
}
break;
}
case TracingMarkerComponent: {
if (enableTransitionTracing) {
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
if (flags & Passive) {
commitTracingMarkerPassiveMountEffect(finishedWork);
}
break;
}
// Intentional fallthrough to next branch
}
// eslint-disable-next-line-no-fallthrough
default: {
recursivelyTraverseAtomicPassiveEffects(
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
break;
}
}
}
export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
setCurrentDebugFiberInDEV(finishedWork);
commitPassiveUnmountOnFiber(finishedWork);
@ -3275,6 +3644,11 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
}
break;
}
// TODO: Disconnect passive effects when a tree is hidden, perhaps after
// a delay.
// case OffscreenComponent: {
// ...
// }
default: {
recursivelyTraversePassiveUnmountEffects(finishedWork);
break;

View File

@ -31,6 +31,7 @@ import {
} from './ReactFiberLane.new';
import {NoFlags, Placement, Hydrating} from './ReactFiberFlags';
import {HostRoot, OffscreenComponent} from './ReactWorkTags';
import {OffscreenVisible} from './ReactFiberOffscreenComponent';
export type ConcurrentUpdate = {
next: ConcurrentUpdate,
@ -217,7 +218,10 @@ function markUpdateLaneFromFiberToRoot(
// account for it. (There may be other cases that we haven't discovered,
// too.)
const offscreenInstance: OffscreenInstance | null = parent.stateNode;
if (offscreenInstance !== null && offscreenInstance.isHidden) {
if (
offscreenInstance !== null &&
!(offscreenInstance.visibility & OffscreenVisible)
) {
isHidden = true;
}
}

View File

@ -31,6 +31,7 @@ import {
} from './ReactFiberLane.old';
import {NoFlags, Placement, Hydrating} from './ReactFiberFlags';
import {HostRoot, OffscreenComponent} from './ReactWorkTags';
import {OffscreenVisible} from './ReactFiberOffscreenComponent';
export type ConcurrentUpdate = {
next: ConcurrentUpdate,
@ -217,7 +218,10 @@ function markUpdateLaneFromFiberToRoot(
// account for it. (There may be other cases that we haven't discovered,
// too.)
const offscreenInstance: OffscreenInstance | null = parent.stateNode;
if (offscreenInstance !== null && offscreenInstance.isHidden) {
if (
offscreenInstance !== null &&
!(offscreenInstance.visibility & OffscreenVisible)
) {
isHidden = true;
}
}

View File

@ -87,7 +87,7 @@ export const MutationMask =
export const LayoutMask = Update | Callback | Ref | Visibility;
// TODO: Split into PassiveMountMask and PassiveUnmountMask
export const PassiveMask = Passive | ChildDeletion;
export const PassiveMask = Passive | Visibility | ChildDeletion;
// Union of tags that don't get reset on clones.
// This allows certain concepts to persist without recalculating them,

View File

@ -42,8 +42,13 @@ export type OffscreenQueue = {|
wakeables: Set<Wakeable> | null,
|};
type OffscreenVisibility = number;
export const OffscreenVisible = /* */ 0b01;
export const OffscreenPassiveEffectsConnected = /* */ 0b10;
export type OffscreenInstance = {|
isHidden: boolean,
visibility: OffscreenVisibility,
pendingMarkers: Set<TracingMarkerInstance> | null,
transitions: Set<Transition> | null,
retryCache: WeakSet<Wakeable> | Set<Wakeable> | null,

View File

@ -6,6 +6,7 @@ let getCacheForType;
let Scheduler;
let act;
let Suspense;
let Offscreen;
let useCacheRefresh;
let startTransition;
let useState;
@ -23,6 +24,7 @@ describe('ReactCache', () => {
Scheduler = require('scheduler');
act = require('jest-react').act;
Suspense = React.Suspense;
Offscreen = React.unstable_Offscreen;
getCacheSignal = React.unstable_getCacheSignal;
getCacheForType = React.unstable_getCacheForType;
useCacheRefresh = React.unstable_useCacheRefresh;
@ -1590,4 +1592,36 @@ describe('ReactCache', () => {
]);
expect(root).toMatchRenderedOutput('Bye!');
});
// @gate enableOffscreen
// @gate enableCache
test('prerender a new cache boundary inside an Offscreen tree', async () => {
function App({prerenderMore}) {
return (
<Offscreen mode="hidden">
<div>
{prerenderMore ? (
<Cache>
<AsyncText text="More" />
</Cache>
) : null}
</div>
</Offscreen>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App prerenderMore={false} />);
});
expect(Scheduler).toHaveYielded([]);
expect(root).toMatchRenderedOutput(<div hidden={true} />);
seedNextTextCache('More');
await act(async () => {
root.render(<App prerenderMore={true} />);
});
expect(Scheduler).toHaveYielded(['More']);
expect(root).toMatchRenderedOutput(<div hidden={true}>More</div>);
});
});

View File

@ -486,22 +486,29 @@ describe('ReactOffscreen', () => {
// hidden tree share the same lane, but are processed at different times
// because of the timing of when they were scheduled.
// This functions checks whether the "outer" and "inner" states are
// consistent in the rendered output.
let currentOuter = null;
let currentInner = null;
function areOuterAndInnerConsistent() {
return (
currentOuter === null ||
currentInner === null ||
currentOuter === currentInner
);
}
let setInner;
function Child({outer}) {
function Child() {
const [inner, _setInner] = useState(0);
setInner = _setInner;
useEffect(() => {
// Inner and outer values are always updated simultaneously, so they
// should always be consistent.
if (inner !== outer) {
Scheduler.unstable_yieldValue(
'Tearing! Inner and outer are inconsistent!',
);
} else {
Scheduler.unstable_yieldValue('Inner and outer are consistent');
}
}, [inner, outer]);
currentInner = inner;
return () => {
currentInner = null;
};
}, [inner]);
return <Text text={'Inner: ' + inner} />;
}
@ -510,11 +517,19 @@ describe('ReactOffscreen', () => {
function App({show}) {
const [outer, _setOuter] = useState(0);
setOuter = _setOuter;
useEffect(() => {
currentOuter = outer;
return () => {
currentOuter = null;
};
}, [outer]);
return (
<>
<Text text={'Outer: ' + outer} />
<Offscreen mode={show ? 'visible' : 'hidden'}>
<Child outer={outer} />
<Child />
</Offscreen>
</>
);
@ -525,17 +540,14 @@ describe('ReactOffscreen', () => {
await act(async () => {
root.render(<App show={false} />);
});
expect(Scheduler).toHaveYielded([
'Outer: 0',
'Inner: 0',
'Inner and outer are consistent',
]);
expect(Scheduler).toHaveYielded(['Outer: 0', 'Inner: 0']);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer: 0" />
<span hidden={true} prop="Inner: 0" />
</>,
);
expect(areOuterAndInnerConsistent()).toBe(true);
await act(async () => {
// Update a value both inside and outside the hidden tree. These values
@ -570,8 +582,6 @@ describe('ReactOffscreen', () => {
// update were erroneously processed, then Inner would be inconsistent
// with Outer.
'Inner: 1',
'Inner and outer are consistent',
]);
expect(root).toMatchRenderedOutput(
<>
@ -579,18 +589,16 @@ describe('ReactOffscreen', () => {
<span prop="Inner: 1" />
</>,
);
expect(areOuterAndInnerConsistent()).toBe(true);
});
expect(Scheduler).toHaveYielded([
'Outer: 2',
'Inner: 2',
'Inner and outer are consistent',
]);
expect(Scheduler).toHaveYielded(['Outer: 2', 'Inner: 2']);
expect(root).toMatchRenderedOutput(
<>
<span prop="Outer: 2" />
<span prop="Inner: 2" />
</>,
);
expect(areOuterAndInnerConsistent()).toBe(true);
});
// @gate enableOffscreen
@ -867,4 +875,127 @@ describe('ReactOffscreen', () => {
]);
},
);
// @gate enableOffscreen
it('defer passive effects when prerendering a new Offscreen tree', async () => {
function Child({label}) {
useEffect(() => {
Scheduler.unstable_yieldValue('Mount ' + label);
return () => {
Scheduler.unstable_yieldValue('Unmount ' + label);
};
}, [label]);
return <Text text={label} />;
}
function App({showMore}) {
return (
<>
<Child label="Shell" />
<Offscreen mode={showMore ? 'visible' : 'hidden'}>
<Child label="More" />
</Offscreen>
</>
);
}
const root = ReactNoop.createRoot();
// Mount the app without showing the extra content
await act(async () => {
root.render(<App showMore={false} />);
});
expect(Scheduler).toHaveYielded([
// First mount the outer visible shell
'Shell',
'Mount Shell',
// Then prerender the hidden extra context. The passive effects in the
// hidden tree should not fire
'More',
// Does not fire
// 'Mount More',
]);
// The hidden content has been prerendered
expect(root).toMatchRenderedOutput(
<>
<span prop="Shell" />
<span hidden={true} prop="More" />
</>,
);
// Reveal the prerendered tree
await act(async () => {
root.render(<App showMore={true} />);
});
expect(Scheduler).toHaveYielded([
'Shell',
'More',
// Mount the passive effects in the newly revealed tree, the ones that
// were skipped during pre-rendering.
'Mount More',
]);
});
// @gate enableOffscreen
it("don't defer passive effects when prerendering in a tree whose effects are already connected", async () => {
function Child({label}) {
useEffect(() => {
Scheduler.unstable_yieldValue('Mount ' + label);
return () => {
Scheduler.unstable_yieldValue('Unmount ' + label);
};
}, [label]);
return <Text text={label} />;
}
function App({showMore, step}) {
return (
<>
<Child label={'Shell ' + step} />
<Offscreen mode={showMore ? 'visible' : 'hidden'}>
<Child label={'More ' + step} />
</Offscreen>
</>
);
}
const root = ReactNoop.createRoot();
// Mount the app, including the extra content
await act(async () => {
root.render(<App showMore={true} step={1} />);
});
expect(Scheduler).toHaveYielded([
'Shell 1',
'More 1',
'Mount Shell 1',
'Mount More 1',
]);
expect(root).toMatchRenderedOutput(
<>
<span prop="Shell 1" />
<span prop="More 1" />
</>,
);
// Hide the extra content. while also updating one of its props
await act(async () => {
root.render(<App showMore={false} step={2} />);
});
expect(Scheduler).toHaveYielded([
// First update the outer visible shell
'Shell 2',
'Unmount Shell 1',
'Mount Shell 2',
// Then prerender the update to the hidden content. Since the effects
// are already connected inside the hidden tree, we don't defer updates
// to them.
'More 2',
'Unmount More 1',
'Mount More 2',
]);
});
});