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. 'use strict';
    
  12. 
    
  13. let JSDOM;
    
  14. let Stream;
    
  15. let React;
    
  16. let ReactDOMClient;
    
  17. let ReactDOMFizzStatic;
    
  18. let Suspense;
    
  19. let textCache;
    
  20. let document;
    
  21. let writable;
    
  22. let container;
    
  23. let buffer = '';
    
  24. let hasErrored = false;
    
  25. let fatalError = undefined;
    
  26. 
    
  27. describe('ReactDOMFizzStatic', () => {
    
  28.   beforeEach(() => {
    
  29.     jest.resetModules();
    
  30.     JSDOM = require('jsdom').JSDOM;
    
  31.     React = require('react');
    
  32.     ReactDOMClient = require('react-dom/client');
    
  33.     if (__EXPERIMENTAL__) {
    
  34.       ReactDOMFizzStatic = require('react-dom/static');
    
  35.     }
    
  36.     Stream = require('stream');
    
  37.     Suspense = React.Suspense;
    
  38. 
    
  39.     textCache = new Map();
    
  40. 
    
  41.     // Test Environment
    
  42.     const jsdom = new JSDOM(
    
  43.       '<!DOCTYPE html><html><head></head><body><div id="container">',
    
  44.       {
    
  45.         runScripts: 'dangerously',
    
  46.       },
    
  47.     );
    
  48.     document = jsdom.window.document;
    
  49.     container = document.getElementById('container');
    
  50. 
    
  51.     buffer = '';
    
  52.     hasErrored = false;
    
  53. 
    
  54.     writable = new Stream.PassThrough();
    
  55.     writable.setEncoding('utf8');
    
  56.     writable.on('data', chunk => {
    
  57.       buffer += chunk;
    
  58.     });
    
  59.     writable.on('error', error => {
    
  60.       hasErrored = true;
    
  61.       fatalError = error;
    
  62.     });
    
  63.   });
    
  64. 
    
  65.   async function act(callback) {
    
  66.     await callback();
    
  67.     // Await one turn around the event loop.
    
  68.     // This assumes that we'll flush everything we have so far.
    
  69.     await new Promise(resolve => {
    
  70.       setImmediate(resolve);
    
  71.     });
    
  72.     if (hasErrored) {
    
  73.       throw fatalError;
    
  74.     }
    
  75.     // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
    
  76.     // We also want to execute any scripts that are embedded.
    
  77.     // We assume that we have now received a proper fragment of HTML.
    
  78.     const bufferedContent = buffer;
    
  79.     buffer = '';
    
  80.     const fakeBody = document.createElement('body');
    
  81.     fakeBody.innerHTML = bufferedContent;
    
  82.     while (fakeBody.firstChild) {
    
  83.       const node = fakeBody.firstChild;
    
  84.       if (node.nodeName === 'SCRIPT') {
    
  85.         const script = document.createElement('script');
    
  86.         script.textContent = node.textContent;
    
  87.         for (let i = 0; i < node.attributes.length; i++) {
    
  88.           const attribute = node.attributes[i];
    
  89.           script.setAttribute(attribute.name, attribute.value);
    
  90.         }
    
  91.         fakeBody.removeChild(node);
    
  92.         container.appendChild(script);
    
  93.       } else {
    
  94.         container.appendChild(node);
    
  95.       }
    
  96.     }
    
  97.   }
    
  98. 
    
  99.   function getVisibleChildren(element) {
    
  100.     const children = [];
    
  101.     let node = element.firstChild;
    
  102.     while (node) {
    
  103.       if (node.nodeType === 1) {
    
  104.         if (
    
  105.           (node.tagName !== 'SCRIPT' || node.hasAttribute('type')) &&
    
  106.           node.tagName !== 'TEMPLATE' &&
    
  107.           node.tagName !== 'template' &&
    
  108.           !node.hasAttribute('hidden') &&
    
  109.           !node.hasAttribute('aria-hidden')
    
  110.         ) {
    
  111.           const props = {};
    
  112.           const attributes = node.attributes;
    
  113.           for (let i = 0; i < attributes.length; i++) {
    
  114.             if (
    
  115.               attributes[i].name === 'id' &&
    
  116.               attributes[i].value.includes(':')
    
  117.             ) {
    
  118.               // We assume this is a React added ID that's a non-visual implementation detail.
    
  119.               continue;
    
  120.             }
    
  121.             props[attributes[i].name] = attributes[i].value;
    
  122.           }
    
  123.           props.children = getVisibleChildren(node);
    
  124.           children.push(React.createElement(node.tagName.toLowerCase(), props));
    
  125.         }
    
  126.       } else if (node.nodeType === 3) {
    
  127.         children.push(node.data);
    
  128.       }
    
  129.       node = node.nextSibling;
    
  130.     }
    
  131.     return children.length === 0
    
  132.       ? undefined
    
  133.       : children.length === 1
    
  134.       ? children[0]
    
  135.       : children;
    
  136.   }
    
  137. 
    
  138.   function resolveText(text) {
    
  139.     const record = textCache.get(text);
    
  140.     if (record === undefined) {
    
  141.       const newRecord = {
    
  142.         status: 'resolved',
    
  143.         value: text,
    
  144.       };
    
  145.       textCache.set(text, newRecord);
    
  146.     } else if (record.status === 'pending') {
    
  147.       const thenable = record.value;
    
  148.       record.status = 'resolved';
    
  149.       record.value = text;
    
  150.       thenable.pings.forEach(t => t());
    
  151.     }
    
  152.   }
    
  153. 
    
  154.   /*
    
  155.   function rejectText(text, error) {
    
  156.     const record = textCache.get(text);
    
  157.     if (record === undefined) {
    
  158.       const newRecord = {
    
  159.         status: 'rejected',
    
  160.         value: error,
    
  161.       };
    
  162.       textCache.set(text, newRecord);
    
  163.     } else if (record.status === 'pending') {
    
  164.       const thenable = record.value;
    
  165.       record.status = 'rejected';
    
  166.       record.value = error;
    
  167.       thenable.pings.forEach(t => t());
    
  168.     }
    
  169.   }
    
  170.   */
    
  171. 
    
  172.   function readText(text) {
    
  173.     const record = textCache.get(text);
    
  174.     if (record !== undefined) {
    
  175.       switch (record.status) {
    
  176.         case 'pending':
    
  177.           throw record.value;
    
  178.         case 'rejected':
    
  179.           throw record.value;
    
  180.         case 'resolved':
    
  181.           return record.value;
    
  182.       }
    
  183.     } else {
    
  184.       const thenable = {
    
  185.         pings: [],
    
  186.         then(resolve) {
    
  187.           if (newRecord.status === 'pending') {
    
  188.             thenable.pings.push(resolve);
    
  189.           } else {
    
  190.             Promise.resolve().then(() => resolve(newRecord.value));
    
  191.           }
    
  192.         },
    
  193.       };
    
  194. 
    
  195.       const newRecord = {
    
  196.         status: 'pending',
    
  197.         value: thenable,
    
  198.       };
    
  199.       textCache.set(text, newRecord);
    
  200. 
    
  201.       throw thenable;
    
  202.     }
    
  203.   }
    
  204. 
    
  205.   function Text({text}) {
    
  206.     return text;
    
  207.   }
    
  208. 
    
  209.   function AsyncText({text}) {
    
  210.     return readText(text);
    
  211.   }
    
  212. 
    
  213.   // @gate experimental
    
  214.   it('should render a fully static document, send it and then hydrate it', async () => {
    
  215.     function App() {
    
  216.       return (
    
  217.         <div>
    
  218.           <Suspense fallback={<Text text="Loading..." />}>
    
  219.             <AsyncText text="Hello" />
    
  220.           </Suspense>
    
  221.         </div>
    
  222.       );
    
  223.     }
    
  224. 
    
  225.     const promise = ReactDOMFizzStatic.prerenderToNodeStream(<App />);
    
  226. 
    
  227.     resolveText('Hello');
    
  228. 
    
  229.     const result = await promise;
    
  230. 
    
  231.     expect(result.postponed).toBe(null);
    
  232. 
    
  233.     await act(async () => {
    
  234.       result.prelude.pipe(writable);
    
  235.     });
    
  236.     expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
    
  237. 
    
  238.     await act(async () => {
    
  239.       ReactDOMClient.hydrateRoot(container, <App />);
    
  240.     });
    
  241. 
    
  242.     expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
    
  243.   });
    
  244. 
    
  245.   // @gate experimental
    
  246.   it('should support importMap option', async () => {
    
  247.     const importMap = {
    
  248.       foo: 'path/to/foo.js',
    
  249.     };
    
  250.     const result = await ReactDOMFizzStatic.prerenderToNodeStream(
    
  251.       <html>
    
  252.         <body>hello world</body>
    
  253.       </html>,
    
  254.       {importMap},
    
  255.     );
    
  256. 
    
  257.     await act(async () => {
    
  258.       result.prelude.pipe(writable);
    
  259.     });
    
  260.     expect(getVisibleChildren(container)).toEqual([
    
  261.       <script type="importmap">{JSON.stringify(importMap)}</script>,
    
  262.       'hello world',
    
  263.     ]);
    
  264.   });
    
  265. });