Repository: facebook/react
Pull request: [tests] add more portal activity tests

I copied some tests from [`Activity-test.js`](https://github.com/facebook/react/blob/1d68bce19c9409ed70604d1d16b70b68ce71dc4a/packages/react-reconciler/src/__tests__/Activity-test.js) and made them portal specific just to confirm my understanding of how Portals + Activity interact is correct. Seems good to include them. 

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMActivity-test.js
patch: @@ -10,23 +10,37 @@
 'use strict';
 
 let React;
-let Activity;
-let useState;
 let ReactDOM;
 let ReactDOMClient;
+let Scheduler;
 let act;
+let Activity;
+let useState;
+let useLayoutEffect;
+let useEffect;
+let LegacyHidden;
+let assertLog;
+let Suspense;
 
 describe('ReactDOMActivity', () => {
   let container;
 
   beforeEach(() => {
     jest.resetModules();
     React = require('react');
+    Scheduler = require('scheduler/unstable_mock');
     Activity = React.Activity;
     useState = React.useState;
+    Suspense = React.Suspense;
+    useState = React.useState;
+    LegacyHidden = React.unstable_LegacyHidden;
+    useLayoutEffect = React.useLayoutEffect;
+    useEffect = React.useEffect;
     ReactDOM = require('react-dom');
     ReactDOMClient = require('react-dom/client');
-    act = require('internal-test-utils').act;
+    const InternalTestUtils = require('internal-test-utils');
+    act = InternalTestUtils.act;
+    assertLog = InternalTestUtils.assertLog;
     container = document.createElement('div');
     document.body.appendChild(container);
   });
@@ -35,6 +49,11 @@ describe('ReactDOMActivity', () => {
     document.body.removeChild(container);
   });
 
+  function Text(props) {
+    Scheduler.log(props.text);
+    return <span prop={props.text}>{props.children}</span>;
+  }
+
   // @gate enableActivity
   it(
     'hiding an Activity boundary also hides the direct children of any ' +
@@ -53,7 +72,7 @@ describe('ReactDOMActivity', () => {
         );
       }
 
-      function App({portalContents}) {
+      function App() {
         return (
           <Accordion>
             <div>
@@ -99,7 +118,7 @@ describe('ReactDOMActivity', () => {
         );
       }
 
-      function App({portalContents}) {
+      function App() {
         return (
           <Activity mode="hidden">
             <div>
@@ -131,4 +150,416 @@ describe('ReactDOMActivity', () => {
       );
     },
   );
+
+  // @gate enableActivity
+  it('hides new portals added to an already hidden tree', async () => {
+    function Child() {
+      return <Text text="Child" />;
+    }
+
+    const portalContainer = document.createElement('div');
+
+    function Portal({children}) {
+      return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
+    }
+
+    const root = ReactDOMClient.createRoot(container);
+    // Mount hidden tree.
+    await act(() => {
+      root.render(
+        <Activity mode="hidden">
+          <Text text="Parent" />
+        </Activity>,
+      );
+    });
+    assertLog(['Parent']);
+    expect(container.innerHTML).toBe(
+      '<span prop="Parent" style="display: none;"></span>',
+    );
+    expect(portalContainer.innerHTML).toBe('');
+
+    // Add a portal inside the hidden tree.
+    await act(() => {
+      root.render(
+        <Activity mode="hidden">
+          <Text text="Parent" />
+          <Portal>
+            <Child />
+          </Portal>
+        </Activity>,
+      );
+    });
+    assertLog(['Parent', 'Child']);
+    expect(container.innerHTML).toBe(
+      '<span prop="Parent" style="display: none;"></span><div style="display: none;"></div>',
+    );
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="Child" style="display: none;"></span>',
+    );
+
+    // Now reveal it.
+    await act(() => {
+      root.render(
+        <Activity mode="visible">
+          <Text text="Parent" />
+          <Portal>
+            <Child />
+          </Portal>
+        </Activity>,
+      );
+    });
+
+    assertLog(['Parent', 'Child']);
+    expect(container.innerHTML).toBe(
+      '<span prop="Parent" style=""></span><div style=""></div>',
+    );
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="Child" style=""></span>',
+    );
+  });
+
+  // @gate enableActivity
+  it('hides new insertions inside an already hidden portal', async () => {
+    function Child({text}) {
+      useLayoutEffect(() => {
+        Scheduler.log(`Mount layout ${text}`);
+        return () => {
+          Scheduler.log(`Unmount layout ${text}`);
+        };
+      }, [text]);
+      return <Text text={text} />;
+    }
+
+    const portalContainer = document.createElement('div');
+
+    function Portal({children}) {
+      return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
+    }
+
+    const root = ReactDOMClient.createRoot(container);
+    // Mount hidden tree.
+    await act(() => {
+      root.render(
+        <Activity mode="hidden">
+          <Portal>
+            <Child text="A" />
+          </Portal>
+        </Activity>,
+      );
+    });
+    assertLog(['A']);
+    expect(container.innerHTML).toBe('<div style="display: none;"></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="A" style="display: none;"></span>',
+    );
+
+    // Add a node inside the hidden portal.
+    await act(() => {
+      root.render(
+        <Activity mode="hidden">
+          <Portal>
+            <Child text="A" />
+            <Child text="B" />
+          </Portal>
+        </Activity>,
+      );
+    });
+    assertLog(['A', 'B']);
+    expect(container.innerHTML).toBe('<div style="display: none;"></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="A" style="display: none;"></span><span prop="B" style="display: none;"></span>',
+    );
+
+    // Now reveal it.
+    await act(() => {
+      root.render(
+        <Activity mode="visible">
+          <Portal>
+            <Child text="A" />
+            <Child text="B" />
+          </Portal>
+        </Activity>,
+      );
+    });
+
+    assertLog(['A', 'B', 'Mount layout A', 'Mount layout B']);
+    expect(container.innerHTML).toBe('<div style=""></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="A" style=""></span><span prop="B" style=""></span>',
+    );
+  });
+
+  // @gate enableActivity
+  it('reveal an inner Suspense boundary without revealing an outer Activity on the same host child', async () => {
+    const promise = new Promise(() => {});
+
+    function Child({showInner}) {
+      useLayoutEffect(() => {
+        Scheduler.log('Mount layout');
+        return () => {
+          Scheduler.log('Unmount layout');
+        };
+      }, []);
+      return (
+        <>
+          {showInner ? null : promise}
+          <Text text="Child" />
+        </>
+      );
+    }
+
+    const portalContainer = document.createElement('div');
+
+    function Portal({children}) {
+      return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
+    }
+
+    const root = ReactDOMClient.createRoot(container);
+
+    // Prerender the whole tree.
+    await act(() => {
+      root.render(
+        <Activity mode="hidden">
+          <Portal>
+            <Suspense name="Inner" fallback={<span>Loading</span>}>
+              <Child showInner={true} />
+            </Suspense>
+          </Portal>
+        </Activity>,
+      );
+    });
+
+    assertLog(['Child']);
+    expect(container.innerHTML).toBe('<div style="display: none;"></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="Child" style="display: none;"></span>',
+    );
+
+    // Re-suspend the inner.
+    await act(() => {
+      root.render(
+        <Activity mode="hidden">
+          <Portal>
+            <Suspense name="Inner" fallback={<span>Loading</span>}>
+              <Child showInner={false} />
+            </Suspense>
+          </Portal>
+        </Activity>,
+      );
+    });
+    assertLog([]);
+    expect(container.innerHTML).toBe('<div style="display: none;"></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="Child" style="display: none;"></span><span style="display: none;">Loading</span>',
+    );
+
+    // Toggle to visible while suspended.
+    await act(() => {
+      root.render(
+        <Activity mode="visible">
+          <Portal>
+            <Suspense name="Inner" fallback={<span>Loading</span>}>
+              <Child showInner={false} />
+            </Suspense>
+          </Portal>
+        </Activity>,
+      );
+    });
+    assertLog([]);
+    expect(container.innerHTML).toBe('<div style=""></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="Child" style="display: none;"></span><span style="">Loading</span>',
+    );
+
+    // Now reveal.
+    await act(() => {
+      root.render(
+        <Activity mode="visible">
+          <Portal>
+            <Suspense name="Inner" fallback={<span>Loading</span>}>
+              <Child showInner={true} />
+            </Suspense>
+          </Portal>
+        </Activity>,
+      );
+    });
+    assertLog(['Child', 'Mount layout']);
+    expect(container.innerHTML).toBe('<div style=""></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="Child" style=""></span>',
+    );
+  });
+
+  // @gate enableActivity
+  it('mounts/unmounts layout effects in portal when visibility changes (starting visible)', async () => {
+    function Child() {
+      useLayoutEffect(() => {
+        Scheduler.log('Mount layout');
+        return () => {
+          Scheduler.log('Unmount layout');
+        };
+      }, []);
+      return <Text text="Child" />;
+    }
+
+    const portalContainer = document.createElement('div');
+
+    function Portal({children}) {
+      return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
+    }
+
+    const root = ReactDOMClient.createRoot(container);
+    // Mount visible tree.
+    await act(() => {
+      root.render(
+        <Activity mode="visible">
+          <Portal>
+            <Child />
+          </Portal>
+        </Activity>,
+      );
+    });
+    assertLog(['Child', 'Mount layout']);
+    expect(container.innerHTML).toBe('<div></div>');
+    expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
+
+    // Hide the tree. The layout effect is unmounted.
+    await act(() => {
+      root.render(
+        <Activity mode="hidden">
+          <Portal>
+            <Child />
+          </Portal>
+        </Activity>,
+      );
+    });
+    assertLog(['Unmount layout', 'Child']);
+    expect(container.innerHTML).toBe('<div style="display: none;"></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="Child" style="display: none;"></span>',
+    );
+  });
+
+  // @gate enableActivity
+  it('mounts/unmounts layout effects in portal when visibility changes (starting hidden)', async () => {
+    function Child() {
+      useLayoutEffect(() => {
+        Scheduler.log('Mount layout');
+        return () => {
+          Scheduler.log('Unmount layout');
+        };
+      }, []);
+      return <Text text="Child" />;
+    }
+
+    const portalContainer = document.createElement('div');
+
+    function Portal({children}) {
+      return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
+    }
+
+    const root = ReactDOMClient.createRoot(container);
+    // Mount hidden tree.
+    await act(() => {
+      root.render(
+        <Activity mode="hidden">
+          <Portal>
+            <Child />
+          </Portal>
+        </Activity>,
+      );
+    });
+    // No layout effect.
+    assertLog(['Child']);
+    expect(container.innerHTML).toBe('<div style="display: none;"></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="Child" style="display: none;"></span>',
+    );
+
+    // Unhide the tree. The layout effect is mounted.
+    await act(() => {
+      root.render(
+        <Activity mode="visible">
+          <Portal>
+            <Child />
+          </Portal>
+        </Activity>,
+      );
+    });
+    assertLog(['Child', 'Mount layout']);
+    expect(container.innerHTML).toBe('<div style=""></div>');
+    expect(portalContainer.innerHTML).toBe(
+      '<span prop="Child" style=""></span>',
+    );
+  });
+
+  // @gate enableLegacyHidden
+  it('does not toggle effects or hide nodes for LegacyHidden component inside portal', async () => {
+    function Child() {
+      useLayoutEffect(() => {
+        Scheduler.log('Mount layout');
+        return () => {
+          Scheduler.log('Unmount layout');
+        };
+      }, []);
+      useEffect(() => {
+        Scheduler.log('Mount passive');
+        return () => {
+          Scheduler.log('Unmount passive');
+        };
+      }, []);
+      return <Text text="Child" />;
+    }
+
+    const portalContainer = document.createElement('div');
+
+    function Portal({children}) {
+      return <div>{ReactDOM.createPortal(children, portalContainer)}</div>;
+    }
+
+    const root = ReactDOMClient.createRoot(container);
+    // Mount visible tree.
+    await act(() => {
+      root.render(
+        <LegacyHidden mode="visible">
+          <Portal>
+            <Child />
+          </Portal>
+        </LegacyHidden>,
+      );
+    });
+    assertLog(['Child', 'Mount layout', 'Mount passive']);
+    expect(container.innerHTML).toBe('<div></div>');
+    expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
+
+    // Hide the tree.
+    await act(() => {
+      root.render(
+        <LegacyHidden mode="hidden">
+          <Portal>
+            <Child />
+          </Portal>
+        </LegacyHidden>,
+      );
+    });
+    // Effects not unmounted.
+    assertLog(['Child']);
+    expect(container.innerHTML).toBe('<div></div>');
+    expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
+
+    // Unhide the tree.
+    await act(() => {
+      root.render(
+        <LegacyHidden mode="visible">
+          <Portal>
+            <Child />
+          </Portal>
+        </LegacyHidden>,
+      );
+    });
+    // Effects already mounted.
+    assertLog(['Child']);
+    expect(container.innerHTML).toBe('<div></div>');
+    expect(portalContainer.innerHTML).toBe('<span prop="Child"></span>');
+  });
 });