Skip to content

Commit

Permalink
Mount/unmount passive effects on hide/show
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
acdlite committed Jul 22, 2022
1 parent 6a6dbe4 commit b44956c
Show file tree
Hide file tree
Showing 3 changed files with 319 additions and 33 deletions.
117 changes: 101 additions & 16 deletions packages/react-reconciler/src/ReactFiberCommitWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -3555,6 +3555,17 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {

function detachAlternateSiblings(parentFiber: Fiber) {
if (deletedTreeCleanUpLevel >= 1) {
// A fiber was deleted from this parent fiber, but it's still part of the
// previous (alternate) parent fiber's list of children. Because children
// are a linked list, an earlier sibling that's still alive will be
// connected to the deleted fiber via its `alternate`:
//
// live fiber --alternate--> previous live fiber --sibling--> deleted
// fiber
//
// We can't disconnect `alternate` on nodes that haven't been deleted yet,
// but we can disconnect the `sibling` and `child` pointers.

const previousFiber = parentFiber.alternate;
if (previousFiber !== null) {
let detachedChild = previousFiber.child;
Expand Down Expand Up @@ -3613,17 +3624,6 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
);
}
}
// A fiber was deleted from this parent fiber, but it's still part of
// the previous (alternate) parent fiber's list of children. Because
// children are a linked list, an earlier sibling that's still alive
// will be connected to the deleted fiber via its `alternate`:
//
// live fiber
// --alternate--> previous live fiber
// --sibling--> deleted fiber
//
// We can't disconnect `alternate` on nodes that haven't been deleted
// yet, but we can disconnect the `sibling` and `child` pointers.
detachAlternateSiblings(parentFiber);
}

Expand Down Expand Up @@ -3655,18 +3655,103 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
}
break;
}
// TODO: Disconnect passive effects when a tree is hidden, perhaps after
// a delay.
// case OffscreenComponent: {
// ...
// }
case OffscreenComponent: {
const instance: OffscreenInstance = finishedWork.stateNode;
const nextState: OffscreenState | null = finishedWork.memoizedState;

const isHidden = nextState !== null;

if (
isHidden &&
instance.visibility & OffscreenPassiveEffectsConnected &&
// For backwards compatibility, don't unmount when a tree suspends. In
// the future we may change this to unmount after a delay.
(finishedWork.return === null ||
finishedWork.return.tag !== SuspenseComponent)
) {
// The effects are currently connected. Disconnect them.
// TODO: Add option or heuristic to delay before disconnecting the
// effects. Then if the tree reappears before the delay has elapsed, we
// can skip toggling the effects entirely.
instance.visibility &= ~OffscreenPassiveEffectsConnected;
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
} else {
recursivelyTraversePassiveUnmountEffects(finishedWork);
}

break;
}
default: {
recursivelyTraversePassiveUnmountEffects(finishedWork);
break;
}
}
}

function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void {
// Deletions effects can be scheduled on any fiber type. They need to happen
// before the children effects have fired.
const deletions = parentFiber.deletions;

if ((parentFiber.flags & ChildDeletion) !== NoFlags) {
if (deletions !== null) {
for (let i = 0; i < deletions.length; i++) {
const childToDelete = deletions[i];
// TODO: Convert this to use recursion
nextEffect = childToDelete;
commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
childToDelete,
parentFiber,
);
}
}
detachAlternateSiblings(parentFiber);
}

const prevDebugFiber = getCurrentDebugFiberInDEV();
// TODO: Check PassiveStatic flag
let child = parentFiber.child;
while (child !== null) {
setCurrentDebugFiberInDEV(child);
disconnectPassiveEffect(child);
child = child.sibling;
}
setCurrentDebugFiberInDEV(prevDebugFiber);
}

function disconnectPassiveEffect(finishedWork: Fiber): void {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
// TODO: Check PassiveStatic flag
commitHookPassiveUnmountEffects(
finishedWork,
finishedWork.return,
HookPassive,
);
// When disconnecting passive effects, we fire the effects in the same
// order as during a deletiong: parent before child
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
break;
}
case OffscreenComponent: {
const instance: OffscreenInstance = finishedWork.stateNode;
if (instance.visibility & OffscreenPassiveEffectsConnected) {
instance.visibility &= ~OffscreenPassiveEffectsConnected;
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
} else {
// The effects are already disconnected.
}
break;
}
default: {
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
break;
}
}
}

function commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
deletedSubtreeRoot: Fiber,
nearestMountedAncestor: Fiber | null,
Expand Down
117 changes: 101 additions & 16 deletions packages/react-reconciler/src/ReactFiberCommitWork.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -3555,6 +3555,17 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {

function detachAlternateSiblings(parentFiber: Fiber) {
if (deletedTreeCleanUpLevel >= 1) {
// A fiber was deleted from this parent fiber, but it's still part of the
// previous (alternate) parent fiber's list of children. Because children
// are a linked list, an earlier sibling that's still alive will be
// connected to the deleted fiber via its `alternate`:
//
// live fiber --alternate--> previous live fiber --sibling--> deleted
// fiber
//
// We can't disconnect `alternate` on nodes that haven't been deleted yet,
// but we can disconnect the `sibling` and `child` pointers.

const previousFiber = parentFiber.alternate;
if (previousFiber !== null) {
let detachedChild = previousFiber.child;
Expand Down Expand Up @@ -3613,17 +3624,6 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
);
}
}
// A fiber was deleted from this parent fiber, but it's still part of
// the previous (alternate) parent fiber's list of children. Because
// children are a linked list, an earlier sibling that's still alive
// will be connected to the deleted fiber via its `alternate`:
//
// live fiber
// --alternate--> previous live fiber
// --sibling--> deleted fiber
//
// We can't disconnect `alternate` on nodes that haven't been deleted
// yet, but we can disconnect the `sibling` and `child` pointers.
detachAlternateSiblings(parentFiber);
}

Expand Down Expand Up @@ -3655,18 +3655,103 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
}
break;
}
// TODO: Disconnect passive effects when a tree is hidden, perhaps after
// a delay.
// case OffscreenComponent: {
// ...
// }
case OffscreenComponent: {
const instance: OffscreenInstance = finishedWork.stateNode;
const nextState: OffscreenState | null = finishedWork.memoizedState;

const isHidden = nextState !== null;

if (
isHidden &&
instance.visibility & OffscreenPassiveEffectsConnected &&
// For backwards compatibility, don't unmount when a tree suspends. In
// the future we may change this to unmount after a delay.
(finishedWork.return === null ||
finishedWork.return.tag !== SuspenseComponent)
) {
// The effects are currently connected. Disconnect them.
// TODO: Add option or heuristic to delay before disconnecting the
// effects. Then if the tree reappears before the delay has elapsed, we
// can skip toggling the effects entirely.
instance.visibility &= ~OffscreenPassiveEffectsConnected;
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
} else {
recursivelyTraversePassiveUnmountEffects(finishedWork);
}

break;
}
default: {
recursivelyTraversePassiveUnmountEffects(finishedWork);
break;
}
}
}

function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void {
// Deletions effects can be scheduled on any fiber type. They need to happen
// before the children effects have fired.
const deletions = parentFiber.deletions;

if ((parentFiber.flags & ChildDeletion) !== NoFlags) {
if (deletions !== null) {
for (let i = 0; i < deletions.length; i++) {
const childToDelete = deletions[i];
// TODO: Convert this to use recursion
nextEffect = childToDelete;
commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
childToDelete,
parentFiber,
);
}
}
detachAlternateSiblings(parentFiber);
}

const prevDebugFiber = getCurrentDebugFiberInDEV();
// TODO: Check PassiveStatic flag
let child = parentFiber.child;
while (child !== null) {
setCurrentDebugFiberInDEV(child);
disconnectPassiveEffect(child);
child = child.sibling;
}
setCurrentDebugFiberInDEV(prevDebugFiber);
}

function disconnectPassiveEffect(finishedWork: Fiber): void {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
// TODO: Check PassiveStatic flag
commitHookPassiveUnmountEffects(
finishedWork,
finishedWork.return,
HookPassive,
);
// When disconnecting passive effects, we fire the effects in the same
// order as during a deletiong: parent before child
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
break;
}
case OffscreenComponent: {
const instance: OffscreenInstance = finishedWork.stateNode;
if (instance.visibility & OffscreenPassiveEffectsConnected) {
instance.visibility &= ~OffscreenPassiveEffectsConnected;
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
} else {
// The effects are already disconnected.
}
break;
}
default: {
recursivelyTraverseDisconnectPassiveEffects(finishedWork);
break;
}
}
}

function commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
deletedSubtreeRoot: Fiber,
nearestMountedAncestor: Fiber | null,
Expand Down
Loading

0 comments on commit b44956c

Please sign in to comment.