Skip to content

Commit 80f3d88

Browse files
authored
Mount/unmount passive effects when Offscreen visibility changes (#24977)
* Remove unnecessary try-catch from passive deletion The individual unmount calls are already wrapped in a catch block, so this outer one serves no purpose. * Extract passive unmount effects to separate functions I'm about to add a "disconnect passive effects" function that will share much of the same code as commitPassiveUnmountOnFiber. 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. * Mount/unmount passive effects on hide/show This changes the behavior of Offscreen so that passive effects are unmounted when the tree is hidden, and re-mounted when the tree is revealed again. This is already how layout effects worked. In the future we will likely add an option or heuristic to only unmount the effects of a hidden tree after a delay. That way if the tree quickly switches back to visible, we can skip toggling the effects entirely. This change does not apply to suspended trees, which happen to use the Offscreen fiber type as an implementation detail. Passive effects remain mounted while the tree is suspended, for the reason described above — it's likely that the suspended tree will resolve and switch back to visible within a short time span. At a high level, what this capability enables is a feature we refer to as "resuable state". The real value proposition here isn't so much the behavior of effects — it's that you can switch back to a previously rendered tree without losing the state of the UI. * Add more coverage for nested Offscreen cases
1 parent 4ea064e commit 80f3d88

5 files changed

+616
-177
lines changed

packages/react-reconciler/src/ReactFiberCommitWork.new.js

+161-79
Original file line numberDiff line numberDiff line change
@@ -3553,6 +3553,60 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
35533553
resetCurrentDebugFiberInDEV();
35543554
}
35553555

3556+
function detachAlternateSiblings(parentFiber: Fiber) {
3557+
if (deletedTreeCleanUpLevel >= 1) {
3558+
// A fiber was deleted from this parent fiber, but it's still part of the
3559+
// previous (alternate) parent fiber's list of children. Because children
3560+
// are a linked list, an earlier sibling that's still alive will be
3561+
// connected to the deleted fiber via its `alternate`:
3562+
//
3563+
// live fiber --alternate--> previous live fiber --sibling--> deleted
3564+
// fiber
3565+
//
3566+
// We can't disconnect `alternate` on nodes that haven't been deleted yet,
3567+
// but we can disconnect the `sibling` and `child` pointers.
3568+
3569+
const previousFiber = parentFiber.alternate;
3570+
if (previousFiber !== null) {
3571+
let detachedChild = previousFiber.child;
3572+
if (detachedChild !== null) {
3573+
previousFiber.child = null;
3574+
do {
3575+
const detachedSibling = detachedChild.sibling;
3576+
detachedChild.sibling = null;
3577+
detachedChild = detachedSibling;
3578+
} while (detachedChild !== null);
3579+
}
3580+
}
3581+
}
3582+
}
3583+
3584+
function commitHookPassiveUnmountEffects(
3585+
finishedWork: Fiber,
3586+
nearestMountedAncestor,
3587+
hookFlags: HookFlags,
3588+
) {
3589+
if (
3590+
enableProfilerTimer &&
3591+
enableProfilerCommitHooks &&
3592+
finishedWork.mode & ProfileMode
3593+
) {
3594+
startPassiveEffectTimer();
3595+
commitHookEffectListUnmount(
3596+
hookFlags,
3597+
finishedWork,
3598+
nearestMountedAncestor,
3599+
);
3600+
recordPassiveEffectDuration(finishedWork);
3601+
} else {
3602+
commitHookEffectListUnmount(
3603+
hookFlags,
3604+
finishedWork,
3605+
nearestMountedAncestor,
3606+
);
3607+
}
3608+
}
3609+
35563610
function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
35573611
// Deletions effects can be scheduled on any fiber type. They need to happen
35583612
// before the children effects have fired.
@@ -3562,44 +3616,15 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
35623616
if (deletions !== null) {
35633617
for (let i = 0; i < deletions.length; i++) {
35643618
const childToDelete = deletions[i];
3565-
try {
3566-
// TODO: Convert this to use recursion
3567-
nextEffect = childToDelete;
3568-
commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3569-
childToDelete,
3570-
parentFiber,
3571-
);
3572-
} catch (error) {
3573-
captureCommitPhaseError(childToDelete, parentFiber, error);
3574-
}
3575-
}
3576-
}
3577-
3578-
if (deletedTreeCleanUpLevel >= 1) {
3579-
// A fiber was deleted from this parent fiber, but it's still part of
3580-
// the previous (alternate) parent fiber's list of children. Because
3581-
// children are a linked list, an earlier sibling that's still alive
3582-
// will be connected to the deleted fiber via its `alternate`:
3583-
//
3584-
// live fiber
3585-
// --alternate--> previous live fiber
3586-
// --sibling--> deleted fiber
3587-
//
3588-
// We can't disconnect `alternate` on nodes that haven't been deleted
3589-
// yet, but we can disconnect the `sibling` and `child` pointers.
3590-
const previousFiber = parentFiber.alternate;
3591-
if (previousFiber !== null) {
3592-
let detachedChild = previousFiber.child;
3593-
if (detachedChild !== null) {
3594-
previousFiber.child = null;
3595-
do {
3596-
const detachedSibling = detachedChild.sibling;
3597-
detachedChild.sibling = null;
3598-
detachedChild = detachedSibling;
3599-
} while (detachedChild !== null);
3600-
}
3619+
// TODO: Convert this to use recursion
3620+
nextEffect = childToDelete;
3621+
commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3622+
childToDelete,
3623+
parentFiber,
3624+
);
36013625
}
36023626
}
3627+
detachAlternateSiblings(parentFiber);
36033628
}
36043629

