Repository: facebook/react
Pull request: [Tests][Fizz] Better HTML parsing behavior for Fizz tests

In anticipation of making Fiber use the document global for dispatching Float methods that arrive from Flight I needed to update some tests that commonly recreated the JSDOM instance after importing react.

This change updates a few tests to only create JSDOM once per test, before importing react-dom/client.

Additionally the current act implementation for server streaming did not adequately model streaming semantics so I rewrite the act implementation in a way that better mirrors how a browser would parse incoming HTML.

The new act implementation does the following

1. the first time it processes meaningful streamed content it figures out whether it is rendering into the existing document container or if it needs to reset the document. this is based on whether the streamed content contains tags `<html>` or `<body>` etc...
2. Once the streaming container is set it will typically continue to stream into that container for future calls to act. The exception is if the streaming container is the `<head>` in which case it will switch to streaming into the body once it receives a `<body>` tag.

This means for tests that render something like a `<div>...</div>` it will naturally stream into the default `<div id="container">...` and for tests that render a full document the HTML will parse like a real browser would (with some very minor edge case differences)

I also refactored the way we move nodes from buffered content into the document and execute any scripts we find. Previously we were using window.eval and I switched this to just setting the external script content as script text. Additionally the nonce logic is reworked to be a bit simpler.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -10,7 +10,7 @@
 
 'use strict';
 import {
-  replaceScriptsAndMove,
+  insertNodesAndExecuteScripts,
   mergeOptions,
   stripExternalRuntimeInNodes,
   withLoadingReadyState,
@@ -29,8 +29,6 @@ let useSyncExternalStoreWithSelector;
 let use;
 let PropTypes;
 let textCache;
-let window;
-let document;
 let writable;
 let CSPnonce = null;
 let container;
@@ -43,20 +41,32 @@ let waitForAll;
 let assertLog;
 let waitForPaint;
 let clientAct;
-
-function resetJSDOM(markup) {
-  // Test Environment
-  const jsdom = new JSDOM(markup, {
-    runScripts: 'dangerously',
-  });
-  window = jsdom.window;
-  document = jsdom.window.document;
-}
+let streamingContainer;
 
 describe('ReactDOMFizzServer', () => {
   beforeEach(() => {
     jest.resetModules();
     JSDOM = require('jsdom').JSDOM;
+
+    const jsdom = new JSDOM(
+      '<!DOCTYPE html><html><head></head><body><div id="container">',
+      {
+        runScripts: 'dangerously',
+      },
+    );
+    // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else
+    Object.defineProperty(jsdom.window, 'matchMedia', {
+      writable: true,
+      value: jest.fn().mockImplementation(query => ({
+        matches: query === 'all' || query === '',
+        media: query,
+      })),
+    });
+    streamingContainer = null;
+    global.window = jsdom.window;
+    global.document = jsdom.window.document;
+    container = document.getElementById('container');
+
     Scheduler = require('scheduler');
     React = require('react');
     ReactDOMClient = require('react-dom/client');
@@ -93,9 +103,6 @@ describe('ReactDOMFizzServer', () => {
 
     textCache = new Map();
 
-    resetJSDOM('<!DOCTYPE html><html><head></head><body><div id="container">');
-    container = document.getElementById('container');
-
     buffer = '';
     hasErrored = false;
 
@@ -140,6 +147,9 @@ describe('ReactDOMFizzServer', () => {
       .join('');
   }
 
+  const bodyStartMatch = /<body(?:>| .*?>)/;
+  const headStartMatch = /<head(?:>| .*?>)/;
+
   async function act(callback) {
     await callback();
     // Await one turn around the event loop.
@@ -153,40 +163,123 @@ describe('ReactDOMFizzServer', () => {
     // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
     // We also want to execute any scripts that are embedded.
     // We assume that we have now received a proper fragment of HTML.
-    const bufferedContent = buffer;
+    let bufferedContent = buffer;
     buffer = '';
-    const fakeBody = document.createElement('body');
-    fakeBody.innerHTML = bufferedContent;
-    const parent =
-      container.nodeName === '#document' ? container.body : container;
 
-    await withLoadingReadyState(async () => {
-      while (fakeBody.firstChild) {
-        const node = fakeBody.firstChild;
-        await replaceScriptsAndMove(window, CSPnonce, node, parent);
-      }
-    }, document);
-  }
-
-  async function actIntoEmptyDocument(callback) {
-    await callback();
-    // Await one turn around the event loop.
-    // This assumes that we'll flush everything we have so far.
-    await new Promise(resolve => {
-      setImmediate(resolve);
-    });
-    if (hasErrored) {
-      throw fatalError;
+    if (!bufferedContent) {
+      return;
     }
-    // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
-    // We also want to execute any scripts that are embedded.
-    // We assume that we have now received a proper fragment of HTML.
-    const bufferedContent = buffer;
-    resetJSDOM(bufferedContent);
-    container = document;
-    buffer = '';
+
     await withLoadingReadyState(async () => {
-      await replaceScriptsAndMove(window, CSPnonce, document.documentElement);
+      const bodyMatch = bufferedContent.match(bodyStartMatch);
+      const headMatch = bufferedContent.match(headStartMatch);
+
+      if (streamingContainer === null) {
+        // This is the first streamed content. We decide here where to insert it. If we get <html>, <head>, or <body>
+        // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
+        // container. This is not really production behavior because you can't correctly stream into a deep div effectively
+        // but it's pragmatic for tests.
+
+        if (
+          bufferedContent.startsWith('<head>') ||
+          bufferedContent.startsWith('<head ') ||
+          bufferedContent.startsWith('<body>') ||
+          bufferedContent.startsWith('<body ')
+        ) {
+          // wrap in doctype to normalize the parsing process
+          bufferedContent = '<!DOCTYPE html><html>' + bufferedContent;
+        } else if (
+          bufferedContent.startsWith('<html>') ||
+          bufferedContent.startsWith('<html ')
+        ) {
+          throw new Error(
+            'Recieved <html> without a <!DOCTYPE html> which is almost certainly a bug in React',
+          );
+        }
+
+        if (bufferedContent.startsWith('<!DOCTYPE html>')) {
+          // we can just use the whole document
+          const tempDom = new JSDOM(bufferedContent);
+
+          // Wipe existing head and body content
+          document.head.innerHTML = '';
+          document.body.innerHTML = '';
+
+          // Copy the <html> attributes over
+          const tempHtmlNode = tempDom.window.document.documentElement;
+          for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
+            const attr = tempHtmlNode.attributes[i];
+            document.documentElement.setAttribute(attr.name, attr.value);
+          }
+
+          if (headMatch) {
+            // We parsed a head open tag. we need to copy head attributes and insert future
+            // content into <head>
+            streamingContainer = document.head;
+            const tempHeadNode = tempDom.window.document.head;
+            for (let i = 0; i < tempHeadNode.attributes.length; i++) {
+              const attr = tempHeadNode.attributes[i];
+              document.head.setAttribute(attr.name, attr.value);
+            }
+            const source = document.createElement('head');
+            source.innerHTML = tempHeadNode.innerHTML;
+            await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
+          }
+
+          if (bodyMatch) {
+            // We parsed a body open tag. we need to copy head attributes and insert future
+            // content into <body>
+            streamingContainer = document.body;
+            const tempBodyNode = tempDom.window.document.body;
+            for (let i = 0; i < tempBodyNode.attributes.length; i++) {
+              const attr = tempBodyNode.attributes[i];
+              document.body.setAttribute(attr.name, attr.value);
+            }
+            const source = document.createElement('body');
+            source.innerHTML = tempBodyNode.innerHTML;
+            await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
+          }
+
+          if (!headMatch && !bodyMatch) {
+            throw new Error('expected <head> or <body> after <html>');
+          }
+        } else {
+          // we assume we are streaming into the default container'
+          streamingContainer = container;
+          const div = document.createElement('div');
+          div.innerHTML = bufferedContent;
+          await insertNodesAndExecuteScripts(div, container, CSPnonce);
+        }
+      } else if (streamingContainer === document.head) {
+        bufferedContent = '<!DOCTYPE html><html><head>' + bufferedContent;
+        const tempDom = new JSDOM(bufferedContent);
+
+        const tempHeadNode = tempDom.window.document.head;
+        const source = document.createElement('head');
+        source.innerHTML = tempHeadNode.innerHTML;
+        await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
+
+        if (bodyMatch) {
+          streamingContainer = document.body;
+
+          const tempBodyNode = tempDom.window.document.body;
+          for (let i = 0; i < tempBodyNode.attributes.length; i++) {
+            const attr = tempBodyNode.attributes[i];
+            document.body.setAttribute(attr.name, attr.value);
+          }
+          const bodySource = document.createElement('body');
+          bodySource.innerHTML = tempBodyNode.innerHTML;
+          await insertNodesAndExecuteScripts(
+            bodySource,
+            document.body,
+            CSPnonce,
+          );
+        }
+      } else {
+        const div = document.createElement('div');
+        div.innerHTML = bufferedContent;
+        await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
+      }
     }, document);
   }
 
@@ -3467,7 +3560,7 @@ describe('ReactDOMFizzServer', () => {
   });
 
   it('accepts an integrity property for bootstrapScripts and bootstrapModules', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -3584,7 +3677,7 @@ describe('ReactDOMFizzServer', () => {
 
   // @gate enableFizzExternalRuntime
   it('supports option to load runtime as an external script', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -3631,7 +3724,7 @@ describe('ReactDOMFizzServer', () => {
         </div>
       );
     }
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(<App />);
       pipe(writable);
     });
@@ -3644,7 +3737,7 @@ describe('ReactDOMFizzServer', () => {
   });
 
   it('does not send the external runtime for static pages', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -4446,7 +4539,7 @@ describe('ReactDOMFizzServer', () => {
         </body>
       );
     }
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html data-html="html">
           <AsyncNoOutput />
@@ -4456,17 +4549,13 @@ describe('ReactDOMFizzServer', () => {
       );
       pipe(writable);
     });
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       resolveText('body');
     });
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       resolveText('nooutput');
     });
-    // We need to use actIntoEmptyDocument because act assumes that buffered
-    // content should be fake streamed into the body which is normally true
-    // but in this test the entire shell was delayed and we need the initial
-    // construction to be done to get the parsing right
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       resolveText('head');
     });
     expect(getVisibleChildren(document)).toEqual(
@@ -4487,7 +4576,7 @@ describe('ReactDOMFizzServer', () => {
       chunks.push(chunk);
     });
 
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -4953,23 +5042,21 @@ describe('ReactDOMFizzServer', () => {
   });
 
   describe('title children', () => {
-    function prepareJSDOMForTitle() {
-      resetJSDOM('<!DOCTYPE html><html><head>\u0000');
-      container = document.getElementsByTagName('head')[0];
-    }
-
     it('should accept a single string child', async () => {
       // a Single string child
       function App() {
-        return <title>hello</title>;
+        return (
+          <head>
+            <title>hello</title>
+          </head>
+        );
       }
 
-      prepareJSDOMForTitle();
       await act(() => {
         const {pipe} = renderToPipeableStream(<App />);
         pipe(writable);
       });
-      expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
+      expect(getVisibleChildren(document.head)).toEqual(<title>hello</title>);
 
       const errors = [];
       ReactDOMClient.hydrateRoot(container, <App />, {
@@ -4979,21 +5066,24 @@ describe('ReactDOMFizzServer', () => {
       });
       await waitForAll([]);
       expect(errors).toEqual([]);
-      expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
+      expect(getVisibleChildren(document.head)).toEqual(<title>hello</title>);
     });
 
     it('should accept children array of length 1 containing a string', async () => {
       // a Single string child
       function App() {
-        return <title>{['hello']}</title>;
+        return (
+          <head>
+            <title>{['hello']}</title>
+          </head>
+        );
       }
 
-      prepareJSDOMForTitle();
       await act(() => {
         const {pipe} = renderToPipeableStream(<App />);
         pipe(writable);
       });
-      expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
+      expect(getVisibleChildren(document.head)).toEqual(<title>hello</title>);
 
       const errors = [];
       ReactDOMClient.hydrateRoot(container, <App />, {
@@ -5003,16 +5093,18 @@ describe('ReactDOMFizzServer', () => {
       });
       await waitForAll([]);
       expect(errors).toEqual([]);
-      expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
+      expect(getVisibleChildren(document.head)).toEqual(<title>hello</title>);
     });
 
     it('should warn in dev when given an array of length 2 or more', async () => {
       function App() {
-        return <title>{['hello1', 'hello2']}</title>;
+        return (
+          <head>
+            <title>{['hello1', 'hello2']}</title>
+          </head>
+        );
       }
 
-      prepareJSDOMForTitle();
-
       await expect(async () => {
         await act(() => {
           const {pipe} = renderToPipeableStream(<App />);
@@ -5023,15 +5115,15 @@ describe('ReactDOMFizzServer', () => {
       ]);
 
       if (gate(flags => flags.enableFloat)) {
-        expect(getVisibleChildren(container)).toEqual(<title />);
+        expect(getVisibleChildren(document.head)).toEqual(<title />);
       } else {
-        expect(getVisibleChildren(container)).toEqual(
+        expect(getVisibleChildren(document.head)).toEqual(
           <title>{'hello1<!-- -->hello2'}</title>,
         );
       }
 
       const errors = [];
-      ReactDOMClient.hydrateRoot(container, <App />, {
+      ReactDOMClient.hydrateRoot(document.head, <App />, {
         onRecoverableError(error) {
           errors.push(error.message);
         },
@@ -5040,7 +5132,7 @@ describe('ReactDOMFizzServer', () => {
       if (gate(flags => flags.enableFloat)) {
         expect(errors).toEqual([]);
         // with float, the title doesn't render on the client or on the server
-        expect(getVisibleChildren(container)).toEqual(<title />);
+        expect(getVisibleChildren(document.head)).toEqual(<title />);
       } else {
         expect(errors).toEqual(
           [
@@ -5051,7 +5143,7 @@ describe('ReactDOMFizzServer', () => {
             'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
           ].filter(Boolean),
         );
-        expect(getVisibleChildren(container)).toEqual(
+        expect(getVisibleChildren(document.head)).toEqual(
           <title>{['hello1', 'hello2']}</title>,
         );
       }
@@ -5064,16 +5156,14 @@ describe('ReactDOMFizzServer', () => {
 
       function App() {
         return (
-          <>
+          <head>
             <title>
               <IndirectTitle />
             </title>
-          </>
+          </head>
         );
       }
 
-      prepareJSDOMForTitle();
-
       if (gate(flags => flags.enableFloat)) {
         await expect(async () => {
           await act(() => {
@@ -5096,15 +5186,15 @@ describe('ReactDOMFizzServer', () => {
 
       if (gate(flags => flags.enableFloat)) {
         // object titles are toStringed when float is on
-        expect(getVisibleChildren(container)).toEqual(
+        expect(getVisibleChildren(document.head)).toEqual(
           <title>{'[object Object]'}</title>,
         );
       } else {
-        expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
+        expect(getVisibleChildren(document.head)).toEqual(<title>hello</title>);
       }
 
       const errors = [];
-      ReactDOMClient.hydrateRoot(container, <App />, {
+      ReactDOMClient.hydrateRoot(document.head, <App />, {
         onRecoverableError(error) {
           errors.push(error.message);
         },
@@ -5113,344 +5203,341 @@ describe('ReactDOMFizzServer', () => {
       expect(errors).toEqual([]);
       if (gate(flags => flags.enableFloat)) {
         // object titles are toStringed when float is on
-        expect(getVisibleChildren(container)).toEqual(
+        expect(getVisibleChildren(document.head)).toEqual(
           <title>{'[object Object]'}</title>,
         );
       } else {
-        expect(getVisibleChildren(container)).toEqual(<title>hello</title>);
+        expect(getVisibleChildren(document.head)).toEqual(<title>hello</title>);
       }
     });
+  });
 
-    // @gate enableUseHook
-    it('basic use(promise)', async () => {
-      const promiseA = Promise.resolve('A');
-      const promiseB = Promise.resolve('B');
-      const promiseC = Promise.resolve('C');
+  // @gate enableUseHook
+  it('basic use(promise)', async () => {
+    const promiseA = Promise.resolve('A');
+    const promiseB = Promise.resolve('B');
+    const promiseC = Promise.resolve('C');
 
-      function Async() {
-        return use(promiseA) + use(promiseB) + use(promiseC);
-      }
+    function Async() {
+      return use(promiseA) + use(promiseB) + use(promiseC);
+    }
 
-      function App() {
-        return (
-          <Suspense fallback="Loading...">
-            <Async />
-          </Suspense>
-        );
-      }
+    function App() {
+      return (
+        <Suspense fallback="Loading...">
+          <Async />
+        </Suspense>
+      );
+    }
 
-      await act(() => {
-        const {pipe} = renderToPipeableStream(<App />);
-        pipe(writable);
-      });
+    await act(() => {
+      const {pipe} = renderToPipeableStream(<App />);
+      pipe(writable);
+    });
 
-      // TODO: The `act` implementation in this file doesn't unwrap microtasks
-      // automatically. We can't use the same `act` we use for Fiber tests
-      // because that relies on the mock Scheduler. Doesn't affect any public
-      // API but we might want to fix this for our own internal tests.
-      //
-      // For now, wait for each promise in sequence.
-      await act(async () => {
-        await promiseA;
-      });
-      await act(async () => {
-        await promiseB;
-      });
-      await act(async () => {
-        await promiseC;
-      });
+    // TODO: The `act` implementation in this file doesn't unwrap microtasks
+    // automatically. We can't use the same `act` we use for Fiber tests
+    // because that relies on the mock Scheduler. Doesn't affect any public
+    // API but we might want to fix this for our own internal tests.
+    //
+    // For now, wait for each promise in sequence.
+    await act(async () => {
+      await promiseA;
+    });
+    await act(async () => {
+      await promiseB;
+    });
+    await act(async () => {
+      await promiseC;
+    });
 
-      expect(getVisibleChildren(container)).toEqual('ABC');
+    expect(getVisibleChildren(container)).toEqual('ABC');
 
-      ReactDOMClient.hydrateRoot(container, <App />);
-      await waitForAll([]);
-      expect(getVisibleChildren(container)).toEqual('ABC');
-    });
+    ReactDOMClient.hydrateRoot(container, <App />);
+    await waitForAll([]);
+    expect(getVisibleChildren(container)).toEqual('ABC');
+  });
 
-    // @gate enableUseHook
-    it('basic use(context)', async () => {
-      const ContextA = React.createContext('default');
-      const ContextB = React.createContext('B');
-      const ServerContext = React.createServerContext(
-        'ServerContext',
-        'default',
+  // @gate enableUseHook
+  it('basic use(context)', async () => {
+    const ContextA = React.createContext('default');
+    const ContextB = React.createContext('B');
+    const ServerContext = React.createServerContext('ServerContext', 'default');
+    function Client() {
+      return use(ContextA) + use(ContextB);
+    }
+    function ServerComponent() {
+      return use(ServerContext);
+    }
+    function Server() {
+      return (
+        <ServerContext.Provider value="C">
+          <ServerComponent />
+        </ServerContext.Provider>
       );
-      function Client() {
-        return use(ContextA) + use(ContextB);
-      }
-      function ServerComponent() {
-        return use(ServerContext);
-      }
-      function Server() {
-        return (
-          <ServerContext.Provider value="C">
-            <ServerComponent />
-          </ServerContext.Provider>
-        );
-      }
-      function App() {
-        return (
-          <>
-            <ContextA.Provider value="A">
-              <Client />
-            </ContextA.Provider>
-            <Server />
-          </>
-        );
-      }
-
-      await act(() => {
-        const {pipe} = renderToPipeableStream(<App />);
-        pipe(writable);
-      });
-      expect(getVisibleChildren(container)).toEqual(['AB', 'C']);
+    }
+    function App() {
+      return (
+        <>
+          <ContextA.Provider value="A">
+            <Client />
+          </ContextA.Provider>
+          <Server />
+        </>
+      );
+    }
 
-      // Hydration uses a different renderer runtime (Fiber instead of Fizz).
-      // We reset _currentRenderer here to not trigger a warning about multiple
-      // renderers concurrently using these contexts
-      ContextA._currentRenderer = null;
-      ServerContext._currentRenderer = null;
-      ReactDOMClient.hydrateRoot(container, <App />);
-      await waitForAll([]);
-      expect(getVisibleChildren(container)).toEqual(['AB', 'C']);
+    await act(() => {
+      const {pipe} = renderToPipeableStream(<App />);
+      pipe(writable);
     });
+    expect(getVisibleChildren(container)).toEqual(['AB', 'C']);
 
-    // @gate enableUseHook
-    it('use(promise) in multiple components', async () => {
-      const promiseA = Promise.resolve('A');
-      const promiseB = Promise.resolve('B');
-      const promiseC = Promise.resolve('C');
-      const promiseD = Promise.resolve('D');
-
-      function Child({prefix}) {
-        return prefix + use(promiseC) + use(promiseD);
-      }
+    // Hydration uses a different renderer runtime (Fiber instead of Fizz).
+    // We reset _currentRenderer here to not trigger a warning about multiple
+    // renderers concurrently using these contexts
+    ContextA._currentRenderer = null;
+    ServerContext._currentRenderer = null;
+    ReactDOMClient.hydrateRoot(container, <App />);
+    await waitForAll([]);
+    expect(getVisibleChildren(container)).toEqual(['AB', 'C']);
+  });
 
-      function Parent() {
-        return <Child prefix={use(promiseA) + use(promiseB)} />;
-      }
+  // @gate enableUseHook
+  it('use(promise) in multiple components', async () => {
+    const promiseA = Promise.resolve('A');
+    const promiseB = Promise.resolve('B');
+    const promiseC = Promise.resolve('C');
+    const promiseD = Promise.resolve('D');
 
-      function App() {
-        return (
-          <Suspense fallback="Loading...">
-            <Parent />
-          </Suspense>
-        );
-      }
+    function Child({prefix}) {
+      return prefix + use(promiseC) + use(promiseD);
+    }
 
-      await act(() => {
-        const {pipe} = renderToPipeableStream(<App />);
-        pipe(writable);
-      });
+    function Parent() {
+      return <Child prefix={use(promiseA) + use(promiseB)} />;
+    }
 
-      // TODO: The `act` implementation in this file doesn't unwrap microtasks
-      // automatically. We can't use the same `act` we use for Fiber tests
-      // because that relies on the mock Scheduler. Doesn't affect any public
-      // API but we might want to fix this for our own internal tests.
-      //
-      // For now, wait for each promise in sequence.
-      await act(async () => {
-        await promiseA;
-      });
-      await act(async () => {
-        await promiseB;
-      });
-      await act(async () => {
-        await promiseC;
-      });
-      await act(async () => {
-        await promiseD;
-      });
+    function App() {
+      return (
+        <Suspense fallback="Loading...">
+          <Parent />
+        </Suspense>
+      );
+    }
 
-      expect(getVisibleChildren(container)).toEqual('ABCD');
+    await act(() => {
+      const {pipe} = renderToPipeableStream(<App />);
+      pipe(writable);
+    });
 
-      ReactDOMClient.hydrateRoot(container, <App />);
-      await waitForAll([]);
-      expect(getVisibleChildren(container)).toEqual('ABCD');
+    // TODO: The `act` implementation in this file doesn't unwrap microtasks
+    // automatically. We can't use the same `act` we use for Fiber tests
+    // because that relies on the mock Scheduler. Doesn't affect any public
+    // API but we might want to fix this for our own internal tests.
+    //
+    // For now, wait for each promise in sequence.
+    await act(async () => {
+      await promiseA;
+    });
+    await act(async () => {
+      await promiseB;
+    });
+    await act(async () => {
+      await promiseC;
+    });
+    await act(async () => {
+      await promiseD;
     });
 
-    // @gate enableUseHook
-    it('using a rejected promise will throw', async () => {
-      const promiseA = Promise.resolve('A');
-      const promiseB = Promise.reject(new Error('Oops!'));
-      const promiseC = Promise.resolve('C');
+    expect(getVisibleChildren(container)).toEqual('ABCD');
 
-      // Jest/Node will raise an unhandled rejected error unless we await this. It
-      // works fine in the browser, though.
-      await expect(promiseB).rejects.toThrow('Oops!');
+    ReactDOMClient.hydrateRoot(container, <App />);
+    await waitForAll([]);
+    expect(getVisibleChildren(container)).toEqual('ABCD');
+  });
 
-      function Async() {
-        return use(promiseA) + use(promiseB) + use(promiseC);
-      }
+  // @gate enableUseHook
+  it('using a rejected promise will throw', async () => {
+    const promiseA = Promise.resolve('A');
+    const promiseB = Promise.reject(new Error('Oops!'));
+    const promiseC = Promise.resolve('C');
 
-      class ErrorBoundary extends React.Component {
-        state = {error: null};
-        static getDerivedStateFromError(error) {
-          return {error};
-        }
-        render() {
-          if (this.state.error) {
-            return this.state.error.message;
-          }
-          return this.props.children;
+    // Jest/Node will raise an unhandled rejected error unless we await this. It
+    // works fine in the browser, though.
+    await expect(promiseB).rejects.toThrow('Oops!');
+
+    function Async() {
+      return use(promiseA) + use(promiseB) + use(promiseC);
+    }
+
+    class ErrorBoundary extends React.Component {
+      state = {error: null};
+      static getDerivedStateFromError(error) {
+        return {error};
+      }
+      render() {
+        if (this.state.error) {
+          return this.state.error.message;
         }
+        return this.props.children;
       }
+    }
 
-      function App() {
-        return (
-          <Suspense fallback="Loading...">
-            <ErrorBoundary>
-              <Async />
-            </ErrorBoundary>
-          </Suspense>
-        );
-      }
+    function App() {
+      return (
+        <Suspense fallback="Loading...">
+          <ErrorBoundary>
+            <Async />
+          </ErrorBoundary>
+        </Suspense>
+      );
+    }
 
-      const reportedServerErrors = [];
-      await act(() => {
-        const {pipe} = renderToPipeableStream(<App />, {
-          onError(error) {
-            reportedServerErrors.push(error);
-          },
-        });
-        pipe(writable);
+    const reportedServerErrors = [];
+    await act(() => {
+      const {pipe} = renderToPipeableStream(<App />, {
+        onError(error) {
+          reportedServerErrors.push(error);
+        },
       });
+      pipe(writable);
+    });
 
-      // TODO: The `act` implementation in this file doesn't unwrap microtasks
-      // automatically. We can't use the same `act` we use for Fiber tests
-      // because that relies on the mock Scheduler. Doesn't affect any public
-      // API but we might want to fix this for our own internal tests.
-      //
-      // For now, wait for each promise in sequence.
-      await act(async () => {
-        await promiseA;
-      });
-      await act(async () => {
-        await expect(promiseB).rejects.toThrow('Oops!');
-      });
-      await act(async () => {
-        await promiseC;
-      });
+    // TODO: The `act` implementation in this file doesn't unwrap microtasks
+    // automatically. We can't use the same `act` we use for Fiber tests
+    // because that relies on the mock Scheduler. Doesn't affect any public
+    // API but we might want to fix this for our own internal tests.
+    //
+    // For now, wait for each promise in sequence.
+    await act(async () => {
+      await promiseA;
+    });
+    await act(async () => {
+      await expect(promiseB).rejects.toThrow('Oops!');
+    });
+    await act(async () => {
+      await promiseC;
+    });
 
-      expect(getVisibleChildren(container)).toEqual('Loading...');
-      expect(reportedServerErrors.length).toBe(1);
-      expect(reportedServerErrors[0].message).toBe('Oops!');
+    expect(getVisibleChildren(container)).toEqual('Loading...');
+    expect(reportedServerErrors.length).toBe(1);
+    expect(reportedServerErrors[0].message).toBe('Oops!');
 
-      const reportedClientErrors = [];
-      ReactDOMClient.hydrateRoot(container, <App />, {
-        onRecoverableError(error) {
-          reportedClientErrors.push(error);
-        },
-      });
-      await waitForAll([]);
-      expect(getVisibleChildren(container)).toEqual('Oops!');
-      expect(reportedClientErrors.length).toBe(1);
-      if (__DEV__) {
-        expect(reportedClientErrors[0].message).toBe('Oops!');
-      } else {
-        expect(reportedClientErrors[0].message).toBe(
-          'The server could not finish this Suspense boundary, likely due to ' +
-            'an error during server rendering. Switched to client rendering.',
-        );
-      }
+    const reportedClientErrors = [];
+    ReactDOMClient.hydrateRoot(container, <App />, {
+      onRecoverableError(error) {
+        reportedClientErrors.push(error);
+      },
     });
+    await waitForAll([]);
+    expect(getVisibleChildren(container)).toEqual('Oops!');
+    expect(reportedClientErrors.length).toBe(1);
+    if (__DEV__) {
+      expect(reportedClientErrors[0].message).toBe('Oops!');
+    } else {
+      expect(reportedClientErrors[0].message).toBe(
+        'The server could not finish this Suspense boundary, likely due to ' +
+          'an error during server rendering. Switched to client rendering.',
+      );
+    }
+  });
 
-    // @gate enableUseHook
-    it("use a promise that's already been instrumented and resolved", async () => {
-      const thenable = {
-        status: 'fulfilled',
-        value: 'Hi',
-        then() {},
-      };
-
-      // This will never suspend because the thenable already resolved
-      function App() {
-        return use(thenable);
-      }
+  // @gate enableUseHook
+  it("use a promise that's already been instrumented and resolved", async () => {
+    const thenable = {
+      status: 'fulfilled',
+      value: 'Hi',
+      then() {},
+    };
 
-      await act(() => {
-        const {pipe} = renderToPipeableStream(<App />);
-        pipe(writable);
-      });
-      expect(getVisibleChildren(container)).toEqual('Hi');
+    // This will never suspend because the thenable already resolved
+    function App() {
+      return use(thenable);
+    }
 
-      ReactDOMClient.hydrateRoot(container, <App />);
-      await waitForAll([]);
-      expect(getVisibleChildren(container)).toEqual('Hi');
+    await act(() => {
+      const {pipe} = renderToPipeableStream(<App />);
+      pipe(writable);
     });
+    expect(getVisibleChildren(container)).toEqual('Hi');
 
-    // @gate enableUseHook
-    it('unwraps thenable that fulfills synchronously without suspending', async () => {
-      function App() {
-        const thenable = {
-          then(resolve) {
-            // This thenable immediately resolves, synchronously, without waiting
-            // a microtask.
-            resolve('Hi');
-          },
-        };
-        try {
-          return <Text text={use(thenable)} />;
-        } catch {
-          throw new Error(
-            '`use` should not suspend because the thenable resolved synchronously.',
-          );
-        }
+    ReactDOMClient.hydrateRoot(container, <App />);
+    await waitForAll([]);
+    expect(getVisibleChildren(container)).toEqual('Hi');
+  });
+
+  // @gate enableUseHook
+  it('unwraps thenable that fulfills synchronously without suspending', async () => {
+    function App() {
+      const thenable = {
+        then(resolve) {
+          // This thenable immediately resolves, synchronously, without waiting
+          // a microtask.
+          resolve('Hi');
+        },
+      };
+      try {
+        return <Text text={use(thenable)} />;
+      } catch {
+        throw new Error(
+          '`use` should not suspend because the thenable resolved synchronously.',
+        );
       }
-      // Because the thenable resolves synchronously, we should be able to finish
-      // rendering synchronously, with no fallback.
-      await act(() => {
-        const {pipe} = renderToPipeableStream(<App />);
-        pipe(writable);
-      });
-      expect(getVisibleChildren(container)).toEqual('Hi');
+    }
+    // Because the thenable resolves synchronously, we should be able to finish
+    // rendering synchronously, with no fallback.
+    await act(() => {
+      const {pipe} = renderToPipeableStream(<App />);
+      pipe(writable);
     });
+    expect(getVisibleChildren(container)).toEqual('Hi');
+  });
 
-    it('promise as node', async () => {
-      const promise = Promise.resolve('Hi');
-      await act(async () => {
-        const {pipe} = renderToPipeableStream(promise);
-        pipe(writable);
-      });
-
-      // TODO: The `act` implementation in this file doesn't unwrap microtasks
-      // automatically. We can't use the same `act` we use for Fiber tests
-      // because that relies on the mock Scheduler. Doesn't affect any public
-      // API but we might want to fix this for our own internal tests.
-      await act(async () => {
-        await promise;
-      });
-
-      expect(getVisibleChildren(container)).toEqual('Hi');
+  it('promise as node', async () => {
+    const promise = Promise.resolve('Hi');
+    await act(async () => {
+      const {pipe} = renderToPipeableStream(promise);
+      pipe(writable);
     });
 
-    it('context as node', async () => {
-      const Context = React.createContext('Hi');
-      await act(async () => {
-        const {pipe} = renderToPipeableStream(Context);
-        pipe(writable);
-      });
-      expect(getVisibleChildren(container)).toEqual('Hi');
+    // TODO: The `act` implementation in this file doesn't unwrap microtasks
+    // automatically. We can't use the same `act` we use for Fiber tests
+    // because that relies on the mock Scheduler. Doesn't affect any public
+    // API but we might want to fix this for our own internal tests.
+    await act(async () => {
+      await promise;
     });
 
-    it('recursive Usable as node', async () => {
-      const Context = React.createContext('Hi');
-      const promiseForContext = Promise.resolve(Context);
-      await act(async () => {
-        const {pipe} = renderToPipeableStream(promiseForContext);
-        pipe(writable);
-      });
+    expect(getVisibleChildren(container)).toEqual('Hi');
+  });
 
-      // TODO: The `act` implementation in this file doesn't unwrap microtasks
-      // automatically. We can't use the same `act` we use for Fiber tests
-      // because that relies on the mock Scheduler. Doesn't affect any public
-      // API but we might want to fix this for our own internal tests.
-      await act(async () => {
-        await promiseForContext;
-      });
+  it('context as node', async () => {
+    const Context = React.createContext('Hi');
+    await act(async () => {
+      const {pipe} = renderToPipeableStream(Context);
+      pipe(writable);
+    });
+    expect(getVisibleChildren(container)).toEqual('Hi');
+  });
 
-      expect(getVisibleChildren(container)).toEqual('Hi');
+  it('recursive Usable as node', async () => {
+    const Context = React.createContext('Hi');
+    const promiseForContext = Promise.resolve(Context);
+    await act(async () => {
+      const {pipe} = renderToPipeableStream(promiseForContext);
+      pipe(writable);
     });
+
+    // TODO: The `act` implementation in this file doesn't unwrap microtasks
+    // automatically. We can't use the same `act` we use for Fiber tests
+    // because that relies on the mock Scheduler. Doesn't affect any public
+    // API but we might want to fix this for our own internal tests.
+    await act(async () => {
+      await promiseForContext;
+    });
+
+    expect(getVisibleChildren(container)).toEqual('Hi');
   });
 
   describe('useEffectEvent', () => {
@@ -5555,7 +5642,7 @@ describe('ReactDOMFizzServer', () => {
   });
 
   it('can render scripts with simple children', async () => {
-    await actIntoEmptyDocument(async () => {
+    await act(async () => {
       const {pipe} = renderToPipeableStream(
         <html>
           <body>
@@ -5583,7 +5670,7 @@ describe('ReactDOMFizzServer', () => {
     };
 
     try {
-      await actIntoEmptyDocument(async () => {
+      await act(async () => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>

filename: packages/react-dom/src/__tests__/ReactDOMFloat-test.js
patch: @@ -10,7 +10,7 @@
 
 'use strict';
 import {
-  replaceScriptsAndMove,
+  insertNodesAndExecuteScripts,
   mergeOptions,
   withLoadingReadyState,
 } from '../test-utils/FizzTestUtils';
@@ -24,8 +24,6 @@ let ReactDOMFizzServer;
 let Suspense;
 let textCache;
 let loadCache;
-let window;
-let document;
 let writable;
 const CSPnonce = null;
 let container;
@@ -38,28 +36,32 @@ let waitForThrow;
 let assertLog;
 let Scheduler;
 let clientAct;
-
-function resetJSDOM(markup) {
-  // Test Environment
-  const jsdom = new JSDOM(markup, {
-    runScripts: 'dangerously',
-  });
-  // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else
-  Object.defineProperty(jsdom.window, 'matchMedia', {
-    writable: true,
-    value: jest.fn().mockImplementation(query => ({
-      matches: query === 'all' || query === '',
-      media: query,
-    })),
-  });
-  window = jsdom.window;
-  document = jsdom.window.document;
-}
+let streamingContainer;
 
 describe('ReactDOMFloat', () => {
   beforeEach(() => {
     jest.resetModules();
     JSDOM = require('jsdom').JSDOM;
+
+    const jsdom = new JSDOM(
+      '<!DOCTYPE html><html><head></head><body><div id="container">',
+      {
+        runScripts: 'dangerously',
+      },
+    );
+    // We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else
+    Object.defineProperty(jsdom.window, 'matchMedia', {
+      writable: true,
+      value: jest.fn().mockImplementation(query => ({
+        matches: query === 'all' || query === '',
+        media: query,
+      })),
+    });
+    streamingContainer = null;
+    global.window = jsdom.window;
+    global.document = jsdom.window.document;
+    container = document.getElementById('container');
+
     React = require('react');
     ReactDOM = require('react-dom');
     ReactDOMClient = require('react-dom/client');
@@ -77,9 +79,6 @@ describe('ReactDOMFloat', () => {
     textCache = new Map();
     loadCache = new Set();
 
-    resetJSDOM('<!DOCTYPE html><html><head></head><body><div id="container">');
-    container = document.getElementById('container');
-
     buffer = '';
     hasErrored = false;
 
@@ -100,6 +99,9 @@ describe('ReactDOMFloat', () => {
     }
   });
 
+  const bodyStartMatch = /<body(?:>| .*?>)/;
+  const headStartMatch = /<head(?:>| .*?>)/;
+
   async function act(callback) {
     await callback();
     // Await one turn around the event loop.
@@ -113,44 +115,123 @@ describe('ReactDOMFloat', () => {
     // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
     // We also want to execute any scripts that are embedded.
     // We assume that we have now received a proper fragment of HTML.
-    const bufferedContent = buffer;
+    let bufferedContent = buffer;
     buffer = '';
-    const fakeBody = document.createElement('body');
-    fakeBody.innerHTML = bufferedContent;
-    const parent =
-      container.nodeName === '#document' ? container.body : container;
-    await withLoadingReadyState(async () => {
-      while (fakeBody.firstChild) {
-        const node = fakeBody.firstChild;
-        await replaceScriptsAndMove(
-          document.defaultView,
-          CSPnonce,
-          node,
-          parent,
-        );
-      }
-    }, document);
-  }
 
-  async function actIntoEmptyDocument(callback) {
-    await callback();
-    // Await one turn around the event loop.
-    // This assumes that we'll flush everything we have so far.
-    await new Promise(resolve => {
-      setImmediate(resolve);
-    });
-    if (hasErrored) {
-      throw fatalError;
+    if (!bufferedContent) {
+      return;
     }
-    // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
-    // We also want to execute any scripts that are embedded.
-    // We assume that we have now received a proper fragment of HTML.
-    const bufferedContent = buffer;
-    resetJSDOM(bufferedContent);
-    container = document;
-    buffer = '';
+
     await withLoadingReadyState(async () => {
-      await replaceScriptsAndMove(window, null, document.documentElement);
+      const bodyMatch = bufferedContent.match(bodyStartMatch);
+      const headMatch = bufferedContent.match(headStartMatch);
+
+      if (streamingContainer === null) {
+        // This is the first streamed content. We decide here where to insert it. If we get <html>, <head>, or <body>
+        // we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
+        // container. This is not really production behavior because you can't correctly stream into a deep div effectively
+        // but it's pragmatic for tests.
+
+        if (
+          bufferedContent.startsWith('<head>') ||
+          bufferedContent.startsWith('<head ') ||
+          bufferedContent.startsWith('<body>') ||
+          bufferedContent.startsWith('<body ')
+        ) {
+          // wrap in doctype to normalize the parsing process
+          bufferedContent = '<!DOCTYPE html><html>' + bufferedContent;
+        } else if (
+          bufferedContent.startsWith('<html>') ||
+          bufferedContent.startsWith('<html ')
+        ) {
+          throw new Error(
+            'Recieved <html> without a <!DOCTYPE html> which is almost certainly a bug in React',
+          );
+        }
+
+        if (bufferedContent.startsWith('<!DOCTYPE html>')) {
+          // we can just use the whole document
+          const tempDom = new JSDOM(bufferedContent);
+
+          // Wipe existing head and body content
+          document.head.innerHTML = '';
+          document.body.innerHTML = '';
+
+          // Copy the <html> attributes over
+          const tempHtmlNode = tempDom.window.document.documentElement;
+          for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
+            const attr = tempHtmlNode.attributes[i];
+            document.documentElement.setAttribute(attr.name, attr.value);
+          }
+
+          if (headMatch) {
+            // We parsed a head open tag. we need to copy head attributes and insert future
+            // content into <head>
+            streamingContainer = document.head;
+            const tempHeadNode = tempDom.window.document.head;
+            for (let i = 0; i < tempHeadNode.attributes.length; i++) {
+              const attr = tempHeadNode.attributes[i];
+              document.head.setAttribute(attr.name, attr.value);
+            }
+            const source = document.createElement('head');
+            source.innerHTML = tempHeadNode.innerHTML;
+            await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
+          }
+
+          if (bodyMatch) {
+            // We parsed a body open tag. we need to copy head attributes and insert future
+            // content into <body>
+            streamingContainer = document.body;
+            const tempBodyNode = tempDom.window.document.body;
+            for (let i = 0; i < tempBodyNode.attributes.length; i++) {
+              const attr = tempBodyNode.attributes[i];
+              document.body.setAttribute(attr.name, attr.value);
+            }
+            const source = document.createElement('body');
+            source.innerHTML = tempBodyNode.innerHTML;
+            await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
+          }
+
+          if (!headMatch && !bodyMatch) {
+            throw new Error('expected <head> or <body> after <html>');
+          }
+        } else {
+          // we assume we are streaming into the default container'
+          streamingContainer = container;
+          const div = document.createElement('div');
+          div.innerHTML = bufferedContent;
+          await insertNodesAndExecuteScripts(div, container, CSPnonce);
+        }
+      } else if (streamingContainer === document.head) {
+        bufferedContent = '<!DOCTYPE html><html><head>' + bufferedContent;
+        const tempDom = new JSDOM(bufferedContent);
+
+        const tempHeadNode = tempDom.window.document.head;
+        const source = document.createElement('head');
+        source.innerHTML = tempHeadNode.innerHTML;
+        await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
+
+        if (bodyMatch) {
+          streamingContainer = document.body;
+
+          const tempBodyNode = tempDom.window.document.body;
+          for (let i = 0; i < tempBodyNode.attributes.length; i++) {
+            const attr = tempBodyNode.attributes[i];
+            document.body.setAttribute(attr.name, attr.value);
+          }
+          const bodySource = document.createElement('body');
+          bodySource.innerHTML = tempBodyNode.innerHTML;
+          await insertNodesAndExecuteScripts(
+            bodySource,
+            document.body,
+            CSPnonce,
+          );
+        }
+      } else {
+        const div = document.createElement('div');
+        div.innerHTML = bufferedContent;
+        await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
+      }
     }, document);
   }
 
@@ -350,7 +431,7 @@ describe('ReactDOMFloat', () => {
 
   // @gate enableFloat
   it('can hydrate non Resources in head when Resources are also inserted there', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head>
@@ -375,7 +456,7 @@ describe('ReactDOMFloat', () => {
           <meta property="foo" content="bar" />
           <title>foo</title>
           <link rel="foo" href="bar" />
-          <noscript>&lt;link rel="icon" href="icon"/&gt;</noscript>
+          <noscript>&lt;link rel="icon" href="icon"&gt;</noscript>
           <base target="foo" href="bar" />
         </head>
         <body>foo</body>
@@ -406,7 +487,7 @@ describe('ReactDOMFloat', () => {
           <meta property="foo" content="bar" />
           <title>foo</title>
           <link rel="foo" href="bar" />
-          <noscript>&lt;link rel="icon" href="icon"/&gt;</noscript>
+          <noscript>&lt;link rel="icon" href="icon"&gt;</noscript>
           <base target="foo" href="bar" />
           <script async="" src="foo" />
         </head>
@@ -598,7 +679,7 @@ describe('ReactDOMFloat', () => {
       );
     }
 
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       buffer = `<!DOCTYPE html><html><head>${ReactDOMFizzServer.renderToString(
         <App />,
       )}</head><body>foo</body></html>`;
@@ -625,7 +706,7 @@ describe('ReactDOMFloat', () => {
       );
     }
 
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       buffer = `<!DOCTYPE html><html>${ReactDOMFizzServer.renderToString(
         <App />,
       )}<body>foo</body></html>`;
@@ -649,7 +730,7 @@ describe('ReactDOMFloat', () => {
       chunks.push(chunk);
     });
 
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <>
           <title>foo</title>
@@ -681,7 +762,7 @@ describe('ReactDOMFloat', () => {
       );
     }
 
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -703,7 +784,7 @@ describe('ReactDOMFloat', () => {
 
   // @gate enableFloat
   it('can avoid inserting a late stylesheet if it already rendered on the client', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       renderToPipeableStream(
         <html>
           <body>
@@ -829,7 +910,7 @@ body {
   background-color: red;
 }`;
 
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       renderToPipeableStream(
         <html>
           <body>
@@ -1125,7 +1206,7 @@ body {
         </html>
       );
     }
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(<App />);
       pipe(writable);
     });
@@ -1211,7 +1292,7 @@ body {
 
   // @gate enableFloat
   it('treats stylesheet links with a precedence as a resource', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -1264,7 +1345,7 @@ body {
       );
     }
 
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -1302,7 +1383,7 @@ body {
     function PresetPrecedence() {
       ReactDOM.preinit('preset', {as: 'style', precedence: 'preset'});
     }
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -1584,7 +1665,7 @@ body {
 
   // @gate enableFloat
   it('normalizes stylesheet resource precedence for all boundaries inlined as part of the shell flush', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -1668,7 +1749,7 @@ body {
 
   // @gate enableFloat
   it('stylesheet resources are inserted according to precedence order on the client', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -1791,7 +1872,7 @@ body {
 
   // @gate enableFloat
   it('will include child boundary stylesheet resources in the boundary reveal instruction', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -1910,7 +1991,7 @@ body {
 
   // @gate enableFloat
   it('will hoist resources of child boundaries emitted as part of a partial boundary to the parent boundary', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -2132,7 +2213,7 @@ body {
       );
     }
     await expect(async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(<App />);
         pipe(writable);
       });
@@ -2218,7 +2299,7 @@ body {
 
   // @gate enableFloat
   it('boundary stylesheet resource dependencies hoist to a parent boundary when flushed inline', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(
         <html>
           <head />
@@ -2353,7 +2434,7 @@ body {
         </html>
       );
     }
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       const {pipe} = renderToPipeableStream(<App />);
       pipe(writable);
     });
@@ -2417,7 +2498,7 @@ body {
         </html>
       );
     }
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       renderToPipeableStream(<App />).pipe(writable);
     });
 
@@ -2573,7 +2654,7 @@ body {
       );
     }
 
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       renderToPipeableStream(<App />).pipe(writable);
     });
 
@@ -2593,7 +2674,7 @@ body {
           <link rel="stylesheet" href="stylesheet" />
           <script src="sync rendered" data-meaningful="" />
           <style>{'body { background-color: red; }'}</style>
-          <noscript>&lt;meta name="noscript" content="noscript"/&gt;</noscript>
+          <noscript>&lt;meta name="noscript" content="noscript"&gt;</noscript>
           <link rel="foo" href="foo" />
         </head>
         <body>
@@ -2659,7 +2740,7 @@ body {
           <script src="sync rendered" data-meaningful="" />
           <style>{'body { background-color: red; }'}</style>
           <script src="async rendered" async="" />
-          <noscript>&lt;meta name="noscript" content="noscript"/&gt;</noscript>
+          <noscript>&lt;meta name="noscript" content="noscript"&gt;</noscript>
           <link rel="foo" href="foo" />
           <style>{'body { background-color: blue; }'}</style>
           <div />
@@ -2713,7 +2794,7 @@ body {
   });
 
   it('does not preload nomodule scripts', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       renderToPipeableStream(
         <html>
           <body>
@@ -2839,7 +2920,7 @@ body {
   });
 
   it('assumes stylesheets that load in the shell loaded already', async () => {
-    await actIntoEmptyDocument(() => {
+    await act(() => {
       renderToPipeableStream(
         <html>
           <body>
@@ -3321,7 +3402,7 @@ body {
       }
 
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(<App url="foo" />).pipe(writable);
         });
       }).toErrorDev([
@@ -3390,7 +3471,7 @@ body {
       }
 
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(<App url="foo" />).pipe(writable);
         });
       }).toErrorDev(
@@ -3469,7 +3550,7 @@ body {
         return <div>hello</div>;
       }
 
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(<App />);
         pipe(writable);
       });
@@ -3555,7 +3636,7 @@ body {
         );
       }
 
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(<App />);
         pipe(writable);
       });
@@ -3620,7 +3701,7 @@ body {
       }
 
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(
             <html>
               <body>
@@ -3645,7 +3726,7 @@ body {
       }
 
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(
             <html>
               <body>
@@ -3689,7 +3770,7 @@ body {
         return <div>hello</div>;
       }
 
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(<App />);
         pipe(writable);
       });
@@ -3799,7 +3880,7 @@ body {
         return <div>hello</div>;
       }
 
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(<App />);
         pipe(writable);
       });
@@ -3916,7 +3997,7 @@ body {
       }
 
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(
             <html>
               <body>
@@ -3952,7 +4033,7 @@ body {
       }
 
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(
             <html>
               <body>
@@ -3977,7 +4058,7 @@ body {
       }
 
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(
             <html>
               <body>
@@ -4007,7 +4088,7 @@ body {
       }
 
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(
             <html>
               <body>
@@ -4025,7 +4106,7 @@ body {
   describe('Stylesheet Resources', () => {
     // @gate enableFloat
     it('treats link rel stylesheet elements as a stylesheet resource when it includes a precedence when server rendering', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <head />
@@ -4078,7 +4159,7 @@ body {
 
     // @gate enableFloat
     it('treats link rel stylesheet elements as a stylesheet resource when it includes a precedence when hydrating', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <head />
@@ -4116,7 +4197,7 @@ body {
 
     // @gate enableFloat
     it('preloads stylesheets without a precedence prop when server rendering', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <head />
@@ -4144,7 +4225,7 @@ body {
 
     // @gate enableFloat
     it('hoists stylesheet resources to the correct precedence', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <head />
@@ -4239,7 +4320,7 @@ body {
 
     // @gate enableFloat && enableHostSingletons && enableClientRenderFallbackOnTextMismatch
     it('retains styles even when a new html, head, and/body mount', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <head />
@@ -4291,7 +4372,7 @@ body {
 
     // @gate enableFloat && !enableHostSingletons
     it('retains styles even when a new html, head, and/body mount - without HostSingleton', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <head />
@@ -4521,7 +4602,7 @@ body {
           </html>
         );
       }
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(<App />);
         pipe(writable);
       });
@@ -4575,7 +4656,7 @@ body {
 
     // @gate enableFloat
     it('escapes hrefs when selecting matching elements in the document when using preload and preinit', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <head />
@@ -4638,7 +4719,7 @@ body {
 
     // @gate enableFloat
     it('does not create stylesheet resources when inside an <svg> context', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -4699,7 +4780,7 @@ body {
 
     // @gate enableFloat
     it('does not create stylesheet resources when inside a <noscript> context', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -4716,7 +4797,7 @@ body {
           <head />
           <body>
             <noscript>
-              &lt;link rel="stylesheet" href="foo" precedence="default"/&gt;
+              &lt;link rel="stylesheet" href="foo" precedence="default"&gt;
             </noscript>
           </body>
         </html>,
@@ -4742,7 +4823,7 @@ body {
     // @gate enableFloat
     it('warns if you provide a `precedence` prop with other props that invalidate the creation of a stylesheet resource', async () => {
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(
             <html>
               <body>
@@ -4826,7 +4907,7 @@ body {
         );
       }
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           const {pipe} = renderToPipeableStream(<App />);
           pipe(writable);
         });
@@ -4854,7 +4935,7 @@ body {
         );
       }
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           const {pipe} = renderToPipeableStream(<App />);
           pipe(writable);
         });
@@ -4865,7 +4946,7 @@ body {
 
     // @gate enableFloat
     it('will not block displaying a Suspense boundary on a stylesheet with media that does not match', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         renderToPipeableStream(
           <html>
             <body>
@@ -4986,7 +5067,7 @@ body {
 body {
   background-color: red;
 }`;
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         renderToPipeableStream(
           <html>
             <body>
@@ -5024,7 +5105,7 @@ background-color: blue;
 body {
 background-color: green;
 }`;
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         renderToPipeableStream(
           <html>
             <body>
@@ -5129,7 +5210,7 @@ background-color: green;
     // @gate enableFloat
     it('can emit styles early when a partial boundary flushes', async () => {
       const css = 'body { background-color: red; }';
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         renderToPipeableStream(
           <html>
             <body>
@@ -5197,7 +5278,7 @@ background-color: green;
     });
 
     it('can hoist styles flushed early even when no other style dependencies are flushed on completion', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         renderToPipeableStream(
           <html>
             <body>
@@ -5261,7 +5342,7 @@ background-color: green;
     });
 
     it('can emit multiple style rules into a single style tag for a given precedence', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         renderToPipeableStream(
           <html>
             <body>
@@ -5440,7 +5521,7 @@ background-color: green;
 
     it('warns if you render a <style> with an href with a space on the server', async () => {
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           renderToPipeableStream(
             <html>
               <body>
@@ -5460,7 +5541,7 @@ background-color: green;
   describe('Script Resources', () => {
     // @gate enableFloat
     it('treats async scripts without onLoad or onError as Resources', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <head />
@@ -5526,7 +5607,7 @@ background-color: green;
 
     // @gate enableFloat
     it('does not create script resources when inside an <svg> context', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -5587,7 +5668,7 @@ background-color: green;
 
     // @gate enableFloat
     it('does not create script resources when inside a <noscript> context', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -5646,7 +5727,7 @@ background-color: green;
         );
       }
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           const {pipe} = renderToPipeableStream(<App />);
           pipe(writable);
         });
@@ -5679,7 +5760,7 @@ background-color: green;
         );
       }
       await expect(async () => {
-        await actIntoEmptyDocument(() => {
+        await act(() => {
           const {pipe} = renderToPipeableStream(<App />);
           pipe(writable);
         });
@@ -5692,7 +5773,7 @@ background-color: green;
   describe('Hoistables', () => {
     // @gate enableFloat
     it('can hoist meta tags on the server and hydrate them on the client', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -5768,7 +5849,7 @@ background-color: green;
 
     // @gate enableFloat
     it('can hoist link (non-stylesheet) tags on the server and hydrate them on the client', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -5844,7 +5925,7 @@ background-color: green;
 
     // @gate enableFloat
     it('can hoist title tags on the server and hydrate them on the client', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -5920,7 +6001,7 @@ background-color: green;
 
     // @gate enableFloat
     it('prioritizes ordering for certain hoistables over others when rendering on the server', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -5962,7 +6043,7 @@ background-color: green;
       let content = '';
       writable.on('data', chunk => (content += chunk));
 
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -6010,7 +6091,7 @@ background-color: green;
 
     // @gate enableFloat
     it('supports rendering hoistables outside of <html> scope', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <>
             <meta name="before" />
@@ -6098,7 +6179,7 @@ background-color: green;
           </html>
         );
       }
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         renderToPipeableStream(<App />).pipe(writable);
       });
 
@@ -6188,7 +6269,7 @@ background-color: green;
 
     // @gate enableFloat
     it('does not hoist inside an <svg> context', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -6222,7 +6303,7 @@ background-color: green;
 
     // @gate enableFloat
     it('does not hoist inside noscript context', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <html>
             <body>
@@ -6249,7 +6330,7 @@ background-color: green;
 
     // @gate enableFloat && enableHostSingletons && (enableClientRenderFallbackOnTextMismatch || !__DEV__)
     it('can render a title before a singleton even if that singleton clears its contents', async () => {
-      await actIntoEmptyDocument(() => {
+      await act(() => {
         const {pipe} = renderToPipeableStream(
           <>
             <title>foo</title>

filename: packages/react-dom/src/test-utils/FizzTestUtils.js
patch: @@ -70,66 +70,98 @@ async function getRollupResult(scriptSrc: string): Promise<string | null> {
   }
 }
 
-// Utility function to process received HTML nodes and execute
-//  embedded scripts by:
-//  1. Matching nonce attributes and moving node into an existing
-//      parent container (if passed)
-//  2. Resolving scripts with sources
-//  3. Moving data attribute nodes to the body
-async function replaceScriptsAndMove(
-  window: any,
+async function insertNodesAndExecuteScripts(
+  source: Document | Element,
+  target: Node,
   CSPnonce: string | null,
-  node: Node,
-  parent: Node | null,
 ) {
-  if (
-    node.nodeType === 1 &&
-    (node.nodeName === 'SCRIPT' || node.nodeName === 'script')
-  ) {
-    // $FlowFixMe[incompatible-cast]
-    const element = (node: HTMLElement);
-    const script = window.document.createElement('SCRIPT');
-    const scriptSrc = element.getAttribute('src');
-    if (scriptSrc) {
-      const rollupOutput = await getRollupResult(scriptSrc);
-      if (rollupOutput) {
-        // Manually call eval(...) here, since changing the HTML text content
-        //  may interfere with hydration
-        window.eval(rollupOutput);
-      }
-      for (let i = 0; i < element.attributes.length; i++) {
-        const attr = element.attributes.item(i);
-        script.setAttribute(attr.name, attr.value);
+  const ownerDocument = target.ownerDocument || target;
+
+  // We need to remove the script content for any scripts that would not run based on CSP
+  // We restore the script content after moving the nodes into the target
+  const badNonceScriptNodes: Map<Element, string> = new Map();
+  if (CSPnonce) {
+    const scripts = source.querySelectorAll('script');
+    for (let i = 0; i < scripts.length; i++) {
+      const script = scripts[i];
+      if (
+        !script.hasAttribute('src') &&
+        script.getAttribute('nonce') !== CSPnonce
+      ) {
+        badNonceScriptNodes.set(script, script.textContent);
+        script.textContent = '';
       }
-    } else if (element === null || element.getAttribute('nonce') === CSPnonce) {
-      script.textContent = node.textContent;
     }
-    if (parent) {
-      element.parentNode?.removeChild(element);
-      parent.appendChild(script);
-    } else {
-      element.parentNode?.replaceChild(script, element);
+  }
+  let lastChild = null;
+  while (source.firstChild) {
+    const node = source.firstChild;
+    if (lastChild === node) {
+      throw new Error('Infinite loop.');
     }
-  } else if (
-    node.nodeType === 1 &&
-    // $FlowFixMe[prop-missing]
-    node.dataset != null &&
-    (node.dataset.rxi != null ||
-      node.dataset.rri != null ||
-      node.dataset.rci != null ||
-      node.dataset.rsi != null)
-  ) {
-    // External runtime assumes that instruction data nodes are eventually
-    // appended to the body
-    window.document.body.appendChild(node);
-  } else {
-    for (let i = 0; i < node.childNodes.length; i++) {
-      const inner = node.childNodes[i];
-      await replaceScriptsAndMove(window, CSPnonce, inner, null);
+    lastChild = node;
+
+    if (node.nodeType === 1) {
+      const element: Element = (node: any);
+      if (
+        // $FlowFixMe[prop-missing]
+        element.dataset != null &&
+        (element.dataset.rxi != null ||
+          element.dataset.rri != null ||
+          element.dataset.rci != null ||
+          element.dataset.rsi != null)
+      ) {
+        // Fizz external runtime instructions are expected to be in the body.
+        // When we have renderIntoContainer and renderDocument this will be
+        // more enforceable. At the moment you can misconfigure your stream and end up
+        // with instructions that are deep in the document
+        (ownerDocument.body: any).appendChild(element);
+      } else {
+        target.appendChild(element);
+
+        if (element.nodeName === 'SCRIPT') {
+          await executeScript(element);
+        } else {
+          const scripts = element.querySelectorAll('script');
+          for (let i = 0; i < scripts.length; i++) {
+            const script = scripts[i];
+            await executeScript(script);
+          }
+        }
+      }
+    } else {
+      target.appendChild(node);
     }
-    if (parent != null) {
-      parent.appendChild(node);
+  }
+
+  // restore the textContent now that we have finished attempting to execute scripts
+  badNonceScriptNodes.forEach((scriptContent, script) => {
+    script.textContent = scriptContent;
+  });
+}
+
+async function executeScript(script: Element) {
+  const ownerDocument = script.ownerDocument;
+  if (script.parentNode == null) {
+    throw new Error(
+      'executeScript expects to be called on script nodes that are currently in a document',
+    );
+  }
+  const parent = script.parentNode;
+  const scriptSrc = script.getAttribute('src');
+  if (scriptSrc) {
+    const rollupOutput = await getRollupResult(scriptSrc);
+    if (rollupOutput) {
+      const transientScript = ownerDocument.createElement('script');
+      transientScript.textContent = rollupOutput;
+      parent.appendChild(transientScript);
+      parent.removeChild(transientScript);
     }
+  } else {
+    const newScript = ownerDocument.createElement('script');
+    newScript.textContent = script.textContent;
+    parent.insertBefore(newScript, script);
+    parent.removeChild(script);
   }
 }
 
@@ -191,7 +223,7 @@ async function withLoadingReadyState<T>(
 }
 
 export {
-  replaceScriptsAndMove,
+  insertNodesAndExecuteScripts,
   mergeOptions,
   stripExternalRuntimeInNodes,
   withLoadingReadyState,