Repository: facebook/react
Pull request: Prevent infinite re-renders in StrictMode + Offscreen

@gnoff [reported](https://github.com/facebook/react/pull/25179) an infinite render caused by [recent changes](https://github.com/facebook/react/pull/25049) to StrictMode. 

The root cause is the special handling of Offscreen in `doubleInvokeEffectsInDEV`. To fix this, we add a check to see if Visibility flag for Offscreen is set before double invoking effects. 

Changed files:
filename: packages/react-reconciler/src/ReactFiberWorkLoop.new.js
patch: @@ -124,6 +124,7 @@ import {
   LayoutMask,
   PassiveMask,
   PlacementDEV,
+  Visibility,
 } from './ReactFiberFlags';
 import {
   NoLanes,
@@ -3184,9 +3185,15 @@ function doubleInvokeEffectsInDEV(
 ) {
   const isStrictModeFiber = fiber.type === REACT_STRICT_MODE_TYPE;
   const isInStrictMode = parentIsInStrictMode || isStrictModeFiber;
+
   if (fiber.flags & PlacementDEV || fiber.tag === OffscreenComponent) {
     setCurrentDebugFiberInDEV(fiber);
-    if (isInStrictMode) {
+    const isNotOffscreen = fiber.tag !== OffscreenComponent;
+    // Checks if Offscreen is being revealed. For all other components, evaluates to true.
+    const hasOffscreenBecomeVisible =
+      isNotOffscreen ||
+      (fiber.flags & Visibility && fiber.memoizedState === null);
+    if (isInStrictMode && hasOffscreenBecomeVisible) {
       disappearLayoutEffects(fiber);
       disconnectPassiveEffect(fiber);
       reappearLayoutEffects(root, fiber.alternate, fiber, false);

filename: packages/react-reconciler/src/ReactFiberWorkLoop.old.js
patch: @@ -124,6 +124,7 @@ import {
   LayoutMask,
   PassiveMask,
   PlacementDEV,
+  Visibility,
 } from './ReactFiberFlags';
 import {
   NoLanes,
@@ -3184,9 +3185,15 @@ function doubleInvokeEffectsInDEV(
 ) {
   const isStrictModeFiber = fiber.type === REACT_STRICT_MODE_TYPE;
   const isInStrictMode = parentIsInStrictMode || isStrictModeFiber;
+
   if (fiber.flags & PlacementDEV || fiber.tag === OffscreenComponent) {
     setCurrentDebugFiberInDEV(fiber);
-    if (isInStrictMode) {
+    const isNotOffscreen = fiber.tag !== OffscreenComponent;
+    // Checks if Offscreen is being revealed. For all other components, evaluates to true.
+    const hasOffscreenBecomeVisible =
+      isNotOffscreen ||
+      (fiber.flags & Visibility && fiber.memoizedState === null);
+    if (isInStrictMode && hasOffscreenBecomeVisible) {
       disappearLayoutEffects(fiber);
       disconnectPassiveEffect(fiber);
       reappearLayoutEffects(root, fiber.alternate, fiber, false);

filename: packages/react-reconciler/src/__tests__/ReactOffscreenStrictMode-test.js
patch: @@ -71,6 +71,21 @@ describe('ReactOffscreenStrictMode', () => {
 
     log = [];
 
+    act(() => {
+      ReactNoop.render(
+        <React.StrictMode>
+          <Offscreen mode="hidden">
+            <Component label="A" />
+            <Component label="B" />
+          </Offscreen>
+        </React.StrictMode>,
+      );
+    });
+
+    expect(log).toEqual(['A: render', 'A: render', 'B: render', 'B: render']);
+
+    log = [];
+
     act(() => {
       ReactNoop.render(
         <React.StrictMode>
@@ -91,5 +106,51 @@ describe('ReactOffscreenStrictMode', () => {
       'A: useLayoutEffect mount',
       'A: useEffect mount',
     ]);
+
+    log = [];
+
+    act(() => {
+      ReactNoop.render(
+        <React.StrictMode>
+          <Offscreen mode="hidden">
+            <Component label="A" />
+          </Offscreen>
+        </React.StrictMode>,
+      );
+    });
+
+    expect(log).toEqual([
+      'A: useLayoutEffect unmount',
+      'A: useEffect unmount',
+      'A: render',
+      'A: render',
+    ]);
+  });
+
+  it('should not cause infinite render loop when StrictMode is used with Suspense and synchronous set states', () => {
+    // This is a regression test, see https://github.com/facebook/react/pull/25179 for more details.
+    function App() {
+      const [state, setState] = React.useState(false);
+
+      React.useLayoutEffect(() => {
+        setState(true);
+      }, []);
+
+      React.useEffect(() => {
+        // Empty useEffect with empty dependency array is needed to trigger infinite render loop.
+      }, []);
+
+      return state;
+    }
+
+    act(() => {
+      ReactNoop.render(
+        <React.StrictMode>
+          <React.Suspense>
+            <App />
+          </React.Suspense>
+        </React.StrictMode>,
+      );
+    });
   });
 });