diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js
index dbaba801b185a..9bf674bfc64ab 100644
--- a/packages/react-art/src/ReactARTHostConfig.js
+++ b/packages/react-art/src/ReactARTHostConfig.js
@@ -243,6 +243,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration';
export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes';
export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors';
export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks';
+export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources';
export function appendInitialChild(parentInstance, child) {
if (typeof child === 'string') {
diff --git a/packages/react-dom/index.experimental.js b/packages/react-dom/index.experimental.js
index 44f7dcfe0b1e2..73f14f91953f1 100644
--- a/packages/react-dom/index.experimental.js
+++ b/packages/react-dom/index.experimental.js
@@ -22,3 +22,5 @@ export {
unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority.
version,
} from './src/client/ReactDOM';
+
+export {preinit, preload} from './src/shared/ReactDOMFloat';
diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 4d8e677e03739..8ec7e473c37b6 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -329,6 +329,7 @@ describe('ReactDOMFizzServer', () => {
);
pipe(writable);
});
+
expect(getVisibleChildren(container)).toEqual(
Loading...
@@ -4391,7 +4392,7 @@ describe('ReactDOMFizzServer', () => {
});
// @gate enableFloat
- it('recognizes stylesheet links as attributes during hydration', async () => {
+ it('recognizes stylesheet links as resources during hydration', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<>
@@ -4469,15 +4470,13 @@ describe('ReactDOMFizzServer', () => {
);
try {
expect(Scheduler).toFlushWithoutYielding();
+ // The reason data-bar is not found on the link is props are only used to generate the resource instance
+ // if it does not already exist but in this case it was left behind in the document. In the future
+ // changing props on resources will warn in dev
expect(getVisibleChildren(document)).toEqual(
-
+
a body
,
@@ -4498,7 +4497,7 @@ describe('ReactDOMFizzServer', () => {
// Temporarily this test is expected to fail everywhere. When we have resource hoisting
// it should start to pass and we can adjust the gate accordingly
- // @gate false && enableFloat
+ // @gate experimental && enableFloat
it('should insert missing resources during hydration', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
@@ -4525,7 +4524,7 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(document)).toEqual(
-
+
foo
,
@@ -4546,111 +4545,6 @@ describe('ReactDOMFizzServer', () => {
}
});
- // @gate experimental && enableFloat
- it('fail hydration if a suitable resource cannot be found in the DOM for a given location (href)', async () => {
- gate(flags => {
- if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
- throw new Error('bailing out of test');
- }
- });
- await actIntoEmptyDocument(() => {
- const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
-
-
- a body
- ,
- );
- pipe(writable);
- });
-
- const errors = [];
- ReactDOMClient.hydrateRoot(
- document,
-
-
-
-
- a body
- ,
- {
- onRecoverableError(err, errInfo) {
- errors.push(err.message);
- },
- },
- );
- expect(() => {
- expect(Scheduler).toFlushWithoutYielding();
- }).toErrorDev(
- [
- 'Warning: A matching Hydratable Resource was not found in the DOM for ',
- 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
- ],
- {withoutStack: 1},
- );
- expect(errors).toEqual([
- 'Hydration failed because the initial UI does not match what was rendered on the server.',
- 'Hydration failed because the initial UI does not match what was rendered on the server.',
- 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
- ]);
- });
-
- // @gate experimental && enableFloat
- it('should error in dev when rendering more than one resource for a given location (href)', async () => {
- gate(flags => {
- if (!(__EXPERIMENTAL__ && flags.enableFloat)) {
- throw new Error('bailing out of test');
- }
- });
- await actIntoEmptyDocument(() => {
- const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
- <>
-
-
-
- a body
-
- >,
- );
- pipe(writable);
- });
- expect(getVisibleChildren(document)).toEqual(
-
-
-
-
-
- a body
- ,
- );
-
- const errors = [];
- ReactDOMClient.hydrateRoot(
- document,
- <>
-
-
-
-
-
- a body
-
- >,
- {
- onRecoverableError(err, errInfo) {
- errors.push(err.message);
- },
- },
- );
- expect(() => {
- expect(Scheduler).toFlushWithoutYielding();
- }).toErrorDev([
- 'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"',
- 'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"',
- ]);
- expect(errors).toEqual([]);
- });
-
describe('text separators', () => {
// To force performWork to start before resolving AsyncText but before piping we need to wait until
// after scheduleWork which currently uses setImmediate to delay performWork
diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
new file mode 100644
index 0000000000000..19b523afcac65
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js
@@ -0,0 +1,2652 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ */
+
+'use strict';
+
+let JSDOM;
+let Stream;
+let Scheduler;
+let React;
+let ReactDOM;
+let ReactDOMClient;
+let ReactDOMFizzServer;
+let Suspense;
+let textCache;
+let document;
+let writable;
+const CSPnonce = null;
+let container;
+let buffer = '';
+let hasErrored = false;
+let fatalError = undefined;
+
+describe('ReactDOMFizzServer', () => {
+ 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;
+ });
+ });
+
+ function normalizeCodeLocInfo(str) {
+ if (typeof str !== 'string') {
+ console.log(str);
+ }
+ return (
+ str &&
+ 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;
+ if (
+ node.nodeName === 'SCRIPT' &&
+ (CSPnonce === null || node.getAttribute('nonce') === CSPnonce)
+ ) {
+ const script = document.createElement('script');
+ script.textContent = node.textContent;
+ fakeBody.removeChild(node);
+ parent.appendChild(script);
+ } else {
+ parent.appendChild(node);
+ }
+ }
+ }
+
+ 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 = '';
+ }
+
+ function getVisibleChildren(element) {
+ const children = [];
+ let node = element.firstChild;
+ while (node) {
+ if (node.nodeType === 1) {
+ if (
+ 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++) {
+ 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 = getVisibleChildren(node);
+ children.push(React.createElement(node.tagName.toLowerCase(), props));
+ }
+ } 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;
+ }
+ } 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);
+ }
+
+ // @gate enableFloat
+ it('treats stylesheet links with a precedence as a resource', async () => {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+
+ Hello
+
+ ,
+ );
+ pipe(writable);
+ });
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+ Hello
+ ,
+ );
+
+ ReactDOMClient.hydrateRoot(
+ document,
+
+ Hello
+ ,
+ );
+ expect(Scheduler).toFlushWithoutYielding();
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+ Hello
+ ,
+ );
+ });
+
+ // @gate enableFloat
+ it('inserts text separators following text when followed by an element that is converted to a resource and thus removed from the html inline', async () => {
+ // If you render many of these as siblings the values get emitted as a single text with no separator sometimes
+ // because the link gets elided as a resource
+ function AsyncTextWithResource({text, href, precedence}) {
+ const value = readText(text);
+ return (
+ <>
+ {value}
+
+ >
+ );
+ }
+
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+
+
+
+ ,
+ );
+ pipe(writable);
+ resolveText('foo');
+ resolveText('bar');
+ resolveText('baz');
+ });
+
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+
+
+
+ {'foo'}
+ {'bar'}
+ {'baz'}
+
+ ,
+ );
+ });
+
+ // @gate enableFloat
+ it('hoists late stylesheets the correct precedence', async () => {
+ function AsyncListItemWithResource({text, href, precedence, ...rest}) {
+ const value = readText(text);
+ return (
+
+
+
+
+
+ ,
+ );
+ });
+
+ // @gate enableFloat
+ it('normalizes style resource precedence for all boundaries inlined as part of the shell flush', async () => {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+ outer
+
+
+
+
+
+
+ middle
+
+
+
+
+
+
+ inner
+
+
+
+
+
+
+
+
+
+
+
middle
+
+
+
+
+
+
+
+ ,
+ );
+ pipe(writable);
+ });
+
+ // The reason the href's aren't ordered linearly is that when boundaries complete their resources
+ // get hoisted to the shell directly so they can flush in the head. If a boundary doesn't suspend then
+ // child boundaries will complete before the parent boundary and thus have their resources hoist
+ // early. The reason precedences are still ordered correctly between child and parent is because
+ // the precedence ordering is determined upon first discovernig a resource rather than on hoist and
+ // so it follows render order
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ outer
+
+ middle
inner
+
+
middle
+
+
+ ,
+ );
+ });
+
+ // @gate enableFloat
+ it('style resources are inserted according to precedence order on the client', async () => {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+
+ ,
+ );
+
+ // This will enqueue a style resource in a deep blocked boundary (loading baz...).
+ await act(() => {
+ resolveText('baz');
+ });
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
loading...
+
+ ,
+ );
+
+ // This will enqueue a style resource in the intermediate blocked boundary (loading bar...).
+ await act(() => {
+ resolveText('bar');
+ });
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
loading...
+
+ ,
+ );
+
+ // This will complete a segment in the top level boundary that is still blocked on another segment.
+ // It will flush the completed segment however the inner boundaries should not emit their style dependencies
+ // because they are not going to be revealed yet. instead their dependencies are hoisted to the blocked
+ // boundary (top level).
+ await act(() => {
+ resolveText('foo');
+ });
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
loading...
+
+
+
+
+ ,
+ );
+
+ // This resolves the last blocked segment on the top level boundary so we see all dependencies of the
+ // nested boundaries emitted at this level
+ await act(() => {
+ resolveText('qux');
+ });
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ if (__DEV__) {
+ expect(mockError).toHaveBeenCalledTimes(2);
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: React does not recognize the `%s` prop on a DOM element.' +
+ ' If you intentionally want it to appear in the DOM as a custom attribute,' +
+ ' spell it as lowercase `%s` instead. If you accidentally passed it from a' +
+ ' parent component, remove it from the DOM element.%s',
+ 'nonStandardAttr',
+ 'nonstandardattr',
+ componentStack(['link', 'div', 'body', 'html', 'App']),
+ );
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: Invalid values for props %s on <%s> tag. Either remove them from' +
+ ' the element, or pass a string or number value to keep them in the DOM. For' +
+ ' details, see https://reactjs.org/link/attribute-behavior %s',
+ '`shouldnotincludefunctions`, `norsymbols`',
+ 'link',
+ componentStack(['link', 'div', 'body', 'html', 'App']),
+ );
+ mockError.mockClear();
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+
+ // Now we flush the stylesheet with the boundary
+ await act(() => {
+ resolveText('unblock');
+ });
+
+ expect(getVisibleChildren(document)).toEqual(
+
+
+
+
+
+
+
loading...
+
+ ,
+ );
+ if (__DEV__) {
+ // The way the test is currently set up the props that would warn have already warned
+ // so no new warnings appear. This is really testing the same code pathway so
+ // exercising that more here isn't all that useful
+ expect(mockError).toHaveBeenCalledTimes(0);
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+
+ // @gate enableFloat
+ it('boundary style resource dependencies hoist to a parent boundary when flushed inline', async () => {
+ function BlockedOn({text, children}) {
+ readText(text);
+ return children;
+ }
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+ ,
+ );
+
+ if (__DEV__) {
+ expect(mockError).toHaveBeenCalledTimes(2);
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A %s with href "%s" did not specify the crossOrigin prop. Font preloads must always use' +
+ ' crossOrigin "anonymous" to be functional for loading font files.%s',
+ 'preload Resource (as "font")',
+ 'foo',
+ componentStack(['link', 'body', 'html']),
+ );
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A %s with href "%s" specified a crossOrigin value of "use-credentials". Font preloads must always use' +
+ ' crossOrigin "anonymous" to be functional for loading font files.%s',
+ 'preload Resource (as "font")',
+ 'baz',
+ componentStack(['link', 'body', 'html']),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+
+ describe('pre-function input validation', () => {
+ function Preloads({scenarios}) {
+ for (let i = 0; i < scenarios.length; i++) {
+ const href = scenarios[i][0];
+ const options = scenarios[i][1];
+ ReactDOM.preload(href, options);
+ }
+ }
+ function Preinits({scenarios}) {
+ for (let i = 0; i < scenarios.length; i++) {
+ const href = scenarios[i][0];
+ const options = scenarios[i][1];
+ ReactDOM.preinit(href, options);
+ }
+ }
+ async function render(Component, scenarios) {
+ const originalConsoleError = console.error;
+ const mockError = jest.fn();
+ console.error = (...args) => {
+ mockError(...args.map(normalizeCodeLocInfo));
+ };
+ try {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+
+ ,
+ );
+ pipe(writable);
+ });
+ for (let i = 0; i < scenarios.length; i++) {
+ const assertion = scenarios[i][2];
+ assertion(mockError, i);
+ }
+ } finally {
+ console.error = originalConsoleError;
+ }
+ }
+
+ // @gate enableFloat
+ it('warns when an invalid href argument is provided to ReactDOM.preload on the server', async () => {
+ const expectedMessage =
+ 'Warning: ReactDOM.preload expected the first argument to be a string representing an href but found %s instead.%s';
+ const expectedStack = componentStack(['Preloads', 'head', 'html']);
+ function makeArgs(...substitutions) {
+ return [expectedMessage, ...substitutions, expectedStack];
+ }
+ await render(Preloads, [
+ [
+ '',
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('an empty string'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ undefined,
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('undefined'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ null,
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('null'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 232132,
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('something with type "number"'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ {},
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('something with type "object"'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ ]);
+ });
+
+ // @gate enableFloat
+ it('warns when an invalid href argument is provided to ReactDOM.preinit on the server', async () => {
+ const expectedMessage =
+ 'Warning: ReactDOM.preinit expected the first argument to be a string representing an href but found %s instead.%s';
+ const expectedStack = componentStack(['Preinits', 'head', 'html']);
+ function makeArgs(...substitutions) {
+ return [expectedMessage, ...substitutions, expectedStack];
+ }
+ await render(Preinits, [
+ [
+ '',
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('an empty string'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ undefined,
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('undefined'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ null,
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('null'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 232132,
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('something with type "number"'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ {},
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('something with type "object"'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ ]);
+ });
+
+ // @gate enableFloat
+ it('warns when an invalid options argument is provided to ReactDOM.preload on the server', async () => {
+ const expectedMessage =
+ 'Warning: ReactDOM.preload expected the second argument to be an options argument containing at least an "as" property' +
+ ' specifying the Resource type. It found %s instead. The href for the preload call where this warning originated is "%s".%s';
+ const expectedStack = componentStack(['Preloads', 'head', 'html']);
+ function makeArgs(...substitutions) {
+ return [expectedMessage, ...substitutions, expectedStack];
+ }
+ await render(Preloads, [
+ [
+ 'foo',
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('undefined', 'foo'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'foo',
+ null,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('null', 'foo'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'foo',
+ 'bar',
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('something with type "string"', 'foo'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'foo',
+ 123,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('something with type "number"', 'foo'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ ]);
+ });
+
+ // @gate enableFloat
+ it('warns when an invalid options argument is provided to ReactDOM.preinit on the server', async () => {
+ const expectedMessage =
+ 'Warning: ReactDOM.preinit expected the second argument to be an options argument containing at least an "as" property' +
+ ' specifying the Resource type. It found %s instead. %s for preinit %s %s. The href for' +
+ ' the preinit call where this warning originated is "%s".%s';
+ const expectedStack = componentStack(['Preinits', 'head', 'html']);
+ function makeArgs(...substitutions) {
+ return [expectedMessage, ...substitutions, expectedStack];
+ }
+ await render(Preinits, [
+ [
+ 'foo',
+ undefined,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'undefined',
+ 'Currently, the only valid resource type',
+ 'is',
+ '"style"',
+ 'foo',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'foo',
+ null,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'null',
+ 'Currently, the only valid resource type',
+ 'is',
+ '"style"',
+ 'foo',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'foo',
+ 'bar',
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'something with type "string"',
+ 'Currently, the only valid resource type',
+ 'is',
+ '"style"',
+ 'foo',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'foo',
+ 123,
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'something with type "number"',
+ 'Currently, the only valid resource type',
+ 'is',
+ '"style"',
+ 'foo',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ ]);
+ });
+
+ // @gate enableFloat
+ it('warns when an invalid "as" option is provided to ReactDOM.preload on the server', async () => {
+ const expectedMessage =
+ 'Warning: ReactDOM.preload expected the a valid "as" type in the options (second) argument but found %s instead.' +
+ ' Please provide a type from one of the following valid values instead: %s. The href for the preload call' +
+ ' where this warning originated is "%s".%s';
+ const expectedStack = componentStack(['Preloads', 'head', 'html']);
+ function makeArgs(...substitutions) {
+ return [expectedMessage, ...substitutions, expectedStack];
+ }
+ await render(Preloads, [
+ [
+ 'foo',
+ {},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('undefined', '"style" and "font"', 'foo'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'bar',
+ {as: null},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('null', '"style" and "font"', 'bar'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'baz',
+ {as: 123},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'something with type "number"',
+ '"style" and "font"',
+ 'baz',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'qux',
+ {as: {}},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'something with type "object"',
+ '"style" and "font"',
+ 'qux',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'quux',
+ {as: 'bar'},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs('"bar"', '"style" and "font"', 'quux'),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ ]);
+ });
+
+ // @gate enableFloat
+ it('warns when an invalid "as" option is provided to ReactDOM.preinit on the server', async () => {
+ const expectedMessage =
+ 'Warning: ReactDOM.preinit expected a valid "as" type in the options (second) argument but found %s instead.' +
+ ' %s for preinit %s %s. The href for the preinit call where this warning originated is "%s".%s';
+ const expectedStack = componentStack(['Preinits', 'head', 'html']);
+ function makeArgs(...substitutions) {
+ return [expectedMessage, ...substitutions, expectedStack];
+ }
+ await render(Preinits, [
+ [
+ 'foo',
+ {},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'undefined',
+ 'Currently, the only valid resource type',
+ 'is',
+ '"style"',
+ 'foo',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'bar',
+ {as: null},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'null',
+ 'Currently, the only valid resource type',
+ 'is',
+ '"style"',
+ 'bar',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'baz',
+ {as: 123},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'something with type "number"',
+ 'Currently, the only valid resource type',
+ 'is',
+ '"style"',
+ 'baz',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'qux',
+ {as: {}},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ 'something with type "object"',
+ 'Currently, the only valid resource type',
+ 'is',
+ '"style"',
+ 'qux',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ [
+ 'quux',
+ {as: 'bar'},
+ (mockError, scenarioNumber) => {
+ if (__DEV__) {
+ expect(mockError.mock.calls[scenarioNumber]).toEqual(
+ makeArgs(
+ '"bar"',
+ 'Currently, the only valid resource type',
+ 'is',
+ '"style"',
+ 'quux',
+ ),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ },
+ ],
+ ]);
+ });
+ });
+
+ describe('prop validation', () => {
+ // @gate enableFloat
+ it('warns when style Resource have different values for media for the same href', async () => {
+ const originalConsoleError = console.error;
+ const mockError = jest.fn();
+ console.error = (...args) => {
+ mockError(...args.map(normalizeCodeLocInfo));
+ };
+ try {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ pipe(writable);
+ });
+
+ if (__DEV__) {
+ expect(mockError).toHaveBeenCalledTimes(3);
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' +
+ ' are encountered so this new value will be ignored. Please update the props to agree or change' +
+ ' the href to be distinct. %s.%s',
+ 'style Resource',
+ 'foo',
+ 'is missing the media prop found on',
+ 'an earlier instance of this Resource',
+ 'the original prop value was "all"',
+ componentStack(['link', 'head', 'html']),
+ );
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' +
+ ' are encountered so this new value will be ignored. Please update the props to agree or change' +
+ ' the href to be distinct. %s.%s',
+ 'style Resource',
+ 'bar',
+ 'has a media prop not found on',
+ 'an earlier instance of this Resource',
+ 'the extra prop value is "all"',
+ componentStack(['link', 'head', 'html']),
+ );
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' +
+ ' are encountered so this new value will be ignored. Please update the props to agree or change' +
+ ' the href to be distinct. %s.%s',
+ 'style Resource',
+ 'baz',
+ 'has a media prop value that is different from',
+ 'an earlier instance of this Resource',
+ 'the original value was "some". The new value is "all"',
+ componentStack(['link', 'head', 'html']),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+
+ // @gate enableFloat
+ it('warns when style resource props differ or are added for the same href', async () => {
+ const originalConsoleError = console.error;
+ const mockError = jest.fn();
+ console.error = (...args) => {
+ mockError(...args.map(normalizeCodeLocInfo));
+ };
+ try {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+ pipe(writable);
+ });
+
+ if (__DEV__) {
+ expect(mockError).toHaveBeenCalledTimes(3);
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' +
+ ' are encountered so this new value will be ignored. Please update the props to agree or change' +
+ ' the href to be distinct. %s.%s',
+ 'style Resource',
+ 'foo',
+ 'has a precedence prop value that is different from',
+ 'an earlier instance of this Resource',
+ 'the original value was "foo". The new value is "foonew"',
+ componentStack(['link', 'head', 'html']),
+ );
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' +
+ ' are encountered so this new value will be ignored. Please update the props to agree or change' +
+ ' the href to be distinct. %s.%s',
+ 'style Resource',
+ 'bar',
+ 'has a data-foo prop not found on',
+ 'an earlier instance of this Resource',
+ 'the extra prop value is "a new value"',
+ componentStack(['link', 'head', 'html']),
+ );
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' +
+ ' are encountered so this new value will be ignored. Please update the props to agree or change' +
+ ' the href to be distinct. %s.%s',
+ 'style Resource',
+ 'baz',
+ 'has a data-foo prop value that is different from',
+ 'an earlier instance of this Resource',
+ 'the original value was "an original value". The new value is "a new value"',
+ componentStack(['link', 'head', 'html']),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+
+ // At the moment there are no other resource types besides "style"
+ xit('errors creating a style Resource when there is a preload resource with a disagreeing as type', async () => {
+ const originalConsoleError = console.error;
+ const mockError = jest.fn();
+ console.error = (...args) => {
+ mockError(...args.map(normalizeCodeLocInfo));
+ };
+ try {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+
+
+
+ ,
+ );
+ pipe(writable);
+ });
+
+ if (__DEV__) {
+ expect(mockError).toHaveBeenCalledTimes(1);
+ expect(mockError).toHaveBeenCalledWith(
+ 'While creating a style Resource for href "%s" a preload resource for this same' +
+ ' href was found with an "as" prop of "%s". When preloading a stylesheet the "as" prop must be of type' +
+ ' "style". This most likely ocurred by rending a preload link with an incorrect "as" prop or by calling' +
+ ' ReactDOM.preload with an incorrect "as" option.',
+ 'foo',
+ 'font',
+ componentStack(['link', 'head', 'html']),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+
+ // @gate enableFloat
+ it('warns when style Resource includes onLoad and/or onError props', async () => {
+ const originalConsoleError = console.error;
+ const mockError = jest.fn();
+ console.error = (...args) => {
+ mockError(...args.map(normalizeCodeLocInfo));
+ };
+ try {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+
+ {}}
+ onError={() => {}}
+ />
+ {}}
+ />
+ {}}
+ />
+
+ ,
+ );
+ pipe(writable);
+ });
+
+ if (__DEV__) {
+ expect(mockError).toHaveBeenCalledTimes(3);
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included %s %s.' +
+ ' When using onLoad or onError React will opt out of Resource behavior. If you meant for this' +
+ ' element to be treated as a Resource remove the %s %s. Otherwise remove the precedence prop.%s',
+ 'foo',
+ 'onLoad and onError',
+ 'props',
+ 'onLoad and onError',
+ 'props',
+ componentStack(['link', 'head', 'html']),
+ );
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included %s %s.' +
+ ' When using onLoad or onError React will opt out of Resource behavior. If you meant for this' +
+ ' element to be treated as a Resource remove the %s %s. Otherwise remove the precedence prop.%s',
+ 'bar',
+ 'onLoad',
+ 'prop',
+ 'onLoad',
+ 'prop',
+ componentStack(['link', 'head', 'html']),
+ );
+ expect(mockError).toHaveBeenCalledWith(
+ 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included %s %s.' +
+ ' When using onLoad or onError React will opt out of Resource behavior. If you meant for this' +
+ ' element to be treated as a Resource remove the %s %s. Otherwise remove the precedence prop.%s',
+ 'baz',
+ 'onError',
+ 'prop',
+ 'onError',
+ 'prop',
+ componentStack(['link', 'head', 'html']),
+ );
+ } else {
+ expect(mockError).not.toHaveBeenCalled();
+ }
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+
+ // @gate enableFloat
+ it('warns when preload Resources have new or different values for props', async () => {
+ const originalConsoleError = console.error;
+ const mockError = jest.fn();
+ console.error = (...args) => {
+ mockError(...args.map(normalizeCodeLocInfo));
+ };
+ try {
+ await actIntoEmptyDocument(() => {
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
+
+