Repository: facebook/react
Pull request: Mount/unmount passive effects when Offscreen visibility changes

## Based on #24967 

*^ review that one first. Or you can review the whole stack commit-by-commit.*

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.

Changed files:
filename: packages/react-reconciler/src/ReactFiberCommitWork.new.js
patch: @@ -3553,6 +3553,60 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
   resetCurrentDebugFiberInDEV();
 }
 
+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;
+      if (detachedChild !== null) {
+        previousFiber.child = null;
+        do {
+          const detachedSibling = detachedChild.sibling;
+          detachedChild.sibling = null;
+          detachedChild = detachedSibling;
+        } while (detachedChild !== null);
+      }
+    }
+  }
+}
+
+function commitHookPassiveUnmountEffects(
+  finishedWork: Fiber,
+  nearestMountedAncestor,
+  hookFlags: HookFlags,
+) {
+  if (
+    enableProfilerTimer &&
+    enableProfilerCommitHooks &&
+    finishedWork.mode & ProfileMode
+  ) {
+    startPassiveEffectTimer();
+    commitHookEffectListUnmount(
+      hookFlags,
+      finishedWork,
+      nearestMountedAncestor,
+    );
+    recordPassiveEffectDuration(finishedWork);
+  } else {
+    commitHookEffectListUnmount(
+      hookFlags,
+      finishedWork,
+      nearestMountedAncestor,
+    );
+  }
+}
+
 function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
   // Deletions effects can be scheduled on any fiber type. They need to happen
   // before the children effects have fired.
@@ -3562,44 +3616,15 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
     if (deletions !== null) {
       for (let i = 0; i < deletions.length; i++) {
         const childToDelete = deletions[i];
-        try {
-          // TODO: Convert this to use recursion
-          nextEffect = childToDelete;
-          commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
-            childToDelete,
-            parentFiber,
-          );
-        } catch (error) {
-          captureCommitPhaseError(childToDelete, parentFiber, error);
-        }
-      }
-    }
-
-    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;
-        if (detachedChild !== null) {
-          previousFiber.child = null;
-          do {
-            const detachedSibling = detachedChild.sibling;
-            detachedChild.sibling = null;
-            detachedChild = detachedSibling;
-          } while (detachedChild !== null);
-        }
+        // TODO: Convert this to use recursion
+        nextEffect = childToDelete;
+        commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
+          childToDelete,
+          parentFiber,
+        );
       }
     }
+    detachAlternateSiblings(parentFiber);
   }
 
   const prevDebugFiber = getCurrentDebugFiberInDEV();
@@ -3622,40 +3647,111 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
     case SimpleMemoComponent: {
       recursivelyTraversePassiveUnmountEffects(finishedWork);
       if (finishedWork.flags & Passive) {
-        if (
-          enableProfilerTimer &&
-          enableProfilerCommitHooks &&
-          finishedWork.mode & ProfileMode
-        ) {
-          startPassiveEffectTimer();
-          commitHookEffectListUnmount(
-            HookPassive | HookHasEffect,
-            finishedWork,
-            finishedWork.return,
-          );
-          recordPassiveEffectDuration(finishedWork);
-        } else {
-          commitHookEffectListUnmount(
-            HookPassive | HookHasEffect,
-            finishedWork,
-            finishedWork.return,
-          );
-        }
+        commitHookPassiveUnmountEffects(
+          finishedWork,
+          finishedWork.return,
+          HookPassive | HookHasEffect,
+        );
       }
       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,
@@ -3728,25 +3824,11 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber(
     case FunctionComponent:
     case ForwardRef:
     case SimpleMemoComponent: {
-      if (
-        enableProfilerTimer &&
-        enableProfilerCommitHooks &&
-        current.mode & ProfileMode
-      ) {
-        startPassiveEffectTimer();
-        commitHookEffectListUnmount(
-          HookPassive,
-          current,
-          nearestMountedAncestor,
-        );
-        recordPassiveEffectDuration(current);
-      } else {
-        commitHookEffectListUnmount(
-          HookPassive,
-          current,
-          nearestMountedAncestor,
-        );
-      }
+      commitHookPassiveUnmountEffects(
+        current,
+        nearestMountedAncestor,
+        HookPassive,
+      );
       break;
     }
     // TODO: run passive unmount effects when unmounting a root.

filename: packages/react-reconciler/src/ReactFiberCommitWork.old.js
patch: @@ -3553,6 +3553,60 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
   resetCurrentDebugFiberInDEV();
 }
 
+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;
+      if (detachedChild !== null) {
+        previousFiber.child = null;
+        do {
+          const detachedSibling = detachedChild.sibling;
+          detachedChild.sibling = null;
+          detachedChild = detachedSibling;
+        } while (detachedChild !== null);
+      }
+    }
+  }
+}
+
+function commitHookPassiveUnmountEffects(
+  finishedWork: Fiber,
+  nearestMountedAncestor,
+  hookFlags: HookFlags,
+) {
+  if (
+    enableProfilerTimer &&
+    enableProfilerCommitHooks &&
+    finishedWork.mode & ProfileMode
+  ) {
+    startPassiveEffectTimer();
+    commitHookEffectListUnmount(
+      hookFlags,
+      finishedWork,
+      nearestMountedAncestor,
+    );
+    recordPassiveEffectDuration(finishedWork);
+  } else {
+    commitHookEffectListUnmount(
+      hookFlags,
+      finishedWork,
+      nearestMountedAncestor,
+    );
+  }
+}
+
 function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
   // Deletions effects can be scheduled on any fiber type. They need to happen
   // before the children effects have fired.
