Repository: facebook/react
Pull request: Fix: LegacyHidden should not toggle effects

LegacyHidden is a transitional API that we added to replace the old `<div hidden={true} />` API that we used to use for pre-rendering. The plan is to replace this with the Offscreen component, once it's ready.

The idea is that LegacyHidden has identical behavior to Offscreen except that it doesn't change the behavior of effects. (Which is basically how `<div hidden={true} />` worked — it prerendered the hidden content in the background, but nothing else.) That way, while we're rolling this out, we could toggle the feature behind a feature flag either for performance testing or as a kill switch.

It looks like we accidentally enabled the effects flag for both Offscreen _and_ LegacyHidden. I suppose it's a good thing that nobody has complained yet, since we eventually do want to ship this behavior everywhere?

But I do think we should remove it from LegacyHidden, and roll it out by gating the component type in the downstream repo. That way if there's an issue related to the use of LegacyHidden, we can disable that without disabling the behavior for Suspense boundaries.

In retrospect, I might have implemented this as an unstable prop on Offscreen instead of a completely separate type — though at the time, Offscreen didn't exist. I originally added LegacyHidden to unblock the Lanes refactor, so I could move the deprioritization logic out of the HostComponent implementation.

Not a big deal since we're going to remove this soon. The implementation is almost the same regardless: before disconnecting or reconnecting the effects, check the fiber tag. The rest of the logic is the same.

Changed files:
filename: packages/react-reconciler/src/ReactFiberCommitWork.new.js
patch: @@ -2131,8 +2131,7 @@ function commitMutationEffectsOnFiber(finishedWork: Fiber, root: FiberRoot) {
         }
         break;
       }
