1. /**
    
  2.  * Copyright (c) Meta Platforms, Inc. and affiliates.
    
  3.  *
    
  4.  * This source code is licensed under the MIT license found in the
    
  5.  * LICENSE file in the root directory of this source tree.
    
  6.  *
    
  7.  * @emails react-core
    
  8.  */
    
  9. 
    
  10. 'use strict';
    
  11. 
    
  12. let React;
    
  13. let ReactDOM;
    
  14. let ReactDOMClient;
    
  15. let Scheduler;
    
  16. let act;
    
  17. let container;
    
  18. let waitForAll;
    
  19. let assertLog;
    
  20. let fakeModuleCache;
    
  21. 
    
  22. describe('ReactSuspenseEffectsSemanticsDOM', () => {
    
  23.   beforeEach(() => {
    
  24.     jest.resetModules();
    
  25. 
    
  26.     React = require('react');
    
  27.     ReactDOM = require('react-dom');
    
  28.     ReactDOMClient = require('react-dom/client');
    
  29.     Scheduler = require('scheduler');
    
  30.     act = require('internal-test-utils').act;
    
  31. 
    
  32.     const InternalTestUtils = require('internal-test-utils');
    
  33.     waitForAll = InternalTestUtils.waitForAll;
    
  34.     assertLog = InternalTestUtils.assertLog;
    
  35. 
    
  36.     container = document.createElement('div');
    
  37.     document.body.appendChild(container);
    
  38. 
    
  39.     fakeModuleCache = new Map();
    
  40.   });
    
  41. 
    
  42.   afterEach(() => {
    
  43.     document.body.removeChild(container);
    
  44.   });
    
  45. 
    
  46.   async function fakeImport(Component) {
    
  47.     const record = fakeModuleCache.get(Component);
    
  48.     if (record === undefined) {
    
  49.       const newRecord = {
    
  50.         status: 'pending',
    
  51.         value: {default: Component},
    
  52.         pings: [],
    
  53.         then(ping) {
    
  54.           switch (newRecord.status) {
    
  55.             case 'pending': {
    
  56.               newRecord.pings.push(ping);
    
  57.               return;
    
  58.             }
    
  59.             case 'resolved': {
    
  60.               ping(newRecord.value);
    
  61.               return;
    
  62.             }
    
  63.             case 'rejected': {
    
  64.               throw newRecord.value;
    
  65.             }
    
  66.           }
    
  67.         },
    
  68.       };
    
  69.       fakeModuleCache.set(Component, newRecord);
    
  70.       return newRecord;
    
  71.     }
    
  72.     return record;
    
  73.   }
    
  74. 
    
  75.   function resolveFakeImport(moduleName) {
    
  76.     const record = fakeModuleCache.get(moduleName);
    
  77.     if (record === undefined) {
    
  78.       throw new Error('Module not found');
    
  79.     }
    
  80.     if (record.status !== 'pending') {
    
  81.       throw new Error('Module already resolved');
    
  82.     }
    
  83.     record.status = 'resolved';
    
  84.     record.pings.forEach(ping => ping(record.value));
    
  85.   }
    
  86. 
    
  87.   function Text(props) {
    
  88.     Scheduler.log(props.text);
    
  89.     return props.text;
    
  90.   }
    
  91. 
    
  92.   it('should not cause a cycle when combined with a render phase update', async () => {
    
  93.     let scheduleSuspendingUpdate;
    
  94. 
    
  95.     function App() {
    
  96.       const [value, setValue] = React.useState(true);
    
  97. 
    
  98.       scheduleSuspendingUpdate = () => setValue(!value);
    
  99. 
    
  100.       return (
    
  101.         <>
    
  102.           <React.Suspense fallback="Loading...">
    
  103.             <ComponentThatCausesBug value={value} />
    
  104.             <ComponentThatSuspendsOnUpdate shouldSuspend={!value} />
    
  105.           </React.Suspense>
    
  106.         </>
    
  107.       );
    
  108.     }
    
  109. 
    
  110.     function ComponentThatCausesBug({value}) {
    
  111.       const [mirroredValue, setMirroredValue] = React.useState(value);
    
  112.       if (mirroredValue !== value) {
    
  113.         setMirroredValue(value);
    
  114.       }
    
  115. 
    
  116.       // eslint-disable-next-line no-unused-vars
    
  117.       const [_, setRef] = React.useState(null);
    
  118. 
    
  119.       return <div ref={setRef} />;
    
  120.     }
    
  121. 
    
  122.     const neverResolves = {then() {}};
    
  123. 
    
  124.     function ComponentThatSuspendsOnUpdate({shouldSuspend}) {
    
  125.       if (shouldSuspend) {
    
  126.         // Fake Suspend
    
  127.         throw neverResolves;
    
  128.       }
    
  129.       return null;
    
  130.     }
    
  131. 
    
  132.     await act(() => {
    
  133.       const root = ReactDOMClient.createRoot(container);
    
  134.       root.render(<App />);
    
  135.     });
    
  136. 
    
  137.     await act(() => {
    
  138.       scheduleSuspendingUpdate();
    
  139.     });
    
  140.   });
    
  141. 
    
  142.   it('does not destroy ref cleanup twice when hidden child is removed', async () => {
    
  143.     function ChildA({label}) {
    
  144.       return (
    
  145.         <span
    
  146.           ref={node => {
    
  147.             if (node) {
    
  148.               Scheduler.log('Ref mount: ' + label);
    
  149.             } else {
    
  150.               Scheduler.log('Ref unmount: ' + label);
    
  151.             }
    
  152.           }}>
    
  153.           <Text text={label} />
    
  154.         </span>
    
  155.       );
    
  156.     }
    
  157. 
    
  158.     function ChildB({label}) {
    
  159.       return (
    
  160.         <span
    
  161.           ref={node => {
    
  162.             if (node) {
    
  163.               Scheduler.log('Ref mount: ' + label);
    
  164.             } else {
    
  165.               Scheduler.log('Ref unmount: ' + label);
    
  166.             }
    
  167.           }}>
    
  168.           <Text text={label} />
    
  169.         </span>
    
  170.       );
    
  171.     }
    
  172. 
    
  173.     const LazyChildA = React.lazy(() => fakeImport(ChildA));
    
  174.     const LazyChildB = React.lazy(() => fakeImport(ChildB));
    
  175. 
    
  176.     function Parent({swap}) {
    
  177.       return (
    
  178.         <React.Suspense fallback={<Text text="Loading..." />}>
    
  179.           {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
    
  180.         </React.Suspense>
    
  181.       );
    
  182.     }
    
  183. 
    
  184.     const root = ReactDOMClient.createRoot(container);
    
  185.     await act(() => {
    
  186.       root.render(<Parent swap={false} />);
    
  187.     });
    
  188.     assertLog(['Loading...']);
    
  189. 
    
  190.     await act(() => resolveFakeImport(ChildA));
    
  191.     assertLog(['A', 'Ref mount: A']);
    
  192.     expect(container.innerHTML).toBe('<span>A</span>');
    
  193. 
    
  194.     // Swap the position of A and B
    
  195.     ReactDOM.flushSync(() => {
    
  196.       root.render(<Parent swap={true} />);
    
  197.     });
    
  198.     assertLog(['Loading...', 'Ref unmount: A']);
    
  199.     expect(container.innerHTML).toBe(
    
  200.       '<span style="display: none;">A</span>Loading...',
    
  201.     );
    
  202. 
    
  203.     await act(() => resolveFakeImport(ChildB));
    
  204.     assertLog(['B', 'Ref mount: B']);
    
  205.     expect(container.innerHTML).toBe('<span>B</span>');
    
  206.   });
    
  207. 
    
  208.   it('does not call componentWillUnmount twice when hidden child is removed', async () => {
    
  209.     class ChildA extends React.Component {
    
  210.       componentDidMount() {
    
  211.         Scheduler.log('Did mount: ' + this.props.label);
    
  212.       }
    
  213.       componentWillUnmount() {
    
  214.         Scheduler.log('Will unmount: ' + this.props.label);
    
  215.       }
    
  216.       render() {
    
  217.         return <Text text={this.props.label} />;
    
  218.       }
    
  219.     }
    
  220. 
    
  221.     class ChildB extends React.Component {
    
  222.       componentDidMount() {
    
  223.         Scheduler.log('Did mount: ' + this.props.label);
    
  224.       }
    
  225.       componentWillUnmount() {
    
  226.         Scheduler.log('Will unmount: ' + this.props.label);
    
  227.       }
    
  228.       render() {
    
  229.         return <Text text={this.props.label} />;
    
  230.       }
    
  231.     }
    
  232. 
    
  233.     const LazyChildA = React.lazy(() => fakeImport(ChildA));
    
  234.     const LazyChildB = React.lazy(() => fakeImport(ChildB));
    
  235. 
    
  236.     function Parent({swap}) {
    
  237.       return (
    
  238.         <React.Suspense fallback={<Text text="Loading..." />}>
    
  239.           {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
    
  240.         </React.Suspense>
    
  241.       );
    
  242.     }
    
  243. 
    
  244.     const root = ReactDOMClient.createRoot(container);
    
  245.     await act(() => {
    
  246.       root.render(<Parent swap={false} />);
    
  247.     });
    
  248.     assertLog(['Loading...']);
    
  249. 
    
  250.     await act(() => resolveFakeImport(ChildA));
    
  251.     assertLog(['A', 'Did mount: A']);
    
  252.     expect(container.innerHTML).toBe('A');
    
  253. 
    
  254.     // Swap the position of A and B
    
  255.     ReactDOM.flushSync(() => {
    
  256.       root.render(<Parent swap={true} />);
    
  257.     });
    
  258.     assertLog(['Loading...', 'Will unmount: A']);
    
  259.     expect(container.innerHTML).toBe('Loading...');
    
  260. 
    
  261.     await act(() => resolveFakeImport(ChildB));
    
  262.     assertLog(['B', 'Did mount: B']);
    
  263.     expect(container.innerHTML).toBe('B');
    
  264.   });
    
  265. 
    
  266.   it('does not destroy layout effects twice when parent suspense is removed', async () => {
    
  267.     function ChildA({label}) {
    
  268.       React.useLayoutEffect(() => {
    
  269.         Scheduler.log('Did mount: ' + label);
    
  270.         return () => {
    
  271.           Scheduler.log('Will unmount: ' + label);
    
  272.         };
    
  273.       }, []);
    
  274.       return <Text text={label} />;
    
  275.     }
    
  276.     function ChildB({label}) {
    
  277.       React.useLayoutEffect(() => {
    
  278.         Scheduler.log('Did mount: ' + label);
    
  279.         return () => {
    
  280.           Scheduler.log('Will unmount: ' + label);
    
  281.         };
    
  282.       }, []);
    
  283.       return <Text text={label} />;
    
  284.     }
    
  285.     const LazyChildA = React.lazy(() => fakeImport(ChildA));
    
  286.     const LazyChildB = React.lazy(() => fakeImport(ChildB));
    
  287. 
    
  288.     function Parent({swap}) {
    
  289.       return (
    
  290.         <React.Suspense fallback={<Text text="Loading..." />}>
    
  291.           {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
    
  292.         </React.Suspense>
    
  293.       );
    
  294.     }
    
  295. 
    
  296.     const root = ReactDOMClient.createRoot(container);
    
  297.     await act(() => {
    
  298.       root.render(<Parent swap={false} />);
    
  299.     });
    
  300.     assertLog(['Loading...']);
    
  301. 
    
  302.     await act(() => resolveFakeImport(ChildA));
    
  303.     assertLog(['A', 'Did mount: A']);
    
  304.     expect(container.innerHTML).toBe('A');
    
  305. 
    
  306.     // Swap the position of A and B
    
  307.     ReactDOM.flushSync(() => {
    
  308.       root.render(<Parent swap={true} />);
    
  309.     });
    
  310.     assertLog(['Loading...', 'Will unmount: A']);
    
  311.     expect(container.innerHTML).toBe('Loading...');
    
  312. 
    
  313.     // Destroy the whole tree, including the hidden A
    
  314.     ReactDOM.flushSync(() => {
    
  315.       root.render(<h1>Hello</h1>);
    
  316.     });
    
  317.     await waitForAll([]);
    
  318.     expect(container.innerHTML).toBe('<h1>Hello</h1>');
    
  319.   });
    
  320. 
    
  321.   it('does not destroy ref cleanup twice when parent suspense is removed', async () => {
    
  322.     function ChildA({label}) {
    
  323.       return (
    
  324.         <span
    
  325.           ref={node => {
    
  326.             if (node) {
    
  327.               Scheduler.log('Ref mount: ' + label);
    
  328.             } else {
    
  329.               Scheduler.log('Ref unmount: ' + label);
    
  330.             }
    
  331.           }}>
    
  332.           <Text text={label} />
    
  333.         </span>
    
  334.       );
    
  335.     }
    
  336. 
    
  337.     function ChildB({label}) {
    
  338.       return (
    
  339.         <span
    
  340.           ref={node => {
    
  341.             if (node) {
    
  342.               Scheduler.log('Ref mount: ' + label);
    
  343.             } else {
    
  344.               Scheduler.log('Ref unmount: ' + label);
    
  345.             }
    
  346.           }}>
    
  347.           <Text text={label} />
    
  348.         </span>
    
  349.       );
    
  350.     }
    
  351. 
    
  352.     const LazyChildA = React.lazy(() => fakeImport(ChildA));
    
  353.     const LazyChildB = React.lazy(() => fakeImport(ChildB));
    
  354. 
    
  355.     function Parent({swap}) {
    
  356.       return (
    
  357.         <React.Suspense fallback={<Text text="Loading..." />}>
    
  358.           {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
    
  359.         </React.Suspense>
    
  360.       );
    
  361.     }
    
  362. 
    
  363.     const root = ReactDOMClient.createRoot(container);
    
  364.     await act(() => {
    
  365.       root.render(<Parent swap={false} />);
    
  366.     });
    
  367.     assertLog(['Loading...']);
    
  368. 
    
  369.     await act(() => resolveFakeImport(ChildA));
    
  370.     assertLog(['A', 'Ref mount: A']);
    
  371.     expect(container.innerHTML).toBe('<span>A</span>');
    
  372. 
    
  373.     // Swap the position of A and B
    
  374.     ReactDOM.flushSync(() => {
    
  375.       root.render(<Parent swap={true} />);
    
  376.     });
    
  377.     assertLog(['Loading...', 'Ref unmount: A']);
    
  378.     expect(container.innerHTML).toBe(
    
  379.       '<span style="display: none;">A</span>Loading...',
    
  380.     );
    
  381. 
    
  382.     // Destroy the whole tree, including the hidden A
    
  383.     ReactDOM.flushSync(() => {
    
  384.       root.render(<h1>Hello</h1>);
    
  385.     });
    
  386.     await waitForAll([]);
    
  387.     expect(container.innerHTML).toBe('<h1>Hello</h1>');
    
  388.   });
    
  389. 
    
  390.   it('does not call componentWillUnmount twice when parent suspense is removed', async () => {
    
  391.     class ChildA extends React.Component {
    
  392.       componentDidMount() {
    
  393.         Scheduler.log('Did mount: ' + this.props.label);
    
  394.       }
    
  395.       componentWillUnmount() {
    
  396.         Scheduler.log('Will unmount: ' + this.props.label);
    
  397.       }
    
  398.       render() {
    
  399.         return <Text text={this.props.label} />;
    
  400.       }
    
  401.     }
    
  402. 
    
  403.     class ChildB extends React.Component {
    
  404.       componentDidMount() {
    
  405.         Scheduler.log('Did mount: ' + this.props.label);
    
  406.       }
    
  407.       componentWillUnmount() {
    
  408.         Scheduler.log('Will unmount: ' + this.props.label);
    
  409.       }
    
  410.       render() {
    
  411.         return <Text text={this.props.label} />;
    
  412.       }
    
  413.     }
    
  414. 
    
  415.     const LazyChildA = React.lazy(() => fakeImport(ChildA));
    
  416.     const LazyChildB = React.lazy(() => fakeImport(ChildB));
    
  417. 
    
  418.     function Parent({swap}) {
    
  419.       return (
    
  420.         <React.Suspense fallback={<Text text="Loading..." />}>
    
  421.           {swap ? <LazyChildB label="B" /> : <LazyChildA label="A" />}
    
  422.         </React.Suspense>
    
  423.       );
    
  424.     }
    
  425. 
    
  426.     const root = ReactDOMClient.createRoot(container);
    
  427.     await act(() => {
    
  428.       root.render(<Parent swap={false} />);
    
  429.     });
    
  430.     assertLog(['Loading...']);
    
  431. 
    
  432.     await act(() => resolveFakeImport(ChildA));
    
  433.     assertLog(['A', 'Did mount: A']);
    
  434.     expect(container.innerHTML).toBe('A');
    
  435. 
    
  436.     // Swap the position of A and B
    
  437.     ReactDOM.flushSync(() => {
    
  438.       root.render(<Parent swap={true} />);
    
  439.     });
    
  440.     assertLog(['Loading...', 'Will unmount: A']);
    
  441.     expect(container.innerHTML).toBe('Loading...');
    
  442. 
    
  443.     // Destroy the whole tree, including the hidden A
    
  444.     ReactDOM.flushSync(() => {
    
  445.       root.render(<h1>Hello</h1>);
    
  446.     });
    
  447.     await waitForAll([]);
    
  448.     expect(container.innerHTML).toBe('<h1>Hello</h1>');
    
  449.   });
    
  450. 
    
  451.   it('regression: unmount hidden tree, in legacy mode', async () => {
    
  452.     // In legacy mode, when a tree suspends and switches to a fallback, the
    
  453.     // effects are not unmounted. So we have to unmount them during a deletion.
    
  454. 
    
  455.     function Child() {
    
  456.       React.useLayoutEffect(() => {
    
  457.         Scheduler.log('Mount');
    
  458.         return () => {
    
  459.           Scheduler.log('Unmount');
    
  460.         };
    
  461.       }, []);
    
  462.       return <Text text="Child" />;
    
  463.     }
    
  464. 
    
  465.     function Sibling() {
    
  466.       return <Text text="Sibling" />;
    
  467.     }
    
  468.     const LazySibling = React.lazy(() => fakeImport(Sibling));
    
  469. 
    
  470.     function App({showMore}) {
    
  471.       return (
    
  472.         <React.Suspense fallback={<Text text="Loading..." />}>
    
  473.           <Child />
    
  474.           {showMore ? <LazySibling /> : null}
    
  475.         </React.Suspense>
    
  476.       );
    
  477.     }
    
  478. 
    
  479.     // Initial render
    
  480.     ReactDOM.render(<App showMore={false} />, container);
    
  481.     assertLog(['Child', 'Mount']);
    
  482. 
    
  483.     // Update that suspends, causing the existing tree to switches it to
    
  484.     // a fallback.
    
  485.     ReactDOM.render(<App showMore={true} />, container);
    
  486.     assertLog([
    
  487.       'Child',
    
  488.       'Loading...',
    
  489. 
    
  490.       // In a concurrent root, the effect would unmount here. But this is legacy
    
  491.       // mode, so it doesn't.
    
  492.       // Unmount
    
  493.     ]);
    
  494. 
    
  495.     // Delete the tree and unmount the effect
    
  496.     ReactDOM.render(null, container);
    
  497.     assertLog(['Unmount']);
    
  498.   });
    
  499. 
    
  500.   it('does not call cleanup effects twice after a bailout', async () => {
    
  501.     const never = new Promise(resolve => {});
    
  502.     function Never() {
    
  503.       throw never;
    
  504.     }
    
  505. 
    
  506.     let setSuspended;
    
  507.     let setLetter;
    
  508. 
    
  509.     function App() {
    
  510.       const [suspended, _setSuspended] = React.useState(false);
    
  511.       setSuspended = _setSuspended;
    
  512.       const [letter, _setLetter] = React.useState('A');
    
  513.       setLetter = _setLetter;
    
  514. 
    
  515.       return (
    
  516.         <React.Suspense fallback="Loading...">
    
  517.           <Child letter={letter} />
    
  518.           {suspended && <Never />}
    
  519.         </React.Suspense>
    
  520.       );
    
  521.     }
    
  522. 
    
  523.     let nextId = 0;
    
  524.     const freed = new Set();
    
  525.     let setStep;
    
  526. 
    
  527.     function Child({letter}) {
    
  528.       const [, _setStep] = React.useState(0);
    
  529.       setStep = _setStep;
    
  530. 
    
  531.       React.useLayoutEffect(() => {
    
  532.         const localId = nextId++;
    
  533.         Scheduler.log('Did mount: ' + letter + localId);
    
  534.         return () => {
    
  535.           if (freed.has(localId)) {
    
  536.             throw Error('Double free: ' + letter + localId);
    
  537.           }
    
  538.           freed.add(localId);
    
  539.           Scheduler.log('Will unmount: ' + letter + localId);
    
  540.         };
    
  541.       }, [letter]);
    
  542.     }
    
  543. 
    
  544.     const root = ReactDOMClient.createRoot(container);
    
  545.     await act(() => {
    
  546.       root.render(<App />);
    
  547.     });
    
  548.     assertLog(['Did mount: A0']);
    
  549. 
    
  550.     await act(() => {
    
  551.       setStep(1);
    
  552.       setSuspended(false);
    
  553.     });
    
  554.     assertLog([]);
    
  555. 
    
  556.     await act(() => {
    
  557.       setStep(1);
    
  558.     });
    
  559.     assertLog([]);
    
  560. 
    
  561.     await act(() => {
    
  562.       setSuspended(true);
    
  563.     });
    
  564.     assertLog(['Will unmount: A0']);
    
  565. 
    
  566.     await act(() => {
    
  567.       setSuspended(false);
    
  568.       setLetter('B');
    
  569.     });
    
  570.     assertLog(['Did mount: B1']);
    
  571. 
    
  572.     await act(() => {
    
  573.       root.unmount();
    
  574.     });
    
  575.     assertLog(['Will unmount: B1']);
    
  576.   });
    
  577. });