From 725debcaa5ade3944211b4bb4a03c955961bf691 Mon Sep 17 00:00:00 2001
From: Jimmy Lai
Date: Fri, 11 Nov 2022 11:35:51 +0100
Subject: [PATCH] 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
-
-
-
-
-
foo bar',
- '',
- ]);
- });
- 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
+
+
+
+
+ foo bar',
+ '',
+ ]);
});
- // 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(
-
-
-
-
-
-
-
- hello world
-
- ,
- );
-
- ReactDOMClient.hydrateRoot(
- document,
-
-
-
-
-