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. 'use strict';
    
  12. 
    
  13. let React;
    
  14. let ReactDOMFizzStatic;
    
  15. let Suspense;
    
  16. 
    
  17. describe('ReactDOMFizzStaticNode', () => {
    
  18.   beforeEach(() => {
    
  19.     jest.resetModules();
    
  20.     React = require('react');
    
  21.     if (__EXPERIMENTAL__) {
    
  22.       ReactDOMFizzStatic = require('react-dom/static');
    
  23.     }
    
  24.     Suspense = React.Suspense;
    
  25.   });
    
  26. 
    
  27.   const theError = new Error('This is an error');
    
  28.   function Throw() {
    
  29.     throw theError;
    
  30.   }
    
  31.   const theInfinitePromise = new Promise(() => {});
    
  32.   function InfiniteSuspend() {
    
  33.     throw theInfinitePromise;
    
  34.   }
    
  35. 
    
  36.   function readContent(readable) {
    
  37.     return new Promise((resolve, reject) => {
    
  38.       let content = '';
    
  39.       readable.on('data', chunk => {
    
  40.         content += Buffer.from(chunk).toString('utf8');
    
  41.       });
    
  42.       readable.on('error', error => {
    
  43.         reject(error);
    
  44.       });
    
  45.       readable.on('end', () => resolve(content));
    
  46.     });
    
  47.   }
    
  48. 
    
  49.   // @gate experimental
    
  50.   it('should call prerenderToNodeStream', async () => {
    
  51.     const result = await ReactDOMFizzStatic.prerenderToNodeStream(
    
  52.       <div>hello world</div>,
    
  53.     );
    
  54.     const prelude = await readContent(result.prelude);
    
  55.     expect(prelude).toMatchInlineSnapshot(`"<div>hello world</div>"`);
    
  56.   });
    
  57. 
    
  58.   // @gate experimental
    
  59.   it('should emit DOCTYPE at the root of the document', async () => {
    
  60.     const result = await ReactDOMFizzStatic.prerenderToNodeStream(
    
  61.       <html>
    
  62.         <body>hello world</body>
    
  63.       </html>,
    
  64.     );
    
  65.     const prelude = await readContent(result.prelude);
    
  66.     if (gate(flags => flags.enableFloat)) {
    
  67.       expect(prelude).toMatchInlineSnapshot(
    
  68.         `"<!DOCTYPE html><html><head></head><body>hello world</body></html>"`,
    
  69.       );
    
  70.     } else {
    
  71.       expect(prelude).toMatchInlineSnapshot(
    
  72.         `"<!DOCTYPE html><html><body>hello world</body></html>"`,
    
  73.       );
    
  74.     }
    
  75.   });
    
  76. 
    
  77.   // @gate experimental
    
  78.   it('should emit bootstrap script src at the end', async () => {
    
  79.     const result = await ReactDOMFizzStatic.prerenderToNodeStream(
    
  80.       <div>hello world</div>,
    
  81.       {
    
  82.         bootstrapScriptContent: 'INIT();',
    
  83.         bootstrapScripts: ['init.js'],
    
  84.         bootstrapModules: ['init.mjs'],
    
  85.       },
    
  86.     );
    
  87.     const prelude = await readContent(result.prelude);
    
  88.     expect(prelude).toMatchInlineSnapshot(
    
  89.       `"<link rel="preload" as="script" fetchPriority="low" href="init.js"/><link rel="modulepreload" fetchPriority="low" href="init.mjs"/><div>hello world</div><script>INIT();</script><script src="init.js" async=""></script><script type="module" src="init.mjs" async=""></script>"`,
    
  90.     );
    
  91.   });
    
  92. 
    
  93.   // @gate experimental
    
  94.   it('emits all HTML as one unit', async () => {
    
  95.     let hasLoaded = false;
    
  96.     let resolve;
    
  97.     const promise = new Promise(r => (resolve = r));
    
  98.     function Wait() {
    
  99.       if (!hasLoaded) {
    
  100.         throw promise;
    
  101.       }
    
  102.       return 'Done';
    
  103.     }
    
  104.     const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(
    
  105.       <div>
    
  106.         <Suspense fallback="Loading">
    
  107.           <Wait />
    
  108.         </Suspense>
    
  109.       </div>,
    
  110.     );
    
  111. 
    
  112.     await jest.runAllTimers();
    
  113. 
    
  114.     // Resolve the loading.
    
  115.     hasLoaded = true;
    
  116.     await resolve();
    
  117. 
    
  118.     const result = await resultPromise;
    
  119.     const prelude = await readContent(result.prelude);
    
  120.     expect(prelude).toMatchInlineSnapshot(
    
  121.       `"<div><!--$-->Done<!-- --><!--/$--></div>"`,
    
  122.     );
    
  123.   });
    
  124. 
    
  125.   // @gate experimental
    
  126.   it('should reject the promise when an error is thrown at the root', async () => {
    
  127.     const reportedErrors = [];
    
  128.     let caughtError = null;
    
  129.     try {
    
  130.       await ReactDOMFizzStatic.prerenderToNodeStream(
    
  131.         <div>
    
  132.           <Throw />
    
  133.         </div>,
    
  134.         {
    
  135.           onError(x) {
    
  136.             reportedErrors.push(x);
    
  137.           },
    
  138.         },
    
  139.       );
    
  140.     } catch (error) {
    
  141.       caughtError = error;
    
  142.     }
    
  143.     expect(caughtError).toBe(theError);
    
  144.     expect(reportedErrors).toEqual([theError]);
    
  145.   });
    
  146. 
    
  147.   // @gate experimental
    
  148.   it('should reject the promise when an error is thrown inside a fallback', async () => {
    
  149.     const reportedErrors = [];
    
  150.     let caughtError = null;
    
  151.     try {
    
  152.       await ReactDOMFizzStatic.prerenderToNodeStream(
    
  153.         <div>
    
  154.           <Suspense fallback={<Throw />}>
    
  155.             <InfiniteSuspend />
    
  156.           </Suspense>
    
  157.         </div>,
    
  158.         {
    
  159.           onError(x) {
    
  160.             reportedErrors.push(x);
    
  161.           },
    
  162.         },
    
  163.       );
    
  164.     } catch (error) {
    
  165.       caughtError = error;
    
  166.     }
    
  167.     expect(caughtError).toBe(theError);
    
  168.     expect(reportedErrors).toEqual([theError]);
    
  169.   });
    
  170. 
    
  171.   // @gate experimental
    
  172.   it('should not error the stream when an error is thrown inside suspense boundary', async () => {
    
  173.     const reportedErrors = [];
    
  174.     const result = await ReactDOMFizzStatic.prerenderToNodeStream(
    
  175.       <div>
    
  176.         <Suspense fallback={<div>Loading</div>}>
    
  177.           <Throw />
    
  178.         </Suspense>
    
  179.       </div>,
    
  180.       {
    
  181.         onError(x) {
    
  182.           reportedErrors.push(x);
    
  183.         },
    
  184.       },
    
  185.     );
    
  186. 
    
  187.     const prelude = await readContent(result.prelude);
    
  188.     expect(prelude).toContain('Loading');
    
  189.     expect(reportedErrors).toEqual([theError]);
    
  190.   });
    
  191. 
    
  192.   // @gate experimental
    
  193.   it('should be able to complete by aborting even if the promise never resolves', async () => {
    
  194.     const errors = [];
    
  195.     const controller = new AbortController();
    
  196.     const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(
    
  197.       <div>
    
  198.         <Suspense fallback={<div>Loading</div>}>
    
  199.           <InfiniteSuspend />
    
  200.         </Suspense>
    
  201.       </div>,
    
  202.       {
    
  203.         signal: controller.signal,
    
  204.         onError(x) {
    
  205.           errors.push(x.message);
    
  206.         },
    
  207.       },
    
  208.     );
    
  209. 
    
  210.     await jest.runAllTimers();
    
  211. 
    
  212.     controller.abort();
    
  213. 
    
  214.     const result = await resultPromise;
    
  215. 
    
  216.     const prelude = await readContent(result.prelude);
    
  217.     expect(prelude).toContain('Loading');
    
  218. 
    
  219.     expect(errors).toEqual(['This operation was aborted']);
    
  220.   });
    
  221. 
    
  222.   // @gate experimental
    
  223.   it('should reject if aborting before the shell is complete', async () => {
    
  224.     const errors = [];
    
  225.     const controller = new AbortController();
    
  226.     const promise = ReactDOMFizzStatic.prerenderToNodeStream(
    
  227.       <div>
    
  228.         <InfiniteSuspend />
    
  229.       </div>,
    
  230.       {
    
  231.         signal: controller.signal,
    
  232.         onError(x) {
    
  233.           errors.push(x.message);
    
  234.         },
    
  235.       },
    
  236.     );
    
  237. 
    
  238.     await jest.runAllTimers();
    
  239. 
    
  240.     const theReason = new Error('aborted for reasons');
    
  241.     controller.abort(theReason);
    
  242. 
    
  243.     let caughtError = null;
    
  244.     try {
    
  245.       await promise;
    
  246.     } catch (error) {
    
  247.       caughtError = error;
    
  248.     }
    
  249.     expect(caughtError).toBe(theReason);
    
  250.     expect(errors).toEqual(['aborted for reasons']);
    
  251.   });
    
  252. 
    
  253.   // @gate experimental
    
  254.   it('should be able to abort before something suspends', async () => {
    
  255.     const errors = [];
    
  256.     const controller = new AbortController();
    
  257.     function App() {
    
  258.       controller.abort();
    
  259.       return (
    
  260.         <Suspense fallback={<div>Loading</div>}>
    
  261.           <InfiniteSuspend />
    
  262.         </Suspense>
    
  263.       );
    
  264.     }
    
  265.     const streamPromise = ReactDOMFizzStatic.prerenderToNodeStream(
    
  266.       <div>
    
  267.         <App />
    
  268.       </div>,
    
  269.       {
    
  270.         signal: controller.signal,
    
  271.         onError(x) {
    
  272.           errors.push(x.message);
    
  273.         },
    
  274.       },
    
  275.     );
    
  276. 
    
  277.     let caughtError = null;
    
  278.     try {
    
  279.       await streamPromise;
    
  280.     } catch (error) {
    
  281.       caughtError = error;
    
  282.     }
    
  283.     expect(caughtError.message).toBe('This operation was aborted');
    
  284.     expect(errors).toEqual(['This operation was aborted']);
    
  285.   });
    
  286. 
    
  287.   // @gate experimental
    
  288.   it('should reject if passing an already aborted signal', async () => {
    
  289.     const errors = [];
    
  290.     const controller = new AbortController();
    
  291.     const theReason = new Error('aborted for reasons');
    
  292.     controller.abort(theReason);
    
  293. 
    
  294.     const promise = ReactDOMFizzStatic.prerenderToNodeStream(
    
  295.       <div>
    
  296.         <Suspense fallback={<div>Loading</div>}>
    
  297.           <InfiniteSuspend />
    
  298.         </Suspense>
    
  299.       </div>,
    
  300.       {
    
  301.         signal: controller.signal,
    
  302.         onError(x) {
    
  303.           errors.push(x.message);
    
  304.         },
    
  305.       },
    
  306.     );
    
  307. 
    
  308.     // Technically we could still continue rendering the shell but currently the
    
  309.     // semantics mean that we also abort any pending CPU work.
    
  310.     let caughtError = null;
    
  311.     try {
    
  312.       await promise;
    
  313.     } catch (error) {
    
  314.       caughtError = error;
    
  315.     }
    
  316.     expect(caughtError).toBe(theReason);
    
  317.     expect(errors).toEqual(['aborted for reasons']);
    
  318.   });
    
  319. 
    
  320.   // @gate experimental
    
  321.   it('supports custom abort reasons with a string', async () => {
    
  322.     const promise = new Promise(r => {});
    
  323.     function Wait() {
    
  324.       throw promise;
    
  325.     }
    
  326.     function App() {
    
  327.       return (
    
  328.         <div>
    
  329.           <p>
    
  330.             <Suspense fallback={'p'}>
    
  331.               <Wait />
    
  332.             </Suspense>
    
  333.           </p>
    
  334.           <span>
    
  335.             <Suspense fallback={'span'}>
    
  336.               <Wait />
    
  337.             </Suspense>
    
  338.           </span>
    
  339.         </div>
    
  340.       );
    
  341.     }
    
  342. 
    
  343.     const errors = [];
    
  344.     const controller = new AbortController();
    
  345.     const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
    
  346.       signal: controller.signal,
    
  347.       onError(x) {
    
  348.         errors.push(x);
    
  349.         return 'a digest';
    
  350.       },
    
  351.     });
    
  352. 
    
  353.     await jest.runAllTimers();
    
  354. 
    
  355.     controller.abort('foobar');
    
  356. 
    
  357.     await resultPromise;
    
  358. 
    
  359.     expect(errors).toEqual(['foobar', 'foobar']);
    
  360.   });
    
  361. 
    
  362.   // @gate experimental
    
  363.   it('supports custom abort reasons with an Error', async () => {
    
  364.     const promise = new Promise(r => {});
    
  365.     function Wait() {
    
  366.       throw promise;
    
  367.     }
    
  368.     function App() {
    
  369.       return (
    
  370.         <div>
    
  371.           <p>
    
  372.             <Suspense fallback={'p'}>
    
  373.               <Wait />
    
  374.             </Suspense>
    
  375.           </p>
    
  376.           <span>
    
  377.             <Suspense fallback={'span'}>
    
  378.               <Wait />
    
  379.             </Suspense>
    
  380.           </span>
    
  381.         </div>
    
  382.       );
    
  383.     }
    
  384. 
    
  385.     const errors = [];
    
  386.     const controller = new AbortController();
    
  387.     const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
    
  388.       signal: controller.signal,
    
  389.       onError(x) {
    
  390.         errors.push(x.message);
    
  391.         return 'a digest';
    
  392.       },
    
  393.     });
    
  394. 
    
  395.     await jest.runAllTimers();
    
  396. 
    
  397.     controller.abort(new Error('uh oh'));
    
  398. 
    
  399.     await resultPromise;
    
  400. 
    
  401.     expect(errors).toEqual(['uh oh', 'uh oh']);
    
  402.   });
    
  403. });