-      case OffscreenComponent:
-      case LegacyHiddenComponent: {
+      case OffscreenComponent: {
         const newState: OffscreenState | null = finishedWork.memoizedState;
         const isHidden = newState !== null;
         const current = finishedWork.alternate;
@@ -2145,27 +2144,26 @@ function commitMutationEffectsOnFiber(finishedWork: Fiber, root: FiberRoot) {
           hideOrUnhideAllChildren(offscreenBoundary, isHidden);
         }
 
-        if (isHidden) {
-          if (!wasHidden) {
-            if (
-              enableSuspenseLayoutEffectSemantics &&
-              (offscreenBoundary.mode & ConcurrentMode) !== NoMode
-            ) {
-              nextEffect = offscreenBoundary;
-              let offscreenChild = offscreenBoundary.child;
-              while (offscreenChild !== null) {
-                nextEffect = offscreenChild;
-                disappearLayoutEffects_begin(offscreenChild);
-                offscreenChild = offscreenChild.sibling;
+        if (enableSuspenseLayoutEffectSemantics) {
+          if (isHidden) {
+            if (!wasHidden) {
+              if ((offscreenBoundary.mode & ConcurrentMode) !== NoMode) {
+                nextEffect = offscreenBoundary;
+                let offscreenChild = offscreenBoundary.child;
+                while (offscreenChild !== null) {
+                  nextEffect = offscreenChild;
+                  disappearLayoutEffects_begin(offscreenChild);
+                  offscreenChild = offscreenChild.sibling;
+                }
               }
             }
+          } else {
+            if (wasHidden) {
+              // TODO: Move re-appear call here for symmetry?
+            }
           }
-        } else {
-          if (wasHidden) {
-            // TODO: Move re-appear call here for symmetry?
-          }
+          break;
         }
-        break;
       }
     }
   }

filename: packages/react-reconciler/src/ReactFiberCommitWork.old.js
patch: @@ -2131,8 +2131,7 @@ function commitMutationEffectsOnFiber(finishedWork: Fiber, root: FiberRoot) {
         }
         break;
       }
-      case OffscreenComponent:
-      case LegacyHiddenComponent: {
+      case OffscreenComponent: {
         const newState: OffscreenState | null = finishedWork.memoizedState;
         const isHidden = newState !== null;
         const current = finishedWork.alternate;
@@ -2145,27 +2144,26 @@ function commitMutationEffectsOnFiber(finishedWork: Fiber, root: FiberRoot) {
           hideOrUnhideAllChildren(offscreenBoundary, isHidden);
         }
 
-        if (isHidden) {
-          if (!wasHidden) {
-            if (
-              enableSuspenseLayoutEffectSemantics &&
-              (offscreenBoundary.mode & ConcurrentMode) !== NoMode
-            ) {
-              nextEffect = offscreenBoundary;
-              let offscreenChild = offscreenBoundary.child;
-              while (offscreenChild !== null) {
-                nextEffect = offscreenChild;
-                disappearLayoutEffects_begin(offscreenChild);
-                offscreenChild = offscreenChild.sibling;
+        if (enableSuspenseLayoutEffectSemantics) {
+          if (isHidden) {
+            if (!wasHidden) {
+              if ((offscreenBoundary.mode & ConcurrentMode) !== NoMode) {
+                nextEffect = offscreenBoundary;
+                let offscreenChild = offscreenBoundary.child;
+                while (offscreenChild !== null) {
+                  nextEffect = offscreenChild;
+                  disappearLayoutEffects_begin(offscreenChild);
+                  offscreenChild = offscreenChild.sibling;
+                }
               }
             }
+          } else {
+            if (wasHidden) {
+              // TODO: Move re-appear call here for symmetry?
+            }
           }
-        } else {
-          if (wasHidden) {
-            // TODO: Move re-appear call here for symmetry?
-          }
+          break;
         }
-        break;
       }
     }
   }

filename: packages/react-reconciler/src/ReactFiberCompleteWork.new.js
patch: @@ -1361,7 +1361,9 @@ function completeWork(
         const prevIsHidden = prevState !== null;
         if (
           prevIsHidden !== nextIsHidden &&
-          newProps.mode !== 'unstable-defer-without-hiding'
+          newProps.mode !== 'unstable-defer-without-hiding' &&
+          // LegacyHidden doesn't do any hiding — it only pre-renders.
+          workInProgress.tag !== LegacyHiddenComponent
         ) {
           workInProgress.flags |= Visibility;
         }

filename: packages/react-reconciler/src/ReactFiberCompleteWork.old.js
patch: @@ -1361,7 +1361,9 @@ function completeWork(
         const prevIsHidden = prevState !== null;
         if (
           prevIsHidden !== nextIsHidden &&
-          newProps.mode !== 'unstable-defer-without-hiding'
+          newProps.mode !== 'unstable-defer-without-hiding' &&
+          // LegacyHidden doesn't do any hiding — it only pre-renders.
+          workInProgress.tag !== LegacyHiddenComponent
         ) {
           workInProgress.flags |= Visibility;
         }

filename: packages/react-reconciler/src/__tests__/ReactOffscreen-test.js
patch: @@ -337,4 +337,52 @@ describe('ReactOffscreen', () => {
       expect(root).toMatchRenderedOutput(<span prop="Child" />);
     }
   });
+
+  // @gate experimental || www
+  it('does not toggle effects for LegacyHidden component', async () => {
+    // LegacyHidden is meant to be the same as offscreen except it doesn't
+    // do anything to effects. Only used by www, as a temporary migration step.
+    function Child({text}) {
+      useLayoutEffect(() => {
+        Scheduler.unstable_yieldValue('Mount layout');
+        return () => {
+          Scheduler.unstable_yieldValue('Unmount layout');
+        };
+      }, []);
+      return <Text text="Child" />;
+    }
+
+    const root = ReactNoop.createRoot();
+    await act(async () => {
+      root.render(
+        <LegacyHidden mode="visible">
+          <Child />
+        </LegacyHidden>,
+      );
+    });
+    expect(Scheduler).toHaveYielded(['Child', 'Mount layout']);
+
+    await act(async () => {
+      root.render(
+        <LegacyHidden mode="hidden">
+          <Child />
+        </LegacyHidden>,
+      );
+    });
+    expect(Scheduler).toHaveYielded(['Child']);
+
+    await act(async () => {
+      root.render(
+        <LegacyHidden mode="visible">
+          <Child />
+        </LegacyHidden>,
+      );
+    });
+    expect(Scheduler).toHaveYielded(['Child']);
+
+    await act(async () => {
+      root.render(null);
+    });
+    expect(Scheduler).toHaveYielded(['Unmount layout']);
+  });
 });