From 637ddfcce1fb755879f1a4a68373f50f1553046d Mon Sep 17 00:00:00 2001 From: Jimmy Lai Date: Fri, 11 Nov 2022 11:35:51 +0100 Subject: [PATCH 1/9] add warning when precomputed chunk is too big + add browser tests for Float --- .../src/server/ReactDOMServerFormatConfig.js | 5 +- .../src/__tests__/ReactDOMFloat-test.js | 11337 ++++++++-------- .../src/ReactServerStreamConfigBrowser.js | 17 +- 3 files changed, 5807 insertions(+), 5552 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index b6a5727314cbe..d98c2b256167a 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -2443,7 +2443,10 @@ export function writeCompletedBoundaryInstruction( if (!responseState.sentCompleteBoundaryFunction) { responseState.sentCompleteBoundaryFunction = true; responseState.sentStyleInsertionFunction = true; - writeChunk(destination, completeBoundaryWithStylesScript1FullBoth); + writeChunk( + destination, + completeBoundaryWithStylesScript1FullBoth.slice(), + ); } else if (!responseState.sentStyleInsertionFunction) { responseState.sentStyleInsertionFunction = true; writeChunk(destination, completeBoundaryWithStylesScript1FullPartial); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 650bb3a32d1a7..de9547afd8ac7 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -8,6 +8,12 @@ */ 'use strict'; +// Polyfills for test environment +global.ReadableStream = require('web-streams-polyfill/ponyfill/es6').ReadableStream; +global.WritableStream = require('web-streams-polyfill/ponyfill/es6').WritableStream; +global.TextEncoder = require('util').TextEncoder; +global.TextDecoder = require('util').TextDecoder; + import {replaceScriptsAndMove, mergeOptions} from '../test-utils/FizzTestUtils'; let JSDOM; @@ -28,3718 +34,3862 @@ let hasErrored = false; let fatalError = undefined; const renderOptions = {}; -describe('ReactDOMFloat', () => { - beforeEach(() => { - jest.resetModules(); - JSDOM = require('jsdom').JSDOM; - Scheduler = require('scheduler'); - React = require('react'); - ReactDOM = require('react-dom'); - ReactDOMClient = require('react-dom/client'); - ReactDOMFizzServer = require('react-dom/server'); - Stream = require('stream'); - Suspense = React.Suspense; - - textCache = new Map(); - - // Test Environment - const jsdom = new JSDOM( - '
', - { - runScripts: 'dangerously', - }, - ); - document = jsdom.window.document; - container = document.getElementById('container'); - - buffer = ''; - hasErrored = false; - - writable = new Stream.PassThrough(); - writable.setEncoding('utf8'); - writable.on('data', chunk => { - buffer += chunk; - }); - writable.on('error', error => { - hasErrored = true; - fatalError = error; - }); - }); +describe(`ReactDOMFloat`, () => { + ['browser', 'node'].forEach(runtime => { + describe(runtime, () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + Scheduler = require('scheduler'); + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMClient = require('react-dom/client'); + ReactDOMFizzServer = + runtime === 'node' + ? require('react-dom/server') + : require('react-dom/server.browser'); + Stream = require('stream'); + Suspense = React.Suspense; + + textCache = new Map(); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + document = jsdom.window.document; + container = document.getElementById('container'); - function normalizeCodeLocInfo(str) { - return ( - typeof str === 'string' && - str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) { - return '\n in ' + name + ' (at **)'; - }) - ); - } - - function componentStack(components) { - return components - .map(component => `\n in ${component} (at **)`) - .join(''); - } - - async function act(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; - } - // 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; - buffer = ''; - const fakeBody = document.createElement('body'); - fakeBody.innerHTML = bufferedContent; - const parent = - container.nodeName === '#document' ? container.body : container; - while (fakeBody.firstChild) { - const node = fakeBody.firstChild; - await replaceScriptsAndMove(document.defaultView, CSPnonce, node, parent); - } - } - - 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; - } - // 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; - // Test Environment - const jsdom = new JSDOM(bufferedContent, { - runScripts: 'dangerously', - }); - document = jsdom.window.document; - container = document; - buffer = ''; - await replaceScriptsAndMove(jsdom.window, null, document.documentElement); - } - - function getMeaningfulChildren(element) { - const children = []; - let node = element.firstChild; - while (node) { - if (node.nodeType === 1) { - if ( - // some tags are ambiguous and might be hidden because they look like non-meaningful children - // so we have a global override where if this data attribute is included we also include the node - node.hasAttribute('data-meaningful') || - (node.tagName === 'SCRIPT' && - node.hasAttribute('src') && - node.getAttribute('src') !== - renderOptions.unstable_externalRuntimeSrc && - node.hasAttribute('async')) || - (node.tagName !== 'SCRIPT' && - node.tagName !== 'TEMPLATE' && - node.tagName !== 'template' && - !node.hasAttribute('hidden') && - !node.hasAttribute('aria-hidden')) - ) { - const props = {}; - const attributes = node.attributes; - for (let i = 0; i < attributes.length; i++) { + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + function normalizeCodeLocInfo(str) { + return ( + typeof str === 'string' && + str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) { + return '\n in ' + name + ' (at **)'; + }) + ); + } + + function componentStack(components) { + return components + .map(component => `\n in ${component} (at **)`) + .join(''); + } + + async function act(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; + } + // 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; + buffer = ''; + const fakeBody = document.createElement('body'); + fakeBody.innerHTML = bufferedContent; + const parent = + container.nodeName === '#document' ? container.body : container; + while (fakeBody.firstChild) { + const node = fakeBody.firstChild; + await replaceScriptsAndMove( + document.defaultView, + CSPnonce, + node, + parent, + ); + } + } + + 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; + } + // 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; + // Test Environment + const jsdom = new JSDOM(bufferedContent, { + runScripts: 'dangerously', + }); + document = jsdom.window.document; + container = document; + buffer = ''; + await replaceScriptsAndMove( + jsdom.window, + null, + document.documentElement, + ); + } + + function getMeaningfulChildren(element) { + const children = []; + let node = element.firstChild; + while (node) { + if (node.nodeType === 1) { if ( - attributes[i].name === 'id' && - attributes[i].value.includes(':') + // some tags are ambiguous and might be hidden because they look like non-meaningful children + // so we have a global override where if this data attribute is included we also include the node + node.hasAttribute('data-meaningful') || + (node.tagName === 'SCRIPT' && + node.hasAttribute('src') && + node.getAttribute('src') !== + renderOptions.unstable_externalRuntimeSrc && + node.hasAttribute('async')) || + (node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden')) ) { - // We assume this is a React added ID that's a non-visual implementation detail. - continue; + const props = {}; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } + props[attributes[i].name] = attributes[i].value; + } + props.children = getMeaningfulChildren(node); + children.push( + React.createElement(node.tagName.toLowerCase(), props), + ); } - props[attributes[i].name] = attributes[i].value; + } else if (node.nodeType === 3) { + children.push(node.data); } - props.children = getMeaningfulChildren(node); - children.push(React.createElement(node.tagName.toLowerCase(), props)); + node = node.nextSibling; } - } else if (node.nodeType === 3) { - children.push(node.data); - } - node = node.nextSibling; - } - return children.length === 0 - ? undefined - : children.length === 1 - ? children[0] - : children; - } - - function resolveText(text) { - const record = textCache.get(text); - if (record === undefined) { - const newRecord = { - status: 'resolved', - value: text, - }; - textCache.set(text, newRecord); - } else if (record.status === 'pending') { - const thenable = record.value; - record.status = 'resolved'; - record.value = text; - thenable.pings.forEach(t => t()); - } - } - - function readText(text) { - const record = textCache.get(text); - if (record !== undefined) { - switch (record.status) { - case 'pending': - throw record.value; - case 'rejected': - throw record.value; - case 'resolved': - return record.value; + return children.length === 0 + ? undefined + : children.length === 1 + ? children[0] + : children; } - } else { - const thenable = { - pings: [], - then(resolve) { - if (newRecord.status === 'pending') { - thenable.pings.push(resolve); - } else { - Promise.resolve().then(() => resolve(newRecord.value)); - } - }, - }; - - const newRecord = { - status: 'pending', - value: thenable, - }; - textCache.set(text, newRecord); - - throw thenable; - } - } - - function AsyncText({text}) { - return readText(text); - } - - function renderToPipeableStream(jsx, options) { - // Merge options with renderOptions, which may contain featureFlag specific behavior - return ReactDOMFizzServer.renderToPipeableStream( - jsx, - mergeOptions(options, renderOptions), - ); - } - - // @gate enableFloat - it('can render resources before singletons', async () => { - const root = ReactDOMClient.createRoot(document); - root.render( - <> - foo - - - - - hello world - - , - ); - try { - expect(Scheduler).toFlushWithoutYielding(); - } catch (e) { - // for DOMExceptions that happen when expecting this test to fail we need - // to clear the scheduler first otherwise the expected failure will fail - expect(Scheduler).toFlushWithoutYielding(); - throw e; - } - expect(getMeaningfulChildren(document)).toEqual( - - - foo - - - hello world - , - ); - }); - function renderSafelyAndExpect(root, children) { - root.render(children); - return expect(() => { - try { - expect(Scheduler).toFlushWithoutYielding(); - } catch (e) { - try { - expect(Scheduler).toFlushWithoutYielding(); - } catch (f) {} + function resolveText(text) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } } - }); - } - - // @gate enableFloat - it('can hydrate non Resources in head when Resources are also inserted there', async () => { - await actIntoEmptyDocument(() => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - - - - {}} /> - foo - - - foobar', - '', - ]); - }); - describe('HostResource', () => { - // @gate enableFloat - it('warns when you update props to an invalid type', async () => { - const root = ReactDOMClient.createRoot(container); - root.render( -
- - -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - root.render( -
- {}} href="bar" /> - {}} /> -
, - ); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev([ - 'Warning: A previously rendered as a Resource with href "foo" with rel ""stylesheet"" but was updated with an invalid rel: something with type "function". When a link does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', - 'Warning: A previously rendered as a Resource with href "bar" but was updated with an invalid href prop: something with type "function". When a link does not have a valid href prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', - ]); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - -
-
-
- - , - ); - }); - }); - - describe('ReactDOM.preload', () => { - // @gate enableFloat - it('inserts a preload resource into the stream when called during server rendering', async () => { - function Component() { - ReactDOM.preload('foo', {as: 'style'}); - return 'foo'; + function readText(text) { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + throw record.value; + case 'rejected': + throw record.value; + case 'resolved': + return record.value; + } + } else { + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + + throw thenable; + } } - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - - - - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - foo - , - ); - }); - // @gate enableFloat - it('inserts a preload resource into the document during render when called during client rendering', async () => { - function Component() { - ReactDOM.preload('foo', {as: 'style'}); - return 'foo'; + function AsyncText({text}) { + return readText(text); } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - -
foo
- - , - ); - }); - // @gate enableFloat - it('inserts a preload resource when called in a layout effect', async () => { - function App() { - React.useLayoutEffect(() => { - ReactDOM.preload('foo', {as: 'style'}); - }, []); - return 'foobar'; + function nodeWriteableStreamToWebWritableStream(nodeStream) { + const webStream = new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + nodeStream.write(chunk, error => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + close() { + return new Promise((resolve, reject) => { + nodeStream.end(error => { + if (error) { + reject(error); + } + resolve(); + }); + }); + }, + abort(error) { + return new Promise((resolve, reject) => { + nodeStream.destroy(error, () => { + if (error) { + reject(error); + } + resolve(); + }); + }); + }, + }); + return webStream; } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); - - expect(getMeaningfulChildren(document)).toEqual( - - - - - -
foobar
- - , - ); - }); - // @gate enableFloat - it('inserts a preload resource when called in a passive effect', async () => { - function App() { - React.useEffect(() => { - ReactDOM.preload('foo', {as: 'style'}); - }, []); - return 'foobar'; + function renderToPipeableStream(jsx, options) { + if (runtime === 'browser') { + const streamPromise = ReactDOMFizzServer.renderToReadableStream( + jsx, + mergeOptions(options, renderOptions), + ); + return { + pipe: destination => { + streamPromise.then(stream => + stream.pipeTo( + nodeWriteableStreamToWebWritableStream(destination), + ), + ); + return destination; + }, + abort: () => { + streamPromise.then(stream => stream.abort()); + }, + }; + } + // Merge options with renderOptions, which may contain featureFlag specific behavior + return ReactDOMFizzServer.renderToPipeableStream( + jsx, + mergeOptions(options, renderOptions), + ); } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); - - expect(getMeaningfulChildren(document)).toEqual( - - - - - -
foobar
- - , - ); - }); - - // @gate enableFloat - it('inserts a preload resource when called in module scope if a root has already been created', async () => { - // The requirement that a root be created has to do with bootstrapping the dispatcher. - // We are intentionally avoiding setting it to the default via import due to cycles and - // we are trying to avoid doing a mutable initailation in module scope. - ReactDOM.preload('foo', {as: 'style'}); - ReactDOMClient.createRoot(container); - ReactDOM.preload('bar', {as: 'style'}); - // We need to use global.document because preload falls back - // to the window.document global when no other documents have been used - // The way the JSDOM runtim is created for these tests the local document - // global does not point to the global.document - expect(getMeaningfulChildren(global.document)).toEqual( - - - - - - , - ); - }); - // @gate enableFloat - it('supports script preloads', async () => { - function ServerApp() { - ReactDOM.preload('foo', {as: 'script', integrity: 'foo hash'}); - ReactDOM.preload('bar', { - as: 'script', - crossOrigin: 'use-credentials', - integrity: 'bar hash', - }); - return ( - - - - hi - - foo - + // @gate enableFloat + it('can render resources before singletons', async () => { + const root = ReactDOMClient.createRoot(document); + root.render( + <> + foo + + + + + hello world + + , ); - } - function ClientApp() { - ReactDOM.preload('foo', {as: 'script', integrity: 'foo hash'}); - ReactDOM.preload('qux', {as: 'script'}); - return ( + try { + expect(Scheduler).toFlushWithoutYielding(); + } catch (e) { + // for DOMExceptions that happen when expecting this test to fail we need + // to clear the scheduler first otherwise the expected failure will fail + expect(Scheduler).toFlushWithoutYielding(); + throw e; + } + expect(getMeaningfulChildren(document)).toEqual( - hi + foo + - foo - - + hello world + , ); - } - - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - hi - - foo - , - ); - - ReactDOMClient.hydrateRoot(document, ); - expect(Scheduler).toFlushWithoutYielding(); - - expect(getMeaningfulChildren(document)).toEqual( - - - - - - hi - - - - foo - , - ); - }); - }); - describe('ReactDOM.preinit as style', () => { - // @gate enableFloat - it('creates a style Resource when called during server rendering before first flush', async () => { - function Component() { - ReactDOM.preinit('foo', {as: 'style'}); - return 'foo'; + function renderSafelyAndExpect(root, children) { + root.render(children); + return expect(() => { + try { + expect(Scheduler).toFlushWithoutYielding(); + } catch (e) { + try { + expect(Scheduler).toFlushWithoutYielding(); + } catch (f) {} + } + }); } - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( + + // @gate enableFloat + it('can hydrate non Resources in head when Resources are also inserted there', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + + {}} /> + foo + + + foobar', + '', + ]); }); - // The plain async script is converted to a resource and emitted as part of the shell - // The async script with onLoad is preloaded in the shell but is expecting to be added - // during hydration. This is novel, the script is NOT a HostResource but it also will - // never hydrate - // The regular script is just a normal html that should hydrate with a HostComponent - expect(getMeaningfulChildren(document)).toEqual( - - - foobar', + '', + ]); + }); + + describe('HostResource', () => { + // @gate enableFloat + it('warns when you update props to an invalid type', async () => { + const root = ReactDOMClient.createRoot(container); + root.render( +
+ + +
, + ); + expect(Scheduler).toFlushWithoutYielding(); + root.render( +
+ {}} href="bar" /> + {}} /> +
, + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev([ + 'Warning: A previously rendered as a Resource with href "foo" with rel ""stylesheet"" but was updated with an invalid rel: something with type "function". When a link does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', + 'Warning: A previously rendered as a Resource with href "bar" but was updated with an invalid href prop: something with type "function". When a link does not have a valid href prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', + ]); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
+
+
+ + , + ); + }); + }); + + describe('ReactDOM.preload', () => { + // @gate enableFloat + it('inserts a preload resource into the stream when called during server rendering', async () => { + function Component() { + ReactDOM.preload('foo', {as: 'style'}); + return 'foo'; + } + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + + + , ); + pipe(writable); }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + foo + , + ); + }); - function renderSafelyAndExpect(root, children) { - root.render(children); - return expect(() => { - try { - expect(Scheduler).toFlushWithoutYielding(); - } catch (e) { - try { - expect(Scheduler).toFlushWithoutYielding(); - } catch (f) {} - } - }); + // @gate enableFloat + it('inserts a preload resource into the document during render when called during client rendering', async () => { + function Component() { + ReactDOM.preload('foo', {as: 'style'}); + return 'foo'; } + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
foo
+ + , + ); + }); - // @gate enableFloat - it('can hydrate non Resources in head when Resources are also inserted there', async () => { - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - - {}} /> - foo - - - foobar', - '', - ]); + // @gate enableFloat && enableHostSingletons && enableClientRenderFallbackOnTextMismatch + it('retains styles even when a new html, head, and/body mount', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + + + + server + + , + ); + pipe(writable); }); + const errors = []; + ReactDOMClient.hydrateRoot( + document, + + + + + + client + , + { + onRecoverableError(error) { + errors.push(error.message); + }, + }, + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + [ + 'Warning: Text content did not match. Server: "server" Client: "client"', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', + ], + {withoutStack: 1}, + ); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + client + , + ); + }); - describe('HostResource', () => { - // @gate enableFloat - it('warns when you update props to an invalid type', async () => { - const root = ReactDOMClient.createRoot(container); - root.render( -
+ // @gate enableFloat && !enableHostSingletons + it('retains styles even when a new html, head, and/body mount - without HostSingleton', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + - -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - root.render( -
- {}} href="bar" /> - {}} /> -
, - ); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev([ - 'Warning: A previously rendered as a Resource with href "foo" with rel ""stylesheet"" but was updated with an invalid rel: something with type "function". When a link does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', - 'Warning: A previously rendered as a Resource with href "bar" but was updated with an invalid href prop: something with type "function". When a link does not have a valid href prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', - ]); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - -
-
-
- - , - ); - }); + + server + + , + ); + pipe(writable); }); + const errors = []; + ReactDOMClient.hydrateRoot( + document, + + + + + + client + , + { + onRecoverableError(error) { + errors.push(error.message); + }, + }, + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + [ + 'Warning: Text content did not match. Server: "server" Client: "client"', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', + ], + {withoutStack: 1}, + ); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + client + , + ); + }); - describe('ReactDOM.preload', () => { - // @gate enableFloat - it('inserts a preload resource into the stream when called during server rendering', async () => { - function Component() { - ReactDOM.preload('foo', {as: 'style'}); - return 'foo'; - } - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - - - - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - foo - , - ); - }); + // @gate enableFloat && enableHostSingletons + it('retains styles in head through head remounts', async () => { + const root = ReactDOMClient.createRoot(document); + root.render( + + + + + + {null} + hello + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + hello + , + ); + + root.render( + + + + + {null} + + hello + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + // The reason we do not see preloads in the head is they are inserted synchronously + // during render and then when the new singleton mounts it resets it's content, retaining only styles + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + hello + , + ); + }); + }); - // @gate enableFloat - it('inserts a preload resource into the document during render when called during client rendering', async () => { - function Component() { - ReactDOM.preload('foo', {as: 'style'}); - return 'foo'; - } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - -
foo
- - , - ); - }); + describe('script resources', () => { + // @gate enableFloat + it('treats async scripts without onLoad or onError as Resources', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + +