From e5708b3ea9190c1285c9081ff338e46be9ff39bc Mon Sep 17 00:00:00 2001 From: Josh Story Date: Thu, 20 Apr 2023 14:27:02 -0700 Subject: [PATCH] [Tests][Fizz] Better HTML parsing behavior for Fizz tests (#26570) 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 `` or `` 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 `` in which case it will switch to streaming into the body once it receives a `` tag. This means for tests that render something like a `
...
` it will naturally stream into the default `
...` 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. --- .../src/__tests__/ReactDOMFizzServer-test.js | 873 ++++++++++-------- .../src/__tests__/ReactDOMFloat-test.js | 343 ++++--- .../react-dom/src/test-utils/FizzTestUtils.js | 140 +-- 3 files changed, 778 insertions(+), 578 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index da59ac66fa..aa9222adb0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -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( + '
', + { + 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('
'); - container = document.getElementById('container'); - buffer = ''; hasErrored = false; @@ -140,6 +147,9 @@ describe('ReactDOMFizzServer', () => { .join(''); } + const bodyStartMatch = /| .*?>)/; + const headStartMatch = /| .*?>)/; + 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 , , or + // 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('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith(' without a which is almost certainly a bug in React', + ); + } + + if (bufferedContent.startsWith('')) { + // 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 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 + 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 + 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 or after '); + } + } 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 = '' + 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( @@ -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( @@ -3631,7 +3724,7 @@ describe('ReactDOMFizzServer', () => {
); } - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream(); 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( @@ -4446,7 +4539,7 @@ describe('ReactDOMFizzServer', () => { ); } - await actIntoEmptyDocument(() => { + await act(() => { const {pipe} = renderToPipeableStream( @@ -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( @@ -4953,23 +5042,21 @@ describe('ReactDOMFizzServer', () => { }); describe('title children', () => { - function prepareJSDOMForTitle() { - resetJSDOM('\u0000'); - container = document.getElementsByTagName('head')[0]; - } - it('should accept a single string child', async () => { // a Single string child function App() { - return hello; + return ( + + hello + + ); } - prepareJSDOMForTitle(); await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); const errors = []; ReactDOMClient.hydrateRoot(container, , { @@ -4979,21 +5066,24 @@ describe('ReactDOMFizzServer', () => { }); await waitForAll([]); expect(errors).toEqual([]); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); }); it('should accept children array of length 1 containing a string', async () => { // a Single string child function App() { - return {['hello']}; + return ( + + {['hello']} + + ); } - prepareJSDOMForTitle(); await act(() => { const {pipe} = renderToPipeableStream(); pipe(writable); }); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); const errors = []; ReactDOMClient.hydrateRoot(container, , { @@ -5003,16 +5093,18 @@ describe('ReactDOMFizzServer', () => { }); await waitForAll([]); expect(errors).toEqual([]); - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); }); it('should warn in dev when given an array of length 2 or more', async () => { function App() { - return {['hello1', 'hello2']}; + return ( + + {['hello1', 'hello2']} + + ); } - prepareJSDOMForTitle(); - await expect(async () => { await act(() => { const {pipe} = renderToPipeableStream(); @@ -5023,15 +5115,15 @@ describe('ReactDOMFizzServer', () => { ]); if (gate(flags => flags.enableFloat)) { - expect(getVisibleChildren(container)).toEqual(); + expect(getVisibleChildren(document.head)).toEqual(<title />); } else { - expect(getVisibleChildren(container)).toEqual( + expect(getVisibleChildren(document.head)).toEqual( <title>{'hello1<!-- -->hello2'}, ); } const errors = []; - ReactDOMClient.hydrateRoot(container, , { + ReactDOMClient.hydrateRoot(document.head, , { 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(); + 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']}, ); } @@ -5064,16 +5156,14 @@ describe('ReactDOMFizzServer', () => { function App() { return ( - <> + <IndirectTitle /> - + ); } - 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( {'[object Object]'}, ); } else { - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); } const errors = []; - ReactDOMClient.hydrateRoot(container, , { + ReactDOMClient.hydrateRoot(document.head, , { 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( {'[object Object]'}, ); } else { - expect(getVisibleChildren(container)).toEqual(hello); + expect(getVisibleChildren(document.head)).toEqual(hello); } }); + }); - // @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 ( - - - - ); - } - - await act(() => { - const {pipe} = renderToPipeableStream(); - 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; - }); - - expect(getVisibleChildren(container)).toEqual('ABC'); - - ReactDOMClient.hydrateRoot(container, ); - 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', + function App() { + return ( + + + ); - function Client() { - return use(ContextA) + use(ContextB); - } - function ServerComponent() { - return use(ServerContext); - } - function Server() { - return ( - - - - ); - } - function App() { - return ( - <> - - - - - - ); - } + } - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual(['AB', 'C']); - - // 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, ); - await waitForAll([]); - expect(getVisibleChildren(container)).toEqual(['AB', 'C']); + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); - // @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); - } - - function Parent() { - return ; - } - - function App() { - return ( - - - - ); - } - - await act(() => { - const {pipe} = renderToPipeableStream(); - 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; - }); - await act(async () => { - await promiseD; - }); - - expect(getVisibleChildren(container)).toEqual('ABCD'); - - ReactDOMClient.hydrateRoot(container, ); - 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; }); - // @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('ABC'); - // 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, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('ABC'); + }); - function Async() { - return use(promiseA) + use(promiseB) + use(promiseC); + // @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 ( + + + + ); + } + function App() { + return ( + <> + + + + + + ); + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual(['AB', 'C']); + + // 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, ); + await waitForAll([]); + 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); + } + + function Parent() { + return ; + } + + function App() { + return ( + + + + ); + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + 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; + }); + await act(async () => { + await promiseD; + }); + + expect(getVisibleChildren(container)).toEqual('ABCD'); + + ReactDOMClient.hydrateRoot(container, ); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual('ABCD'); + }); + + // @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'); + + // 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}; } - - 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; + render() { + if (this.state.error) { + return this.state.error.message; } + return this.props.children; } + } - function App() { - return ( - - - - - - ); - } + function App() { + return ( + + + + + + ); + } - const reportedServerErrors = []; - await act(() => { - const {pipe} = renderToPipeableStream(, { - 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; - }); - - expect(getVisibleChildren(container)).toEqual('Loading...'); - expect(reportedServerErrors.length).toBe(1); - expect(reportedServerErrors[0].message).toBe('Oops!'); - - const reportedClientErrors = []; - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - reportedClientErrors.push(error); + const reportedServerErrors = []; + await act(() => { + const {pipe} = renderToPipeableStream(, { + onError(error) { + reportedServerErrors.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.', + 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; + }); + + expect(getVisibleChildren(container)).toEqual('Loading...'); + expect(reportedServerErrors.length).toBe(1); + expect(reportedServerErrors[0].message).toBe('Oops!'); + + const reportedClientErrors = []; + ReactDOMClient.hydrateRoot(container, , { + 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); + } + + await act(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual('Hi'); + + ReactDOMClient.hydrateRoot(container, ); + 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 ; + } 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(); + 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); }); - // @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); - } - - await act(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual('Hi'); - - ReactDOMClient.hydrateRoot(container, ); - await waitForAll([]); - 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; }); - // @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 ; - } 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(); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual('Hi'); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); + + it('context as node', async () => { + const Context = React.createContext('Hi'); + await act(async () => { + const {pipe} = renderToPipeableStream(Context); + pipe(writable); + }); + 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); }); - 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'); + // 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'); - }); - - 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'); - }); + 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( @@ -5583,7 +5670,7 @@ describe('ReactDOMFizzServer', () => { }; try { - await actIntoEmptyDocument(async () => { + await act(async () => { const {pipe} = renderToPipeableStream( diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 28fb79c1a7..728b91564c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -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( + '
', + { + 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('
'); - container = document.getElementById('container'); - buffer = ''; hasErrored = false; @@ -100,6 +99,9 @@ describe('ReactDOMFloat', () => { } }); + const bodyStartMatch = /| .*?>)/; + const headStartMatch = /| .*?>)/; + 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 , , or + // 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('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith('') || + bufferedContent.startsWith(' without a which is almost certainly a bug in React', + ); + } + + if (bufferedContent.startsWith('')) { + // 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 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 + 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 + 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 or after '); + } + } 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 = '' + 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( @@ -375,7 +456,7 @@ describe('ReactDOMFloat', () => { foo - + foo @@ -406,7 +487,7 @@ describe('ReactDOMFloat', () => { foo - +