36053630
const prevDebugFiber = getCurrentDebugFiberInDEV();
@@ -3622,40 +3647,111 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
36223647
case SimpleMemoComponent: {
36233648
recursivelyTraversePassiveUnmountEffects(finishedWork);
36243649
if (finishedWork.flags & Passive) {
3625-
if (
3626-
enableProfilerTimer &&
3627-
enableProfilerCommitHooks &&
3628-
finishedWork.mode & ProfileMode
3629-
) {
3630-
startPassiveEffectTimer();
3631-
commitHookEffectListUnmount(
3632-
HookPassive | HookHasEffect,
3633-
finishedWork,
3634-
finishedWork.return,
3635-
);
3636-
recordPassiveEffectDuration(finishedWork);
3637-
} else {
3638-
commitHookEffectListUnmount(
3639-
HookPassive | HookHasEffect,
3640-
finishedWork,
3641-
finishedWork.return,
3642-
);
3643-
}
3650+
commitHookPassiveUnmountEffects(
3651+
finishedWork,
3652+
finishedWork.return,
3653+
HookPassive | HookHasEffect,
3654+
);
36443655
}
36453656
break;
36463657
}
3647-
// TODO: Disconnect passive effects when a tree is hidden, perhaps after
3648-
// a delay.
3649-
// case OffscreenComponent: {
3650-
// ...
3651-
// }
3658+
case OffscreenComponent: {
3659+
const instance: OffscreenInstance = finishedWork.stateNode;
3660+
const nextState: OffscreenState | null = finishedWork.memoizedState;
3661+
3662+
const isHidden = nextState !== null;
3663+
3664+
if (
3665+
isHidden &&
3666+
instance.visibility & OffscreenPassiveEffectsConnected &&
3667+
// For backwards compatibility, don't unmount when a tree suspends. In
3668+
// the future we may change this to unmount after a delay.
3669+
(finishedWork.return === null ||
3670+
finishedWork.return.tag !== SuspenseComponent)
3671+
) {
3672+
// The effects are currently connected. Disconnect them.
3673+
// TODO: Add option or heuristic to delay before disconnecting the
3674+
// effects. Then if the tree reappears before the delay has elapsed, we
3675+
// can skip toggling the effects entirely.
3676+
instance.visibility &= ~OffscreenPassiveEffectsConnected;
3677+
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3678+
} else {
3679+
recursivelyTraversePassiveUnmountEffects(finishedWork);
3680+
}
3681+
3682+
break;
3683+
}
36523684
default: {
36533685
recursivelyTraversePassiveUnmountEffects(finishedWork);
36543686
break;
36553687
}
36563688
}
36573689
}
36583690

