1. let React;
    
  2. let startTransition;
    
  3. let ReactNoop;
    
  4. let resolveSuspenseyThing;
    
  5. let getSuspenseyThingStatus;
    
  6. let Suspense;
    
  7. let Activity;
    
  8. let SuspenseList;
    
  9. let useMemo;
    
  10. let Scheduler;
    
  11. let act;
    
  12. let assertLog;
    
  13. let waitForPaint;
    
  14. 
    
  15. describe('ReactSuspenseyCommitPhase', () => {
    
  16.   beforeEach(() => {
    
  17.     jest.resetModules();
    
  18. 
    
  19.     React = require('react');
    
  20.     ReactNoop = require('react-noop-renderer');
    
  21.     Scheduler = require('scheduler');
    
  22.     Suspense = React.Suspense;
    
  23.     if (gate(flags => flags.enableSuspenseList)) {
    
  24.       SuspenseList = React.unstable_SuspenseList;
    
  25.     }
    
  26.     Activity = React.unstable_Activity;
    
  27.     useMemo = React.useMemo;
    
  28.     startTransition = React.startTransition;
    
  29.     resolveSuspenseyThing = ReactNoop.resolveSuspenseyThing;
    
  30.     getSuspenseyThingStatus = ReactNoop.getSuspenseyThingStatus;
    
  31. 
    
  32.     const InternalTestUtils = require('internal-test-utils');
    
  33.     act = InternalTestUtils.act;
    
  34.     assertLog = InternalTestUtils.assertLog;
    
  35.     waitForPaint = InternalTestUtils.waitForPaint;
    
  36.   });
    
  37. 
    
  38.   function Text({text}) {
    
  39.     Scheduler.log(text);
    
  40.     return text;
    
  41.   }
    
  42. 
    
  43.   function SuspenseyImage({src}) {
    
  44.     return (
    
  45.       <suspensey-thing
    
  46.         src={src}
    
  47.         onLoadStart={() => Scheduler.log(`Image requested [${src}]`)}
    
  48.       />
    
  49.     );
    
  50.   }
    
  51. 
    
  52.   test('suspend commit during initial mount', async () => {
    
  53.     const root = ReactNoop.createRoot();
    
  54.     await act(async () => {
    
  55.       startTransition(() => {
    
  56.         root.render(
    
  57.           <Suspense fallback={<Text text="Loading..." />}>
    
  58.             <SuspenseyImage src="A" />
    
  59.           </Suspense>,
    
  60.         );
    
  61.       });
    
  62.     });
    
  63.     assertLog(['Image requested [A]', 'Loading...']);
    
  64.     expect(getSuspenseyThingStatus('A')).toBe('pending');
    
  65.     expect(root).toMatchRenderedOutput('Loading...');
    
  66. 
    
  67.     // This should synchronously commit
    
  68.     resolveSuspenseyThing('A');
    
  69.     expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
    
  70.   });
    
  71. 
    
  72.   test('suspend commit during update', async () => {
    
  73.     const root = ReactNoop.createRoot();
    
  74.     await act(() => resolveSuspenseyThing('A'));
    
  75.     await act(async () => {
    
  76.       startTransition(() => {
    
  77.         root.render(
    
  78.           <Suspense fallback={<Text text="Loading..." />}>
    
  79.             <SuspenseyImage src="A" />
    
  80.           </Suspense>,
    
  81.         );
    
  82.       });
    
  83.     });
    
  84.     expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
    
  85. 
    
  86.     // Update to a new image src. The transition should suspend because
    
  87.     // the src hasn't loaded yet, and the image is in an already-visible tree.
    
  88.     await act(async () => {
    
  89.       startTransition(() => {
    
  90.         root.render(
    
  91.           <Suspense fallback={<Text text="Loading..." />}>
    
  92.             <SuspenseyImage src="B" />
    
  93.           </Suspense>,
    
  94.         );
    
  95.       });
    
  96.     });
    
  97.     assertLog(['Image requested [B]']);
    
  98.     expect(getSuspenseyThingStatus('B')).toBe('pending');
    
  99.     // Should remain on previous screen
    
  100.     expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
    
  101. 
    
  102.     // This should synchronously commit
    
  103.     resolveSuspenseyThing('B');
    
  104.     expect(root).toMatchRenderedOutput(<suspensey-thing src="B" />);
    
  105.   });
    
  106. 
    
  107.   test('does not suspend commit during urgent update', async () => {
    
  108.     const root = ReactNoop.createRoot();
    
  109.     await act(async () => {
    
  110.       root.render(
    
  111.         <Suspense fallback={<Text text="Loading..." />}>
    
  112.           <SuspenseyImage src="A" />
    
  113.         </Suspense>,
    
  114.       );
    
  115.     });
    
  116.     // We intentionally don't preload during an urgent update because the
    
  117.     // resource will be inserted synchronously, anyway.
    
  118.     // TODO: Maybe we should, though? Could be that the browser is able to start
    
  119.     // the preload in background even though the main thread is blocked. Likely
    
  120.     // a micro-optimization either way because typically new content is loaded
    
  121.     // during a transition, not an urgent render.
    
  122.     expect(getSuspenseyThingStatus('A')).toBe(null);
    
  123.     expect(root).toMatchRenderedOutput(<suspensey-thing src="A" />);
    
  124.   });
    
  125. 
    
  126.   test('an urgent update interrupts a suspended commit', async () => {
    
  127.     const root = ReactNoop.createRoot();
    
  128. 
    
  129.     // Mount an image. This transition will suspend because it's not inside a
    
  130.     // Suspense boundary.
    
  131.     await act(() => {
    
  132.       startTransition(() => {
    
  133.         root.render(<SuspenseyImage src="A" />);
    
  134.       });
    
  135.     });
    
  136.     assertLog(['Image requested [A]']);
    
  137.     // Nothing showing yet.
    
  138.     expect(root).toMatchRenderedOutput(null);
    
  139. 
    
  140.     // If there's an update, it should interrupt the suspended commit.
    
  141.     await act(() => {
    
  142.       root.render(<Text text="Something else" />);
    
  143.     });
    
  144.     assertLog(['Something else']);
    
  145.     expect(root).toMatchRenderedOutput('Something else');
    
  146.   });
    
  147. 
    
  148.   test('a transition update interrupts a suspended commit', async () => {
    
  149.     const root = ReactNoop.createRoot();
    
  150. 
    
  151.     // Mount an image. This transition will suspend because it's not inside a
    
  152.     // Suspense boundary.
    
  153.     await act(() => {
    
  154.       startTransition(() => {
    
  155.         root.render(<SuspenseyImage src="A" />);
    
  156.       });
    
  157.     });
    
  158.     assertLog(['Image requested [A]']);
    
  159.     // Nothing showing yet.
    
  160.     expect(root).toMatchRenderedOutput(null);
    
  161. 
    
  162.     // If there's an update, it should interrupt the suspended commit.
    
  163.     await act(() => {
    
  164.       startTransition(() => {
    
  165.         root.render(<Text text="Something else" />);
    
  166.       });
    
  167.     });
    
  168.     assertLog(['Something else']);
    
  169.     expect(root).toMatchRenderedOutput('Something else');
    
  170.   });
    
  171. 
    
  172.   // @gate enableSuspenseList
    
  173.   test('demonstrate current behavior when used with SuspenseList (not ideal)', async () => {
    
  174.     function App() {
    
  175.       return (
    
  176.         <SuspenseList revealOrder="forwards">
    
  177.           <Suspense fallback={<Text text="Loading A" />}>
    
  178.             <SuspenseyImage src="A" />
    
  179.           </Suspense>
    
  180.           <Suspense fallback={<Text text="Loading B" />}>
    
  181.             <SuspenseyImage src="B" />
    
  182.           </Suspense>
    
  183.           <Suspense fallback={<Text text="Loading C" />}>
    
  184.             <SuspenseyImage src="C" />
    
  185.           </Suspense>
    
  186.         </SuspenseList>
    
  187.       );
    
  188.     }
    
  189. 
    
  190.     const root = ReactNoop.createRoot();
    
  191.     await act(() => {
    
  192.       startTransition(() => {
    
  193.         root.render(<App />);
    
  194.       });
    
  195.     });
    
  196.     assertLog([
    
  197.       'Image requested [A]',
    
  198.       'Loading A',
    
  199.       'Loading B',
    
  200.       'Loading C',
    
  201.       'Image requested [B]',
    
  202.       'Image requested [C]',
    
  203.     ]);
    
  204.     expect(root).toMatchRenderedOutput('Loading ALoading BLoading C');
    
  205. 
    
  206.     // TODO: Notice that none of these items appear until they've all loaded.
    
  207.     // That's not ideal; we should commit each row as it becomes ready to
    
  208.     // commit. That means we need to prepare both the fallback and the primary
    
  209.     // tree during the render phase. Related to Activity, too.
    
  210.     resolveSuspenseyThing('A');
    
  211.     expect(root).toMatchRenderedOutput('Loading ALoading BLoading C');
    
  212.     resolveSuspenseyThing('B');
    
  213.     expect(root).toMatchRenderedOutput('Loading ALoading BLoading C');
    
  214.     resolveSuspenseyThing('C');
    
  215.     expect(root).toMatchRenderedOutput(
    
  216.       <>
    
  217.         <suspensey-thing src="A" />
    
  218.         <suspensey-thing src="B" />
    
  219.         <suspensey-thing src="C" />
    
  220.       </>,
    
  221.     );
    
  222.   });
    
  223. 
    
  224.   test('avoid triggering a fallback if resource loads immediately', async () => {
    
  225.     const root = ReactNoop.createRoot();
    
  226.     await act(async () => {
    
  227.       startTransition(() => {
    
  228.         // Intentionally rendering <suspensey-thing>s in a variety of tree
    
  229.         // positions to test that the work loop resumes correctly in each case.
    
  230.         root.render(
    
  231.           <Suspense fallback={<Text text="Loading..." />}>
    
  232.             <suspensey-thing
    
  233.               src="A"
    
  234.               onLoadStart={() => Scheduler.log('Request [A]')}>
    
  235.               <suspensey-thing
    
  236.                 src="B"
    
  237.                 onLoadStart={() => Scheduler.log('Request [B]')}
    
  238.               />
    
  239.             </suspensey-thing>
    
  240.             <suspensey-thing
    
  241.               src="C"
    
  242.               onLoadStart={() => Scheduler.log('Request [C]')}
    
  243.             />
    
  244.           </Suspense>,
    
  245.         );
    
  246.       });
    
  247.       // React will yield right after the resource suspends.
    
  248.       // TODO: The child is preloaded first because we preload in the complete
    
  249.       // phase. Ideally it should be in the begin phase, but we currently don't
    
  250.       // create the instance until complete. However, it's unclear if we even
    
  251.       // need the instance for preloading. So we should probably move this to
    
  252.       // the begin phase.
    
  253.       await waitForPaint(['Request [B]']);
    
  254.       // Resolve in an immediate task. This could happen if the resource is
    
  255.       // already loaded into the cache.
    
  256.       resolveSuspenseyThing('B');
    
  257.       await waitForPaint(['Request [A]']);
    
  258.       resolveSuspenseyThing('A');
    
  259.       await waitForPaint(['Request [C]']);
    
  260.       resolveSuspenseyThing('C');
    
  261.     });
    
  262.     expect(root).toMatchRenderedOutput(
    
  263.       <>
    
  264.         <suspensey-thing src="A">
    
  265.           <suspensey-thing src="B" />
    
  266.         </suspensey-thing>
    
  267.         <suspensey-thing src="C" />
    
  268.       </>,
    
  269.     );
    
  270.   });
    
  271. 
    
  272.   // @gate enableActivity
    
  273.   test("host instances don't suspend during prerendering, but do suspend when they are revealed", async () => {
    
  274.     function More() {
    
  275.       Scheduler.log('More');
    
  276.       return <SuspenseyImage src="More" />;
    
  277.     }
    
  278. 
    
  279.     function Details({showMore}) {
    
  280.       Scheduler.log('Details');
    
  281.       const more = useMemo(() => <More />, []);
    
  282.       return (
    
  283.         <>
    
  284.           <div>Main Content</div>
    
  285.           <Activity mode={showMore ? 'visible' : 'hidden'}>{more}</Activity>
    
  286.         </>
    
  287.       );
    
  288.     }
    
  289. 
    
  290.     const root = ReactNoop.createRoot();
    
  291.     await act(async () => {
    
  292.       root.render(<Details showMore={false} />);
    
  293.       // First render the outer component, without the hidden content
    
  294.       await waitForPaint(['Details']);
    
  295.       expect(root).toMatchRenderedOutput(<div>Main Content</div>);
    
  296.     });
    
  297.     // Then prerender the hidden content.
    
  298.     assertLog(['More', 'Image requested [More]']);
    
  299.     // The prerender should commit even though the image is still loading,
    
  300.     // because it's hidden.
    
  301.     expect(root).toMatchRenderedOutput(
    
  302.       <>
    
  303.         <div>Main Content</div>
    
  304.         <suspensey-thing hidden={true} src="More" />
    
  305.       </>,
    
  306.     );
    
  307. 
    
  308.     // Reveal the prerendered content. This update should suspend, because the
    
  309.     // image that is being revealed still hasn't loaded.
    
  310.     await act(() => {
    
  311.       startTransition(() => {
    
  312.         root.render(<Details showMore={true} />);
    
  313.       });
    
  314.     });
    
  315.     // The More component should not render again, because it was memoized,
    
  316.     // and it already prerendered.
    
  317.     assertLog(['Details']);
    
  318.     expect(root).toMatchRenderedOutput(
    
  319.       <>
    
  320.         <div>Main Content</div>
    
  321.         <suspensey-thing hidden={true} src="More" />
    
  322.       </>,
    
  323.     );
    
  324. 
    
  325.     // Now resolve the image. The transition should complete.
    
  326.     resolveSuspenseyThing('More');
    
  327.     expect(root).toMatchRenderedOutput(
    
  328.       <>
    
  329.         <div>Main Content</div>
    
  330.         <suspensey-thing src="More" />
    
  331.       </>,
    
  332.     );
    
  333.   });
    
  334. });