@@ -3562,44 +3616,15 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
     if (deletions !== null) {
       for (let i = 0; i < deletions.length; i++) {
         const childToDelete = deletions[i];
-        try {
-          // TODO: Convert this to use recursion
-          nextEffect = childToDelete;
-          commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
-            childToDelete,
-            parentFiber,
-          );
-        } catch (error) {
-          captureCommitPhaseError(childToDelete, parentFiber, error);
-        }
-      }
-    }
-
-    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;
-        if (detachedChild !== null) {
-          previousFiber.child = null;
-          do {
-            const detachedSibling = detachedChild.sibling;
-            detachedChild.sibling = null;
-            detachedChild = detachedSibling;
-          } while (detachedChild !== null);
-        }
+        // TODO: Convert this to use recursion
+        nextEffect = childToDelete;
+        commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
+          childToDelete,
+          parentFiber,
+        );
       }
     }
+    detachAlternateSiblings(parentFiber);
   }
 
   const prevDebugFiber = getCurrentDebugFiberInDEV();
@@ -3622,40 +3647,111 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
     case SimpleMemoComponent: {
       recursivelyTraversePassiveUnmountEffects(finishedWork);
       if (finishedWork.flags & Passive) {
-        if (
-          enableProfilerTimer &&
-          enableProfilerCommitHooks &&
-          finishedWork.mode & ProfileMode
-        ) {
-          startPassiveEffectTimer();
-          commitHookEffectListUnmount(
-            HookPassive | HookHasEffect,
-            finishedWork,
-            finishedWork.return,
-          );
-          recordPassiveEffectDuration(finishedWork);
-        } else {
-          commitHookEffectListUnmount(
-            HookPassive | HookHasEffect,
-            finishedWork,
-            finishedWork.return,
-          );
-        }
+        commitHookPassiveUnmountEffects(
+          finishedWork,
+          finishedWork.return,
+          HookPassive | HookHasEffect,
+        );
       }
       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,
@@ -3728,25 +3824,11 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber(
     case FunctionComponent:
     case ForwardRef:
     case SimpleMemoComponent: {
-      if (
-        enableProfilerTimer &&
-        enableProfilerCommitHooks &&
-        current.mode & ProfileMode
-      ) {
-        startPassiveEffectTimer();
-        commitHookEffectListUnmount(
-          HookPassive,
-          current,
-          nearestMountedAncestor,
-        );
-        recordPassiveEffectDuration(current);
-      } else {
-        commitHookEffectListUnmount(
-          HookPassive,
-          current,
-          nearestMountedAncestor,
-        );
-      }
+      commitHookPassiveUnmountEffects(
+        current,
+        nearestMountedAncestor,
+        HookPassive,
+      );
       break;
     }
     // TODO: run passive unmount effects when unmounting a root.

filename: packages/react-reconciler/src/ReactFiberCompleteWork.new.js
patch: @@ -1513,15 +1513,22 @@ function completeWork(
       const nextState: OffscreenState | null = workInProgress.memoizedState;
       const nextIsHidden = nextState !== null;
 
-      if (current !== null) {
-        const prevState: OffscreenState | null = current.memoizedState;
-        const prevIsHidden = prevState !== null;
-        if (
-          prevIsHidden !== nextIsHidden &&
-          // LegacyHidden doesn't do any hiding — it only pre-renders.
-          (!enableLegacyHidden || workInProgress.tag !== LegacyHiddenComponent)
-        ) {
-          workInProgress.flags |= Visibility;
+      // Schedule a Visibility effect if the visibility has changed
+      if (enableLegacyHidden && workInProgress.tag === LegacyHiddenComponent) {
+        // LegacyHidden doesn't do any hiding — it only pre-renders.
+      } else {
+        if (current !== null) {
+          const prevState: OffscreenState | null = current.memoizedState;
+          const prevIsHidden = prevState !== null;
+          if (prevIsHidden !== nextIsHidden) {
+            workInProgress.flags |= Visibility;
+          }
+        } else {
+          // On initial mount, we only need a Visibility effect if the tree
+          // is hidden.
+          if (nextIsHidden) {
+            workInProgress.flags |= Visibility;
+          }
         }
       }
 

filename: packages/react-reconciler/src/ReactFiberCompleteWork.old.js
patch: @@ -1513,15 +1513,22 @@ function completeWork(
       const nextState: OffscreenState | null = workInProgress.memoizedState;
       const nextIsHidden = nextState !== null;
 
-      if (current !== null) {
-        const prevState: OffscreenState | null = current.memoizedState;
-        const prevIsHidden = prevState !== null;
-        if (
-          prevIsHidden !== nextIsHidden &&
-          // LegacyHidden doesn't do any hiding — it only pre-renders.
-          (!enableLegacyHidden || workInProgress.tag !== LegacyHiddenComponent)
-        ) {
-          workInProgress.flags |= Visibility;
+      // Schedule a Visibility effect if the visibility has changed
+      if (enableLegacyHidden && workInProgress.tag === LegacyHiddenComponent) {
+        // LegacyHidden doesn't do any hiding — it only pre-renders.
+      } else {
+        if (current !== null) {
+          const prevState: OffscreenState | null = current.memoizedState;
+          const prevIsHidden = prevState !== null;
+          if (prevIsHidden !== nextIsHidden) {
+            workInProgress.flags |= Visibility;
+          }
+        } else {
+          // On initial mount, we only need a Visibility effect if the tree
+          // is hidden.
+          if (nextIsHidden) {
+            workInProgress.flags |= Visibility;
+          }
         }
       }
 

filename: packages/react-reconciler/src/__tests__/ReactOffscreen-test.js
patch: @@ -7,6 +7,7 @@ let Offscreen;
 let useState;
 let useLayoutEffect;
 let useEffect;
+let useMemo;
 let startTransition;
 
 describe('ReactOffscreen', () => {
@@ -22,6 +23,7 @@ describe('ReactOffscreen', () => {
     useState = React.useState;
     useLayoutEffect = React.useLayoutEffect;
     useEffect = React.useEffect;
+    useMemo = React.useMemo;
     startTransition = React.startTransition;
   });
 
@@ -939,7 +941,122 @@ describe('ReactOffscreen', () => {
   });
 
   // @gate enableOffscreen
-  it("don't defer passive effects when prerendering in a tree whose effects are already connected", async () => {
+  it('passive effects are connected and disconnected when the visibility changes', async () => {
+    function Child({step}) {
+      useEffect(() => {
+        Scheduler.unstable_yieldValue(`Commit mount [${step}]`);
+        return () => {
+          Scheduler.unstable_yieldValue(`Commit unmount [${step}]`);
+        };
+      }, [step]);
+      return <Text text={step} />;
+    }
+
+    function App({show, step}) {
+      return (
+        <Offscreen mode={show ? 'visible' : 'hidden'}>
+          {useMemo(
+            () => (
+              <Child step={step} />
+            ),
+            [step],
+          )}
+        </Offscreen>
+      );
+    }
+
+    const root = ReactNoop.createRoot();
+    await act(async () => {
+      root.render(<App show={true} step={1} />);
+    });
+    expect(Scheduler).toHaveYielded([1, 'Commit mount [1]']);
+    expect(root).toMatchRenderedOutput(<span prop={1} />);
+
+    // Hide the tree. This will unmount the effect.
+    await act(async () => {
+      root.render(<App show={false} step={1} />);
+    });
+    expect(Scheduler).toHaveYielded(['Commit unmount [1]']);
+    expect(root).toMatchRenderedOutput(<span hidden={true} prop={1} />);
+
+    // Update.
+    await act(async () => {
+      root.render(<App show={false} step={2} />);
+    });
+    // The update is prerendered but no effects are fired
+    expect(Scheduler).toHaveYielded([2]);
+    expect(root).toMatchRenderedOutput(<span hidden={true} prop={2} />);
+
+    // Reveal the tree.
+    await act(async () => {
+      root.render(<App show={true} step={2} />);
+    });
+    // The update doesn't render because it was already prerendered, but we do
+    // fire the effect.
+    expect(Scheduler).toHaveYielded(['Commit mount [2]']);
+    expect(root).toMatchRenderedOutput(<span prop={2} />);
+  });
+
+  // @gate enableOffscreen
+  it('passive effects are unmounted on hide in the same order as during a deletion: parent before child', async () => {
+    function Child({label}) {
+      useEffect(() => {
+        Scheduler.unstable_yieldValue('Mount Child');
+        return () => {
+          Scheduler.unstable_yieldValue('Unmount Child');
+        };
+      }, []);
+      return <div>Hi</div>;
+    }
+    function Parent() {
+      useEffect(() => {
+        Scheduler.unstable_yieldValue('Mount Parent');
+        return () => {
+          Scheduler.unstable_yieldValue('Unmount Parent');
+        };
+      }, []);
+      return <Child />;
+    }
+
+    function App({show}) {
+      return (
+        <Offscreen mode={show ? 'visible' : 'hidden'}>
+          <Parent />
+        </Offscreen>
+      );
+    }
+
+    const root = ReactNoop.createRoot();
+    await act(async () => {
+      root.render(<App show={true} />);
+    });
+    expect(Scheduler).toHaveYielded(['Mount Child', 'Mount Parent']);
+
+    // First demonstrate what happens during a normal deletion
+    await act(async () => {
+      root.render(null);
+    });
+    expect(Scheduler).toHaveYielded(['Unmount Parent', 'Unmount Child']);
+
+    // Now redo the same thing but hide instead of deleting
+    await act(async () => {
+      root.render(<App show={true} />);
+    });
+    expect(Scheduler).toHaveYielded(['Mount Child', 'Mount Parent']);
+    await act(async () => {
+      root.render(<App show={false} />);
+    });
+    // The order is the same as during a deletion: parent before child
+    expect(Scheduler).toHaveYielded(['Unmount Parent', 'Unmount Child']);
+  });
+
+  // TODO: As of now, there's no way to hide a tree without also unmounting its
+  // effects. (Except for Suspense, which has its own tests associated with it.)
+  // Re-enable this test once we add this ability. For example, we'll likely add
+  // either an option or a heuristic to mount passive effects inside a hidden
+  // tree after a delay.
+  // @gate enableOffscreen
+  it.skip("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);
@@ -998,4 +1115,148 @@ describe('ReactOffscreen', () => {
       'Mount More 2',
     ]);
   });
+
+  // @gate enableOffscreen
+  it('does not mount effects when prerendering a nested Offscreen boundary', async () => {
+    function Child({label}) {
+      useEffect(() => {
+        Scheduler.unstable_yieldValue('Mount ' + label);
+        return () => {
+          Scheduler.unstable_yieldValue('Unmount ' + label);
+        };
+      }, [label]);
+      return <Text text={label} />;
+    }
+
+    function App({showOuter, showInner}) {
+      return (
+        <Offscreen mode={showOuter ? 'visible' : 'hidden'}>
+          {useMemo(
+            () => (
+              <div>
+                <Child label="Outer" />
+                {showInner ? (
+                  <Offscreen mode="visible">
+                    <div>
+                      <Child label="Inner" />
+                    </div>
+                  </Offscreen>
+                ) : null}
+              </div>
+            ),
+            [showInner],
+          )}
+        </Offscreen>
+      );
+    }
+
+    const root = ReactNoop.createRoot();
+
+    // Prerender the outer contents. No effects should mount.
+    await act(async () => {
+      root.render(<App showOuter={false} showInner={false} />);
+    });
+    expect(Scheduler).toHaveYielded(['Outer']);
+    expect(root).toMatchRenderedOutput(
+      <div hidden={true}>
+        <span prop="Outer" />
+      </div>,
+    );
+
+    // Prerender the inner contents. No effects should mount.
+    await act(async () => {
+      root.render(<App showOuter={false} showInner={true} />);
+    });
+    expect(Scheduler).toHaveYielded(['Outer', 'Inner']);
+    expect(root).toMatchRenderedOutput(
+      <div hidden={true}>
+        <span prop="Outer" />
+        <div>
+          <span prop="Inner" />
+        </div>
+      </div>,
+    );
+
+    // Reveal the prerendered tree
+    await act(async () => {
+      root.render(<App showOuter={true} showInner={true} />);
+    });
+    // The effects fire, but the tree is not re-rendered because it already
+    // prerendered.
+    expect(Scheduler).toHaveYielded(['Mount Outer', 'Mount Inner']);
+    expect(root).toMatchRenderedOutput(
+      <div>
+        <span prop="Outer" />
+        <div>
+          <span prop="Inner" />
+        </div>
+      </div>,
+    );
+  });
+
+  // @gate enableOffscreen
+  it('reveal an outer Offscreen boundary without revealing an inner one', async () => {
+    function Child({label}) {
+      useEffect(() => {
+        Scheduler.unstable_yieldValue('Mount ' + label);
+        return () => {
+          Scheduler.unstable_yieldValue('Unmount ' + label);
+        };
+      }, [label]);
+      return <Text text={label} />;
+    }
+
+    function App({showOuter, showInner}) {
+      return (
+        <Offscreen mode={showOuter ? 'visible' : 'hidden'}>
+          {useMemo(
+            () => (
+              <div>
+                <Child label="Outer" />
+                <Offscreen mode={showInner ? 'visible' : 'hidden'}>
+                  <div>
+                    <Child label="Inner" />
+                  </div>
+                </Offscreen>
+              </div>
+            ),
+            [showInner],
+          )}
+        </Offscreen>
+      );
+    }
+
+    const root = ReactNoop.createRoot();
+
+    // Prerender the whole tree.
+    await act(async () => {
+      root.render(<App showOuter={false} showInner={false} />);
+    });
+    expect(Scheduler).toHaveYielded(['Outer', 'Inner']);
+    // Both the inner and the outer tree should be hidden. Hiding the inner tree
+    // is arguably redundant, but the advantage of hiding both is that later you
+    // can reveal the outer tree without having to examine the inner one.
+    expect(root).toMatchRenderedOutput(
+      <div hidden={true}>
+        <span prop="Outer" />
+        <div hidden={true}>
+          <span prop="Inner" />
+        </div>
+      </div>,
+    );
+
+    // Reveal the outer contents. The inner tree remains hidden.
+    await act(async () => {
+      root.render(<App showOuter={true} showInner={false} />);
+    });
+    expect(Scheduler).toHaveYielded(['Mount Outer']);
+    expect(root).toMatchRenderedOutput(
+      <div>
+        <span prop="Outer" />
+        <div hidden={true}>
+          <span prop="Inner" />
+        </div>
+      </div>,
+    );
+  });
 });