Repository: facebook/react
Pull request: Bugfix: In legacy mode, call suspended tree's unmount effects when it is deleted

When a suspended tree switches to a fallback, we unmount the effects. If the suspended tree is then deleted, there's a guard to prevent us from unmounting the effects again (added in ec52a5698e2dfea7050a0b015f0b79abfb2d81b7)

However, in legacy mode, we don't unmount effects when a tree suspends. So if the suspended tree is then deleted, we do need to unmount the effects.

This only affects apps using `ReactDOM.render`, which is deprecated (not supported) in React 18+. (And React Native, which hasn't finished adopting the React 18 behavior yet.)

Until `ReactDOM.render` is completely removed, though, we need to make sure to add concurrent mode checks whenever we add Offscreen-related logic to a shared codepath.

Changed files:
filename: packages/react-reconciler/src/ReactFiberCommitWork.new.js
patch: @@ -1804,24 +1804,36 @@ function commitDeletionEffectsOnFiber(
       return;
     }
     case OffscreenComponent: {
-      // If this offscreen component is hidden, we already unmounted it. Before
-      // deleting the children, track that it's already unmounted so that we
-      // don't attempt to unmount the effects again.
-      // TODO: If the tree is hidden, in most cases we should be able to skip
-      // over the nested children entirely. An exception is we haven't yet found
-      // the topmost host node to delete, which we already track on the stack.
-      // But the other case is portals, which need to be detached no matter how
-      // deeply they are nested. We should use a subtree flag to track whether a
-      // subtree includes a nested portal.
-      const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
-      offscreenSubtreeWasHidden =
-        prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null;
-      recursivelyTraverseDeletionEffects(
-        finishedRoot,
-        nearestMountedAncestor,
-        deletedFiber,
-      );
-      offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
+      if (
+        // TODO: Remove this dead flag
+        enableSuspenseLayoutEffectSemantics &&
+        deletedFiber.mode & ConcurrentMode
+      ) {
+        // If this offscreen component is hidden, we already unmounted it. Before
+        // deleting the children, track that it's already unmounted so that we
+        // don't attempt to unmount the effects again.
+        // TODO: If the tree is hidden, in most cases we should be able to skip
+        // over the nested children entirely. An exception is we haven't yet found
+        // the topmost host node to delete, which we already track on the stack.
+        // But the other case is portals, which need to be detached no matter how
+        // deeply they are nested. We should use a subtree flag to track whether a
+        // subtree includes a nested portal.
+        const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
+        offscreenSubtreeWasHidden =
+          prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null;
+        recursivelyTraverseDeletionEffects(
+          finishedRoot,
+          nearestMountedAncestor,
+          deletedFiber,
+        );
+        offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
+      } else {
+        recursivelyTraverseDeletionEffects(
+          finishedRoot,
+          nearestMountedAncestor,
+          deletedFiber,
+        );
+      }
       break;
     }
     default: {
@@ -2238,13 +2250,21 @@ function commitMutationEffectsOnFiber(
     case OffscreenComponent: {
       const wasHidden = current !== null && current.memoizedState !== null;
 
-      // Before committing the children, track on the stack whether this
-      // offscreen subtree was already hidden, so that we don't unmount the
-      // effects again.
-      const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
-      offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
-      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
-      offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
+      if (
+        // TODO: Remove this dead flag
+        enableSuspenseLayoutEffectSemantics &&
+        finishedWork.mode & ConcurrentMode
+      ) {
+        // Before committing the children, track on the stack whether this
+        // offscreen subtree was already hidden, so that we don't unmount the
+        // effects again.
+        const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
+        offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
+        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
+        offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
+      } else {
+        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
+      }
 
       commitReconciliationEffects(finishedWork);
 

filename: packages/react-reconciler/src/ReactFiberCommitWork.old.js
patch: @@ -1804,24 +1804,36 @@ function commitDeletionEffectsOnFiber(
       return;
     }
     case OffscreenComponent: {
-      // If this offscreen component is hidden, we already unmounted it. Before
-      // deleting the children, track that it's already unmounted so that we
-      // don't attempt to unmount the effects again.
-      // TODO: If the tree is hidden, in most cases we should be able to skip
-      // over the nested children entirely. An exception is we haven't yet found
-      // the topmost host node to delete, which we already track on the stack.
-      // But the other case is portals, which need to be detached no matter how
-      // deeply they are nested. We should use a subtree flag to track whether a
-      // subtree includes a nested portal.
-      const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
-      offscreenSubtreeWasHidden =
-        prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null;
-      recursivelyTraverseDeletionEffects(
-        finishedRoot,
-        nearestMountedAncestor,
-        deletedFiber,
-      );
-      offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
+      if (
+        // TODO: Remove this dead flag
+        enableSuspenseLayoutEffectSemantics &&
+        deletedFiber.mode & ConcurrentMode
+      ) {
+        // If this offscreen component is hidden, we already unmounted it. Before
+        // deleting the children, track that it's already unmounted so that we
+        // don't attempt to unmount the effects again.
+        // TODO: If the tree is hidden, in most cases we should be able to skip
+        // over the nested children entirely. An exception is we haven't yet found
+        // the topmost host node to delete, which we already track on the stack.
+        // But the other case is portals, which need to be detached no matter how
+        // deeply they are nested. We should use a subtree flag to track whether a
+        // subtree includes a nested portal.
+        const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
+        offscreenSubtreeWasHidden =
+          prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null;
+        recursivelyTraverseDeletionEffects(
+          finishedRoot,
+          nearestMountedAncestor,
+          deletedFiber,
+        );
+        offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
+      } else {
+        recursivelyTraverseDeletionEffects(
+          finishedRoot,
+          nearestMountedAncestor,
+          deletedFiber,
+        );
+      }
       break;
     }
     default: {
@@ -2238,13 +2250,21 @@ function commitMutationEffectsOnFiber(
     case OffscreenComponent: {
       const wasHidden = current !== null && current.memoizedState !== null;
 
-      // Before committing the children, track on the stack whether this
-      // offscreen subtree was already hidden, so that we don't unmount the
-      // effects again.
-      const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
-      offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
-      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
-      offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
+      if (
+        // TODO: Remove this dead flag
+        enableSuspenseLayoutEffectSemantics &&
+        finishedWork.mode & ConcurrentMode
+      ) {
+        // Before committing the children, track on the stack whether this
+        // offscreen subtree was already hidden, so that we don't unmount the
+        // effects again.
+        const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
+        offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
+        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
+        offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
+      } else {
+        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
+      }
 
       commitReconciliationEffects(finishedWork);
 

filename: packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemanticsDOM-test.js
patch: @@ -93,60 +93,6 @@ describe('ReactSuspenseEffectsSemanticsDOM', () => {
     });
   });
 
-  it('does not destroy layout effects twice when hidden child is removed', async () => {
-    function ChildA({label}) {
-      React.useLayoutEffect(() => {
-        Scheduler.unstable_yieldValue('Did mount: ' + label);
-        return () => {
-          Scheduler.unstable_yieldValue('Will unmount: ' + label);
-        };
-      }, []);
-      return <Text text={label} />;
-    }
-
-    function ChildB({label}) {
-      React.useLayoutEffect(() => {
-        Scheduler.unstable_yieldValue('Did mount: ' + label);
-        return () => {
-          Scheduler.unstable_yieldValue('Will unmount: ' + label);
-        };
-      }, []);
-      return <Text text={label} />;
-    }
-
-    const LazyChildA = React.lazy(() => fakeImport(ChildA));
-    const LazyChildB = React.lazy(() => fakeImport(ChildB));
-
-    function Parent({swap}) {
-      return (
-        <React.Suspense fallback={<Text text="Loading..." />}>
-          {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
-        </React.Suspense>
-      );
-    }
-
-    const root = ReactDOMClient.createRoot(container);
-    act(() => {
-      root.render(<Parent swap={false} />);
-    });
-    expect(Scheduler).toHaveYielded(['Loading...']);
-
-    await LazyChildA;
-    expect(Scheduler).toFlushAndYield(['A', 'Did mount: A']);
-    expect(container.innerHTML).toBe('A');
-
-    // Swap the position of A and B
-    ReactDOM.flushSync(() => {
-      root.render(<Parent swap={true} />);
-    });
-    expect(Scheduler).toHaveYielded(['Loading...', 'Will unmount: A']);
-    expect(container.innerHTML).toBe('Loading...');
-
-    await LazyChildB;
-    expect(Scheduler).toFlushAndYield(['B', 'Did mount: B']);
-    expect(container.innerHTML).toBe('B');
-  });
-
   it('does not destroy ref cleanup twice when hidden child is removed', async () => {
     function ChildA({label}) {
       return (
@@ -455,4 +401,53 @@ describe('ReactSuspenseEffectsSemanticsDOM', () => {
     expect(Scheduler).toFlushAndYield([]);
     expect(container.innerHTML).toBe('<h1>Hello</h1>');
   });
+
+  it('regression: unmount hidden tree, in legacy mode', async () => {
+    // In legacy mode, when a tree suspends and switches to a fallback, the
+    // effects are not unmounted. So we have to unmount them during a deletion.
+
+    function Child() {
+      React.useLayoutEffect(() => {
+        Scheduler.unstable_yieldValue('Mount');
+        return () => {
+          Scheduler.unstable_yieldValue('Unmount');
+        };
+      }, []);
+      return <Text text="Child" />;
+    }
+
+    function Sibling() {
+      return <Text text="Sibling" />;
+    }
+    const LazySibling = React.lazy(() => fakeImport(Sibling));
+
+    function App({showMore}) {
+      return (
+        <React.Suspense fallback={<Text text="Loading..." />}>
+          <Child />
+          {showMore ? <LazySibling /> : null}
+        </React.Suspense>
+      );
+    }
+
+    // Initial render
+    ReactDOM.render(<App showMore={false} />, container);
+    expect(Scheduler).toHaveYielded(['Child', 'Mount']);
+
+    // Update that suspends, causing the existing tree to switches it to
+    // a fallback.
+    ReactDOM.render(<App showMore={true} />, container);
+    expect(Scheduler).toHaveYielded([
+      'Child',
+      'Loading...',
+
+      // In a concurrent root, the effect would unmount here. But this is legacy
+      // mode, so it doesn't.
+      // Unmount
+    ]);
+
+    // Delete the tree and unmount the effect
+    ReactDOM.render(null, container);
+    expect(Scheduler).toHaveYielded(['Unmount']);
+  });
 });