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 ./scripts/jest/ReactDOMServerIntegrationEnvironment
    
  9.  */
    
  10. 
    
  11. let JSDOM;
    
  12. let React;
    
  13. let startTransition;
    
  14. let ReactDOMClient;
    
  15. let Scheduler;
    
  16. let clientAct;
    
  17. let ReactDOMFizzServer;
    
  18. let Stream;
    
  19. let document;
    
  20. let writable;
    
  21. let container;
    
  22. let buffer = '';
    
  23. let hasErrored = false;
    
  24. let fatalError = undefined;
    
  25. let textCache;
    
  26. let assertLog;
    
  27. 
    
  28. describe('ReactDOMFizzShellHydration', () => {
    
  29.   beforeEach(() => {
    
  30.     jest.resetModules();
    
  31.     JSDOM = require('jsdom').JSDOM;
    
  32.     React = require('react');
    
  33.     ReactDOMClient = require('react-dom/client');
    
  34.     Scheduler = require('scheduler');
    
  35.     clientAct = require('internal-test-utils').act;
    
  36.     ReactDOMFizzServer = require('react-dom/server');
    
  37.     Stream = require('stream');
    
  38. 
    
  39.     const InternalTestUtils = require('internal-test-utils');
    
  40.     assertLog = InternalTestUtils.assertLog;
    
  41. 
    
  42.     startTransition = React.startTransition;
    
  43. 
    
  44.     textCache = new Map();
    
  45. 
    
  46.     // Test Environment
    
  47.     const jsdom = new JSDOM(
    
  48.       '<!DOCTYPE html><html><head></head><body><div id="container">',
    
  49.       {
    
  50.         runScripts: 'dangerously',
    
  51.       },
    
  52.     );
    
  53.     document = jsdom.window.document;
    
  54.     container = document.getElementById('container');
    
  55. 
    
  56.     buffer = '';
    
  57.     hasErrored = false;
    
  58. 
    
  59.     writable = new Stream.PassThrough();
    
  60.     writable.setEncoding('utf8');
    
  61.     writable.on('data', chunk => {
    
  62.       buffer += chunk;
    
  63.     });
    
  64.     writable.on('error', error => {
    
  65.       hasErrored = true;
    
  66.       fatalError = error;
    
  67.     });
    
  68.   });
    
  69. 
    
  70.   afterEach(() => {
    
  71.     jest.restoreAllMocks();
    
  72.   });
    
  73. 
    
  74.   async function serverAct(callback) {
    
  75.     await callback();
    
  76.     // Await one turn around the event loop.
    
  77.     // This assumes that we'll flush everything we have so far.
    
  78.     await new Promise(resolve => {
    
  79.       setImmediate(resolve);
    
  80.     });
    
  81.     if (hasErrored) {
    
  82.       throw fatalError;
    
  83.     }
    
  84.     // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
    
  85.     // We also want to execute any scripts that are embedded.
    
  86.     // We assume that we have now received a proper fragment of HTML.
    
  87.     const bufferedContent = buffer;
    
  88.     buffer = '';
    
  89.     const fakeBody = document.createElement('body');
    
  90.     fakeBody.innerHTML = bufferedContent;
    
  91.     while (fakeBody.firstChild) {
    
  92.       const node = fakeBody.firstChild;
    
  93.       if (node.nodeName === 'SCRIPT') {
    
  94.         const script = document.createElement('script');
    
  95.         script.textContent = node.textContent;
    
  96.         fakeBody.removeChild(node);
    
  97.         container.appendChild(script);
    
  98.       } else {
    
  99.         container.appendChild(node);
    
  100.       }
    
  101.     }
    
  102.   }
    
  103. 
    
  104.   function resolveText(text) {
    
  105.     const record = textCache.get(text);
    
  106.     if (record === undefined) {
    
  107.       const newRecord = {
    
  108.         status: 'resolved',
    
  109.         value: text,
    
  110.       };
    
  111.       textCache.set(text, newRecord);
    
  112.     } else if (record.status === 'pending') {
    
  113.       const thenable = record.value;
    
  114.       record.status = 'resolved';
    
  115.       record.value = text;
    
  116.       thenable.pings.forEach(t => t());
    
  117.     }
    
  118.   }
    
  119. 
    
  120.   function readText(text) {
    
  121.     const record = textCache.get(text);
    
  122.     if (record !== undefined) {
    
  123.       switch (record.status) {
    
  124.         case 'pending':
    
  125.           throw record.value;
    
  126.         case 'rejected':
    
  127.           throw record.value;
    
  128.         case 'resolved':
    
  129.           return record.value;
    
  130.       }
    
  131.     } else {
    
  132.       Scheduler.log(`Suspend! [${text}]`);
    
  133. 
    
  134.       const thenable = {
    
  135.         pings: [],
    
  136.         then(resolve) {
    
  137.           if (newRecord.status === 'pending') {
    
  138.             thenable.pings.push(resolve);
    
  139.           } else {
    
  140.             Promise.resolve().then(() => resolve(newRecord.value));
    
  141.           }
    
  142.         },
    
  143.       };
    
  144. 
    
  145.       const newRecord = {
    
  146.         status: 'pending',
    
  147.         value: thenable,
    
  148.       };
    
  149.       textCache.set(text, newRecord);
    
  150. 
    
  151.       throw thenable;
    
  152.     }
    
  153.   }
    
  154. 
    
  155.   function Text({text}) {
    
  156.     Scheduler.log(text);
    
  157.     return text;
    
  158.   }
    
  159. 
    
  160.   function AsyncText({text}) {
    
  161.     readText(text);
    
  162.     Scheduler.log(text);
    
  163.     return text;
    
  164.   }
    
  165. 
    
  166.   function resetTextCache() {
    
  167.     textCache = new Map();
    
  168.   }
    
  169. 
    
  170.   test('suspending in the shell during hydration', async () => {
    
  171.     const div = React.createRef(null);
    
  172. 
    
  173.     function App() {
    
  174.       return (
    
  175.         <div ref={div}>
    
  176.           <AsyncText text="Shell" />
    
  177.         </div>
    
  178.       );
    
  179.     }
    
  180. 
    
  181.     // Server render
    
  182.     await resolveText('Shell');
    
  183.     await serverAct(async () => {
    
  184.       const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
    
  185.       pipe(writable);
    
  186.     });
    
  187.     assertLog(['Shell']);
    
  188.     const dehydratedDiv = container.getElementsByTagName('div')[0];
    
  189. 
    
  190.     // Clear the cache and start rendering on the client
    
  191.     resetTextCache();
    
  192. 
    
  193.     // Hydration suspends because the data for the shell hasn't loaded yet
    
  194.     await clientAct(async () => {
    
  195.       ReactDOMClient.hydrateRoot(container, <App />);
    
  196.     });
    
  197.     assertLog(['Suspend! [Shell]']);
    
  198.     expect(div.current).toBe(null);
    
  199.     expect(container.textContent).toBe('Shell');
    
  200. 
    
  201.     // The shell loads and hydration finishes
    
  202.     await clientAct(async () => {
    
  203.       await resolveText('Shell');
    
  204.     });
    
  205.     assertLog(['Shell']);
    
  206.     expect(div.current).toBe(dehydratedDiv);
    
  207.     expect(container.textContent).toBe('Shell');
    
  208.   });
    
  209. 
    
  210.   test('suspending in the shell during a normal client render', async () => {
    
  211.     // Same as previous test but during a normal client render, no hydration
    
  212.     function App() {
    
  213.       return <AsyncText text="Shell" />;
    
  214.     }
    
  215. 
    
  216.     const root = ReactDOMClient.createRoot(container);
    
  217.     await clientAct(async () => {
    
  218.       root.render(<App />);
    
  219.     });
    
  220.     assertLog(['Suspend! [Shell]']);
    
  221. 
    
  222.     await clientAct(async () => {
    
  223.       await resolveText('Shell');
    
  224.     });
    
  225.     assertLog(['Shell']);
    
  226.     expect(container.textContent).toBe('Shell');
    
  227.   });
    
  228. 
    
  229.   test(
    
  230.     'updating the root at lower priority than initial hydration does not ' +
    
  231.       'force a client render',
    
  232.     async () => {
    
  233.       function App() {
    
  234.         return <Text text="Initial" />;
    
  235.       }
    
  236. 
    
  237.       // Server render
    
  238.       await resolveText('Initial');
    
  239.       await serverAct(async () => {
    
  240.         const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
    
  241.         pipe(writable);
    
  242.       });
    
  243.       assertLog(['Initial']);
    
  244. 
    
  245.       await clientAct(async () => {
    
  246.         const root = ReactDOMClient.hydrateRoot(container, <App />);
    
  247.         // This has lower priority than the initial hydration, so the update
    
  248.         // won't be processed until after hydration finishes.
    
  249.         startTransition(() => {
    
  250.           root.render(<Text text="Updated" />);
    
  251.         });
    
  252.       });
    
  253.       assertLog(['Initial', 'Updated']);
    
  254.       expect(container.textContent).toBe('Updated');
    
  255.     },
    
  256.   );
    
  257. 
    
  258.   test('updating the root while the shell is suspended forces a client render', async () => {
    
  259.     function App() {
    
  260.       return <AsyncText text="Shell" />;
    
  261.     }
    
  262. 
    
  263.     // Server render
    
  264.     await resolveText('Shell');
    
  265.     await serverAct(async () => {
    
  266.       const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
    
  267.       pipe(writable);
    
  268.     });
    
  269.     assertLog(['Shell']);
    
  270. 
    
  271.     // Clear the cache and start rendering on the client
    
  272.     resetTextCache();
    
  273. 
    
  274.     // Hydration suspends because the data for the shell hasn't loaded yet
    
  275.     const root = await clientAct(async () => {
    
  276.       return ReactDOMClient.hydrateRoot(container, <App />, {
    
  277.         onRecoverableError(error) {
    
  278.           Scheduler.log(error.message);
    
  279.         },
    
  280.       });
    
  281.     });
    
  282.     assertLog(['Suspend! [Shell]']);
    
  283.     expect(container.textContent).toBe('Shell');
    
  284. 
    
  285.     await clientAct(async () => {
    
  286.       root.render(<Text text="New screen" />);
    
  287.     });
    
  288.     assertLog([
    
  289.       'New screen',
    
  290.       'This root received an early update, before anything was able ' +
    
  291.         'hydrate. Switched the entire root to client rendering.',
    
  292.     ]);
    
  293.     expect(container.textContent).toBe('New screen');
    
  294.   });
    
  295. 
    
  296.   test('TODO: A large component stack causes SSR to stack overflow', async () => {
    
  297.     spyOnDevAndProd(console, 'error').mockImplementation(() => {});
    
  298. 
    
  299.     function NestedComponent({depth}: {depth: number}) {
    
  300.       if (depth <= 0) {
    
  301.         return <AsyncText text="Shell" />;
    
  302.       }
    
  303.       return <NestedComponent depth={depth - 1} />;
    
  304.     }
    
  305. 
    
  306.     // Server render
    
  307.     await serverAct(async () => {
    
  308.       ReactDOMFizzServer.renderToPipeableStream(
    
  309.         <NestedComponent depth={3000} />,
    
  310.       );
    
  311.     });
    
  312.     expect(console.error).toHaveBeenCalledTimes(1);
    
  313.     expect(console.error.mock.calls[0][0].toString()).toBe(
    
  314.       'RangeError: Maximum call stack size exceeded',
    
  315.     );
    
  316.   });
    
  317. });