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.  * @jest-environment node
    
  9.  */
    
  10. 
    
  11. let Profiler;
    
  12. let React;
    
  13. let ReactNoop;
    
  14. let Scheduler;
    
  15. let ReactFeatureFlags;
    
  16. let ReactCache;
    
  17. let Suspense;
    
  18. let TextResource;
    
  19. let textResourceShouldFail;
    
  20. let waitForAll;
    
  21. let assertLog;
    
  22. let act;
    
  23. 
    
  24. describe('ReactSuspensePlaceholder', () => {
    
  25.   beforeEach(() => {
    
  26.     jest.resetModules();
    
  27. 
    
  28.     ReactFeatureFlags = require('shared/ReactFeatureFlags');
    
  29. 
    
  30.     ReactFeatureFlags.enableProfilerTimer = true;
    
  31.     ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
    
  32.     React = require('react');
    
  33.     ReactNoop = require('react-noop-renderer');
    
  34.     Scheduler = require('scheduler');
    
  35.     ReactCache = require('react-cache');
    
  36. 
    
  37.     Profiler = React.Profiler;
    
  38.     Suspense = React.Suspense;
    
  39. 
    
  40.     const InternalTestUtils = require('internal-test-utils');
    
  41.     waitForAll = InternalTestUtils.waitForAll;
    
  42.     assertLog = InternalTestUtils.assertLog;
    
  43.     act = InternalTestUtils.act;
    
  44. 
    
  45.     TextResource = ReactCache.unstable_createResource(
    
  46.       ([text, ms = 0]) => {
    
  47.         let listeners = null;
    
  48.         let status = 'pending';
    
  49.         let value = null;
    
  50.         return {
    
  51.           then(resolve, reject) {
    
  52.             switch (status) {
    
  53.               case 'pending': {
    
  54.                 if (listeners === null) {
    
  55.                   listeners = [{resolve, reject}];
    
  56.                   setTimeout(() => {
    
  57.                     if (textResourceShouldFail) {
    
  58.                       Scheduler.log(`Promise rejected [${text}]`);
    
  59.                       status = 'rejected';
    
  60.                       value = new Error('Failed to load: ' + text);
    
  61.                       listeners.forEach(listener => listener.reject(value));
    
  62.                     } else {
    
  63.                       Scheduler.log(`Promise resolved [${text}]`);
    
  64.                       status = 'resolved';
    
  65.                       value = text;
    
  66.                       listeners.forEach(listener => listener.resolve(value));
    
  67.                     }
    
  68.                   }, ms);
    
  69.                 } else {
    
  70.                   listeners.push({resolve, reject});
    
  71.                 }
    
  72.                 break;
    
  73.               }
    
  74.               case 'resolved': {
    
  75.                 resolve(value);
    
  76.                 break;
    
  77.               }
    
  78.               case 'rejected': {
    
  79.                 reject(value);
    
  80.                 break;
    
  81.               }
    
  82.             }
    
  83.           },
    
  84.         };
    
  85.       },
    
  86.       ([text, ms]) => text,
    
  87.     );
    
  88.     textResourceShouldFail = false;
    
  89.   });
    
  90. 
    
  91.   function Text({fakeRenderDuration = 0, text = 'Text'}) {
    
  92.     Scheduler.unstable_advanceTime(fakeRenderDuration);
    
  93.     Scheduler.log(text);
    
  94.     return text;
    
  95.   }
    
  96. 
    
  97.   function AsyncText({fakeRenderDuration = 0, ms, text}) {
    
  98.     Scheduler.unstable_advanceTime(fakeRenderDuration);
    
  99.     try {
    
  100.       TextResource.read([text, ms]);
    
  101.       Scheduler.log(text);
    
  102.       return text;
    
  103.     } catch (promise) {
    
  104.       if (typeof promise.then === 'function') {
    
  105.         Scheduler.log(`Suspend! [${text}]`);
    
  106.       } else {
    
  107.         Scheduler.log(`Error! [${text}]`);
    
  108.       }
    
  109.       throw promise;
    
  110.     }
    
  111.   }
    
  112. 
    
  113.   it('times out children that are already hidden', async () => {
    
  114.     class HiddenText extends React.PureComponent {
    
  115.       render() {
    
  116.         const text = this.props.text;
    
  117.         Scheduler.log(text);
    
  118.         return <span hidden={true}>{text}</span>;
    
  119.       }
    
  120.     }
    
  121. 
    
  122.     function App(props) {
    
  123.       return (
    
  124.         <Suspense fallback={<Text text="Loading..." />}>
    
  125.           <HiddenText text="A" />
    
  126.           <span>
    
  127.             <AsyncText ms={1000} text={props.middleText} />
    
  128.           </span>
    
  129.           <span>
    
  130.             <Text text="C" />
    
  131.           </span>
    
  132.         </Suspense>
    
  133.       );
    
  134.     }
    
  135. 
    
  136.     // Initial mount
    
  137.     ReactNoop.render(<App middleText="B" />);
    
  138. 
    
  139.     await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
    
  140.     expect(ReactNoop).toMatchRenderedOutput('Loading...');
    
  141. 
    
  142.     await act(() => jest.advanceTimersByTime(1000));
    
  143.     assertLog(['Promise resolved [B]', 'A', 'B', 'C']);
    
  144. 
    
  145.     expect(ReactNoop).toMatchRenderedOutput(
    
  146.       <>
    
  147.         <span hidden={true}>A</span>
    
  148.         <span>B</span>
    
  149.         <span>C</span>
    
  150.       </>,
    
  151.     );
    
  152. 
    
  153.     // Update
    
  154.     ReactNoop.render(<App middleText="B2" />);
    
  155.     await waitForAll(['Suspend! [B2]', 'Loading...']);
    
  156. 
    
  157.     // Time out the update
    
  158.     jest.advanceTimersByTime(750);
    
  159.     await waitForAll([]);
    
  160.     expect(ReactNoop).toMatchRenderedOutput(
    
  161.       <>
    
  162.         <span hidden={true}>A</span>
    
  163.         <span hidden={true}>B</span>
    
  164.         <span hidden={true}>C</span>
    
  165.         Loading...
    
  166.       </>,
    
  167.     );
    
  168. 
    
  169.     // Resolve the promise
    
  170.     await act(() => jest.advanceTimersByTime(1000));
    
  171.     assertLog(['Promise resolved [B2]', 'B2', 'C']);
    
  172. 
    
  173.     // Render the final update. A should still be hidden, because it was
    
  174.     // given a `hidden` prop.
    
  175.     expect(ReactNoop).toMatchRenderedOutput(
    
  176.       <>
    
  177.         <span hidden={true}>A</span>
    
  178.         <span>B2</span>
    
  179.         <span>C</span>
    
  180.       </>,
    
  181.     );
    
  182.   });
    
  183. 
    
  184.   it('times out text nodes', async () => {
    
  185.     function App(props) {
    
  186.       return (
    
  187.         <Suspense fallback={<Text text="Loading..." />}>
    
  188.           <Text text="A" />
    
  189.           <AsyncText ms={1000} text={props.middleText} />
    
  190.           <Text text="C" />
    
  191.         </Suspense>
    
  192.       );
    
  193.     }
    
  194. 
    
  195.     // Initial mount
    
  196.     ReactNoop.render(<App middleText="B" />);
    
  197. 
    
  198.     await waitForAll(['A', 'Suspend! [B]', 'Loading...']);
    
  199. 
    
  200.     expect(ReactNoop).not.toMatchRenderedOutput('ABC');
    
  201. 
    
  202.     await act(() => jest.advanceTimersByTime(1000));
    
  203.     assertLog(['Promise resolved [B]', 'A', 'B', 'C']);
    
  204.     expect(ReactNoop).toMatchRenderedOutput('ABC');
    
  205. 
    
  206.     // Update
    
  207.     ReactNoop.render(<App middleText="B2" />);
    
  208.     await waitForAll(['A', 'Suspend! [B2]', 'Loading...']);
    
  209.     // Time out the update
    
  210.     jest.advanceTimersByTime(750);
    
  211.     await waitForAll([]);
    
  212.     expect(ReactNoop).toMatchRenderedOutput('Loading...');
    
  213. 
    
  214.     // Resolve the promise
    
  215.     await act(() => jest.advanceTimersByTime(1000));
    
  216.     assertLog(['Promise resolved [B2]', 'A', 'B2', 'C']);
    
  217. 
    
  218.     // Render the final update. A should still be hidden, because it was
    
  219.     // given a `hidden` prop.
    
  220.     expect(ReactNoop).toMatchRenderedOutput('AB2C');
    
  221.   });
    
  222. 
    
  223.   it('preserves host context for text nodes', async () => {
    
  224.     function App(props) {
    
  225.       return (
    
  226.         // uppercase is a special type that causes React Noop to render child
    
  227.         // text nodes as uppercase.
    
  228.         <uppercase>
    
  229.           <Suspense fallback={<Text text="Loading..." />}>
    
  230.             <Text text="a" />
    
  231.             <AsyncText ms={1000} text={props.middleText} />
    
  232.             <Text text="c" />
    
  233.           </Suspense>
    
  234.         </uppercase>
    
  235.       );
    
  236.     }
    
  237. 
    
  238.     // Initial mount
    
  239.     ReactNoop.render(<App middleText="b" />);
    
  240. 
    
  241.     await waitForAll(['a', 'Suspend! [b]', 'Loading...']);
    
  242. 
    
  243.     expect(ReactNoop).toMatchRenderedOutput(<uppercase>LOADING...</uppercase>);
    
  244. 
    
  245.     await act(() => jest.advanceTimersByTime(1000));
    
  246.     assertLog(['Promise resolved [b]', 'a', 'b', 'c']);
    
  247.     expect(ReactNoop).toMatchRenderedOutput(<uppercase>ABC</uppercase>);
    
  248. 
    
  249.     // Update
    
  250.     ReactNoop.render(<App middleText="b2" />);
    
  251.     await waitForAll(['a', 'Suspend! [b2]', 'Loading...']);
    
  252.     // Time out the update
    
  253.     jest.advanceTimersByTime(750);
    
  254.     await waitForAll([]);
    
  255.     expect(ReactNoop).toMatchRenderedOutput(<uppercase>LOADING...</uppercase>);
    
  256. 
    
  257.     // Resolve the promise
    
  258.     await act(() => jest.advanceTimersByTime(1000));
    
  259.     assertLog(['Promise resolved [b2]', 'a', 'b2', 'c']);
    
  260. 
    
  261.     // Render the final update. A should still be hidden, because it was
    
  262.     // given a `hidden` prop.
    
  263.     expect(ReactNoop).toMatchRenderedOutput(<uppercase>AB2C</uppercase>);
    
  264.   });
    
  265. 
    
  266.   describe('profiler durations', () => {
    
  267.     let App;
    
  268.     let onRender;
    
  269. 
    
  270.     beforeEach(() => {
    
  271.       // Order of parameters: id, phase, actualDuration, treeBaseDuration
    
  272.       onRender = jest.fn();
    
  273. 
    
  274.       const Fallback = () => {
    
  275.         Scheduler.log('Fallback');
    
  276.         Scheduler.unstable_advanceTime(10);
    
  277.         return 'Loading...';
    
  278.       };
    
  279. 
    
  280.       const Suspending = () => {
    
  281.         Scheduler.log('Suspending');
    
  282.         Scheduler.unstable_advanceTime(2);
    
  283.         return <AsyncText ms={1000} text="Loaded" fakeRenderDuration={1} />;
    
  284.       };
    
  285. 
    
  286.       App = ({shouldSuspend, text = 'Text', textRenderDuration = 5}) => {
    
  287.         Scheduler.log('App');
    
  288.         return (
    
  289.           <Profiler id="root" onRender={onRender}>
    
  290.             <Suspense fallback={<Fallback />}>
    
  291.               {shouldSuspend && <Suspending />}
    
  292.               <Text fakeRenderDuration={textRenderDuration} text={text} />
    
  293.             </Suspense>
    
  294.           </Profiler>
    
  295.         );
    
  296.       };
    
  297.     });
    
  298. 
    
  299.     describe('when suspending during mount', () => {
    
  300.       it('properly accounts for base durations when a suspended times out in a legacy tree', async () => {
    
  301.         ReactNoop.renderLegacySyncRoot(<App shouldSuspend={true} />);
    
  302.         assertLog([
    
  303.           'App',
    
  304.           'Suspending',
    
  305.           'Suspend! [Loaded]',
    
  306.           'Text',
    
  307.           'Fallback',
    
  308.         ]);
    
  309.         expect(ReactNoop).toMatchRenderedOutput('Loading...');
    
  310.         expect(onRender).toHaveBeenCalledTimes(1);
    
  311. 
    
  312.         // Initial mount only shows the "Loading..." Fallback.
    
  313.         // The treeBaseDuration then should be 10ms spent rendering Fallback,
    
  314.         // but the actualDuration should also include the 8ms spent rendering the hidden tree.
    
  315.         expect(onRender.mock.calls[0][2]).toBe(18);
    
  316.         expect(onRender.mock.calls[0][3]).toBe(10);
    
  317. 
    
  318.         jest.advanceTimersByTime(1000);
    
  319. 
    
  320.         assertLog(['Promise resolved [Loaded]']);
    
  321. 
    
  322.         ReactNoop.flushSync();
    
  323. 
    
  324.         assertLog(['Loaded']);
    
  325.         expect(ReactNoop).toMatchRenderedOutput('LoadedText');
    
  326.         expect(onRender).toHaveBeenCalledTimes(2);
    
  327. 
    
  328.         // When the suspending data is resolved and our final UI is rendered,
    
  329.         // the baseDuration should only include the 1ms re-rendering AsyncText,
    
  330.         // but the treeBaseDuration should include the full 8ms spent in the tree.
    
  331.         expect(onRender.mock.calls[1][2]).toBe(1);
    
  332.         expect(onRender.mock.calls[1][3]).toBe(8);
    
  333.       });
    
  334. 
    
  335.       it('properly accounts for base durations when a suspended times out in a concurrent tree', async () => {
    
  336.         ReactNoop.render(<App shouldSuspend={true} />);
    
  337. 
    
  338.         await waitForAll([
    
  339.           'App',
    
  340.           'Suspending',
    
  341.           'Suspend! [Loaded]',
    
  342.           'Fallback',
    
  343.         ]);
    
  344.         // Since this is initial render we immediately commit the fallback. Another test below
    
  345.         // deals with the update case where this suspends.
    
  346.         expect(ReactNoop).toMatchRenderedOutput('Loading...');
    
  347.         expect(onRender).toHaveBeenCalledTimes(1);
    
  348. 
    
  349.         // Initial mount only shows the "Loading..." Fallback.
    
  350.         // The treeBaseDuration then should be 10ms spent rendering Fallback,
    
  351.         // but the actualDuration should also include the 3ms spent rendering the hidden tree.
    
  352.         expect(onRender.mock.calls[0][2]).toBe(13);
    
  353.         expect(onRender.mock.calls[0][3]).toBe(10);
    
  354. 
    
  355.         // Resolve the pending promise.
    
  356.         await act(() => jest.advanceTimersByTime(1000));
    
  357.         assertLog([
    
  358.           'Promise resolved [Loaded]',
    
  359.           'Suspending',
    
  360.           'Loaded',
    
  361.           'Text',
    
  362.         ]);
    
  363.         expect(ReactNoop).toMatchRenderedOutput('LoadedText');
    
  364.         expect(onRender).toHaveBeenCalledTimes(2);
    
  365. 
    
  366.         // When the suspending data is resolved and our final UI is rendered,
    
  367.         // both times should include the 8ms re-rendering Suspending and AsyncText.
    
  368.         expect(onRender.mock.calls[1][2]).toBe(8);
    
  369.         expect(onRender.mock.calls[1][3]).toBe(8);
    
  370.       });
    
  371.     });
    
  372. 
    
  373.     describe('when suspending during update', () => {
    
  374.       it('properly accounts for base durations when a suspended times out in a legacy tree', async () => {
    
  375.         ReactNoop.renderLegacySyncRoot(
    
  376.           <App shouldSuspend={false} textRenderDuration={5} />,
    
  377.         );
    
  378.         assertLog(['App', 'Text']);
    
  379.         expect(ReactNoop).toMatchRenderedOutput('Text');
    
  380.         expect(onRender).toHaveBeenCalledTimes(1);
    
  381. 
    
  382.         // Initial mount only shows the "Text" text.
    
  383.         // It should take 5ms to render.
    
  384.         expect(onRender.mock.calls[0][2]).toBe(5);
    
  385.         expect(onRender.mock.calls[0][3]).toBe(5);
    
  386. 
    
  387.         ReactNoop.render(<App shouldSuspend={true} textRenderDuration={5} />);
    
  388.         assertLog([
    
  389.           'App',
    
  390.           'Suspending',
    
  391.           'Suspend! [Loaded]',
    
  392.           'Text',
    
  393.           'Fallback',
    
  394.         ]);
    
  395.         expect(ReactNoop).toMatchRenderedOutput('Loading...');
    
  396.         expect(onRender).toHaveBeenCalledTimes(2);
    
  397. 
    
  398.         // The suspense update should only show the "Loading..." Fallback.
    
  399.         // The actual duration should include 10ms spent rendering Fallback,
    
  400.         // plus the 8ms render all of the hidden, suspended subtree.
    
  401.         // But the tree base duration should only include 10ms spent rendering Fallback,
    
  402.         expect(onRender.mock.calls[1][2]).toBe(18);
    
  403.         expect(onRender.mock.calls[1][3]).toBe(10);
    
  404. 
    
  405.         ReactNoop.renderLegacySyncRoot(
    
  406.           <App shouldSuspend={true} text="New" textRenderDuration={6} />,
    
  407.         );
    
  408.         assertLog([
    
  409.           'App',
    
  410.           'Suspending',
    
  411.           'Suspend! [Loaded]',
    
  412.           'New',
    
  413.           'Fallback',
    
  414.         ]);
    
  415.         expect(ReactNoop).toMatchRenderedOutput('Loading...');
    
  416.         expect(onRender).toHaveBeenCalledTimes(3);
    
  417. 
    
  418.         expect(onRender.mock.calls[1][2]).toBe(18);
    
  419.         expect(onRender.mock.calls[1][3]).toBe(10);
    
  420.         jest.advanceTimersByTime(1000);
    
  421. 
    
  422.         assertLog(['Promise resolved [Loaded]']);
    
  423. 
    
  424.         ReactNoop.flushSync();
    
  425. 
    
  426.         assertLog(['Loaded']);
    
  427.         expect(ReactNoop).toMatchRenderedOutput('LoadedNew');
    
  428.         expect(onRender).toHaveBeenCalledTimes(4);
    
  429. 
    
  430.         // When the suspending data is resolved and our final UI is rendered,
    
  431.         // the baseDuration should only include the 1ms re-rendering AsyncText,
    
  432.         // but the treeBaseDuration should include the full 9ms spent in the tree.
    
  433.         expect(onRender.mock.calls[3][2]).toBe(1);
    
  434.         expect(onRender.mock.calls[3][3]).toBe(9);
    
  435.       });
    
  436. 
    
  437.       it('properly accounts for base durations when a suspended times out in a concurrent tree', async () => {
    
  438.         const Fallback = () => {
    
  439.           Scheduler.log('Fallback');
    
  440.           Scheduler.unstable_advanceTime(10);
    
  441.           return 'Loading...';
    
  442.         };
    
  443. 
    
  444.         const Suspending = () => {
    
  445.           Scheduler.log('Suspending');
    
  446.           Scheduler.unstable_advanceTime(2);
    
  447.           return <AsyncText ms={1000} text="Loaded" fakeRenderDuration={1} />;
    
  448.         };
    
  449. 
    
  450.         App = ({shouldSuspend, text = 'Text', textRenderDuration = 5}) => {
    
  451.           Scheduler.log('App');
    
  452.           return (
    
  453.             <Profiler id="root" onRender={onRender}>
    
  454.               <Suspense fallback={<Fallback />}>
    
  455.                 {shouldSuspend && <Suspending />}
    
  456.                 <Text fakeRenderDuration={textRenderDuration} text={text} />
    
  457.               </Suspense>
    
  458.             </Profiler>
    
  459.           );
    
  460.         };
    
  461. 
    
  462.         ReactNoop.render(
    
  463.           <>
    
  464.             <App shouldSuspend={false} textRenderDuration={5} />
    
  465.             <Suspense fallback={null} />
    
  466.           </>,
    
  467.         );
    
  468. 
    
  469.         await waitForAll(['App', 'Text']);
    
  470.         expect(ReactNoop).toMatchRenderedOutput('Text');
    
  471.         expect(onRender).toHaveBeenCalledTimes(1);
    
  472. 
    
  473.         // Initial mount only shows the "Text" text.
    
  474.         // It should take 5ms to render.
    
  475.         expect(onRender.mock.calls[0][2]).toBe(5);
    
  476.         expect(onRender.mock.calls[0][3]).toBe(5);
    
  477. 
    
  478.         ReactNoop.render(
    
  479.           <>
    
  480.             <App shouldSuspend={true} textRenderDuration={5} />
    
  481.             <Suspense fallback={null} />
    
  482.           </>,
    
  483.         );
    
  484.         await waitForAll([
    
  485.           'App',
    
  486.           'Suspending',
    
  487.           'Suspend! [Loaded]',
    
  488.           'Fallback',
    
  489.         ]);
    
  490.         // Show the fallback UI.
    
  491.         expect(ReactNoop).toMatchRenderedOutput('Loading...');
    
  492.         expect(onRender).toHaveBeenCalledTimes(2);
    
  493. 
    
  494.         jest.advanceTimersByTime(900);
    
  495. 
    
  496.         // The suspense update should only show the "Loading..." Fallback.
    
  497.         // The actual duration should include 10ms spent rendering Fallback,
    
  498.         // plus the 3ms render all of the partially rendered suspended subtree.
    
  499.         // But the tree base duration should only include 10ms spent rendering Fallback.
    
  500.         expect(onRender.mock.calls[1][2]).toBe(13);
    
  501.         expect(onRender.mock.calls[1][3]).toBe(10);
    
  502. 
    
  503.         // Update again while timed out.
    
  504.         // Since this test was originally written we added an optimization to avoid
    
  505.         // suspending in the case that we already timed out. To simulate the old
    
  506.         // behavior, we add a different suspending boundary as a sibling.
    
  507.         ReactNoop.render(
    
  508.           <>
    
  509.             <App shouldSuspend={true} text="New" textRenderDuration={6} />
    
  510.             <Suspense fallback={null}>
    
  511.               <AsyncText ms={100} text="Sibling" fakeRenderDuration={1} />
    
  512.             </Suspense>
    
  513.           </>,
    
  514.         );
    
  515. 
    
  516.         // TODO: This is here only to shift us into the next JND bucket. A
    
  517.         // consequence of AsyncText relying on the same timer queue as React's
    
  518.         // internal Suspense timer. We should decouple our AsyncText helpers
    
  519.         // from timers.
    
  520.         Scheduler.unstable_advanceTime(200);
    
  521. 
    
  522.         await waitForAll([
    
  523.           'App',
    
  524.           'Suspending',
    
  525.           'Suspend! [Loaded]',
    
  526.           'Fallback',
    
  527.           'Suspend! [Sibling]',
    
  528.         ]);
    
  529.         expect(ReactNoop).toMatchRenderedOutput('Loading...');
    
  530.         expect(onRender).toHaveBeenCalledTimes(3);
    
  531. 
    
  532.         // Resolve the pending promise.
    
  533.         await act(async () => {
    
  534.           jest.advanceTimersByTime(100);
    
  535.           assertLog([
    
  536.             'Promise resolved [Loaded]',
    
  537.             'Promise resolved [Sibling]',
    
  538.           ]);
    
  539.           await waitForAll(['Suspending', 'Loaded', 'New', 'Sibling']);
    
  540.         });
    
  541.         expect(onRender).toHaveBeenCalledTimes(4);
    
  542. 
    
  543.         // When the suspending data is resolved and our final UI is rendered,
    
  544.         // both times should include the 6ms rendering Text,
    
  545.         // the 2ms rendering Suspending, and the 1ms rendering AsyncText.
    
  546.         expect(onRender.mock.calls[3][2]).toBe(9);
    
  547.         expect(onRender.mock.calls[3][3]).toBe(9);
    
  548.       });
    
  549.     });
    
  550.   });
    
  551. });