3691+
function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void {
3692+
// Deletions effects can be scheduled on any fiber type. They need to happen
3693+
// before the children effects have fired.
3694+
const deletions = parentFiber.deletions;
3695+
3696+
if ((parentFiber.flags & ChildDeletion) !== NoFlags) {
3697+
if (deletions !== null) {
3698+
for (let i = 0; i < deletions.length; i++) {
3699+
const childToDelete = deletions[i];
3700+
// TODO: Convert this to use recursion
3701+
nextEffect = childToDelete;
3702+
commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3703+
childToDelete,
3704+
parentFiber,
3705+
);
3706+
}
3707+
}
3708+
detachAlternateSiblings(parentFiber);
3709+
}
3710+
3711+
const prevDebugFiber = getCurrentDebugFiberInDEV();
3712+
// TODO: Check PassiveStatic flag
3713+
let child = parentFiber.child;
3714+
while (child !== null) {
3715+
setCurrentDebugFiberInDEV(child);
3716+
disconnectPassiveEffect(child);
3717+
child = child.sibling;
3718+
}
3719+
setCurrentDebugFiberInDEV(prevDebugFiber);
3720+
}
3721+
3722+
function disconnectPassiveEffect(finishedWork: Fiber): void {
3723+
switch (finishedWork.tag) {
3724+
case FunctionComponent:
3725+
case ForwardRef:
3726+
case SimpleMemoComponent: {
3727+
// TODO: Check PassiveStatic flag
3728+
commitHookPassiveUnmountEffects(
3729+
finishedWork,
3730+
finishedWork.return,
3731+
HookPassive,
3732+
);
3733+
// When disconnecting passive effects, we fire the effects in the same
3734+
// order as during a deletiong: parent before child
3735+
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3736+
break;
3737+
}
3738+
case OffscreenComponent: {
3739+
const instance: OffscreenInstance = finishedWork.stateNode;
3740+
if (instance.visibility & OffscreenPassiveEffectsConnected) {
3741+
instance.visibility &= ~OffscreenPassiveEffectsConnected;
3742+
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3743+
} else {
3744+
// The effects are already disconnected.
3745+
}
3746+
break;
3747+
}
3748+
default: {
3749+
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3750+
break;
3751+
}
3752+
}
3753+
}
3754+
36593755
function commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
36603756
deletedSubtreeRoot: Fiber,
36613757
nearestMountedAncestor: Fiber | null,
@@ -3728,25 +3824,11 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber(
37283824
case FunctionComponent:
37293825
case ForwardRef:
37303826
case SimpleMemoComponent: {
3731-
if (
3732-
enableProfilerTimer &&
3733-
enableProfilerCommitHooks &&
3734-
current.mode & ProfileMode
3735-
) {
3736-
startPassiveEffectTimer();
3737-
commitHookEffectListUnmount(
3738-
HookPassive,
3739-
current,
3740-
nearestMountedAncestor,
3741-
);
3742-
recordPassiveEffectDuration(current);
3743-
} else {
3744-
commitHookEffectListUnmount(
3745-
HookPassive,
3746-
current,
3747-
nearestMountedAncestor,
3748-
);
3749-
}
3827+
commitHookPassiveUnmountEffects(
3828+
current,
3829+
nearestMountedAncestor,
3830+
HookPassive,
3831+
);
37503832
break;
37513833
}
37523834
// TODO: run passive unmount effects when unmounting a root.

0 commit comments

Comments
 (0)