mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
e43986f1f3
This is rolled out to 100%. Let me merge it though.
9429 lines
293 KiB
JavaScript
9429 lines
293 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and 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
|
|
* @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
|
|
*/
|
|
|
|
'use strict';
|
|
import {
|
|
insertNodesAndExecuteScripts,
|
|
mergeOptions,
|
|
} from '../test-utils/FizzTestUtils';
|
|
|
|
let JSDOM;
|
|
let Stream;
|
|
let React;
|
|
let ReactDOM;
|
|
let ReactDOMClient;
|
|
let ReactDOMFizzServer;
|
|
let Suspense;
|
|
let SuspenseList;
|
|
let textCache;
|
|
let loadCache;
|
|
let writable;
|
|
let CSPnonce = null;
|
|
let container;
|
|
let buffer = '';
|
|
let hasErrored = false;
|
|
let fatalError = undefined;
|
|
let renderOptions;
|
|
let waitForAll;
|
|
let assertLog;
|
|
let Scheduler;
|
|
let clientAct;
|
|
let streamingContainer;
|
|
let assertConsoleErrorDev;
|
|
|
|
describe('ReactDOMFloat', () => {
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
JSDOM = require('jsdom').JSDOM;
|
|
|
|
const jsdom = new JSDOM(
|
|
'<!DOCTYPE html><html><head></head><body><div id="container">',
|
|
{
|
|
runScripts: 'dangerously',
|
|
},
|
|
);
|
|
// We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else
|
|
Object.defineProperty(jsdom.window, 'matchMedia', {
|
|
writable: true,
|
|
value: jest.fn().mockImplementation(query => ({
|
|
matches: query === 'all' || query === '',
|
|
media: query,
|
|
})),
|
|
});
|
|
streamingContainer = null;
|
|
global.window = jsdom.window;
|
|
global.document = global.window.document;
|
|
global.navigator = global.window.navigator;
|
|
global.Node = global.window.Node;
|
|
global.addEventListener = global.window.addEventListener;
|
|
global.MutationObserver = global.window.MutationObserver;
|
|
// The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
|
|
global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
|
|
setTimeout(cb);
|
|
container = document.getElementById('container');
|
|
|
|
CSPnonce = null;
|
|
React = require('react');
|
|
ReactDOM = require('react-dom');
|
|
ReactDOMClient = require('react-dom/client');
|
|
ReactDOMFizzServer = require('react-dom/server');
|
|
Stream = require('stream');
|
|
Suspense = React.Suspense;
|
|
SuspenseList = React.unstable_SuspenseList;
|
|
Scheduler = require('scheduler/unstable_mock');
|
|
|
|
const InternalTestUtils = require('internal-test-utils');
|
|
waitForAll = InternalTestUtils.waitForAll;
|
|
assertLog = InternalTestUtils.assertLog;
|
|
clientAct = InternalTestUtils.act;
|
|
assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
|
|
|
|
textCache = new Map();
|
|
loadCache = new Set();
|
|
|
|
buffer = '';
|
|
hasErrored = false;
|
|
|
|
writable = new Stream.PassThrough();
|
|
writable.setEncoding('utf8');
|
|
writable.on('data', chunk => {
|
|
buffer += chunk;
|
|
});
|
|
writable.on('error', error => {
|
|
hasErrored = true;
|
|
fatalError = error;
|
|
});
|
|
|
|
renderOptions = {};
|
|
if (gate(flags => flags.shouldUseFizzExternalRuntime)) {
|
|
renderOptions.unstable_externalRuntimeSrc =
|
|
'react-dom/unstable_server-external-runtime';
|
|
}
|
|
});
|
|
|
|
const bodyStartMatch = /<body(?:>| .*?>)/;
|
|
const headStartMatch = /<head(?:>| .*?>)/;
|
|
|
|
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.
|
|
let bufferedContent = buffer;
|
|
buffer = '';
|
|
|
|
if (!bufferedContent) {
|
|
jest.runAllTimers();
|
|
return;
|
|
}
|
|
|
|
const bodyMatch = bufferedContent.match(bodyStartMatch);
|
|
const headMatch = bufferedContent.match(headStartMatch);
|
|
|
|
if (streamingContainer === null) {
|
|
// This is the first streamed content. We decide here where to insert it. If we get <html>, <head>, or <body>
|
|
// we abandon the pre-built document and start from scratch. If we get anything else we assume it goes into the
|
|
// container. This is not really production behavior because you can't correctly stream into a deep div effectively
|
|
// but it's pragmatic for tests.
|
|
|
|
if (
|
|
bufferedContent.startsWith('<head>') ||
|
|
bufferedContent.startsWith('<head ') ||
|
|
bufferedContent.startsWith('<body>') ||
|
|
bufferedContent.startsWith('<body ')
|
|
) {
|
|
// wrap in doctype to normalize the parsing process
|
|
bufferedContent = '<!DOCTYPE html><html>' + bufferedContent;
|
|
} else if (
|
|
bufferedContent.startsWith('<html>') ||
|
|
bufferedContent.startsWith('<html ')
|
|
) {
|
|
throw new Error(
|
|
'Recieved <html> without a <!DOCTYPE html> which is almost certainly a bug in React',
|
|
);
|
|
}
|
|
|
|
if (bufferedContent.startsWith('<!DOCTYPE html>')) {
|
|
// we can just use the whole document
|
|
const tempDom = new JSDOM(bufferedContent);
|
|
|
|
// Wipe existing head and body content
|
|
document.head.innerHTML = '';
|
|
document.body.innerHTML = '';
|
|
|
|
// Copy the <html> attributes over
|
|
const tempHtmlNode = tempDom.window.document.documentElement;
|
|
for (let i = 0; i < tempHtmlNode.attributes.length; i++) {
|
|
const attr = tempHtmlNode.attributes[i];
|
|
document.documentElement.setAttribute(attr.name, attr.value);
|
|
}
|
|
|
|
if (headMatch) {
|
|
// We parsed a head open tag. we need to copy head attributes and insert future
|
|
// content into <head>
|
|
streamingContainer = document.head;
|
|
const tempHeadNode = tempDom.window.document.head;
|
|
for (let i = 0; i < tempHeadNode.attributes.length; i++) {
|
|
const attr = tempHeadNode.attributes[i];
|
|
document.head.setAttribute(attr.name, attr.value);
|
|
}
|
|
const source = document.createElement('head');
|
|
source.innerHTML = tempHeadNode.innerHTML;
|
|
await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
|
|
}
|
|
|
|
if (bodyMatch) {
|
|
// We parsed a body open tag. we need to copy head attributes and insert future
|
|
// content into <body>
|
|
streamingContainer = document.body;
|
|
const tempBodyNode = tempDom.window.document.body;
|
|
for (let i = 0; i < tempBodyNode.attributes.length; i++) {
|
|
const attr = tempBodyNode.attributes[i];
|
|
document.body.setAttribute(attr.name, attr.value);
|
|
}
|
|
const source = document.createElement('body');
|
|
source.innerHTML = tempBodyNode.innerHTML;
|
|
await insertNodesAndExecuteScripts(source, document.body, CSPnonce);
|
|
}
|
|
|
|
if (!headMatch && !bodyMatch) {
|
|
throw new Error('expected <head> or <body> after <html>');
|
|
}
|
|
} else {
|
|
// we assume we are streaming into the default container'
|
|
streamingContainer = container;
|
|
const div = document.createElement('div');
|
|
div.innerHTML = bufferedContent;
|
|
await insertNodesAndExecuteScripts(div, container, CSPnonce);
|
|
}
|
|
} else if (streamingContainer === document.head) {
|
|
bufferedContent = '<!DOCTYPE html><html><head>' + bufferedContent;
|
|
const tempDom = new JSDOM(bufferedContent);
|
|
|
|
const tempHeadNode = tempDom.window.document.head;
|
|
const source = document.createElement('head');
|
|
source.innerHTML = tempHeadNode.innerHTML;
|
|
await insertNodesAndExecuteScripts(source, document.head, CSPnonce);
|
|
|
|
if (bodyMatch) {
|
|
streamingContainer = document.body;
|
|
|
|
const tempBodyNode = tempDom.window.document.body;
|
|
for (let i = 0; i < tempBodyNode.attributes.length; i++) {
|
|
const attr = tempBodyNode.attributes[i];
|
|
document.body.setAttribute(attr.name, attr.value);
|
|
}
|
|
const bodySource = document.createElement('body');
|
|
bodySource.innerHTML = tempBodyNode.innerHTML;
|
|
await insertNodesAndExecuteScripts(bodySource, document.body, CSPnonce);
|
|
}
|
|
} else {
|
|
const div = document.createElement('div');
|
|
div.innerHTML = bufferedContent;
|
|
await insertNodesAndExecuteScripts(div, streamingContainer, CSPnonce);
|
|
}
|
|
await 0;
|
|
// Let throttled boundaries reveal
|
|
jest.runAllTimers();
|
|
}
|
|
|
|
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') &&
|
|
// Ignore the render blocking expect
|
|
(node.getAttribute('rel') !== 'expect' ||
|
|
node.getAttribute('blocking') !== 'render'))
|
|
) {
|
|
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));
|
|
}
|
|
} else if (node.nodeType === 3) {
|
|
children.push(node.data);
|
|
}
|
|
node = node.nextSibling;
|
|
}
|
|
return children.length === 0
|
|
? undefined
|
|
: children.length === 1
|
|
? children[0]
|
|
: children;
|
|
}
|
|
|
|
function BlockedOn({value, children}) {
|
|
readText(value);
|
|
return 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);
|
|
}
|
|
|
|
function renderToPipeableStream(jsx, options) {
|
|
// Merge options with renderOptions, which may contain featureFlag specific behavior
|
|
return ReactDOMFizzServer.renderToPipeableStream(
|
|
jsx,
|
|
mergeOptions(options, renderOptions),
|
|
);
|
|
}
|
|
|
|
function loadPreloads(hrefs) {
|
|
const event = new window.Event('load');
|
|
const nodes = document.querySelectorAll('link[rel="preload"]');
|
|
resolveLoadables(hrefs, nodes, event, href =>
|
|
Scheduler.log('load preload: ' + href),
|
|
);
|
|
}
|
|
|
|
function errorPreloads(hrefs) {
|
|
const event = new window.Event('error');
|
|
const nodes = document.querySelectorAll('link[rel="preload"]');
|
|
resolveLoadables(hrefs, nodes, event, href =>
|
|
Scheduler.log('error preload: ' + href),
|
|
);
|
|
}
|
|
|
|
function loadStylesheets(hrefs) {
|
|
loadStylesheetsFrom(document, hrefs);
|
|
}
|
|
|
|
function loadStylesheetsFrom(root, hrefs) {
|
|
const event = new window.Event('load');
|
|
const nodes = root.querySelectorAll('link[rel="stylesheet"]');
|
|
resolveLoadables(hrefs, nodes, event, href => {
|
|
Scheduler.log('load stylesheet: ' + href);
|
|
});
|
|
}
|
|
|
|
function errorStylesheets(hrefs) {
|
|
const event = new window.Event('error');
|
|
const nodes = document.querySelectorAll('link[rel="stylesheet"]');
|
|
resolveLoadables(hrefs, nodes, event, href => {
|
|
Scheduler.log('error stylesheet: ' + href);
|
|
});
|
|
}
|
|
|
|
function resolveLoadables(hrefs, nodes, event, onLoad) {
|
|
const hrefSet = hrefs ? new Set(hrefs) : null;
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const node = nodes[i];
|
|
if (loadCache.has(node)) {
|
|
continue;
|
|
}
|
|
const href = node.getAttribute('href');
|
|
if (!hrefSet || hrefSet.has(href)) {
|
|
loadCache.add(node);
|
|
onLoad(href);
|
|
node.dispatchEvent(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
it('can render resources before singletons', async () => {
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(
|
|
<>
|
|
<title>foo</title>
|
|
<html>
|
|
<head>
|
|
<link rel="foo" href="foo" />
|
|
</head>
|
|
<body>hello world</body>
|
|
</html>
|
|
</>,
|
|
);
|
|
try {
|
|
await waitForAll([]);
|
|
} 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
|
|
await waitForAll([]);
|
|
throw e;
|
|
}
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<title>foo</title>
|
|
<link rel="foo" href="foo" />
|
|
</head>
|
|
<body>hello world</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can hydrate non Resources in head when Resources are also inserted there', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head>
|
|
<meta property="foo" content="bar" />
|
|
<link rel="foo" href="bar" onLoad={() => {}} />
|
|
<title>foo</title>
|
|
<noscript>
|
|
<link rel="icon" href="icon" />
|
|
</noscript>
|
|
<base target="foo" href="bar" />
|
|
<script async={true} src="foo" onLoad={() => {}} />
|
|
</head>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta property="foo" content="bar" />
|
|
<title>foo</title>
|
|
<link rel="foo" href="bar" />
|
|
<noscript><link rel="icon" href="icon"></noscript>
|
|
<base target="foo" href="bar" />
|
|
<script async="" src="foo" />
|
|
</head>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<head>
|
|
<meta property="foo" content="bar" />
|
|
<link rel="foo" href="bar" onLoad={() => {}} />
|
|
<title>foo</title>
|
|
<noscript>
|
|
<link rel="icon" href="icon" />
|
|
</noscript>
|
|
<base target="foo" href="bar" />
|
|
<script async={true} src="foo" onLoad={() => {}} />
|
|
</head>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta property="foo" content="bar" />
|
|
<title>foo</title>
|
|
<link rel="foo" href="bar" />
|
|
<noscript><link rel="icon" href="icon"></noscript>
|
|
<base target="foo" href="bar" />
|
|
<script async="" src="foo" />
|
|
</head>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('warns if you render resource-like elements above <head> or <body>', async () => {
|
|
const root = ReactDOMClient.createRoot(document);
|
|
|
|
root.render(
|
|
<>
|
|
<noscript>foo</noscript>
|
|
<html>
|
|
<body>foo</body>
|
|
</html>
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
[
|
|
'Cannot render <noscript> outside the main document. Try moving it into the root <head> tag.',
|
|
{withoutStack: true},
|
|
],
|
|
]);
|
|
|
|
root.render(
|
|
<html>
|
|
<template>foo</template>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'Cannot render <template> outside the main document. Try moving it into the root <head> tag.\n' +
|
|
' in html (at **)',
|
|
'In HTML, <template> cannot be a child of <html>.\n' +
|
|
'This will cause a hydration error.\n\n' +
|
|
'> <html>\n' +
|
|
'> <template>\n' +
|
|
' ...\n' +
|
|
'\n' +
|
|
' in template (at **)',
|
|
]);
|
|
|
|
root.render(
|
|
<html>
|
|
<body>foo</body>
|
|
<style>foo</style>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'Cannot render a <style> outside the main document without knowing its precedence ' +
|
|
'and a unique href key. React can hoist and deduplicate <style> tags if you provide a ' +
|
|
'`precedence` prop along with an `href` prop that does not conflict with the `href` ' +
|
|
'values used in any other hoisted <style> or <link rel="stylesheet" ...> tags. ' +
|
|
'Note that hoisting <style> tags is considered an advanced feature that most will not use directly. ' +
|
|
'Consider moving the <style> tag to the <head> or consider adding a `precedence="default"` ' +
|
|
'and `href="some unique resource identifier"`.\n' +
|
|
' in html (at **)',
|
|
'In HTML, <style> cannot be a child of <html>.\n' +
|
|
'This will cause a hydration error.\n\n' +
|
|
'> <html>\n' +
|
|
' <body>\n' +
|
|
'> <style>\n' +
|
|
'\n' +
|
|
' in style (at **)',
|
|
]);
|
|
|
|
root.render(
|
|
<>
|
|
<html>
|
|
<body>foo</body>
|
|
</html>
|
|
<link rel="stylesheet" href="foo" />
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
[
|
|
'Cannot render a <link rel="stylesheet" /> outside the main document without knowing its precedence. ' +
|
|
'Consider adding precedence="default" or moving it into the root <head> tag.',
|
|
{withoutStack: true},
|
|
],
|
|
]);
|
|
|
|
root.render(
|
|
<>
|
|
<html>
|
|
<body>foo</body>
|
|
<script href="foo" />
|
|
</html>
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'Cannot render a sync or defer <script> outside the main document without knowing its order. ' +
|
|
'Try adding async="" or moving it into the root <head> tag.\n' +
|
|
' in html (at **)',
|
|
'In HTML, <script> cannot be a child of <html>.\n' +
|
|
'This will cause a hydration error.\n' +
|
|
'\n' +
|
|
'> <html>\n' +
|
|
' <body>\n' +
|
|
'> <script href="foo">\n' +
|
|
'\n' +
|
|
' in script (at **)',
|
|
]);
|
|
|
|
root.render(
|
|
<html>
|
|
<script async={true} onLoad={() => {}} href="bar" />
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'Cannot render a <script> with onLoad or onError listeners outside the main document. ' +
|
|
'Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or ' +
|
|
'somewhere in the <body>.\n' +
|
|
' in html (at **)',
|
|
]);
|
|
|
|
root.render(
|
|
<>
|
|
<link rel="foo" onLoad={() => {}} href="bar" />
|
|
<html>
|
|
<body>foo</body>
|
|
</html>
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev(
|
|
[
|
|
'Cannot render a <link> with onLoad or onError listeners outside the main document. ' +
|
|
'Try removing onLoad={...} and onError={...} or moving it into the root <head> tag or ' +
|
|
'somewhere in the <body>.',
|
|
],
|
|
{withoutStack: true},
|
|
);
|
|
return;
|
|
});
|
|
|
|
it('can acquire a resource after releasing it in the same commit', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(
|
|
<>
|
|
<script async={true} src="foo" />
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" />
|
|
</head>
|
|
<body>
|
|
<div id="container" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(
|
|
<>
|
|
{null}
|
|
<script data-new="new" async={true} src="foo" />
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
// we don't see the attribute because the resource is the same and was not reconstructed
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" />
|
|
</head>
|
|
<body>
|
|
<div id="container" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('emits an implicit <head> element to hold resources when none is rendered but an <html> is rendered', async () => {
|
|
const chunks = [];
|
|
|
|
writable.on('data', chunk => {
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<>
|
|
<title>foo</title>
|
|
<html>
|
|
<body>bar</body>
|
|
</html>
|
|
<script async={true} src="foo" />
|
|
</>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(chunks).toEqual([
|
|
'<!DOCTYPE html><html><head><script async="" src="foo"></script>' +
|
|
(gate(flags => flags.shouldUseFizzExternalRuntime)
|
|
? '<script src="react-dom/unstable_server-external-runtime" async=""></script>'
|
|
: '') +
|
|
(gate(flags => flags.enableFizzBlockingRender)
|
|
? '<link rel="expect" href="#_R_" blocking="render"/>'
|
|
: '') +
|
|
'<title>foo</title></head>' +
|
|
'<body>bar' +
|
|
(gate(flags => flags.enableFizzBlockingRender)
|
|
? '<template id="_R_"></template>'
|
|
: ''),
|
|
'</body></html>',
|
|
]);
|
|
});
|
|
|
|
it('dedupes if the external runtime is explicitly loaded using preinit', async () => {
|
|
const unstable_externalRuntimeSrc = 'src-of-external-runtime';
|
|
function App() {
|
|
ReactDOM.preinit(unstable_externalRuntimeSrc, {as: 'script'});
|
|
return (
|
|
<div>
|
|
<Suspense fallback={<h1>Loading...</h1>}>
|
|
<AsyncText text="Hello" />
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<App />
|
|
</body>
|
|
</html>,
|
|
{
|
|
unstable_externalRuntimeSrc,
|
|
},
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(
|
|
Array.from(document.querySelectorAll('script[async]')).map(
|
|
n => n.outerHTML,
|
|
),
|
|
).toEqual(['<script src="src-of-external-runtime" async=""></script>']);
|
|
});
|
|
|
|
it('can send style insertion implementation independent of boundary commpletion instruction implementation', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading foo...">
|
|
<BlockedOn value="foo">foo</BlockedOn>
|
|
</Suspense>
|
|
<Suspense fallback="loading bar...">
|
|
<BlockedOn value="bar">
|
|
<link rel="stylesheet" href="bar" precedence="bar" />
|
|
bar
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
{'loading foo...'}
|
|
{'loading bar...'}
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('foo');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
foo
|
|
{'loading bar...'}
|
|
</body>
|
|
</html>,
|
|
);
|
|
await act(() => {
|
|
resolveText('bar');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="bar" data-precedence="bar" />
|
|
</head>
|
|
<body>
|
|
foo
|
|
{'loading bar...'}
|
|
<link rel="preload" href="bar" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can avoid inserting a late stylesheet if it already rendered on the client', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading foo...">
|
|
<BlockedOn value="foo">
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
foo
|
|
</BlockedOn>
|
|
</Suspense>
|
|
<Suspense fallback="loading bar...">
|
|
<BlockedOn value="bar">
|
|
<link rel="stylesheet" href="bar" precedence="bar" />
|
|
bar
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
{'loading foo...'}
|
|
{'loading bar...'}
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
<Suspense fallback="loading foo...">
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
foo
|
|
</Suspense>
|
|
<Suspense fallback="loading bar...">
|
|
<link rel="stylesheet" href="bar" precedence="bar" />
|
|
bar
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
loadPreloads();
|
|
await assertLog(['load preload: foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
<link as="style" href="foo" rel="preload" />
|
|
</head>
|
|
<body>
|
|
{'loading foo...'}
|
|
{'loading bar...'}
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('bar');
|
|
});
|
|
await act(() => {
|
|
loadStylesheets();
|
|
});
|
|
await assertLog(['load stylesheet: foo', 'load stylesheet: bar']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
<link rel="stylesheet" href="bar" data-precedence="bar" />
|
|
<link as="style" href="foo" rel="preload" />
|
|
</head>
|
|
<body>
|
|
{'loading foo...'}
|
|
{'bar'}
|
|
<link as="style" href="bar" rel="preload" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('foo');
|
|
});
|
|
await act(() => {
|
|
loadStylesheets();
|
|
});
|
|
await assertLog([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
<link rel="stylesheet" href="bar" data-precedence="bar" />
|
|
<link as="style" href="foo" rel="preload" />
|
|
</head>
|
|
<body>
|
|
{'foo'}
|
|
{'bar'}
|
|
<link as="style" href="bar" rel="preload" />
|
|
<link as="style" href="foo" rel="preload" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can hoist <link rel="stylesheet" .../> and <style /> tags together, respecting order of discovery', async () => {
|
|
const css = `
|
|
body {
|
|
background-color: red;
|
|
}`;
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" href="one1" precedence="one" />
|
|
<style href="two1" precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three1" precedence="three" />
|
|
<style href="four1" precedence="four">
|
|
{css}
|
|
</style>
|
|
<Suspense>
|
|
<BlockedOn value="block">
|
|
<link rel="stylesheet" href="one2" precedence="one" />
|
|
<link rel="stylesheet" href="two2" precedence="two" />
|
|
<style href="three2" precedence="three">
|
|
{css}
|
|
</style>
|
|
<style href="four2" precedence="four">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="five1" precedence="five" />
|
|
</BlockedOn>
|
|
</Suspense>
|
|
<Suspense>
|
|
<BlockedOn value="block2">
|
|
<style href="one3" precedence="one">
|
|
{css}
|
|
</style>
|
|
<style href="two3" precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three3" precedence="three" />
|
|
<link rel="stylesheet" href="four3" precedence="four" />
|
|
<style href="six1" precedence="six">
|
|
{css}
|
|
</style>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
<Suspense>
|
|
<BlockedOn value="block again">
|
|
<link rel="stylesheet" href="one2" precedence="one" />
|
|
<link rel="stylesheet" href="two2" precedence="two" />
|
|
<style href="three2" precedence="three">
|
|
{css}
|
|
</style>
|
|
<style href="four2" precedence="four">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="five1" precedence="five" />
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="one1" data-precedence="one" />
|
|
<style data-href="two1" data-precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three1" data-precedence="three" />
|
|
<style data-href="four1" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('block');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="one1" data-precedence="one" />
|
|
<link rel="stylesheet" href="one2" data-precedence="one" />
|
|
<style data-href="two1" data-precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="two2" data-precedence="two" />
|
|
<link rel="stylesheet" href="three1" data-precedence="three" />
|
|
<style data-href="three2" data-precedence="three">
|
|
{css}
|
|
</style>
|
|
<style data-href="four1" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
<style data-href="four2" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="five1" data-precedence="five" />
|
|
</head>
|
|
<body>
|
|
<link rel="preload" href="one2" as="style" />
|
|
<link rel="preload" href="two2" as="style" />
|
|
<link rel="preload" href="five1" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('block2');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="one1" data-precedence="one" />
|
|
<link rel="stylesheet" href="one2" data-precedence="one" />
|
|
<style data-href="one3" data-precedence="one">
|
|
{css}
|
|
</style>
|
|
<style data-href="two1" data-precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="two2" data-precedence="two" />
|
|
<style data-href="two3" data-precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three1" data-precedence="three" />
|
|
<style data-href="three2" data-precedence="three">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three3" data-precedence="three" />
|
|
<style data-href="four1" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
<style data-href="four2" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="four3" data-precedence="four" />
|
|
<link rel="stylesheet" href="five1" data-precedence="five" />
|
|
<style data-href="six1" data-precedence="six">
|
|
{css}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<link rel="preload" href="one2" as="style" />
|
|
<link rel="preload" href="two2" as="style" />
|
|
<link rel="preload" href="five1" as="style" />
|
|
<link rel="preload" href="three3" as="style" />
|
|
<link rel="preload" href="four3" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('block again');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="one1" data-precedence="one" />
|
|
<link rel="stylesheet" href="one2" data-precedence="one" />
|
|
<style data-href="one3" data-precedence="one">
|
|
{css}
|
|
</style>
|
|
<style data-href="two1" data-precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="two2" data-precedence="two" />
|
|
<style data-href="two3" data-precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three1" data-precedence="three" />
|
|
<style data-href="three2" data-precedence="three">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three3" data-precedence="three" />
|
|
<style data-href="four1" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
<style data-href="four2" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="four3" data-precedence="four" />
|
|
<link rel="stylesheet" href="five1" data-precedence="five" />
|
|
<style data-href="six1" data-precedence="six">
|
|
{css}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<link rel="preload" href="one2" as="style" />
|
|
<link rel="preload" href="two2" as="style" />
|
|
<link rel="preload" href="five1" as="style" />
|
|
<link rel="preload" href="three3" as="style" />
|
|
<link rel="preload" href="four3" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" href="one4" precedence="one" />
|
|
<style href="two4" precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three4" precedence="three" />
|
|
<style href="four4" precedence="four">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="seven1" precedence="seven" />
|
|
<style href="eight1" precedence="eight">
|
|
{css}
|
|
</style>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog([
|
|
'load preload: one4',
|
|
'load preload: three4',
|
|
'load preload: seven1',
|
|
'load preload: one2',
|
|
'load preload: two2',
|
|
'load preload: five1',
|
|
'load preload: three3',
|
|
'load preload: four3',
|
|
'load stylesheet: one1',
|
|
'load stylesheet: one2',
|
|
'load stylesheet: one4',
|
|
'load stylesheet: two2',
|
|
'load stylesheet: three1',
|
|
'load stylesheet: three3',
|
|
'load stylesheet: three4',
|
|
'load stylesheet: four3',
|
|
'load stylesheet: five1',
|
|
'load stylesheet: seven1',
|
|
]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="one1" data-precedence="one" />
|
|
<link rel="stylesheet" href="one2" data-precedence="one" />
|
|
<style data-href="one3" data-precedence="one">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="one4" data-precedence="one" />
|
|
<style data-href="two1" data-precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="two2" data-precedence="two" />
|
|
<style data-href="two3" data-precedence="two">
|
|
{css}
|
|
</style>
|
|
<style data-href="two4" data-precedence="two">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three1" data-precedence="three" />
|
|
<style data-href="three2" data-precedence="three">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="three3" data-precedence="three" />
|
|
<link rel="stylesheet" href="three4" data-precedence="three" />
|
|
<style data-href="four1" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
<style data-href="four2" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="four3" data-precedence="four" />
|
|
<style data-href="four4" data-precedence="four">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="five1" data-precedence="five" />
|
|
<style data-href="six1" data-precedence="six">
|
|
{css}
|
|
</style>
|
|
<link rel="stylesheet" href="seven1" data-precedence="seven" />
|
|
<style data-href="eight1" data-precedence="eight">
|
|
{css}
|
|
</style>
|
|
<link rel="preload" href="one4" as="style" />
|
|
<link rel="preload" href="three4" as="style" />
|
|
<link rel="preload" href="seven1" as="style" />
|
|
</head>
|
|
<body>
|
|
<link rel="preload" href="one2" as="style" />
|
|
<link rel="preload" href="two2" as="style" />
|
|
<link rel="preload" href="five1" as="style" />
|
|
<link rel="preload" href="three3" as="style" />
|
|
<link rel="preload" href="four3" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('client renders a boundary if a style Resource dependency fails to load', async () => {
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="unblock">
|
|
<link rel="stylesheet" href="foo" precedence="arbitrary" />
|
|
<link rel="stylesheet" href="bar" precedence="arbitrary" />
|
|
Hello
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('unblock');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="arbitrary" />
|
|
<link rel="stylesheet" href="bar" data-precedence="arbitrary" />
|
|
</head>
|
|
<body>
|
|
loading...
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
errorStylesheets(['bar']);
|
|
assertLog(['error stylesheet: bar']);
|
|
|
|
await waitForAll([]);
|
|
|
|
const boundaryTemplateInstance = document.getElementById('B:0');
|
|
const suspenseInstance = boundaryTemplateInstance.previousSibling;
|
|
|
|
expect(suspenseInstance.data).toEqual('$!');
|
|
expect(boundaryTemplateInstance.dataset.dgst).toBe('CSS failed to load');
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="arbitrary" />
|
|
<link rel="stylesheet" href="bar" data-precedence="arbitrary" />
|
|
</head>
|
|
<body>
|
|
loading...
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(document, <App />, {
|
|
onRecoverableError(err, errInfo) {
|
|
errors.push(err.message);
|
|
errors.push(err.digest);
|
|
},
|
|
});
|
|
await waitForAll([]);
|
|
// When binding a stylesheet that was SSR'd in a boundary reveal there is a loadingState promise
|
|
// We need to use that promise to resolve the suspended commit because we don't know if the load or error
|
|
// events have already fired. This requires the load to be awaited for the commit to have a chance to flush
|
|
// We could change this by tracking the loadingState's fulfilled status directly on the loadingState similar
|
|
// to thenables however this slightly increases the fizz runtime code size.
|
|
await clientAct(() => loadStylesheets());
|
|
assertLog(['load stylesheet: foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="arbitrary" />
|
|
<link rel="stylesheet" href="bar" data-precedence="arbitrary" />
|
|
</head>
|
|
<body>
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
Hello
|
|
</body>
|
|
</html>,
|
|
);
|
|
expect(errors).toEqual([
|
|
'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
|
|
'CSS failed to load',
|
|
]);
|
|
});
|
|
|
|
it('treats stylesheet links with a precedence as a resource', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="arbitrary" />
|
|
Hello
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="arbitrary" />
|
|
</head>
|
|
<body>Hello</body>
|
|
</html>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<head />
|
|
<body>Hello</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="arbitrary" />
|
|
</head>
|
|
<body>Hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
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}
|
|
<link rel="stylesheet" href={href} precedence={precedence} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<AsyncTextWithResource text="foo" href="foo" precedence="one" />
|
|
<AsyncTextWithResource text="bar" href="bar" precedence="two" />
|
|
<AsyncTextWithResource text="baz" href="baz" precedence="three" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
resolveText('foo');
|
|
resolveText('bar');
|
|
resolveText('baz');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="bar" data-precedence="two" />
|
|
<link rel="stylesheet" href="baz" data-precedence="three" />
|
|
</head>
|
|
<body>
|
|
{'foo'}
|
|
{'bar'}
|
|
{'baz'}
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('hoists late stylesheets the correct precedence', async () => {
|
|
function PresetPrecedence() {
|
|
ReactDOM.preinit('preset', {as: 'style', precedence: 'preset'});
|
|
}
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="initial" precedence="one" />
|
|
<PresetPrecedence />
|
|
<div>
|
|
<Suspense fallback="loading foo bar...">
|
|
<div>foo</div>
|
|
<link rel="stylesheet" href="foo" precedence="one" />
|
|
<BlockedOn value="bar">
|
|
<div>bar</div>
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
<div>
|
|
<Suspense fallback="loading bar baz qux...">
|
|
<BlockedOn value="bar">
|
|
<div>bar</div>
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
</BlockedOn>
|
|
<BlockedOn value="baz">
|
|
<div>baz</div>
|
|
<link rel="stylesheet" href="baz" precedence="two" />
|
|
</BlockedOn>
|
|
<BlockedOn value="qux">
|
|
<div>qux</div>
|
|
<link rel="stylesheet" href="qux" precedence="one" />
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
<div>
|
|
<Suspense fallback="loading bar baz qux...">
|
|
<BlockedOn value="unblock">
|
|
<BlockedOn value="bar">
|
|
<div>bar</div>
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
</BlockedOn>
|
|
<BlockedOn value="baz">
|
|
<div>baz</div>
|
|
<link rel="stylesheet" href="baz" precedence="two" />
|
|
</BlockedOn>
|
|
<BlockedOn value="qux">
|
|
<div>qux</div>
|
|
<link rel="stylesheet" href="qux" precedence="one" />
|
|
</BlockedOn>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="initial" data-precedence="one" />
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="preset" data-precedence="preset" />
|
|
</head>
|
|
<body>
|
|
<div>loading foo bar...</div>
|
|
<div>loading bar baz qux...</div>
|
|
<div>loading bar baz qux...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('foo');
|
|
resolveText('bar');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="initial" data-precedence="one" />
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="preset" data-precedence="preset" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>loading foo bar...</div>
|
|
<div>loading bar baz qux...</div>
|
|
<div>loading bar baz qux...</div>
|
|
<link rel="preload" href="bar" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
const link = document.querySelector('link[rel="stylesheet"][href="foo"]');
|
|
const event = document.createEvent('Events');
|
|
event.initEvent('load', true, true);
|
|
link.dispatchEvent(event);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="initial" data-precedence="one" />
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="preset" data-precedence="preset" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>loading foo bar...</div>
|
|
<div>loading bar baz qux...</div>
|
|
<div>loading bar baz qux...</div>
|
|
<link rel="preload" href="bar" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
const link = document.querySelector('link[rel="stylesheet"][href="bar"]');
|
|
const event = document.createEvent('Events');
|
|
event.initEvent('load', true, true);
|
|
link.dispatchEvent(event);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="initial" data-precedence="one" />
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="preset" data-precedence="preset" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<div>foo</div>
|
|
<div>bar</div>
|
|
</div>
|
|
<div>loading bar baz qux...</div>
|
|
<div>loading bar baz qux...</div>
|
|
<link rel="preload" href="bar" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('baz');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="initial" data-precedence="one" />
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="preset" data-precedence="preset" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<div>foo</div>
|
|
<div>bar</div>
|
|
</div>
|
|
<div>loading bar baz qux...</div>
|
|
<div>loading bar baz qux...</div>
|
|
<link rel="preload" as="style" href="bar" />
|
|
<link rel="preload" as="style" href="baz" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('qux');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="initial" data-precedence="one" />
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="qux" data-precedence="one" />
|
|
<link rel="stylesheet" href="preset" data-precedence="preset" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="baz" data-precedence="two" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<div>foo</div>
|
|
<div>bar</div>
|
|
</div>
|
|
<div>loading bar baz qux...</div>
|
|
<div>loading bar baz qux...</div>
|
|
<link rel="preload" as="style" href="bar" />
|
|
<link rel="preload" as="style" href="baz" />
|
|
<link rel="preload" as="style" href="qux" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
const bazlink = document.querySelector(
|
|
'link[rel="stylesheet"][href="baz"]',
|
|
);
|
|
const quxlink = document.querySelector(
|
|
'link[rel="stylesheet"][href="qux"]',
|
|
);
|
|
const presetLink = document.querySelector(
|
|
'link[rel="stylesheet"][href="preset"]',
|
|
);
|
|
const event = document.createEvent('Events');
|
|
event.initEvent('load', true, true);
|
|
bazlink.dispatchEvent(event);
|
|
quxlink.dispatchEvent(event);
|
|
presetLink.dispatchEvent(event);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="initial" data-precedence="one" />
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="qux" data-precedence="one" />
|
|
<link rel="stylesheet" href="preset" data-precedence="preset" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="baz" data-precedence="two" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<div>foo</div>
|
|
<div>bar</div>
|
|
</div>
|
|
<div>
|
|
<div>bar</div>
|
|
<div>baz</div>
|
|
<div>qux</div>
|
|
</div>
|
|
<div>loading bar baz qux...</div>
|
|
<link rel="preload" as="style" href="bar" />
|
|
<link rel="preload" as="style" href="baz" />
|
|
<link rel="preload" as="style" href="qux" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('unblock');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="initial" data-precedence="one" />
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="qux" data-precedence="one" />
|
|
<link rel="stylesheet" href="preset" data-precedence="preset" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="baz" data-precedence="two" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<div>foo</div>
|
|
<div>bar</div>
|
|
</div>
|
|
<div>
|
|
<div>bar</div>
|
|
<div>baz</div>
|
|
<div>qux</div>
|
|
</div>
|
|
<div>
|
|
<div>bar</div>
|
|
<div>baz</div>
|
|
<div>qux</div>
|
|
</div>
|
|
<link rel="preload" as="style" href="bar" />
|
|
<link rel="preload" as="style" href="baz" />
|
|
<link rel="preload" as="style" href="qux" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('normalizes stylesheet resource precedence for all boundaries inlined as part of the shell flush', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>
|
|
outer
|
|
<link rel="stylesheet" href="1one" precedence="one" />
|
|
<link rel="stylesheet" href="1two" precedence="two" />
|
|
<link rel="stylesheet" href="1three" precedence="three" />
|
|
<link rel="stylesheet" href="1four" precedence="four" />
|
|
<Suspense fallback={null}>
|
|
<div>
|
|
middle
|
|
<link rel="stylesheet" href="2one" precedence="one" />
|
|
<link rel="stylesheet" href="2two" precedence="two" />
|
|
<link rel="stylesheet" href="2three" precedence="three" />
|
|
<link rel="stylesheet" href="2four" precedence="four" />
|
|
<Suspense fallback={null}>
|
|
<div>
|
|
inner
|
|
<link rel="stylesheet" href="3five" precedence="five" />
|
|
<link rel="stylesheet" href="3one" precedence="one" />
|
|
<link rel="stylesheet" href="3two" precedence="two" />
|
|
<link rel="stylesheet" href="3three" precedence="three" />
|
|
<link rel="stylesheet" href="3four" precedence="four" />
|
|
</div>
|
|
</Suspense>
|
|
</div>
|
|
</Suspense>
|
|
<Suspense fallback={null}>
|
|
<div>middle</div>
|
|
<link rel="stylesheet" href="4one" precedence="one" />
|
|
<link rel="stylesheet" href="4two" precedence="two" />
|
|
<link rel="stylesheet" href="4three" precedence="three" />
|
|
<link rel="stylesheet" href="4four" precedence="four" />
|
|
</Suspense>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="1one" data-precedence="one" />
|
|
<link rel="stylesheet" href="2one" data-precedence="one" />
|
|
<link rel="stylesheet" href="3one" data-precedence="one" />
|
|
<link rel="stylesheet" href="4one" data-precedence="one" />
|
|
|
|
<link rel="stylesheet" href="1two" data-precedence="two" />
|
|
<link rel="stylesheet" href="2two" data-precedence="two" />
|
|
<link rel="stylesheet" href="3two" data-precedence="two" />
|
|
<link rel="stylesheet" href="4two" data-precedence="two" />
|
|
|
|
<link rel="stylesheet" href="1three" data-precedence="three" />
|
|
<link rel="stylesheet" href="2three" data-precedence="three" />
|
|
<link rel="stylesheet" href="3three" data-precedence="three" />
|
|
<link rel="stylesheet" href="4three" data-precedence="three" />
|
|
|
|
<link rel="stylesheet" href="1four" data-precedence="four" />
|
|
<link rel="stylesheet" href="2four" data-precedence="four" />
|
|
<link rel="stylesheet" href="3four" data-precedence="four" />
|
|
<link rel="stylesheet" href="4four" data-precedence="four" />
|
|
|
|
<link rel="stylesheet" href="3five" data-precedence="five" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
outer
|
|
<div>
|
|
middle<div>inner</div>
|
|
</div>
|
|
<div>middle</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('stylesheet resources are inserted according to precedence order on the client', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>
|
|
<link rel="stylesheet" href="foo" precedence="one" />
|
|
<link rel="stylesheet" href="bar" precedence="two" />
|
|
Hello
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="bar" data-precedence="two" />
|
|
</head>
|
|
<body>
|
|
<div>Hello</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>
|
|
<link rel="stylesheet" href="foo" precedence="one" />
|
|
<link rel="stylesheet" href="bar" precedence="two" />
|
|
Hello
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="bar" data-precedence="two" />
|
|
</head>
|
|
<body>
|
|
<div>Hello</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>Goodbye</div>
|
|
<link rel="stylesheet" href="baz" precedence="one" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog([
|
|
'load preload: baz',
|
|
'load stylesheet: foo',
|
|
'load stylesheet: baz',
|
|
'load stylesheet: bar',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />
|
|
<link rel="stylesheet" href="baz" data-precedence="one" />
|
|
<link rel="stylesheet" href="bar" data-precedence="two" />
|
|
<link rel="preload" as="style" href="baz" />
|
|
</head>
|
|
<body>
|
|
<div>Goodbye</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('inserts preloads in render phase eagerly', async () => {
|
|
function Throw() {
|
|
throw new Error('Uh oh!');
|
|
}
|
|
class ErrorBoundary extends React.Component {
|
|
state = {hasError: false, error: null};
|
|
static getDerivedStateFromError(error) {
|
|
return {
|
|
hasError: true,
|
|
error,
|
|
};
|
|
}
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return this.state.error.message;
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(
|
|
<ErrorBoundary>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<div>foo</div>
|
|
<Throw />
|
|
</ErrorBoundary>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body>
|
|
<div id="container">Uh oh!</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('will include child boundary stylesheet resources in the boundary reveal instruction', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>
|
|
<Suspense fallback="loading foo...">
|
|
<BlockedOn value="foo">
|
|
<div>foo</div>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<Suspense fallback="loading bar...">
|
|
<BlockedOn value="bar">
|
|
<div>bar</div>
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
<Suspense fallback="loading baz...">
|
|
<BlockedOn value="baz">
|
|
<div>baz</div>
|
|
<link
|
|
rel="stylesheet"
|
|
href="baz"
|
|
precedence="default"
|
|
/>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading foo...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('bar');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading foo...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('baz');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading foo...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('foo');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="baz" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>loading foo...</div>
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
<link rel="preload" href="baz" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
const event = document.createEvent('Events');
|
|
event.initEvent('load', true, true);
|
|
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).forEach(
|
|
el => {
|
|
el.dispatchEvent(event);
|
|
},
|
|
);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="baz" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<div>foo</div>
|
|
<div>bar</div>
|
|
<div>baz</div>
|
|
</div>
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
<link rel="preload" href="baz" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('will hoist resources of child boundaries emitted as part of a partial boundary to the parent boundary', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>
|
|
<Suspense fallback="loading...">
|
|
<div>
|
|
<BlockedOn value="foo">
|
|
<div>foo</div>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<Suspense fallback="loading bar...">
|
|
<BlockedOn value="bar">
|
|
<div>bar</div>
|
|
<link
|
|
rel="stylesheet"
|
|
href="bar"
|
|
precedence="default"
|
|
/>
|
|
<Suspense fallback="loading baz...">
|
|
<div>
|
|
<BlockedOn value="baz">
|
|
<div>baz</div>
|
|
<link
|
|
rel="stylesheet"
|
|
href="baz"
|
|
precedence="default"
|
|
/>
|
|
</BlockedOn>
|
|
</div>
|
|
</Suspense>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</BlockedOn>
|
|
<BlockedOn value="qux">
|
|
<div>qux</div>
|
|
<link rel="stylesheet" href="qux" precedence="default" />
|
|
</BlockedOn>
|
|
</div>
|
|
</Suspense>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// This will enqueue a stylesheet resource in a deep blocked boundary (loading baz...).
|
|
await act(() => {
|
|
resolveText('baz');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// This will enqueue a stylesheet resource in the intermediate blocked boundary (loading bar...).
|
|
await act(() => {
|
|
resolveText('bar');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// 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(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading...</div>
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
<link rel="preload" href="baz" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// 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(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="baz" data-precedence="default" />
|
|
<link rel="stylesheet" href="qux" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>loading...</div>
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
<link rel="preload" href="baz" as="style" />
|
|
<link rel="preload" href="qux" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// We load all stylesheets and confirm the content is revealed
|
|
await act(() => {
|
|
const event = document.createEvent('Events');
|
|
event.initEvent('load', true, true);
|
|
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).forEach(
|
|
el => {
|
|
el.dispatchEvent(event);
|
|
},
|
|
);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="baz" data-precedence="default" />
|
|
<link rel="stylesheet" href="qux" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<div>
|
|
<div>foo</div>
|
|
<div>bar</div>
|
|
<div>
|
|
<div>baz</div>
|
|
</div>
|
|
<div>qux</div>
|
|
</div>
|
|
</div>
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
<link rel="preload" href="baz" as="style" />
|
|
<link rel="preload" href="qux" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('encodes attributes consistently whether resources are flushed in shell or in late boundaries', async () => {
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>
|
|
<link
|
|
// This preload is explicit so it can flush with a lot of potential attrs
|
|
// We will duplicate this as a style that flushes after the shell
|
|
rel="stylesheet"
|
|
href="foo"
|
|
// precedence is not a special attribute for preloads so this will just flush as is
|
|
precedence="default"
|
|
// Some standard link props
|
|
crossOrigin="anonymous"
|
|
media="all"
|
|
integrity="somehash"
|
|
referrerPolicy="origin"
|
|
// data and non starndard attributes that should flush
|
|
data-foo={'"quoted"'}
|
|
nonStandardAttr="attr"
|
|
properlyformattednonstandardattr="attr"
|
|
// attributes that should be filtered out for violating certain rules
|
|
onSomething="this should be removed b/c event handler"
|
|
shouldnotincludefunctions={() => {}}
|
|
norsymbols={Symbol('foo')}
|
|
/>
|
|
<Suspense fallback={'loading...'}>
|
|
<BlockedOn value="unblock">
|
|
<link
|
|
// This preload is explicit so it can flush with a lot of potential attrs
|
|
// We will duplicate this as a style that flushes after the shell
|
|
rel="stylesheet"
|
|
href="bar"
|
|
// opt-in property to get this treated as a resource
|
|
precedence="default"
|
|
// Some standard link props
|
|
crossOrigin="anonymous"
|
|
media="all"
|
|
integrity="somehash"
|
|
referrerPolicy="origin"
|
|
// data and non starndard attributes that should flush
|
|
data-foo={'"quoted"'}
|
|
nonStandardAttr="attr"
|
|
properlyformattednonstandardattr="attr"
|
|
// attributes that should be filtered out for violating certain rules
|
|
onSomething="this should be removed b/c event handler"
|
|
shouldnotincludefunctions={() => {}}
|
|
norsymbols={Symbol('foo')}
|
|
/>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
data-precedence="default"
|
|
crossorigin="anonymous"
|
|
media="all"
|
|
integrity="somehash"
|
|
referrerpolicy="origin"
|
|
data-foo={'"quoted"'}
|
|
nonstandardattr="attr"
|
|
properlyformattednonstandardattr="attr"
|
|
/>
|
|
</head>
|
|
<body>
|
|
<div>loading...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
assertConsoleErrorDev([
|
|
'React does not recognize the `nonStandardAttr` prop on a DOM element. ' +
|
|
'If you intentionally want it to appear in the DOM as a custom attribute, ' +
|
|
'spell it as lowercase `nonstandardattr` instead. If you accidentally passed it from a ' +
|
|
'parent component, remove it from the DOM element.\n' +
|
|
' in link (at **)\n' +
|
|
' in App (at **)',
|
|
'Invalid values for props `shouldnotincludefunctions`, `norsymbols` on <link> tag. ' +
|
|
'Either remove them from the element, or pass a string or number value to keep them in the DOM. ' +
|
|
'For details, see https://react.dev/link/attribute-behavior \n' +
|
|
' in link (at **)\n' +
|
|
' in App (at **)',
|
|
]);
|
|
|
|
// Now we flush the stylesheet with the boundary
|
|
await act(() => {
|
|
resolveText('unblock');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
data-precedence="default"
|
|
crossorigin="anonymous"
|
|
media="all"
|
|
integrity="somehash"
|
|
referrerpolicy="origin"
|
|
data-foo={'"quoted"'}
|
|
nonstandardattr="attr"
|
|
properlyformattednonstandardattr="attr"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="bar"
|
|
data-precedence="default"
|
|
crossorigin="anonymous"
|
|
media="all"
|
|
integrity="somehash"
|
|
referrerpolicy="origin"
|
|
data-foo={'"quoted"'}
|
|
nonstandardattr="attr"
|
|
properlyformattednonstandardattr="attr"
|
|
/>
|
|
</head>
|
|
<body>
|
|
<div>loading...</div>
|
|
<link
|
|
rel="preload"
|
|
as="style"
|
|
href="bar"
|
|
crossorigin="anonymous"
|
|
media="all"
|
|
integrity="somehash"
|
|
referrerpolicy="origin"
|
|
/>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('boundary stylesheet resource dependencies hoist to a parent boundary when flushed inline', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>
|
|
<Suspense fallback="loading A...">
|
|
<BlockedOn value="unblock">
|
|
<AsyncText text="A" />
|
|
<link rel="stylesheet" href="A" precedence="A" />
|
|
<Suspense fallback="loading AA...">
|
|
<AsyncText text="AA" />
|
|
<link rel="stylesheet" href="AA" precedence="AA" />
|
|
<Suspense fallback="loading AAA...">
|
|
<AsyncText text="AAA" />
|
|
<link rel="stylesheet" href="AAA" precedence="AAA" />
|
|
<Suspense fallback="loading AAAA...">
|
|
<AsyncText text="AAAA" />
|
|
<link rel="stylesheet" href="AAAA" precedence="AAAA" />
|
|
</Suspense>
|
|
</Suspense>
|
|
</Suspense>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading A...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('unblock');
|
|
resolveText('AAAA');
|
|
resolveText('AA');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading A...</div>
|
|
<link rel="preload" as="style" href="A" />
|
|
<link rel="preload" as="style" href="AA" />
|
|
<link rel="preload" as="style" href="AAA" />
|
|
<link rel="preload" as="style" href="AAAA" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('A');
|
|
});
|
|
await act(() => {
|
|
document.querySelectorAll('link[rel="stylesheet"]').forEach(l => {
|
|
const event = document.createEvent('Events');
|
|
event.initEvent('load', true, true);
|
|
l.dispatchEvent(event);
|
|
});
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="A" data-precedence="A" />
|
|
<link rel="stylesheet" href="AA" data-precedence="AA" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
{'A'}
|
|
{'AA'}
|
|
{'loading AAA...'}
|
|
</div>
|
|
<link rel="preload" as="style" href="A" />
|
|
<link rel="preload" as="style" href="AA" />
|
|
<link rel="preload" as="style" href="AAA" />
|
|
<link rel="preload" as="style" href="AAAA" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('AAA');
|
|
});
|
|
await act(() => {
|
|
document.querySelectorAll('link[rel="stylesheet"]').forEach(l => {
|
|
const event = document.createEvent('Events');
|
|
event.initEvent('load', true, true);
|
|
l.dispatchEvent(event);
|
|
});
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="A" data-precedence="A" />
|
|
<link rel="stylesheet" href="AA" data-precedence="AA" />
|
|
<link rel="stylesheet" href="AAA" data-precedence="AAA" />
|
|
<link rel="stylesheet" href="AAAA" data-precedence="AAAA" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
{'A'}
|
|
{'AA'}
|
|
{'AAA'}
|
|
{'AAAA'}
|
|
</div>
|
|
<link rel="preload" as="style" href="A" />
|
|
<link rel="preload" as="style" href="AA" />
|
|
<link rel="preload" as="style" href="AAA" />
|
|
<link rel="preload" as="style" href="AAAA" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('always enforces crossOrigin "anonymous" for font preloads', async () => {
|
|
function App() {
|
|
ReactDOM.preload('foo', {as: 'font', type: 'font/woff2'});
|
|
ReactDOM.preload('bar', {as: 'font', crossOrigin: 'foo'});
|
|
ReactDOM.preload('baz', {as: 'font', crossOrigin: 'use-credentials'});
|
|
ReactDOM.preload('qux', {as: 'font', crossOrigin: 'anonymous'});
|
|
return (
|
|
<html>
|
|
<head />
|
|
<body />
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="preload"
|
|
as="font"
|
|
href="foo"
|
|
crossorigin=""
|
|
type="font/woff2"
|
|
/>
|
|
<link rel="preload" as="font" href="bar" crossorigin="" />
|
|
<link rel="preload" as="font" href="baz" crossorigin="" />
|
|
<link rel="preload" as="font" href="qux" crossorigin="" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('does not hoist anything with an itemprop prop', async () => {
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<head>
|
|
<meta itemProp="outside" content="unscoped" />
|
|
<link itemProp="link" rel="foo" href="foo" />
|
|
<title itemProp="outside-title">title</title>
|
|
<link
|
|
itemProp="outside-stylesheet"
|
|
rel="stylesheet"
|
|
href="bar"
|
|
precedence="default"
|
|
/>
|
|
<style itemProp="outside-style" href="baz" precedence="default">
|
|
outside style
|
|
</style>
|
|
<script itemProp="outside-script" async={true} src="qux" />
|
|
</head>
|
|
<body>
|
|
<div itemScope={true}>
|
|
<div>
|
|
<meta itemProp="inside-meta" content="scoped" />
|
|
<link itemProp="inside-link" rel="foo" href="foo" />
|
|
<title itemProp="inside-title">title</title>
|
|
<link
|
|
itemProp="inside-stylesheet"
|
|
rel="stylesheet"
|
|
href="bar"
|
|
precedence="default"
|
|
/>
|
|
<style itemProp="inside-style" href="baz" precedence="default">
|
|
inside style
|
|
</style>
|
|
<script itemProp="inside-script" async={true} src="qux" />
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta itemprop="outside" content="unscoped" />
|
|
<link itemprop="link" rel="foo" href="foo" />
|
|
<title itemprop="outside-title">title</title>
|
|
<link
|
|
itemprop="outside-stylesheet"
|
|
rel="stylesheet"
|
|
href="bar"
|
|
precedence="default"
|
|
/>
|
|
<style itemprop="outside-style" href="baz" precedence="default">
|
|
outside style
|
|
</style>
|
|
<script itemprop="outside-script" async="" src="qux" />
|
|
</head>
|
|
<body>
|
|
<div itemscope="">
|
|
<div>
|
|
<meta itemprop="inside-meta" content="scoped" />
|
|
<link itemprop="inside-link" rel="foo" href="foo" />
|
|
<title itemprop="inside-title">title</title>
|
|
<link
|
|
itemprop="inside-stylesheet"
|
|
rel="stylesheet"
|
|
href="bar"
|
|
precedence="default"
|
|
/>
|
|
<style itemprop="inside-style" href="baz" precedence="default">
|
|
inside style
|
|
</style>
|
|
<script itemprop="inside-script" async="" src="qux" />
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(document, <App />);
|
|
await waitForAll([]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta itemprop="outside" content="unscoped" />
|
|
<link itemprop="link" rel="foo" href="foo" />
|
|
<title itemprop="outside-title">title</title>
|
|
<link
|
|
itemprop="outside-stylesheet"
|
|
rel="stylesheet"
|
|
href="bar"
|
|
precedence="default"
|
|
/>
|
|
<style itemprop="outside-style" href="baz" precedence="default">
|
|
outside style
|
|
</style>
|
|
<script itemprop="outside-script" async="" src="qux" />
|
|
</head>
|
|
<body>
|
|
<div itemscope="">
|
|
<div>
|
|
<meta itemprop="inside-meta" content="scoped" />
|
|
<link itemprop="inside-link" rel="foo" href="foo" />
|
|
<title itemprop="inside-title">title</title>
|
|
<link
|
|
itemprop="inside-stylesheet"
|
|
rel="stylesheet"
|
|
href="bar"
|
|
precedence="default"
|
|
/>
|
|
<style itemprop="inside-style" href="baz" precedence="default">
|
|
inside style
|
|
</style>
|
|
<script itemprop="inside-script" async="" src="qux" />
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('warns if you render <meta> tag with itemProp outside <body> or <head>', async () => {
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(
|
|
<html>
|
|
<meta itemProp="foo" />
|
|
</html>,
|
|
);
|
|
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. ' +
|
|
'`itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. ' +
|
|
'If you were intending for React to hoist this <meta> remove the `itemProp` prop. ' +
|
|
'Otherwise, try moving this tag into the <head> or <body> of the Document.\n' +
|
|
' in html (at **)',
|
|
'In HTML, <meta> cannot be a child of <html>.\n' +
|
|
'This will cause a hydration error.\n' +
|
|
'\n' +
|
|
'> <html>\n' +
|
|
'> <meta itemProp="foo">' +
|
|
'\n' +
|
|
'\n in meta (at **)',
|
|
]);
|
|
});
|
|
|
|
it('warns if you render a <title> tag with itemProp outside <body> or <head>', async () => {
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(
|
|
<html>
|
|
<title itemProp="foo">title</title>
|
|
</html>,
|
|
);
|
|
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'Cannot render a <title> outside the main document if it has an `itemProp` prop. ' +
|
|
'`itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. ' +
|
|
'If you were intending for React to hoist this <title> remove the `itemProp` prop. ' +
|
|
'Otherwise, try moving this tag into the <head> or <body> of the Document.\n' +
|
|
' in html (at **)',
|
|
'In HTML, <title> cannot be a child of <html>.\n' +
|
|
'This will cause a hydration error.\n' +
|
|
'\n' +
|
|
'> <html>\n' +
|
|
'> <title itemProp="foo">' +
|
|
'\n' +
|
|
'\n in title (at **)',
|
|
]);
|
|
});
|
|
|
|
it('warns if you render a <style> tag with itemProp outside <body> or <head>', async () => {
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(
|
|
<html>
|
|
<style itemProp="foo">style</style>
|
|
</html>,
|
|
);
|
|
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'Cannot render a <style> outside the main document if it has an `itemProp` prop. ' +
|
|
'`itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. ' +
|
|
'If you were intending for React to hoist this <style> remove the `itemProp` prop. ' +
|
|
'Otherwise, try moving this tag into the <head> or <body> of the Document.\n' +
|
|
' in html (at **)',
|
|
'In HTML, <style> cannot be a child of <html>.\n' +
|
|
'This will cause a hydration error.\n' +
|
|
'\n' +
|
|
'> <html>\n' +
|
|
'> <style itemProp="foo">' +
|
|
'\n' +
|
|
'\n in style (at **)',
|
|
]);
|
|
});
|
|
|
|
it('warns if you render a <link> tag with itemProp outside <body> or <head>', async () => {
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(
|
|
<html>
|
|
<link itemProp="foo" />
|
|
</html>,
|
|
);
|
|
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'Cannot render a <link> outside the main document if it has an `itemProp` prop. ' +
|
|
'`itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. ' +
|
|
'If you were intending for React to hoist this <link> remove the `itemProp` prop. ' +
|
|
'Otherwise, try moving this tag into the <head> or <body> of the Document.\n' +
|
|
' in html (at **)',
|
|
'In HTML, <link> cannot be a child of <html>.\n' +
|
|
'This will cause a hydration error.\n' +
|
|
'\n' +
|
|
'> <html>\n' +
|
|
'> <link itemProp="foo">\n' +
|
|
'\n' +
|
|
' in link (at **)',
|
|
]);
|
|
});
|
|
|
|
it('warns if you render a <script> tag with itemProp outside <body> or <head>', async () => {
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(
|
|
<html>
|
|
<script itemProp="foo" />
|
|
</html>,
|
|
);
|
|
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'Cannot render a <script> outside the main document if it has an `itemProp` prop. ' +
|
|
'`itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. ' +
|
|
'If you were intending for React to hoist this <script> remove the `itemProp` prop. ' +
|
|
'Otherwise, try moving this tag into the <head> or <body> of the Document.\n' +
|
|
' in html (at **)',
|
|
'In HTML, <script> cannot be a child of <html>.\n' +
|
|
'This will cause a hydration error.\n' +
|
|
'\n' +
|
|
'> <html>\n' +
|
|
'> <script itemProp="foo">\n' +
|
|
'\n' +
|
|
' in script (at **)',
|
|
]);
|
|
});
|
|
|
|
it('can hydrate resources and components in the head and body even if a browser or 3rd party script injects extra html nodes', async () => {
|
|
// This is a stress test case for hydrating a complex combination of hoistable elements, hoistable resources and host components
|
|
// in an environment that has been manipulated by 3rd party scripts/extensions to modify the <head> and <body>
|
|
function App() {
|
|
return (
|
|
<>
|
|
<link rel="foo" href="foo" />
|
|
<script async={true} src="rendered" />
|
|
<link rel="stylesheet" href="stylesheet" precedence="default" />
|
|
<html itemScope={true}>
|
|
<head>
|
|
{/* Component */}
|
|
<link rel="stylesheet" href="stylesheet" />
|
|
<script src="sync rendered" data-meaningful="" />
|
|
<style>{'body { background-color: red; }'}</style>
|
|
<script src="async rendered" async={true} onLoad={() => {}} />
|
|
<noscript>
|
|
<meta name="noscript" content="noscript" />
|
|
</noscript>
|
|
<link rel="foo" href="foo" onLoad={() => {}} />
|
|
</head>
|
|
<body>
|
|
{/* Component because it has itemProp */}
|
|
<meta name="foo" content="foo" itemProp="a prop" />
|
|
{/* regular Hoistable */}
|
|
<meta name="foo" content="foo" />
|
|
{/* regular Hoistable */}
|
|
<title>title</title>
|
|
<div itemScope={true}>
|
|
<div>
|
|
<div>deep hello</div>
|
|
{/* Component because it has itemProp */}
|
|
<meta name="foo" content="foo" itemProp="a prop" />
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
<link rel="foo" href="foo" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html itemscope="">
|
|
<head>
|
|
{/* Hoisted Resources and elements */}
|
|
<link rel="stylesheet" href="stylesheet" data-precedence="default" />
|
|
<script async="" src="rendered" />
|
|
<link rel="foo" href="foo" />
|
|
<meta name="foo" content="foo" />
|
|
<title>title</title>
|
|
<link rel="foo" href="foo" />
|
|
{/* rendered host components */}
|
|
<link rel="stylesheet" href="stylesheet" />
|
|
<script src="sync rendered" data-meaningful="" />
|
|
<style>{'body { background-color: red; }'}</style>
|
|
<script src="async rendered" async="" />
|
|
<noscript><meta name="noscript" content="noscript"></noscript>
|
|
<link rel="foo" href="foo" />
|
|
</head>
|
|
<body>
|
|
<meta name="foo" content="foo" itemprop="a prop" />
|
|
<div itemscope="">
|
|
<div>
|
|
<div>deep hello</div>
|
|
<meta name="foo" content="foo" itemprop="a prop" />
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// We inject some styles, divs, scripts into the begginning, middle, and end
|
|
// of the head / body.
|
|
const injectedStyle = document.createElement('style');
|
|
injectedStyle.textContent = 'body { background-color: blue; }';
|
|
document.head.prepend(injectedStyle.cloneNode(true));
|
|
document.head.appendChild(injectedStyle.cloneNode(true));
|
|
document.body.prepend(injectedStyle.cloneNode(true));
|
|
document.body.appendChild(injectedStyle.cloneNode(true));
|
|
|
|
const injectedDiv = document.createElement('div');
|
|
document.head.prepend(injectedDiv);
|
|
document.head.appendChild(injectedDiv.cloneNode(true));
|
|
// We do not prepend a <div> in body because this will conflict with hyration
|
|
// We still mostly hydrate by matchign tag and <div> does not have any attributes to
|
|
// differentiate between likely-inject and likely-rendered cases. If a <div> is prepended
|
|
// in the <body> and you render a <div> as the first child of <body> there will be a conflict.
|
|
// We consider this a rare edge case and even if it does happen the fallback to client rendering
|
|
// should patch up the DOM correctly
|
|
document.body.appendChild(injectedDiv.cloneNode(true));
|
|
|
|
const injectedScript = document.createElement('script');
|
|
injectedScript.setAttribute('async', '');
|
|
injectedScript.setAttribute('src', 'injected');
|
|
document.head.prepend(injectedScript);
|
|
document.head.appendChild(injectedScript.cloneNode(true));
|
|
document.body.prepend(injectedScript.cloneNode(true));
|
|
document.body.appendChild(injectedScript.cloneNode(true));
|
|
|
|
// We hydrate the same App and confirm the output is identical except for the async
|
|
// script insertion that happens because we do not SSR async scripts with load handlers.
|
|
// All the extra inject nodes are preset
|
|
const root = ReactDOMClient.hydrateRoot(document, <App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html itemscope="">
|
|
<head>
|
|
<script async="" src="injected" />
|
|
<div />
|
|
<style>{'body { background-color: blue; }'}</style>
|
|
<link rel="stylesheet" href="stylesheet" data-precedence="default" />
|
|
<script async="" src="rendered" />
|
|
<link rel="foo" href="foo" />
|
|
<meta name="foo" content="foo" />
|
|
<title>title</title>
|
|
<link rel="foo" href="foo" />
|
|
<link rel="stylesheet" href="stylesheet" />
|
|
<script src="sync rendered" data-meaningful="" />
|
|
<style>{'body { background-color: red; }'}</style>
|
|
<script src="async rendered" async="" />
|
|
<noscript><meta name="noscript" content="noscript"></noscript>
|
|
<link rel="foo" href="foo" />
|
|
<style>{'body { background-color: blue; }'}</style>
|
|
<div />
|
|
<script async="" src="injected" />
|
|
</head>
|
|
<body>
|
|
<script async="" src="injected" />
|
|
<style>{'body { background-color: blue; }'}</style>
|
|
<meta name="foo" content="foo" itemprop="a prop" />
|
|
<div itemscope="">
|
|
<div>
|
|
<div>deep hello</div>
|
|
<meta name="foo" content="foo" itemprop="a prop" />
|
|
</div>
|
|
</div>
|
|
<style>{'body { background-color: blue; }'}</style>
|
|
<div />
|
|
<script async="" src="injected" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// We unmount. The nodes that remain are
|
|
// 1. Hoisted resources (we don't clean these up on unmount to address races with streaming suspense and navigation)
|
|
// 2. preloads that are injected to hint the browser to load a resource but are not associated to Fibers directly
|
|
// 3. Nodes that React skipped over during hydration
|
|
root.unmount();
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="injected" />
|
|
<div />
|
|
<style>{'body { background-color: blue; }'}</style>
|
|
<link rel="stylesheet" href="stylesheet" data-precedence="default" />
|
|
<script async="" src="rendered" />
|
|
<style>{'body { background-color: blue; }'}</style>
|
|
<div />
|
|
<script async="" src="injected" />
|
|
</head>
|
|
<body>
|
|
<script async="" src="injected" />
|
|
<style>{'body { background-color: blue; }'}</style>
|
|
<style>{'body { background-color: blue; }'}</style>
|
|
<div />
|
|
<script async="" src="injected" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('does not preload nomodule scripts', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<script src="foo" noModule={true} data-meaningful="" />
|
|
<script async={true} src="bar" noModule={true} data-meaningful="" />
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="bar" nomodule="" data-meaningful="" />
|
|
</head>
|
|
<body>
|
|
<script src="foo" nomodule="" data-meaningful="" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can delay commit until css resources load', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
expect(getMeaningfulChildren(container)).toBe(undefined);
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<div>hello</div>
|
|
</>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(container)).toBe(undefined);
|
|
expect(getMeaningfulChildren(document.head)).toEqual(
|
|
<link rel="preload" as="style" href="foo" />,
|
|
);
|
|
|
|
loadPreloads();
|
|
assertLog(['load preload: foo']);
|
|
|
|
// We expect that the stylesheet is inserted now but the commit has not happened yet.
|
|
expect(getMeaningfulChildren(container)).toBe(undefined);
|
|
expect(getMeaningfulChildren(document.head)).toEqual([
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />,
|
|
<link rel="preload" as="style" href="foo" />,
|
|
]);
|
|
|
|
loadStylesheets();
|
|
assertLog(['load stylesheet: foo']);
|
|
|
|
// We expect that the commit finishes synchronously after the stylesheet loads.
|
|
expect(getMeaningfulChildren(container)).toEqual(<div>hello</div>);
|
|
expect(getMeaningfulChildren(document.head)).toEqual([
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />,
|
|
<link rel="preload" as="style" href="foo" />,
|
|
]);
|
|
});
|
|
|
|
// https://github.com/facebook/react/issues/27585
|
|
it('does not reinsert already inserted stylesheets during a delayed commit', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" href="first" precedence="default" />
|
|
<link rel="stylesheet" href="second" precedence="default" />
|
|
server
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="first" data-precedence="default" />
|
|
<link rel="stylesheet" href="second" data-precedence="default" />
|
|
</head>
|
|
<body>server</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.createRoot(document.body);
|
|
expect(getMeaningfulChildren(container)).toBe(undefined);
|
|
root.render(
|
|
<>
|
|
<link rel="stylesheet" href="first" precedence="default" />
|
|
<link rel="stylesheet" href="third" precedence="default" />
|
|
<div>client</div>
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog([
|
|
'load preload: third',
|
|
'load stylesheet: first',
|
|
'load stylesheet: second',
|
|
'load stylesheet: third',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="first" data-precedence="default" />
|
|
<link rel="stylesheet" href="second" data-precedence="default" />
|
|
<link rel="stylesheet" href="third" data-precedence="default" />
|
|
<link rel="preload" href="third" as="style" />
|
|
</head>
|
|
<body>
|
|
<div>client</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// In a transition we add another reference to an already loaded resource
|
|
// https://github.com/facebook/react/issues/27585
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<>
|
|
<link rel="stylesheet" href="first" precedence="default" />
|
|
<link rel="stylesheet" href="third" precedence="default" />
|
|
<div>client</div>
|
|
<link rel="stylesheet" href="first" precedence="default" />
|
|
</>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
// In https://github.com/facebook/react/issues/27585 the order updated
|
|
// to second, third, first
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="first" data-precedence="default" />
|
|
<link rel="stylesheet" href="second" data-precedence="default" />
|
|
<link rel="stylesheet" href="third" data-precedence="default" />
|
|
<link rel="preload" href="third" as="style" />
|
|
</head>
|
|
<body>
|
|
<div>client</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
// eslint-disable-next-line jest/no-disabled-tests
|
|
it.skip('can delay commit until css resources error', async () => {
|
|
// TODO: This test fails and crashes jest. need to figure out why before unskipping.
|
|
const root = ReactDOMClient.createRoot(container);
|
|
expect(getMeaningfulChildren(container)).toBe(undefined);
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
<div>hello</div>
|
|
</>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(container)).toBe(undefined);
|
|
expect(getMeaningfulChildren(document.head)).toEqual([
|
|
<link rel="preload" as="style" href="foo" />,
|
|
<link rel="preload" as="style" href="bar" />,
|
|
]);
|
|
|
|
loadPreloads(['foo']);
|
|
errorPreloads(['bar']);
|
|
assertLog(['load preload: foo', 'error preload: bar']);
|
|
|
|
// We expect that the stylesheet is inserted now but the commit has not happened yet.
|
|
expect(getMeaningfulChildren(container)).toBe(undefined);
|
|
expect(getMeaningfulChildren(document.head)).toEqual([
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />,
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />,
|
|
<link rel="preload" as="style" href="foo" />,
|
|
<link rel="preload" as="style" href="bar" />,
|
|
]);
|
|
|
|
errorStylesheets(['bar']);
|
|
|
|
loadStylesheets(['foo']);
|
|
assertLog(['load stylesheet: foo', 'error stylesheet: bar']);
|
|
|
|
// We expect that the commit finishes synchronously after the stylesheet loads.
|
|
expect(getMeaningfulChildren(container)).toEqual(<div>hello</div>);
|
|
expect(getMeaningfulChildren(document.head)).toEqual([
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />,
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />,
|
|
<link rel="preload" as="style" href="foo" />,
|
|
<link rel="preload" as="style" href="bar" />,
|
|
]);
|
|
});
|
|
|
|
it('assumes stylesheets that load in the shell loaded already', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
hello
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
let root;
|
|
React.startTransition(() => {
|
|
root = ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
hello
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
hello2
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>hello2</body>
|
|
</html>,
|
|
);
|
|
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
hello3
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>hello2</body>
|
|
</html>,
|
|
);
|
|
|
|
loadPreloads();
|
|
assertLog(['load preload: bar']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>hello2</body>
|
|
</html>,
|
|
);
|
|
|
|
loadStylesheets(['bar']);
|
|
assertLog(['load stylesheet: bar']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>hello3</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can interrupt a suspended commit with a new update', async () => {
|
|
function App({children}) {
|
|
return (
|
|
<html>
|
|
<body>{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
const root = ReactDOMClient.createRoot(document);
|
|
|
|
// Do an initial render. This means subsequent insertions will suspend,
|
|
// unless they are wrapped inside a fresh Suspense boundary.
|
|
root.render(<App />);
|
|
await waitForAll([]);
|
|
|
|
// Insert a stylesheet. This will suspend because it's a transition.
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<App>
|
|
hello
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</App>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
// Although the commit suspended, a preload was inserted.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
// Before the stylesheet has loaded, do an urgent update. This will insert a
|
|
// different stylesheet, and cancel the first one. This stylesheet will not
|
|
// suspend, even though it hasn't loaded, because it's an urgent update.
|
|
root.render(
|
|
<App>
|
|
hello2
|
|
{null}
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
</App>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads(['bar']);
|
|
loadStylesheets(['bar']);
|
|
});
|
|
await assertLog(['load preload: bar', 'load stylesheet: bar']);
|
|
|
|
// The bar stylesheet was inserted. There's still a "foo" preload, even
|
|
// though that update was superseded.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>hello2</body>
|
|
</html>,
|
|
);
|
|
|
|
// When "foo" finishes loading, nothing happens, because "foo" was not
|
|
// included in the last root update. However, if we insert "foo" again
|
|
// later, it should immediately commit without suspending, because it's
|
|
// been preloaded.
|
|
loadPreloads(['foo']);
|
|
assertLog(['load preload: foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>hello2</body>
|
|
</html>,
|
|
);
|
|
|
|
// Now insert "foo" again.
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<App>
|
|
hello3
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
</App>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
// Commits without suspending because "foo" was preloaded.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>hello3</body>
|
|
</html>,
|
|
);
|
|
|
|
loadStylesheets(['foo']);
|
|
assertLog(['load stylesheet: foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>hello3</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('will put a Suspense boundary into fallback if it contains a stylesheet not loaded during a sync update', async () => {
|
|
function App({children}) {
|
|
return (
|
|
<html>
|
|
<body>{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
const root = ReactDOMClient.createRoot(document);
|
|
|
|
await clientAct(() => {
|
|
root.render(<App />);
|
|
});
|
|
await waitForAll([]);
|
|
|
|
await clientAct(() => {
|
|
root.render(
|
|
<App>
|
|
<Suspense fallback="loading...">
|
|
<div>
|
|
hello
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</div>
|
|
</Suspense>
|
|
</App>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
|
|
if (gate(flags => flags.alwaysThrottleRetries)) {
|
|
// Although the commit suspended, a preload was inserted.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
loadPreloads(['foo']);
|
|
assertLog(['load preload: foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
}
|
|
|
|
loadStylesheets(['foo']);
|
|
assertLog(['load stylesheet: foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await clientAct(() => {
|
|
root.render(
|
|
<App>
|
|
<Suspense fallback="loading...">
|
|
<div>
|
|
hello
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
</div>
|
|
</Suspense>
|
|
</App>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
if (gate(flags => flags.alwaysThrottleRetries)) {
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>
|
|
<div style="display: none;">hello</div>loading...
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
loadPreloads(['bar']);
|
|
assertLog(['load preload: bar']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>
|
|
<div style="display: none;">hello</div>loading...
|
|
</body>
|
|
</html>,
|
|
);
|
|
}
|
|
loadStylesheets(['bar']);
|
|
assertLog(['load stylesheet: bar']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
</head>
|
|
<body>
|
|
<div style="">hello</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('will assume stylesheets already in the document have loaded if it cannot confirm it is not yet loaded', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div id="foo" />
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
const root = ReactDOMClient.createRoot(document.querySelector('#foo'));
|
|
|
|
root.render(
|
|
<div>
|
|
<Suspense fallback="loading...">
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
hello world
|
|
</Suspense>
|
|
</div>,
|
|
);
|
|
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div id="foo">
|
|
<div>hello world</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('will assume wait for loading stylesheets to load before continuing', async () => {
|
|
let ssr = true;
|
|
function Component() {
|
|
if (ssr) {
|
|
return null;
|
|
} else {
|
|
return (
|
|
<>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<div>hello client</div>
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<div>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="reveal">
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<div>hello world</div>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
<div>
|
|
<Suspense fallback="loading 2...">
|
|
<Component />
|
|
</Suspense>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>loading...</div>
|
|
<div />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('reveal');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>loading...</div>
|
|
<div />
|
|
<link rel="preload" href="foo" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
ssr = false;
|
|
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<div>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="reveal">
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<div>hello world</div>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
<div>
|
|
<Suspense fallback="loading 2...">
|
|
<Component />
|
|
</Suspense>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>loading...</div>
|
|
<div />
|
|
<link rel="preload" href="foo" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
loadStylesheets();
|
|
assertLog(['load stylesheet: foo']);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
"Hydration failed because the server rendered HTML didn't match the client.",
|
|
]);
|
|
jest.runAllTimers();
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<div>hello world</div>
|
|
</div>
|
|
<div>
|
|
<div>hello client</div>
|
|
</div>
|
|
<link rel="preload" href="foo" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can suspend commits on more than one root for the same resource at the same time', async () => {
|
|
document.body.innerHTML = '';
|
|
const container1 = document.createElement('div');
|
|
const container2 = document.createElement('div');
|
|
document.body.appendChild(container1);
|
|
document.body.appendChild(container2);
|
|
|
|
const root1 = ReactDOMClient.createRoot(container1);
|
|
const root2 = ReactDOMClient.createRoot(container2);
|
|
|
|
React.startTransition(() => {
|
|
root1.render(
|
|
<div>
|
|
one
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<link rel="stylesheet" href="one" precedence="default" />
|
|
</div>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
React.startTransition(() => {
|
|
root2.render(
|
|
<div>
|
|
two
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<link rel="stylesheet" href="two" precedence="default" />
|
|
</div>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="one" as="style" />
|
|
<link rel="preload" href="two" as="style" />
|
|
</head>
|
|
<body>
|
|
<div />
|
|
<div />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
loadPreloads(['foo', 'two']);
|
|
assertLog(['load preload: foo', 'load preload: two']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="two" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="one" as="style" />
|
|
<link rel="preload" href="two" as="style" />
|
|
</head>
|
|
<body>
|
|
<div />
|
|
<div />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
loadStylesheets(['foo', 'two']);
|
|
assertLog(['load stylesheet: foo', 'load stylesheet: two']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="two" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="one" as="style" />
|
|
<link rel="preload" href="two" as="style" />
|
|
</head>
|
|
<body>
|
|
<div />
|
|
<div>
|
|
<div>two</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
assertLog(['load preload: one', 'load stylesheet: one']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="two" data-precedence="default" />
|
|
<link rel="stylesheet" href="one" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="one" as="style" />
|
|
<link rel="preload" href="two" as="style" />
|
|
</head>
|
|
<body>
|
|
<div>
|
|
<div>one</div>
|
|
</div>
|
|
<div>
|
|
<div>two</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('stylesheets block render, with a really long timeout', async () => {
|
|
function App({children}) {
|
|
return (
|
|
<html>
|
|
<body>{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(<App />);
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<App>
|
|
hello
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</App>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
// Advance time by 50 seconds. Even still, the transition is suspended.
|
|
jest.advanceTimersByTime(50000);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
// Advance time by 10 seconds more. A full minute total has elapsed. At this
|
|
// point, something must have really gone wrong, so we time out and allow
|
|
// unstyled content to be displayed.
|
|
jest.advanceTimersByTime(10000);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
// We will load these after the commit finishes to ensure nothing errors and nothing new inserts
|
|
loadPreloads(['foo']);
|
|
loadStylesheets(['foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can interrupt a suspended commit with a new transition', async () => {
|
|
function App({children}) {
|
|
return (
|
|
<html>
|
|
<body>{children}</body>
|
|
</html>
|
|
);
|
|
}
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(<App>(empty)</App>);
|
|
|
|
// Start a transition to "A"
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<App>
|
|
A
|
|
<link rel="stylesheet" href="A" precedence="default" />
|
|
</App>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
|
|
// "A" hasn't loaded yet, so we remain on the initial UI. Its preload
|
|
// has been inserted into the head, though.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="A" as="style" />
|
|
</head>
|
|
<body>(empty)</body>
|
|
</html>,
|
|
);
|
|
|
|
// Interrupt the "A" transition with a new one, "B"
|
|
React.startTransition(() => {
|
|
root.render(
|
|
<App>
|
|
B
|
|
<link rel="stylesheet" href="B" precedence="default" />
|
|
</App>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
|
|
// Still on the initial UI because "B" hasn't loaded, but its preload
|
|
// is now in the head, too.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="A" as="style" />
|
|
<link rel="preload" href="B" as="style" />
|
|
</head>
|
|
<body>(empty)</body>
|
|
</html>,
|
|
);
|
|
|
|
// Finish loading
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
assertLog(['load preload: A', 'load preload: B', 'load stylesheet: B']);
|
|
// The "B" transition has finished.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="B" data-precedence="default" />
|
|
<link rel="preload" href="A" as="style" />
|
|
<link rel="preload" href="B" as="style" />
|
|
</head>
|
|
<body>B</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('loading a stylesheet as part of an error boundary UI, during initial render', async () => {
|
|
class ErrorBoundary extends React.Component {
|
|
state = {error: null};
|
|
static getDerivedStateFromError(error) {
|
|
return {error};
|
|
}
|
|
render() {
|
|
const error = this.state.error;
|
|
if (error !== null) {
|
|
return (
|
|
<>
|
|
<link rel="stylesheet" href="A" precedence="default" />
|
|
{error.message}
|
|
</>
|
|
);
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
function Throws() {
|
|
throw new Error('Oops!');
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<body>
|
|
<ErrorBoundary>
|
|
<Suspense fallback="Loading...">
|
|
<Throws />
|
|
</Suspense>
|
|
</ErrorBoundary>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
// Initial server render. Because something threw, a Suspense fallback
|
|
// is shown.
|
|
await act(() => {
|
|
renderToPipeableStream(<App />, {
|
|
onError(x) {
|
|
Scheduler.log('Caught server error: ' + x.message);
|
|
},
|
|
}).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>Loading...</body>
|
|
</html>,
|
|
);
|
|
assertLog(['Caught server error: Oops!']);
|
|
|
|
// Hydrate the tree. The error boundary will capture the error and attempt
|
|
// to show an error screen. However, the error screen includes a stylesheet,
|
|
// so the commit should suspend until the stylesheet loads.
|
|
ReactDOMClient.hydrateRoot(document, <App />);
|
|
await waitForAll([]);
|
|
|
|
// A preload for the stylesheet is inserted, but we still haven't committed
|
|
// the error screen.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link as="style" href="A" rel="preload" />
|
|
</head>
|
|
<body>Loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
// Finish loading the stylesheets. The commit should be unblocked, and the
|
|
// error screen should appear.
|
|
await clientAct(() => loadStylesheets());
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link data-precedence="default" href="A" rel="stylesheet" />
|
|
<link as="style" href="A" rel="preload" />
|
|
</head>
|
|
<body>Oops!</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('will not flush a preload for a new rendered Stylesheet Resource if one was already flushed', async () => {
|
|
function Component() {
|
|
ReactDOM.preload('foo', {as: 'style'});
|
|
return (
|
|
<div>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="blocked">
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
hello
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Component />
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="style" href="foo" />
|
|
</head>
|
|
<body>
|
|
<div>loading...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await act(() => {
|
|
resolveText('blocked');
|
|
});
|
|
await act(loadStylesheets);
|
|
assertLog(['load stylesheet: foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" as="style" href="foo" />
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('will not flush a preload for a new preinitialized Stylesheet Resource if one was already flushed', async () => {
|
|
function Component() {
|
|
ReactDOM.preload('foo', {as: 'style'});
|
|
return (
|
|
<div>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="blocked">
|
|
<Preinit />
|
|
hello
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Preinit() {
|
|
ReactDOM.preinit('foo', {as: 'style'});
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Component />
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="style" href="foo" />
|
|
</head>
|
|
<body>
|
|
<div>loading...</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await act(() => {
|
|
resolveText('blocked');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="style" href="foo" />
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('will not insert a preload if the underlying resource already exists in the Document', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<script async={true} src="bar" />
|
|
<link rel="preload" href="baz" as="font" />
|
|
</head>
|
|
<body>
|
|
<div id="container" />
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<script async="" src="bar" />
|
|
<link rel="preload" href="baz" as="font" />
|
|
</head>
|
|
<body>
|
|
<div id="container" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
container = document.getElementById('container');
|
|
|
|
function ClientApp() {
|
|
ReactDOM.preload('foo', {as: 'style'});
|
|
ReactDOM.preload('bar', {as: 'script'});
|
|
ReactDOM.preload('baz', {as: 'font'});
|
|
return 'foo';
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
await clientAct(() => root.render(<ClientApp />));
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<script async="" src="bar" />
|
|
<link rel="preload" href="baz" as="font" />
|
|
</head>
|
|
<body>
|
|
<div id="container">foo</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('uses imageSrcSet and imageSizes when keying image preloads', async () => {
|
|
function App({isClient}) {
|
|
// Will key off href in absense of imageSrcSet
|
|
ReactDOM.preload('foo', {as: 'image'});
|
|
ReactDOM.preload('foo', {as: 'image'});
|
|
|
|
// Will key off imageSrcSet + imageSizes
|
|
ReactDOM.preload('foo', {as: 'image', imageSrcSet: 'fooset'});
|
|
ReactDOM.preload('foo2', {as: 'image', imageSrcSet: 'fooset'});
|
|
|
|
// Will key off imageSrcSet + imageSizes
|
|
ReactDOM.preload('foo', {
|
|
as: 'image',
|
|
imageSrcSet: 'fooset',
|
|
imageSizes: 'foosizes',
|
|
});
|
|
ReactDOM.preload('foo2', {
|
|
as: 'image',
|
|
imageSrcSet: 'fooset',
|
|
imageSizes: 'foosizes',
|
|
});
|
|
|
|
// Will key off href in absense of imageSrcSet, imageSizes is ignored. these should match the
|
|
// first preloads not not emit a new preload tag
|
|
ReactDOM.preload('foo', {as: 'image', imageSizes: 'foosizes'});
|
|
ReactDOM.preload('foo', {as: 'image', imageSizes: 'foosizes'});
|
|
|
|
// These preloads are for something that isn't an image
|
|
// They should all key off the href
|
|
ReactDOM.preload('bar', {as: 'somethingelse'});
|
|
ReactDOM.preload('bar', {
|
|
as: 'somethingelse',
|
|
imageSrcSet: 'makes no sense',
|
|
});
|
|
ReactDOM.preload('bar', {
|
|
as: 'somethingelse',
|
|
imageSrcSet: 'makes no sense',
|
|
imageSizes: 'makes no sense',
|
|
});
|
|
|
|
if (isClient) {
|
|
// Will key off href in absense of imageSrcSet
|
|
ReactDOM.preload('client', {as: 'image'});
|
|
ReactDOM.preload('client', {as: 'image'});
|
|
|
|
// Will key off imageSrcSet + imageSizes
|
|
ReactDOM.preload('client', {as: 'image', imageSrcSet: 'clientset'});
|
|
ReactDOM.preload('client2', {as: 'image', imageSrcSet: 'clientset'});
|
|
|
|
// Will key off imageSrcSet + imageSizes
|
|
ReactDOM.preload('client', {
|
|
as: 'image',
|
|
imageSrcSet: 'clientset',
|
|
imageSizes: 'clientsizes',
|
|
});
|
|
ReactDOM.preload('client2', {
|
|
as: 'image',
|
|
imageSrcSet: 'clientset',
|
|
imageSizes: 'clientsizes',
|
|
});
|
|
|
|
// Will key off href in absense of imageSrcSet, imageSizes is ignored. these should match the
|
|
// first preloads not not emit a new preload tag
|
|
ReactDOM.preload('client', {as: 'image', imageSizes: 'clientsizes'});
|
|
ReactDOM.preload('client', {as: 'image', imageSizes: 'clientsizes'});
|
|
}
|
|
|
|
return (
|
|
<html>
|
|
<body>hello</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="image" href="foo" />
|
|
<link rel="preload" as="image" imagesrcset="fooset" />
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="fooset"
|
|
imagesizes="foosizes"
|
|
/>
|
|
<link rel="preload" as="somethingelse" href="bar" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(document, <App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="image" href="foo" />
|
|
<link rel="preload" as="image" imagesrcset="fooset" />
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="fooset"
|
|
imagesizes="foosizes"
|
|
/>
|
|
<link rel="preload" as="somethingelse" href="bar" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(<App isClient={true} />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="image" href="foo" />
|
|
<link rel="preload" as="image" imagesrcset="fooset" />
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="fooset"
|
|
imagesizes="foosizes"
|
|
/>
|
|
<link rel="preload" as="somethingelse" href="bar" />
|
|
<link rel="preload" as="image" href="client" />
|
|
<link rel="preload" as="image" imagesrcset="clientset" />
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="clientset"
|
|
imagesizes="clientsizes"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('should handle referrerPolicy on image preload', async () => {
|
|
function App({isClient}) {
|
|
ReactDOM.preload('/server', {
|
|
as: 'image',
|
|
imageSrcSet: '/server',
|
|
imageSizes: '100vw',
|
|
referrerPolicy: 'no-referrer',
|
|
});
|
|
|
|
if (isClient) {
|
|
ReactDOM.preload('/client', {
|
|
as: 'image',
|
|
imageSrcSet: '/client',
|
|
imageSizes: '100vw',
|
|
referrerPolicy: 'no-referrer',
|
|
});
|
|
}
|
|
|
|
return (
|
|
<html>
|
|
<body>hello</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="/server"
|
|
imagesizes="100vw"
|
|
referrerpolicy="no-referrer"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(document, <App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="/server"
|
|
imagesizes="100vw"
|
|
referrerpolicy="no-referrer"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(<App isClient={true} />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="/server"
|
|
imagesizes="100vw"
|
|
referrerpolicy="no-referrer"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="/client"
|
|
imagesizes="100vw"
|
|
referrerpolicy="no-referrer"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can emit preloads for non-lazy images that are rendered', async () => {
|
|
function App() {
|
|
ReactDOM.preload('script', {as: 'script'});
|
|
ReactDOM.preload('a', {as: 'image'});
|
|
ReactDOM.preload('b', {as: 'image'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<img src="a" />
|
|
<img src="b" loading="lazy" />
|
|
<img src="b2" loading="lazy" />
|
|
<img src="c" srcSet="srcsetc" />
|
|
<img src="d" srcSet="srcsetd" sizes="sizesd" />
|
|
<img src="d" srcSet="srcsetd" sizes="sizesd2" />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
// non-lazy images are first, then arbitrary preloads like for the script and lazy images
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="a" as="image" />
|
|
<link rel="preload" as="image" imagesrcset="srcsetc" />
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="srcsetd"
|
|
imagesizes="sizesd"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="srcsetd"
|
|
imagesizes="sizesd2"
|
|
/>
|
|
<link rel="preload" href="script" as="script" />
|
|
<link rel="preload" href="b" as="image" />
|
|
</head>
|
|
<body>
|
|
<img src="a" />
|
|
<img src="b" loading="lazy" />
|
|
<img src="b2" loading="lazy" />
|
|
<img src="c" srcset="srcsetc" />
|
|
<img src="d" srcset="srcsetd" sizes="sizesd" />
|
|
<img src="d" srcset="srcsetd" sizes="sizesd2" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('Does not preload lazy images', async () => {
|
|
function App() {
|
|
ReactDOM.preload('a', {as: 'image'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<img src="a" fetchPriority="low" />
|
|
<img src="b" fetchPriority="low" />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="image" href="a" />
|
|
</head>
|
|
<body>
|
|
<img src="a" fetchpriority="low" />
|
|
<img src="b" fetchpriority="low" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('preloads up to 10 suspensey images as high priority when fetchPriority is not specified', async () => {
|
|
function App() {
|
|
ReactDOM.preload('1', {as: 'image', fetchPriority: 'high'});
|
|
ReactDOM.preload('auto', {as: 'image'});
|
|
ReactDOM.preload('low', {as: 'image', fetchPriority: 'low'});
|
|
ReactDOM.preload('9', {as: 'image', fetchPriority: 'high'});
|
|
ReactDOM.preload('10', {as: 'image', fetchPriority: 'high'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
{/* skipping 1 */}
|
|
<img src="2" />
|
|
<img src="3" fetchPriority="auto" />
|
|
<img src="4" fetchPriority="high" />
|
|
<img src="5" />
|
|
<img src="5low" fetchPriority="low" />
|
|
<img src="6" />
|
|
<img src="7" />
|
|
<img src="8" />
|
|
<img src="9" />
|
|
{/* skipping 10 */}
|
|
<img src="11" />
|
|
<img src="12" fetchPriority="high" />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
{/* First we see the preloads calls that made it to the high priority image queue */}
|
|
<link rel="preload" as="image" href="1" fetchpriority="high" />
|
|
<link rel="preload" as="image" href="9" fetchpriority="high" />
|
|
<link rel="preload" as="image" href="10" fetchpriority="high" />
|
|
{/* Next we see up to 7 more images qualify for high priority image queue */}
|
|
<link rel="preload" as="image" href="2" />
|
|
<link rel="preload" as="image" href="3" fetchpriority="auto" />
|
|
<link rel="preload" as="image" href="4" fetchpriority="high" />
|
|
<link rel="preload" as="image" href="5" />
|
|
<link rel="preload" as="image" href="6" />
|
|
<link rel="preload" as="image" href="7" />
|
|
<link rel="preload" as="image" href="8" />
|
|
{/* Next we see images that are explicitly high priority and thus make it to the high priority image queue */}
|
|
<link rel="preload" as="image" href="12" fetchpriority="high" />
|
|
{/* Next we see the remaining preloads that did not make it to the high priority image queue */}
|
|
<link rel="preload" as="image" href="auto" />
|
|
<link rel="preload" as="image" href="low" fetchpriority="low" />
|
|
<link rel="preload" as="image" href="11" />
|
|
</head>
|
|
<body>
|
|
{/* skipping 1 */}
|
|
<img src="2" />
|
|
<img src="3" fetchpriority="auto" />
|
|
<img src="4" fetchpriority="high" />
|
|
<img src="5" />
|
|
<img src="5low" fetchpriority="low" />
|
|
<img src="6" />
|
|
<img src="7" />
|
|
<img src="8" />
|
|
<img src="9" />
|
|
{/* skipping 10 */}
|
|
<img src="11" />
|
|
<img src="12" fetchpriority="high" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can promote images to high priority when at least one instance specifies a high fetchPriority', async () => {
|
|
function App() {
|
|
// If a ends up in a higher priority queue than b it will flush first
|
|
ReactDOM.preload('a', {as: 'image'});
|
|
ReactDOM.preload('b', {as: 'image'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
<img src="1" />
|
|
<img src="2" />
|
|
<img src="3" />
|
|
<img src="4" />
|
|
<img src="5" />
|
|
<img src="6" />
|
|
<img src="7" />
|
|
<img src="8" />
|
|
<img src="9" />
|
|
<img src="10" />
|
|
<img src="11" />
|
|
<img src="12" />
|
|
<img src="a" fetchPriority="low" />
|
|
<img src="a" />
|
|
<img src="a" fetchPriority="high" />
|
|
<img src="a" />
|
|
<img src="a" />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
{/* The First 10 high priority images were just the first 10 rendered images */}
|
|
<link rel="preload" as="image" href="1" />
|
|
<link rel="preload" as="image" href="2" />
|
|
<link rel="preload" as="image" href="3" />
|
|
<link rel="preload" as="image" href="4" />
|
|
<link rel="preload" as="image" href="5" />
|
|
<link rel="preload" as="image" href="6" />
|
|
<link rel="preload" as="image" href="7" />
|
|
<link rel="preload" as="image" href="8" />
|
|
<link rel="preload" as="image" href="9" />
|
|
<link rel="preload" as="image" href="10" />
|
|
{/* The "a" image was rendered a few times but since at least one of those was with
|
|
fetchPriorty="high" it ends up in the high priority queue */}
|
|
<link rel="preload" as="image" href="a" />
|
|
{/* Stylesheets come in between high priority images and regular preloads */}
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
{/* The remainig images that preloaded at regular priority */}
|
|
<link rel="preload" as="image" href="b" />
|
|
<link rel="preload" as="image" href="11" />
|
|
<link rel="preload" as="image" href="12" />
|
|
</head>
|
|
<body>
|
|
<img src="1" />
|
|
<img src="2" />
|
|
<img src="3" />
|
|
<img src="4" />
|
|
<img src="5" />
|
|
<img src="6" />
|
|
<img src="7" />
|
|
<img src="8" />
|
|
<img src="9" />
|
|
<img src="10" />
|
|
<img src="11" />
|
|
<img src="12" />
|
|
<img src="a" fetchpriority="low" />
|
|
<img src="a" />
|
|
<img src="a" fetchpriority="high" />
|
|
<img src="a" />
|
|
<img src="a" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('preloads from rendered images properly use srcSet and sizes', async () => {
|
|
function App() {
|
|
ReactDOM.preload('1', {as: 'image', imageSrcSet: 'ss1'});
|
|
ReactDOM.preload('2', {
|
|
as: 'image',
|
|
imageSrcSet: 'ss2',
|
|
imageSizes: 's2',
|
|
});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<img src="1" srcSet="ss1" />
|
|
<img src="2" srcSet="ss2" sizes="s2" />
|
|
<img src="3" srcSet="ss3" />
|
|
<img src="4" srcSet="ss4" sizes="s4" />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="image" imagesrcset="ss1" />
|
|
<link rel="preload" as="image" imagesrcset="ss2" imagesizes="s2" />
|
|
<link rel="preload" as="image" imagesrcset="ss3" />
|
|
<link rel="preload" as="image" imagesrcset="ss4" imagesizes="s4" />
|
|
</head>
|
|
<body>
|
|
<img src="1" srcset="ss1" />
|
|
<img src="2" srcset="ss2" sizes="s2" />
|
|
<img src="3" srcset="ss3" />
|
|
<img src="4" srcset="ss4" sizes="s4" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('should not preload images that have a data URIs for src or srcSet', async () => {
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<body>
|
|
<img src="data:1" />
|
|
<img src="data:2" srcSet="ss2" />
|
|
<img srcSet="data:3a, data:3b 2x" />
|
|
<img src="4" srcSet="data:4a, data4b 2x" />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<img src="data:1" />
|
|
<img src="data:2" srcset="ss2" />
|
|
<img srcset="data:3a, data:3b 2x" />
|
|
<img src="4" srcset="data:4a, data4b 2x" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
// https://github.com/vercel/next.js/discussions/54799
|
|
it('omits preloads when an <img> is inside a <picture>', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<picture>
|
|
<img src="foo" />
|
|
</picture>
|
|
<picture>
|
|
<source type="image/webp" srcSet="webpsrc" />
|
|
<img src="jpg fallback" />
|
|
</picture>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<picture>
|
|
<img src="foo" />
|
|
</picture>
|
|
<picture>
|
|
<source type="image/webp" srcset="webpsrc" />
|
|
<img src="jpg fallback" />
|
|
</picture>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
// Fixes: https://github.com/facebook/react/issues/27910
|
|
it('omits preloads for images inside noscript tags', async () => {
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<body>
|
|
<img src="foo" />
|
|
<noscript>
|
|
<img src="bar" />
|
|
</noscript>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" href="foo" as="image" />
|
|
</head>
|
|
<body>
|
|
<img src="foo" />
|
|
<noscript><img src="bar"></noscript>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('should handle media on image preload', async () => {
|
|
function App({isClient}) {
|
|
ReactDOM.preload('/server', {
|
|
as: 'image',
|
|
imageSrcSet: '/server',
|
|
imageSizes: '100vw',
|
|
media: 'print and (min-width: 768px)',
|
|
});
|
|
|
|
if (isClient) {
|
|
ReactDOM.preload('/client', {
|
|
as: 'image',
|
|
imageSrcSet: '/client',
|
|
imageSizes: '100vw',
|
|
media: 'screen and (max-width: 480px)',
|
|
});
|
|
}
|
|
|
|
return (
|
|
<html>
|
|
<body>hello</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="/server"
|
|
imagesizes="100vw"
|
|
media="print and (min-width: 768px)"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(document, <App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="/server"
|
|
imagesizes="100vw"
|
|
media="print and (min-width: 768px)"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(<App isClient={true} />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="/server"
|
|
imagesizes="100vw"
|
|
media="print and (min-width: 768px)"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="image"
|
|
imagesrcset="/client"
|
|
imagesizes="100vw"
|
|
media="screen and (max-width: 480px)"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('should warn if you preload a stylesheet and then render a style tag with the same href', async () => {
|
|
const style = 'body { color: red; }';
|
|
function App() {
|
|
ReactDOM.preload('foo', {as: 'style'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
hello
|
|
<style precedence="default" href="foo">
|
|
{style}
|
|
</style>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'React encountered a hoistable style tag for the same href as a preload: "foo". ' +
|
|
'When using a style tag to inline styles you should not also preload it as a stylsheet.\n' +
|
|
' in style (at **)\n' +
|
|
' in App (at **)',
|
|
]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-precedence="default" data-href="foo">
|
|
{style}
|
|
</style>
|
|
<link rel="preload" as="style" href="foo" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('should preload only once even if you discover a stylesheet, script, or moduleScript late', async () => {
|
|
function App() {
|
|
// We start with preinitializing some resources first
|
|
ReactDOM.preinit('shell preinit/shell', {as: 'style'});
|
|
ReactDOM.preinit('shell preinit/shell', {as: 'script'});
|
|
ReactDOM.preinitModule('shell preinit/shell', {as: 'script'});
|
|
|
|
// We initiate all the shell preloads
|
|
ReactDOM.preload('shell preinit/shell', {as: 'style'});
|
|
ReactDOM.preload('shell preinit/shell', {as: 'script'});
|
|
ReactDOM.preloadModule('shell preinit/shell', {as: 'script'});
|
|
|
|
ReactDOM.preload('shell/shell preinit', {as: 'style'});
|
|
ReactDOM.preload('shell/shell preinit', {as: 'script'});
|
|
ReactDOM.preloadModule('shell/shell preinit', {as: 'script'});
|
|
|
|
ReactDOM.preload('shell/shell render', {as: 'style'});
|
|
ReactDOM.preload('shell/shell render', {as: 'script'});
|
|
ReactDOM.preloadModule('shell/shell render');
|
|
|
|
ReactDOM.preload('shell/late preinit', {as: 'style'});
|
|
ReactDOM.preload('shell/late preinit', {as: 'script'});
|
|
ReactDOM.preloadModule('shell/late preinit');
|
|
|
|
ReactDOM.preload('shell/late render', {as: 'style'});
|
|
ReactDOM.preload('shell/late render', {as: 'script'});
|
|
ReactDOM.preloadModule('shell/late render');
|
|
|
|
// we preinit later ones that should be created by
|
|
ReactDOM.preinit('shell/shell preinit', {as: 'style'});
|
|
ReactDOM.preinit('shell/shell preinit', {as: 'script'});
|
|
ReactDOM.preinitModule('shell/shell preinit');
|
|
|
|
ReactDOM.preinit('late/shell preinit', {as: 'style'});
|
|
ReactDOM.preinit('late/shell preinit', {as: 'script'});
|
|
ReactDOM.preinitModule('late/shell preinit');
|
|
return (
|
|
<html>
|
|
<body>
|
|
<link
|
|
rel="stylesheet"
|
|
precedence="default"
|
|
href="shell/shell render"
|
|
/>
|
|
<script async={true} src="shell/shell render" />
|
|
<script type="module" async={true} src="shell/shell render" />
|
|
<link
|
|
rel="stylesheet"
|
|
precedence="default"
|
|
href="late/shell render"
|
|
/>
|
|
<script async={true} src="late/shell render" />
|
|
<script type="module" async={true} src="late/shell render" />
|
|
<Suspense fallback="late...">
|
|
<BlockedOn value="late">
|
|
<Late />
|
|
</BlockedOn>
|
|
</Suspense>
|
|
<Suspense fallback="later...">
|
|
<BlockedOn value="later">
|
|
<Later />
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
function Late() {
|
|
ReactDOM.preload('late/later preinit', {as: 'style'});
|
|
ReactDOM.preload('late/later preinit', {as: 'script'});
|
|
ReactDOM.preloadModule('late/later preinit');
|
|
|
|
ReactDOM.preload('late/later render', {as: 'style'});
|
|
ReactDOM.preload('late/later render', {as: 'script'});
|
|
ReactDOM.preloadModule('late/later render');
|
|
|
|
ReactDOM.preload('late/shell preinit', {as: 'style'});
|
|
ReactDOM.preload('late/shell preinit', {as: 'script'});
|
|
ReactDOM.preloadModule('late/shell preinit');
|
|
|
|
ReactDOM.preload('late/shell render', {as: 'style'});
|
|
ReactDOM.preload('late/shell render', {as: 'script'});
|
|
ReactDOM.preloadModule('late/shell render');
|
|
|
|
// late preinits don't actually flush so we won't see this in the DOM as a stylesehet but we should see
|
|
// the preload for this resource
|
|
ReactDOM.preinit('shell/late preinit', {as: 'style'});
|
|
ReactDOM.preinit('shell/late preinit', {as: 'script'});
|
|
ReactDOM.preinitModule('shell/late preinit');
|
|
return (
|
|
<>
|
|
Late
|
|
<link
|
|
rel="stylesheet"
|
|
precedence="default"
|
|
href="shell/late render"
|
|
/>
|
|
<script async={true} src="shell/late render" />
|
|
<script type="module" async={true} src="shell/late render" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Later() {
|
|
// late preinits don't actually flush so we won't see this in the DOM as a stylesehet but we should see
|
|
// the preload for this resource
|
|
ReactDOM.preinit('late/later preinit', {as: 'style'});
|
|
ReactDOM.preinit('late/later preinit', {as: 'script'});
|
|
ReactDOM.preinitModule('late/later preinit');
|
|
return (
|
|
<>
|
|
Later
|
|
<link
|
|
rel="stylesheet"
|
|
precedence="default"
|
|
href="late/later render"
|
|
/>
|
|
<script async={true} src="late/later render" />
|
|
<script type="module" async={true} src="late/later render" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell preinit/shell"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/shell preinit"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/shell preinit"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/shell render"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/shell render"
|
|
/>
|
|
<script async="" src="shell preinit/shell" />
|
|
<script async="" src="shell preinit/shell" type="module" />
|
|
<script async="" src="shell/shell preinit" />
|
|
<script async="" src="shell/shell preinit" type="module" />
|
|
<script async="" src="late/shell preinit" />
|
|
<script async="" src="late/shell preinit" type="module" />
|
|
<script async="" src="shell/shell render" />
|
|
<script async="" src="shell/shell render" type="module" />
|
|
<script async="" src="late/shell render" />
|
|
<script async="" src="late/shell render" type="module" />
|
|
<link rel="preload" as="style" href="shell/late preinit" />
|
|
<link rel="preload" as="script" href="shell/late preinit" />
|
|
<link rel="modulepreload" href="shell/late preinit" />
|
|
<link rel="preload" as="style" href="shell/late render" />
|
|
<link rel="preload" as="script" href="shell/late render" />
|
|
<link rel="modulepreload" href="shell/late render" />
|
|
</head>
|
|
<body>
|
|
{'late...'}
|
|
{'later...'}
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('late');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell preinit/shell"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/shell preinit"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/shell preinit"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/shell render"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/shell render"
|
|
/>
|
|
{/* FROM HERE */}
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/late render"
|
|
/>
|
|
{/** TO HERE:
|
|
* This was hoisted by boundary complete instruction. The preload was already emitted in the
|
|
* shell but we see it below because this was inserted clientside by precedence.
|
|
* We don't observe the "shell/late preinit" because these do not flush unless they are flushing
|
|
* with the shell
|
|
* */}
|
|
<script async="" src="shell preinit/shell" />
|
|
<script async="" src="shell preinit/shell" type="module" />
|
|
<script async="" src="shell/shell preinit" />
|
|
<script async="" src="shell/shell preinit" type="module" />
|
|
<script async="" src="late/shell preinit" />
|
|
<script async="" src="late/shell preinit" type="module" />
|
|
<script async="" src="shell/shell render" />
|
|
<script async="" src="shell/shell render" type="module" />
|
|
<script async="" src="late/shell render" />
|
|
<script async="" src="late/shell render" type="module" />
|
|
<link rel="preload" as="style" href="shell/late preinit" />
|
|
<link rel="preload" as="script" href="shell/late preinit" />
|
|
<link rel="modulepreload" href="shell/late preinit" />
|
|
<link rel="preload" as="style" href="shell/late render" />
|
|
<link rel="preload" as="script" href="shell/late render" />
|
|
<link rel="modulepreload" href="shell/late render" />
|
|
</head>
|
|
<body>
|
|
{'late...'}
|
|
{'later...'}
|
|
{/* FROM HERE */}
|
|
<script async="" src="shell/late preinit" />
|
|
<script async="" src="shell/late preinit" type="module" />
|
|
<script async="" src="shell/late render" />
|
|
<script async="" src="shell/late render" type="module" />
|
|
<link rel="preload" as="style" href="late/later preinit" />
|
|
<link rel="preload" as="script" href="late/later preinit" />
|
|
<link rel="modulepreload" href="late/later preinit" />
|
|
<link rel="preload" as="style" href="late/later render" />
|
|
<link rel="preload" as="script" href="late/later render" />
|
|
<link rel="modulepreload" href="late/later render" />
|
|
{/** TO HERE:
|
|
* These resources streamed into the body during the boundary flush. Scripts go first then
|
|
* preloads according to our streaming queue priorities. Note also that late/shell resources
|
|
* where the resource already emitted in the shell and the preload is invoked later do not
|
|
* end up with a preload in the document at all.
|
|
* */}
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('later');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell preinit/shell"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/shell preinit"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/shell preinit"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/shell render"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/shell render"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/late render"
|
|
/>
|
|
{/* FROM HERE */}
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/later render"
|
|
/>
|
|
{/** TO HERE:
|
|
* This was hoisted by boundary complete instruction. The preload was already emitted in the
|
|
* shell but we see it below because this was inserted clientside by precedence
|
|
* We don't observe the "late/later preinit" because these do not flush unless they are flushing
|
|
* with the shell
|
|
* */}
|
|
<script async="" src="shell preinit/shell" />
|
|
<script async="" src="shell preinit/shell" type="module" />
|
|
<script async="" src="shell/shell preinit" />
|
|
<script async="" src="shell/shell preinit" type="module" />
|
|
<script async="" src="late/shell preinit" />
|
|
<script async="" src="late/shell preinit" type="module" />
|
|
<script async="" src="shell/shell render" />
|
|
<script async="" src="shell/shell render" type="module" />
|
|
<script async="" src="late/shell render" />
|
|
<script async="" src="late/shell render" type="module" />
|
|
<link rel="preload" as="style" href="shell/late preinit" />
|
|
<link rel="preload" as="script" href="shell/late preinit" />
|
|
<link rel="modulepreload" href="shell/late preinit" />
|
|
<link rel="preload" as="style" href="shell/late render" />
|
|
<link rel="preload" as="script" href="shell/late render" />
|
|
<link rel="modulepreload" href="shell/late render" />
|
|
</head>
|
|
<body>
|
|
{'late...'}
|
|
{'later...'}
|
|
<script async="" src="shell/late preinit" />
|
|
<script async="" src="shell/late preinit" type="module" />
|
|
<script async="" src="shell/late render" />
|
|
<script async="" src="shell/late render" type="module" />
|
|
<link rel="preload" as="style" href="late/later preinit" />
|
|
<link rel="preload" as="script" href="late/later preinit" />
|
|
<link rel="modulepreload" href="late/later preinit" />
|
|
<link rel="preload" as="style" href="late/later render" />
|
|
<link rel="preload" as="script" href="late/later render" />
|
|
<link rel="modulepreload" href="late/later render" />
|
|
{/* FROM HERE */}
|
|
<script async="" src="late/later preinit" />
|
|
<script async="" src="late/later preinit" type="module" />
|
|
<script async="" src="late/later render" />
|
|
<script async="" src="late/later render" type="module" />
|
|
{/** TO HERE:
|
|
* These resources streamed into the body during the boundary flush. Scripts go first then
|
|
* preloads according to our streaming queue priorities
|
|
* */}
|
|
</body>
|
|
</html>,
|
|
);
|
|
loadStylesheets();
|
|
// Let the styles flush and then flush the boundaries
|
|
await 0;
|
|
await 0;
|
|
jest.runAllTimers();
|
|
assertLog([
|
|
'load stylesheet: shell preinit/shell',
|
|
'load stylesheet: shell/shell preinit',
|
|
'load stylesheet: late/shell preinit',
|
|
'load stylesheet: shell/shell render',
|
|
'load stylesheet: late/shell render',
|
|
'load stylesheet: shell/late render',
|
|
'load stylesheet: late/later render',
|
|
]);
|
|
|
|
ReactDOMClient.hydrateRoot(document, <App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell preinit/shell"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/shell preinit"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/shell preinit"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/shell render"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/shell render"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/late render"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/later render"
|
|
/>
|
|
{/* FROM HERE */}
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="shell/late preinit"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
data-precedence="default"
|
|
href="late/later preinit"
|
|
/>
|
|
{/** TO HERE:
|
|
* The client render patches in the two missing preinit stylesheets when hydration happens
|
|
* Note that this is only because we repeated the calls to preinit on the client
|
|
* */}
|
|
<script async="" src="shell preinit/shell" />
|
|
<script async="" src="shell preinit/shell" type="module" />
|
|
<script async="" src="shell/shell preinit" />
|
|
<script async="" src="shell/shell preinit" type="module" />
|
|
<script async="" src="late/shell preinit" />
|
|
<script async="" src="late/shell preinit" type="module" />
|
|
<script async="" src="shell/shell render" />
|
|
<script async="" src="shell/shell render" type="module" />
|
|
<script async="" src="late/shell render" />
|
|
<script async="" src="late/shell render" type="module" />
|
|
<link rel="preload" as="style" href="shell/late preinit" />
|
|
<link rel="preload" as="script" href="shell/late preinit" />
|
|
<link rel="modulepreload" href="shell/late preinit" />
|
|
<link rel="preload" as="style" href="shell/late render" />
|
|
<link rel="preload" as="script" href="shell/late render" />
|
|
<link rel="modulepreload" href="shell/late render" />
|
|
</head>
|
|
<body>
|
|
{'Late'}
|
|
{'Later'}
|
|
<script async="" src="shell/late preinit" />
|
|
<script async="" src="shell/late preinit" type="module" />
|
|
<script async="" src="shell/late render" />
|
|
<script async="" src="shell/late render" type="module" />
|
|
<link rel="preload" as="style" href="late/later preinit" />
|
|
<link rel="preload" as="script" href="late/later preinit" />
|
|
<link rel="modulepreload" href="late/later preinit" />
|
|
<link rel="preload" as="style" href="late/later render" />
|
|
<link rel="preload" as="script" href="late/later render" />
|
|
<link rel="modulepreload" href="late/later render" />
|
|
<script async="" src="late/later preinit" />
|
|
<script async="" src="late/later preinit" type="module" />
|
|
<script async="" src="late/later render" />
|
|
<script async="" src="late/later render" type="module" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('does not flush hoistables for fallbacks', async () => {
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<body>
|
|
<Suspense
|
|
fallback={
|
|
<>
|
|
<div>fallback1</div>
|
|
<meta name="fallback1" />
|
|
<title>foo</title>
|
|
</>
|
|
}>
|
|
<>
|
|
<div>primary1</div>
|
|
<meta name="primary1" />
|
|
</>
|
|
</Suspense>
|
|
<Suspense
|
|
fallback={
|
|
<>
|
|
<div>fallback2</div>
|
|
<meta name="fallback2" />
|
|
<link rel="foo" href="bar" />
|
|
</>
|
|
}>
|
|
<>
|
|
<div>primary2</div>
|
|
<BlockedOn value="first">
|
|
<meta name="primary2" />
|
|
</BlockedOn>
|
|
</>
|
|
</Suspense>
|
|
<Suspense
|
|
fallback={
|
|
<>
|
|
<div>fallback3</div>
|
|
<meta name="fallback3" />
|
|
<Suspense fallback="deep">
|
|
<div>deep fallback ... primary content</div>
|
|
<meta name="deep fallback" />
|
|
</Suspense>
|
|
</>
|
|
}>
|
|
<>
|
|
<div>primary3</div>
|
|
<BlockedOn value="second">
|
|
<meta name="primary3" />
|
|
</BlockedOn>
|
|
</>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
resolveText('first');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta name="primary1" />
|
|
<meta name="primary2" />
|
|
</head>
|
|
<body>
|
|
<div>primary1</div>
|
|
<div>primary2</div>
|
|
<div>fallback3</div>
|
|
<div>deep fallback ... primary content</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('second');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta name="primary1" />
|
|
<meta name="primary2" />
|
|
</head>
|
|
<body>
|
|
<div>primary1</div>
|
|
<div>primary2</div>
|
|
<div>primary3</div>
|
|
<meta name="primary3" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('avoids flushing hoistables from completed boundaries nested inside fallbacks', async () => {
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<body>
|
|
<Suspense
|
|
fallback={
|
|
<Suspense
|
|
fallback={
|
|
<>
|
|
<div>nested fallback1</div>
|
|
<meta name="nested fallback1" />
|
|
</>
|
|
}>
|
|
<>
|
|
<div>nested primary1</div>
|
|
<meta name="nested primary1" />
|
|
</>
|
|
</Suspense>
|
|
}>
|
|
<BlockedOn value="release" />
|
|
<>
|
|
<div>primary1</div>
|
|
<meta name="primary1" />
|
|
</>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
{/* The primary content hoistables emit */}
|
|
<meta name="primary1" />
|
|
</head>
|
|
<body>
|
|
{/* The fallback content emits but the hoistables do not even if they
|
|
inside a nested suspense boundary that is resolved */}
|
|
<div>nested primary1</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('release');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta name="primary1" />
|
|
</head>
|
|
<body>
|
|
<div>primary1</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('should never flush hoistables before the preamble', async () => {
|
|
let resolve;
|
|
const promise = new Promise(res => {
|
|
resolve = res;
|
|
});
|
|
|
|
function App() {
|
|
ReactDOM.preinit('foo', {as: 'script'});
|
|
React.use(promise);
|
|
return (
|
|
<html>
|
|
<body>hello</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
// we assert the default JSDOM still in tact
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div id="container" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolve();
|
|
});
|
|
|
|
// we assert the DOM was replaced entirely because we streamed an opening html tag
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
describe('ReactDOM.prefetchDNS(href)', () => {
|
|
it('creates a dns-prefetch resource when called', async () => {
|
|
function App({url}) {
|
|
ReactDOM.prefetchDNS(url);
|
|
ReactDOM.prefetchDNS(url);
|
|
ReactDOM.prefetchDNS(url, {});
|
|
ReactDOM.prefetchDNS(url, {crossOrigin: 'use-credentials'});
|
|
return (
|
|
<html>
|
|
<body>hello world</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App url="foo" />).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.prefetchDNS(): Expected only one argument, `href`, ' +
|
|
'but encountered something with type "object" as a second argument instead. ' +
|
|
'This argument is reserved for future options and is currently disallowed. ' +
|
|
'Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.prefetchDNS(): Expected only one argument, `href`, ' +
|
|
'but encountered something with type "object" as a second argument instead. ' +
|
|
'This argument is reserved for future options and is currently disallowed. ' +
|
|
'It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. ' +
|
|
'Browsers do not perform DNS queries using CORS and setting this attribute ' +
|
|
'on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just ' +
|
|
'a single string argument, `href`.\n' +
|
|
' in App (at **)',
|
|
]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="dns-prefetch" href="foo" />
|
|
</head>
|
|
<body>hello world</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(document, <App url="foo" />);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.prefetchDNS(): Expected only one argument, `href`, ' +
|
|
'but encountered something with type "object" as a second argument instead. ' +
|
|
'This argument is reserved for future options and is currently disallowed. ' +
|
|
'Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.prefetchDNS(): Expected only one argument, `href`, ' +
|
|
'but encountered something with type "object" as a second argument instead. ' +
|
|
'This argument is reserved for future options and is currently disallowed. ' +
|
|
'It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. ' +
|
|
'Browsers do not perform DNS queries using CORS and setting this attribute ' +
|
|
'on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just ' +
|
|
'a single string argument, `href`.\n' +
|
|
' in App (at **)',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="dns-prefetch" href="foo" />
|
|
</head>
|
|
<body>hello world</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(<App url="bar" />);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.prefetchDNS(): Expected only one argument, `href`, ' +
|
|
'but encountered something with type "object" as a second argument instead. ' +
|
|
'This argument is reserved for future options and is currently disallowed. ' +
|
|
'Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.prefetchDNS(): Expected only one argument, `href`, ' +
|
|
'but encountered something with type "object" as a second argument instead. ' +
|
|
'This argument is reserved for future options and is currently disallowed. ' +
|
|
'It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. ' +
|
|
'Browsers do not perform DNS queries using CORS and setting this attribute ' +
|
|
'on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just ' +
|
|
'a single string argument, `href`.\n' +
|
|
' in App (at **)',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="dns-prefetch" href="foo" />
|
|
<link rel="dns-prefetch" href="bar" />
|
|
</head>
|
|
<body>hello world</body>
|
|
</html>,
|
|
);
|
|
});
|
|
});
|
|
|
|
it('does not wait for stylesheets of completed fallbacks', async () => {
|
|
function Unblock({value}) {
|
|
resolveText(value);
|
|
return null;
|
|
}
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<div>hello world</div>
|
|
<BlockedOn value="unblock inner boundaries">
|
|
<Suspense
|
|
fallback={
|
|
<>
|
|
<link
|
|
rel="stylesheet"
|
|
href="completed inner"
|
|
precedence="default"
|
|
/>
|
|
<div>inner fallback</div>
|
|
<Unblock value="completed inner" />
|
|
</>
|
|
}>
|
|
<BlockedOn value="completed inner" />
|
|
<div>inner boundary</div>
|
|
</Suspense>
|
|
<Suspense
|
|
fallback={
|
|
<>
|
|
<link
|
|
rel="stylesheet"
|
|
href="in fallback inner"
|
|
precedence="default"
|
|
/>
|
|
<div>inner blocked fallback</div>
|
|
</>
|
|
}>
|
|
<BlockedOn value="in fallback inner" />
|
|
<div>inner blocked boundary</div>
|
|
</Suspense>
|
|
</BlockedOn>
|
|
<BlockedOn value="complete root" />
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(async () => {
|
|
resolveText('unblock inner boundaries');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
loading...
|
|
<link rel="preload" href="completed inner" as="style" />
|
|
<link rel="preload" href="in fallback inner" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('completed inner');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
loading...
|
|
<link rel="preload" href="completed inner" as="style" />
|
|
<link rel="preload" href="in fallback inner" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('complete root');
|
|
});
|
|
await act(() => {
|
|
loadStylesheets();
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="in fallback inner"
|
|
data-precedence="default"
|
|
/>
|
|
</head>
|
|
<body>
|
|
<div>hello world</div>
|
|
<div>inner boundary</div>
|
|
<div>inner blocked fallback</div>
|
|
<link rel="preload" href="completed inner" as="style" />
|
|
<link rel="preload" href="in fallback inner" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
// @gate enableSuspenseList
|
|
it('delays "forwards" SuspenseList rows until the css of previous rows have completed', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<SuspenseList revealOrder="forwards" tail="visible">
|
|
<Suspense fallback="loading foo...">
|
|
<BlockedOn value="foo">
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
foo
|
|
</BlockedOn>
|
|
</Suspense>
|
|
<Suspense fallback="loading bar...">bar</Suspense>
|
|
<BlockedOn value="bar">
|
|
<Suspense fallback="loading baz...">
|
|
<BlockedOn value="baz">baz</BlockedOn>
|
|
</Suspense>
|
|
</BlockedOn>
|
|
</SuspenseList>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
// unblock css loading
|
|
await act(() => {
|
|
resolveText('foo');
|
|
});
|
|
|
|
// bar is still blocking the whole list
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
</head>
|
|
<body>
|
|
{'loading...'}
|
|
<link as="style" href="foo" rel="preload" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// unblock inner loading states
|
|
await act(() => {
|
|
resolveText('bar');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
</head>
|
|
<body>
|
|
{'loading foo...'}
|
|
{'loading bar...'}
|
|
{'loading baz...'}
|
|
<link as="style" href="foo" rel="preload" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// resolve the last boundary
|
|
await act(() => {
|
|
resolveText('baz');
|
|
});
|
|
|
|
// still blocked on the css of the first row
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
</head>
|
|
<body>
|
|
{'loading foo...'}
|
|
{'loading bar...'}
|
|
{'loading baz...'}
|
|
<link as="style" href="foo" rel="preload" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
loadStylesheets();
|
|
});
|
|
await assertLog(['load stylesheet: foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
</head>
|
|
<body>
|
|
{'foo'}
|
|
{'bar'}
|
|
{'baz'}
|
|
<link as="style" href="foo" rel="preload" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
// @gate enableSuspenseList
|
|
it('delays "together" SuspenseList rows until the css of previous rows have completed', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<SuspenseList revealOrder="together">
|
|
<Suspense fallback="loading foo...">
|
|
<BlockedOn value="foo">
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
foo
|
|
</BlockedOn>
|
|
</Suspense>
|
|
<Suspense fallback="loading bar...">bar</Suspense>
|
|
</SuspenseList>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
{'loading foo...'}
|
|
{'loading bar...'}
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('foo');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
</head>
|
|
<body>
|
|
{'loading foo...'}
|
|
{'loading bar...'}
|
|
<link as="style" href="foo" rel="preload" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
loadStylesheets();
|
|
});
|
|
await assertLog(['load stylesheet: foo']);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
</head>
|
|
<body>
|
|
{'foo'}
|
|
{'bar'}
|
|
<link as="style" href="foo" rel="preload" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
describe('ReactDOM.preconnect(href, { crossOrigin })', () => {
|
|
it('creates a preconnect resource when called', async () => {
|
|
function App({url}) {
|
|
ReactDOM.preconnect(url);
|
|
ReactDOM.preconnect(url);
|
|
ReactDOM.preconnect(url, {crossOrigin: true});
|
|
ReactDOM.preconnect(url, {crossOrigin: ''});
|
|
ReactDOM.preconnect(url, {crossOrigin: 'anonymous'});
|
|
ReactDOM.preconnect(url, {crossOrigin: 'use-credentials'});
|
|
return (
|
|
<html>
|
|
<body>hello world</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App url="foo" />).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) ' +
|
|
'to be a string but encountered something with type "boolean" instead. ' +
|
|
'Try removing this option or passing a string value instead.\n' +
|
|
' in App (at **)',
|
|
]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preconnect" href="foo" />
|
|
<link rel="preconnect" href="foo" crossorigin="" />
|
|
<link rel="preconnect" href="foo" crossorigin="use-credentials" />
|
|
</head>
|
|
<body>hello world</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(document, <App url="foo" />);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) ' +
|
|
'to be a string but encountered something with type "boolean" instead. ' +
|
|
'Try removing this option or passing a string value instead.\n' +
|
|
' in App (at **)',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preconnect" href="foo" />
|
|
<link rel="preconnect" href="foo" crossorigin="" />
|
|
<link rel="preconnect" href="foo" crossorigin="use-credentials" />
|
|
</head>
|
|
<body>hello world</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(<App url="bar" />);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) ' +
|
|
'to be a string but encountered something with type "boolean" instead. ' +
|
|
'Try removing this option or passing a string value instead.\n' +
|
|
' in App (at **)',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preconnect" href="foo" />
|
|
<link rel="preconnect" href="foo" crossorigin="" />
|
|
<link rel="preconnect" href="foo" crossorigin="use-credentials" />
|
|
<link rel="preconnect" href="bar" />
|
|
<link rel="preconnect" href="bar" crossorigin="" />
|
|
<link rel="preconnect" href="bar" crossorigin="use-credentials" />
|
|
</head>
|
|
<body>hello world</body>
|
|
</html>,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ReactDOM.preload(href, { as: ... })', () => {
|
|
it('creates a preload resource when called', async () => {
|
|
function App() {
|
|
ReactDOM.preload('foo', {as: 'style'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="blocked">
|
|
<Component />
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
function Component() {
|
|
ReactDOM.preload('bar', {as: 'script'});
|
|
return <div>hello</div>;
|
|
}
|
|
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="style" href="foo" />
|
|
</head>
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('blocked');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="style" href="foo" />
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
<link rel="preload" as="script" href="bar" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
function ClientApp() {
|
|
ReactDOM.preload('foo', {as: 'style'});
|
|
ReactDOM.preload('font', {as: 'font', type: 'font/woff2'});
|
|
React.useInsertionEffect(() => ReactDOM.preload('bar', {as: 'script'}));
|
|
React.useLayoutEffect(() => ReactDOM.preload('baz', {as: 'font'}));
|
|
React.useEffect(() => ReactDOM.preload('qux', {as: 'style'}));
|
|
return (
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<div>hello</div>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
ReactDOMClient.hydrateRoot(document, <ClientApp />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="style" href="foo" />
|
|
<link
|
|
rel="preload"
|
|
as="font"
|
|
href="font"
|
|
crossorigin=""
|
|
type="font/woff2"
|
|
/>
|
|
<link rel="preload" as="font" href="baz" crossorigin="" />
|
|
<link rel="preload" as="style" href="qux" />
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
<link rel="preload" as="script" href="bar" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can seed connection props for stylesheet and script resources', async () => {
|
|
function App() {
|
|
ReactDOM.preload('foo', {
|
|
as: 'style',
|
|
crossOrigin: 'use-credentials',
|
|
integrity: 'some hash',
|
|
fetchPriority: 'low',
|
|
});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<div>hello</div>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
data-precedence="default"
|
|
crossorigin="use-credentials"
|
|
integrity="some hash"
|
|
/>
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('warns if you do not pass in a valid href argument or options argument', async () => {
|
|
function App() {
|
|
ReactDOM.preload();
|
|
ReactDOM.preload('');
|
|
ReactDOM.preload('foo', null);
|
|
ReactDOM.preload('foo', {});
|
|
ReactDOM.preload('foo', {as: 'foo'});
|
|
return <div>foo</div>;
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an ' +
|
|
'`options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag. ' +
|
|
'The `href` argument encountered was `undefined`. ' +
|
|
'The `options` argument encountered was `undefined`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an ' +
|
|
'`options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag. ' +
|
|
'The `href` argument encountered was an empty string. ' +
|
|
'The `options` argument encountered was `undefined`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an ' +
|
|
'`options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag. ' +
|
|
'The `options` argument encountered was `null`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an ' +
|
|
'`options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag. ' +
|
|
'The `as` option encountered was `undefined`.\n' +
|
|
' in App (at **)',
|
|
]);
|
|
});
|
|
|
|
it('supports fetchPriority', async () => {
|
|
function Component({isServer}) {
|
|
ReactDOM.preload(isServer ? 'highserver' : 'highclient', {
|
|
as: 'script',
|
|
fetchPriority: 'high',
|
|
});
|
|
ReactDOM.preload(isServer ? 'lowserver' : 'lowclient', {
|
|
as: 'style',
|
|
fetchPriority: 'low',
|
|
});
|
|
ReactDOM.preload(isServer ? 'autoserver' : 'autoclient', {
|
|
as: 'style',
|
|
fetchPriority: 'auto',
|
|
});
|
|
return 'hello';
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Component isServer={true} />
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="preload"
|
|
as="script"
|
|
href="highserver"
|
|
fetchpriority="high"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="style"
|
|
href="lowserver"
|
|
fetchpriority="low"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="style"
|
|
href="autoserver"
|
|
fetchpriority="auto"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<Component />
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="preload"
|
|
as="script"
|
|
href="highserver"
|
|
fetchpriority="high"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="style"
|
|
href="lowserver"
|
|
fetchpriority="low"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="style"
|
|
href="autoserver"
|
|
fetchpriority="auto"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="script"
|
|
href="highclient"
|
|
fetchpriority="high"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="style"
|
|
href="lowclient"
|
|
fetchpriority="low"
|
|
/>
|
|
<link
|
|
rel="preload"
|
|
as="style"
|
|
href="autoclient"
|
|
fetchpriority="auto"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('supports nonce', async () => {
|
|
function App({url}) {
|
|
ReactDOM.preload(url, {as: 'script', nonce: 'abc'});
|
|
return 'hello';
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App url="server" />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div id="container">
|
|
<link rel="preload" as="script" href="server" nonce="abc" />
|
|
hello
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App url="client" />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="preload" as="script" href="client" nonce="abc" />
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<link rel="preload" as="script" href="server" nonce="abc" />
|
|
hello
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ReactDOM.preloadModule(href, options)', () => {
|
|
it('preloads scripts as modules', async () => {
|
|
function App({ssr}) {
|
|
const prefix = ssr ? 'ssr ' : 'browser ';
|
|
ReactDOM.preloadModule(prefix + 'plain');
|
|
ReactDOM.preloadModule(prefix + 'default', {as: 'script'});
|
|
ReactDOM.preloadModule(prefix + 'cors', {
|
|
crossOrigin: 'use-credentials',
|
|
});
|
|
ReactDOM.preloadModule(prefix + 'integrity', {integrity: 'some hash'});
|
|
ReactDOM.preloadModule(prefix + 'serviceworker', {as: 'serviceworker'});
|
|
return <div>hello</div>;
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App ssr={true} />).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document.body)).toEqual(
|
|
<div id="container">
|
|
<link rel="modulepreload" href="ssr plain" />
|
|
<link rel="modulepreload" href="ssr default" />
|
|
<link
|
|
rel="modulepreload"
|
|
href="ssr cors"
|
|
crossorigin="use-credentials"
|
|
/>
|
|
<link
|
|
rel="modulepreload"
|
|
href="ssr integrity"
|
|
integrity="some hash"
|
|
/>
|
|
<link
|
|
rel="modulepreload"
|
|
href="ssr serviceworker"
|
|
as="serviceworker"
|
|
/>
|
|
<div>hello</div>
|
|
</div>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="modulepreload" href="browser plain" />
|
|
<link rel="modulepreload" href="browser default" />
|
|
<link
|
|
rel="modulepreload"
|
|
href="browser cors"
|
|
crossorigin="use-credentials"
|
|
/>
|
|
<link
|
|
rel="modulepreload"
|
|
href="browser integrity"
|
|
integrity="some hash"
|
|
/>
|
|
<link
|
|
rel="modulepreload"
|
|
href="browser serviceworker"
|
|
as="serviceworker"
|
|
/>
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<link rel="modulepreload" href="ssr plain" />
|
|
<link rel="modulepreload" href="ssr default" />
|
|
<link
|
|
rel="modulepreload"
|
|
href="ssr cors"
|
|
crossorigin="use-credentials"
|
|
/>
|
|
<link
|
|
rel="modulepreload"
|
|
href="ssr integrity"
|
|
integrity="some hash"
|
|
/>
|
|
<link
|
|
rel="modulepreload"
|
|
href="ssr serviceworker"
|
|
as="serviceworker"
|
|
/>
|
|
<div>hello</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('warns if you provide invalid arguments', async () => {
|
|
function App() {
|
|
ReactDOM.preloadModule();
|
|
ReactDOM.preloadModule(() => {});
|
|
ReactDOM.preloadModule('');
|
|
ReactDOM.preloadModule('1', true);
|
|
ReactDOM.preloadModule('2', {as: true});
|
|
return <div>hello</div>;
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, ' +
|
|
'an `options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `href` argument encountered was `undefined`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, ' +
|
|
'an `options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `href` argument encountered was something with type "function".\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, ' +
|
|
'an `options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `href` argument encountered was an empty string.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, ' +
|
|
'an `options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `options` argument encountered was something with type "boolean".\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally,' +
|
|
' an `options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `as` option encountered was something with type "boolean".\n' +
|
|
' in App (at **)',
|
|
]);
|
|
expect(getMeaningfulChildren(document.body)).toEqual(
|
|
<div id="container">
|
|
<link rel="modulepreload" href="1" />
|
|
<link rel="modulepreload" href="2" />
|
|
<div>hello</div>
|
|
</div>,
|
|
);
|
|
|
|
const root = ReactDOMClient.createRoot(
|
|
document.getElementById('container'),
|
|
);
|
|
root.render(<App />);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, ' +
|
|
'an `options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `href` argument encountered was `undefined`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, ' +
|
|
'an `options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `href` argument encountered was something with type "function".\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an ' +
|
|
'`options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `href` argument encountered was an empty string.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an ' +
|
|
'`options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `options` argument encountered was something with type "boolean".\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an ' +
|
|
'`options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag. ' +
|
|
'The `as` option encountered was something with type "boolean".\n' +
|
|
' in App (at **)',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('ReactDOM.preinit(href, { as: ... })', () => {
|
|
it('creates a stylesheet resource when ReactDOM.preinit(..., {as: "style" }) is called', async () => {
|
|
function App() {
|
|
ReactDOM.preinit('foo', {as: 'style'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="bar">
|
|
<Component />
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
function Component() {
|
|
ReactDOM.preinit('bar', {as: 'style'});
|
|
return <div>hello</div>;
|
|
}
|
|
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('bar');
|
|
});
|
|
// The reason we do not see the "bar" stylesheet here is that ReactDOM.preinit is not about
|
|
// encoding a resource dependency but is a hint that a resource will be used in the near future.
|
|
// If we call preinit on the server after the shell has flushed the best we can do is emit a preload
|
|
// because any flushing suspense boundaries are not actually dependent on that resource and we don't
|
|
// want to delay reveal based on when that resource loads.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
<link rel="preload" href="bar" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
function ClientApp() {
|
|
ReactDOM.preinit('bar', {as: 'style'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<div>hello</div>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
ReactDOMClient.hydrateRoot(document, <ClientApp />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
<link rel="preload" href="bar" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('creates a stylesheet resource in the ownerDocument when ReactDOM.preinit(..., {as: "style" }) is called outside of render on the client', async () => {
|
|
function App() {
|
|
React.useEffect(() => {
|
|
ReactDOM.preinit('foo', {as: 'style'});
|
|
}, []);
|
|
return (
|
|
<html>
|
|
<body>foo</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(<App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('creates a stylesheet resource in the ownerDocument when ReactDOM.preinit(..., {as: "style" }) is called in shadowRoot', async () => {
|
|
// This is testing behavior, but it shows that it is not a good idea to preinit inside a shadowRoot. The point is we are asserting a behavior
|
|
// you would want to avoid in a real app.
|
|
const shadow = document.body.attachShadow({mode: 'open'});
|
|
function ShadowComponent() {
|
|
ReactDOM.preinit('bar', {as: 'style'});
|
|
return null;
|
|
}
|
|
function App() {
|
|
React.useEffect(() => {
|
|
ReactDOM.preinit('foo', {as: 'style'});
|
|
}, []);
|
|
return (
|
|
<html>
|
|
<body>
|
|
foo
|
|
{ReactDOM.createPortal(
|
|
<div>
|
|
<ShadowComponent />
|
|
shadow
|
|
</div>,
|
|
shadow,
|
|
)}
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(<App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
</head>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
expect(getMeaningfulChildren(shadow)).toEqual(<div>shadow</div>);
|
|
});
|
|
|
|
it('creates a script resource when ReactDOM.preinit(..., {as: "script" }) is called', async () => {
|
|
function App() {
|
|
ReactDOM.preinit('foo', {as: 'script'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="bar">
|
|
<Component />
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
function Component() {
|
|
ReactDOM.preinit('bar', {as: 'script'});
|
|
return <div>hello</div>;
|
|
}
|
|
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" />
|
|
</head>
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('bar');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" />
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
<script async="" src="bar" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
function ClientApp() {
|
|
ReactDOM.preinit('bar', {as: 'script'});
|
|
return (
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<div>hello</div>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
ReactDOMClient.hydrateRoot(document, <ClientApp />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" />
|
|
</head>
|
|
<body>
|
|
<div>hello</div>
|
|
<script async="" src="bar" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('creates a script resource when ReactDOM.preinit(..., {as: "script" }) is called outside of render on the client', async () => {
|
|
function App() {
|
|
React.useEffect(() => {
|
|
ReactDOM.preinit('foo', {as: 'script'});
|
|
}, []);
|
|
return (
|
|
<html>
|
|
<body>foo</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(<App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" />
|
|
</head>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('warns if you do not pass in a valid href argument or options argument', async () => {
|
|
function App() {
|
|
ReactDOM.preinit();
|
|
ReactDOM.preinit('');
|
|
ReactDOM.preinit('foo', null);
|
|
ReactDOM.preinit('foo', {});
|
|
ReactDOM.preinit('foo', {as: 'foo'});
|
|
return <div>foo</div>;
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preinit(): Expected the `href` argument (first) to be a non-empty string ' +
|
|
'but encountered `undefined` instead.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinit(): Expected the `href` argument (first) to be a non-empty string ' +
|
|
'but encountered an empty string instead.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinit(): Expected the `options` argument (second) to be an object with an `as` ' +
|
|
'property describing the type of resource to be preinitialized but encountered `null` instead.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinit(): Expected the `as` property in the `options` argument (second) to contain ' +
|
|
'a valid value describing the type of resource to be preinitialized but encountered `undefined` instead. ' +
|
|
'Valid values for `as` are "style" and "script".\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinit(): Expected the `as` property in the `options` argument (second) to contain ' +
|
|
'a valid value describing the type of resource to be preinitialized but encountered "foo" instead. ' +
|
|
'Valid values for `as` are "style" and "script".\n' +
|
|
' in App (at **)',
|
|
]);
|
|
});
|
|
|
|
it('accepts a `nonce` option for `as: "script"`', async () => {
|
|
function Component({src}) {
|
|
ReactDOM.preinit(src, {as: 'script', nonce: 'R4nD0m'});
|
|
return 'hello';
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Component src="foo" />
|
|
</body>
|
|
</html>,
|
|
{
|
|
nonce: 'R4nD0m',
|
|
},
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" nonce="R4nD0m" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
await clientAct(() => {
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<Component src="bar" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" nonce="R4nD0m" />
|
|
<script async="" src="bar" nonce="R4nD0m" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('accepts an `integrity` option for `as: "script"`', async () => {
|
|
function Component({src, hash}) {
|
|
ReactDOM.preinit(src, {as: 'script', integrity: hash});
|
|
return 'hello';
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Component src="foo" hash="foo hash" />
|
|
</body>
|
|
</html>,
|
|
{
|
|
nonce: 'R4nD0m',
|
|
},
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" integrity="foo hash" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
await clientAct(() => {
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<Component src="bar" hash="bar hash" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="foo" integrity="foo hash" />
|
|
<script async="" src="bar" integrity="bar hash" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('accepts an `integrity` option for `as: "style"`', async () => {
|
|
function Component({src, hash}) {
|
|
ReactDOM.preinit(src, {as: 'style', integrity: hash});
|
|
return 'hello';
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Component src="foo" hash="foo hash" />
|
|
</body>
|
|
</html>,
|
|
{
|
|
nonce: 'R4nD0m',
|
|
},
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
integrity="foo hash"
|
|
data-precedence="default"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
await clientAct(() => {
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<Component src="bar" hash="bar hash" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
integrity="foo hash"
|
|
data-precedence="default"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="bar"
|
|
integrity="bar hash"
|
|
data-precedence="default"
|
|
/>
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('supports fetchPriority', async () => {
|
|
function Component({isServer}) {
|
|
ReactDOM.preinit(isServer ? 'highserver' : 'highclient', {
|
|
as: 'script',
|
|
fetchPriority: 'high',
|
|
});
|
|
ReactDOM.preinit(isServer ? 'lowserver' : 'lowclient', {
|
|
as: 'style',
|
|
fetchPriority: 'low',
|
|
});
|
|
ReactDOM.preinit(isServer ? 'autoserver' : 'autoclient', {
|
|
as: 'style',
|
|
fetchPriority: 'auto',
|
|
});
|
|
return 'hello';
|
|
}
|
|
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Component isServer={true} />
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="lowserver"
|
|
fetchpriority="low"
|
|
data-precedence="default"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="autoserver"
|
|
fetchpriority="auto"
|
|
data-precedence="default"
|
|
/>
|
|
<script async="" src="highserver" fetchpriority="high" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<Component />
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="lowserver"
|
|
fetchpriority="low"
|
|
data-precedence="default"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="autoserver"
|
|
fetchpriority="auto"
|
|
data-precedence="default"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="lowclient"
|
|
fetchpriority="low"
|
|
data-precedence="default"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="autoclient"
|
|
fetchpriority="auto"
|
|
data-precedence="default"
|
|
/>
|
|
<script async="" src="highserver" fetchpriority="high" />
|
|
<script async="" src="highclient" fetchpriority="high" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ReactDOM.preinitModule(href, options)', () => {
|
|
it('creates a script module resources', async () => {
|
|
function App({ssr}) {
|
|
const prefix = ssr ? 'ssr ' : 'browser ';
|
|
ReactDOM.preinitModule(prefix + 'plain');
|
|
ReactDOM.preinitModule(prefix + 'default', {as: 'script'});
|
|
ReactDOM.preinitModule(prefix + 'cors', {
|
|
crossOrigin: 'use-credentials',
|
|
});
|
|
ReactDOM.preinitModule(prefix + 'integrity', {integrity: 'some hash'});
|
|
ReactDOM.preinitModule(prefix + 'warning', {as: 'style'});
|
|
return <div>hello</div>;
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App ssr={true} />).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `as` option encountered was "style".\n' +
|
|
' in App (at **)',
|
|
]);
|
|
expect(getMeaningfulChildren(document.body)).toEqual(
|
|
<div id="container">
|
|
<script type="module" src="ssr plain" async="" />
|
|
<script type="module" src="ssr default" async="" />
|
|
<script
|
|
type="module"
|
|
src="ssr cors"
|
|
crossorigin="use-credentials"
|
|
async=""
|
|
/>
|
|
<script
|
|
type="module"
|
|
src="ssr integrity"
|
|
integrity="some hash"
|
|
async=""
|
|
/>
|
|
<div>hello</div>
|
|
</div>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `as` option encountered was "style".\n' +
|
|
' in App (at **)',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script type="module" src="browser plain" async="" />
|
|
<script type="module" src="browser default" async="" />
|
|
<script
|
|
type="module"
|
|
src="browser cors"
|
|
crossorigin="use-credentials"
|
|
async=""
|
|
/>
|
|
<script
|
|
type="module"
|
|
src="browser integrity"
|
|
integrity="some hash"
|
|
async=""
|
|
/>
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<script type="module" src="ssr plain" async="" />
|
|
<script type="module" src="ssr default" async="" />
|
|
<script
|
|
type="module"
|
|
src="ssr cors"
|
|
crossorigin="use-credentials"
|
|
async=""
|
|
/>
|
|
<script
|
|
type="module"
|
|
src="ssr integrity"
|
|
integrity="some hash"
|
|
async=""
|
|
/>
|
|
<div>hello</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('warns if you provide invalid arguments', async () => {
|
|
function App() {
|
|
ReactDOM.preinitModule();
|
|
ReactDOM.preinitModule(() => {});
|
|
ReactDOM.preinitModule('');
|
|
ReactDOM.preinitModule('1', true);
|
|
ReactDOM.preinitModule('2', {as: true});
|
|
return <div>hello</div>;
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `href` argument encountered was `undefined`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `href` argument encountered was something with type "function".\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `href` argument encountered was an empty string.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `options` argument encountered was something with type "boolean".\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `as` option encountered was something with type "boolean".\n' +
|
|
' in App (at **)',
|
|
]);
|
|
expect(getMeaningfulChildren(document.body)).toEqual(
|
|
<div id="container">
|
|
<div>hello</div>
|
|
</div>,
|
|
);
|
|
|
|
const root = ReactDOMClient.createRoot(
|
|
document.getElementById('container'),
|
|
);
|
|
root.render(<App />);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `href` argument encountered was `undefined`.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `href` argument encountered was something with type "function".\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `href` argument encountered was an empty string.\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `options` argument encountered was something with type "boolean".\n' +
|
|
' in App (at **)',
|
|
'ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` ' +
|
|
'string and, optionally, an `options` object with a valid `as` property. ' +
|
|
'The `as` option encountered was something with type "boolean".\n' +
|
|
' in App (at **)',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('Stylesheet Resources', () => {
|
|
it('treats link rel stylesheet elements as a stylesheet resource when it includes a precedence when server rendering', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="aresource" precedence="foo" />
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="aresource" data-precedence="foo" />
|
|
</head>
|
|
<body>
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('treats link rel stylesheet elements as a stylesheet resource when it includes a precedence when client rendering', async () => {
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="aresource" precedence="foo" />
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog([
|
|
'load preload: aresource',
|
|
'load stylesheet: aresource',
|
|
]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="aresource" data-precedence="foo" />
|
|
</head>
|
|
<body>
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('treats link rel stylesheet elements as a stylesheet resource when it includes a precedence when hydrating', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="aresource" precedence="foo" />
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="aresource" precedence="foo" />
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="aresource" data-precedence="foo" />
|
|
</head>
|
|
<body>
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('hoists stylesheet resources to the correct precedence', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="foo1" precedence="foo" />
|
|
<link rel="stylesheet" href="default1" precedence="default" />
|
|
<link rel="stylesheet" href="foo2" precedence="foo" />
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo1" data-precedence="foo" />
|
|
<link rel="stylesheet" href="foo2" data-precedence="foo" />
|
|
<link rel="stylesheet" href="default1" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="bar1" precedence="bar" />
|
|
<link rel="stylesheet" href="foo3" precedence="foo" />
|
|
<link rel="stylesheet" href="default2" precedence="default" />
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog([
|
|
'load preload: bar1',
|
|
'load preload: foo3',
|
|
'load preload: default2',
|
|
'load stylesheet: foo1',
|
|
'load stylesheet: foo2',
|
|
'load stylesheet: foo3',
|
|
'load stylesheet: default1',
|
|
'load stylesheet: default2',
|
|
'load stylesheet: bar1',
|
|
]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo1" data-precedence="foo" />
|
|
<link rel="stylesheet" href="foo2" data-precedence="foo" />
|
|
<link rel="stylesheet" href="foo3" data-precedence="foo" />
|
|
<link rel="stylesheet" href="default1" data-precedence="default" />
|
|
<link rel="stylesheet" href="default2" data-precedence="default" />
|
|
<link rel="stylesheet" href="bar1" data-precedence="bar" />
|
|
<link rel="preload" as="style" href="bar1" />
|
|
<link rel="preload" as="style" href="foo3" />
|
|
<link rel="preload" as="style" href="default2" />
|
|
</head>
|
|
<body>
|
|
<div>hello world</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('retains styles even after the last referring Resource unmounts', async () => {
|
|
// This test is true until a future update where there is some form of garbage collection.
|
|
const root = ReactDOMClient.createRoot(document);
|
|
|
|
root.render(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
hello world
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog(['load preload: foo', 'load stylesheet: foo']);
|
|
|
|
root.render(
|
|
<html>
|
|
<head />
|
|
<body>hello world</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
</head>
|
|
<body>hello world</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('retains styles even when a new html, head, and/body mount', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
<link rel="stylesheet" href="bar" precedence="bar" />
|
|
server
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="qux" precedence="qux" />
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
</head>
|
|
<body>client</body>
|
|
</html>,
|
|
{
|
|
onRecoverableError(error) {
|
|
errors.push(error.message);
|
|
},
|
|
},
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog([
|
|
'load preload: qux',
|
|
'load stylesheet: foo',
|
|
'load stylesheet: bar',
|
|
'load stylesheet: qux',
|
|
]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
<link rel="stylesheet" href="bar" data-precedence="bar" />
|
|
<link rel="stylesheet" href="qux" data-precedence="qux" />
|
|
</head>
|
|
<body>client</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('retains styles in head through head remounts', async () => {
|
|
const root = ReactDOMClient.createRoot(document);
|
|
root.render(
|
|
<html>
|
|
<head key={1} />
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
<link rel="stylesheet" href="bar" precedence="bar" />
|
|
{null}
|
|
hello
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog([
|
|
'load preload: foo',
|
|
'load preload: bar',
|
|
'load stylesheet: foo',
|
|
'load stylesheet: bar',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
<link rel="stylesheet" href="bar" data-precedence="bar" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(
|
|
<html>
|
|
<head key={2} />
|
|
<body>
|
|
<link rel="stylesheet" href="foo" precedence="foo" />
|
|
{null}
|
|
<link rel="stylesheet" href="baz" precedence="baz" />
|
|
hello
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog(['load preload: baz', 'load stylesheet: baz']);
|
|
|
|
// 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(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="foo" />
|
|
<link rel="stylesheet" href="bar" data-precedence="bar" />
|
|
<link rel="stylesheet" href="baz" data-precedence="baz" />
|
|
<link rel="preload" href="baz" as="style" />
|
|
</head>
|
|
<body>hello</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can support styles inside portals to a shadowRoot', async () => {
|
|
const shadow = document.body.attachShadow({mode: 'open'});
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(
|
|
<>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
{ReactDOM.createPortal(
|
|
<div>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
data-extra-prop="foo"
|
|
precedence="different"
|
|
/>
|
|
shadow
|
|
</div>,
|
|
shadow,
|
|
)}
|
|
container
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
loadStylesheetsFrom(shadow);
|
|
});
|
|
await assertLog([
|
|
'load preload: foo',
|
|
'load stylesheet: foo',
|
|
'load stylesheet: foo',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
</head>
|
|
<body>
|
|
<div id="container">container</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
expect(getMeaningfulChildren(shadow)).toEqual([
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
data-precedence="different"
|
|
data-extra-prop="foo"
|
|
/>,
|
|
<div>shadow</div>,
|
|
]);
|
|
});
|
|
|
|
it('can support styles inside portals to an element in shadowRoots', async () => {
|
|
const template = document.createElement('template');
|
|
template.innerHTML =
|
|
"<div><div id='shadowcontainer1'></div><div id='shadowcontainer2'></div></div>";
|
|
const shadow = document.body.attachShadow({mode: 'open'});
|
|
shadow.appendChild(template.content);
|
|
|
|
const shadowContainer1 = shadow.getElementById('shadowcontainer1');
|
|
const shadowContainer2 = shadow.getElementById('shadowcontainer2');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(
|
|
<>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
{ReactDOM.createPortal(
|
|
<div>
|
|
<link rel="stylesheet" href="foo" precedence="one" />
|
|
<link rel="stylesheet" href="bar" precedence="two" />1
|
|
</div>,
|
|
shadow,
|
|
)}
|
|
{ReactDOM.createPortal(
|
|
<div>
|
|
<link rel="stylesheet" href="foo" precedence="one" />
|
|
<link rel="stylesheet" href="baz" precedence="one" />2
|
|
</div>,
|
|
shadowContainer1,
|
|
)}
|
|
{ReactDOM.createPortal(
|
|
<div>
|
|
<link rel="stylesheet" href="bar" precedence="two" />
|
|
<link rel="stylesheet" href="qux" precedence="three" />3
|
|
</div>,
|
|
shadowContainer2,
|
|
)}
|
|
container
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
loadStylesheetsFrom(shadow);
|
|
loadStylesheetsFrom(shadowContainer2);
|
|
loadStylesheetsFrom(shadowContainer2);
|
|
});
|
|
await assertLog([
|
|
'load preload: foo',
|
|
'load preload: bar',
|
|
'load preload: baz',
|
|
'load preload: qux',
|
|
'load stylesheet: foo',
|
|
'load stylesheet: foo',
|
|
'load stylesheet: baz',
|
|
'load stylesheet: bar',
|
|
'load stylesheet: qux',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="foo" data-precedence="default" />
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preload" href="bar" as="style" />
|
|
<link rel="preload" href="baz" as="style" />
|
|
<link rel="preload" href="qux" as="style" />
|
|
</head>
|
|
<body>
|
|
<div id="container">container</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
expect(getMeaningfulChildren(shadow)).toEqual([
|
|
<link rel="stylesheet" href="foo" data-precedence="one" />,
|
|
<link rel="stylesheet" href="baz" data-precedence="one" />,
|
|
<link rel="stylesheet" href="bar" data-precedence="two" />,
|
|
<link rel="stylesheet" href="qux" data-precedence="three" />,
|
|
<div>
|
|
<div id="shadowcontainer1">
|
|
<div>2</div>
|
|
</div>
|
|
<div id="shadowcontainer2">
|
|
<div>3</div>
|
|
</div>
|
|
</div>,
|
|
<div>1</div>,
|
|
]);
|
|
});
|
|
|
|
it('escapes hrefs when selecting matching elements in the document when rendering Resources', async () => {
|
|
function App() {
|
|
ReactDOM.preload('preload', {as: 'style'});
|
|
ReactDOM.preload('with\nnewline', {as: 'style'});
|
|
return (
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="stylesheet" href="style" precedence="style" />
|
|
<link rel="stylesheet" href="with\slashes" precedence="style" />
|
|
<div id="container" />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
|
|
container = document.getElementById('container');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
|
|
function ClientApp() {
|
|
ReactDOM.preload('preload', {as: 'style'});
|
|
ReactDOM.preload('with\nnewline', {as: 'style'});
|
|
return (
|
|
<div>
|
|
<link
|
|
rel="stylesheet"
|
|
href={'style"][rel="stylesheet'}
|
|
precedence="style"
|
|
/>
|
|
<link rel="stylesheet" href="with\slashes" precedence="style" />
|
|
foo
|
|
</div>
|
|
);
|
|
}
|
|
root.render(<ClientApp />);
|
|
await waitForAll([]);
|
|
await act(() => {
|
|
loadPreloads();
|
|
loadStylesheets();
|
|
});
|
|
await assertLog([
|
|
'load preload: preload',
|
|
'load preload: with\nnewline',
|
|
'load preload: style"][rel="stylesheet',
|
|
'load stylesheet: style',
|
|
'load stylesheet: with\\slashes',
|
|
'load stylesheet: style"][rel="stylesheet',
|
|
]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="style" data-precedence="style" />
|
|
<link
|
|
rel="stylesheet"
|
|
href="with\slashes"
|
|
data-precedence="style"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href={'style"][rel="stylesheet'}
|
|
data-precedence="style"
|
|
/>
|
|
<link rel="preload" as="style" href="preload" />
|
|
<link rel="preload" href={'with\nnewline'} as="style" />
|
|
<link rel="preload" href={'style"][rel="stylesheet'} as="style" />
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<div>foo</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('escapes hrefs when selecting matching elements in the document when using preload and preinit', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<link rel="preload" href="preload" as="style" />
|
|
<link rel="stylesheet" href="style" precedence="style" />
|
|
<link rel="stylesheet" href="with\slashes" precedence="style" />
|
|
<link rel="preload" href={'with\nnewline'} as="style" />
|
|
<div id="container" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
function App() {
|
|
ReactDOM.preload('preload"][rel="preload', {as: 'style'});
|
|
ReactDOM.preinit('style"][rel="stylesheet', {
|
|
as: 'style',
|
|
precedence: 'style',
|
|
});
|
|
ReactDOM.preinit('with\\slashes', {
|
|
as: 'style',
|
|
precedence: 'style',
|
|
});
|
|
ReactDOM.preload('with\nnewline', {as: 'style'});
|
|
return <div>foo</div>;
|
|
}
|
|
|
|
container = document.getElementById('container');
|
|
const root = ReactDOMClient.createRoot(container);
|
|
root.render(<App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="style" data-precedence="style" />
|
|
<link
|
|
rel="stylesheet"
|
|
href="with\slashes"
|
|
data-precedence="style"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href={'style"][rel="stylesheet'}
|
|
data-precedence="style"
|
|
/>
|
|
<link rel="preload" as="style" href="preload" />
|
|
<link rel="preload" href={'with\nnewline'} as="style" />
|
|
<link rel="preload" href={'preload"][rel="preload'} as="style" />
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<div>foo</div>
|
|
</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('does not create stylesheet resources when inside an <svg> context', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<svg>
|
|
<path>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</path>
|
|
<foreignObject>
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
</foreignObject>
|
|
</svg>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="stylesheet" href="bar" data-precedence="default" />
|
|
</head>
|
|
<body>
|
|
<svg>
|
|
<path>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</path>
|
|
<foreignobject />
|
|
</svg>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.createRoot(document.body);
|
|
root.render(
|
|
<div>
|
|
<svg>
|
|
<path>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</path>
|
|
<foreignObject>
|
|
<link rel="stylesheet" href="bar" precedence="default" />
|
|
</foreignObject>
|
|
</svg>
|
|
</div>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document.body)).toEqual(
|
|
<div>
|
|
<svg>
|
|
<path>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</path>
|
|
<foreignobject />
|
|
</svg>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
it('does not create stylesheet resources when inside a <noscript> context', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<noscript>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</noscript>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<noscript>
|
|
<link rel="stylesheet" href="foo" precedence="default">
|
|
</noscript>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.createRoot(document.body);
|
|
root.render(
|
|
<div>
|
|
<noscript>
|
|
<link rel="stylesheet" href="foo" precedence="default" />
|
|
</noscript>
|
|
</div>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document.body)).toEqual(
|
|
<div>
|
|
{/* On the client, <noscript> never renders children */}
|
|
<noscript />
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
it('warns if you provide a `precedence` prop with other props that invalidate the creation of a stylesheet resource', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<link rel="stylesheet" precedence="default" />
|
|
<link rel="stylesheet" href="" precedence="default" />
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
precedence="default"
|
|
onLoad={() => {}}
|
|
onError={() => {}}
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
precedence="default"
|
|
onLoad={() => {}}
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
precedence="default"
|
|
onError={() => {}}
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
precedence="default"
|
|
disabled={false}
|
|
/>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev(
|
|
[
|
|
'React encountered a `<link rel="stylesheet" .../>` with a `precedence` prop and ' +
|
|
'expected the `href` prop to be a non-empty string but ecountered `undefined` instead. ' +
|
|
'If your intent was to have React hoist and deduplciate this stylesheet using the ' +
|
|
'`precedence` prop ensure there is a non-empty string `href` prop as well, ' +
|
|
'otherwise remove the `precedence` prop.\n' +
|
|
' in link (at **)',
|
|
'React encountered a `<link rel="stylesheet" .../>` with a `precedence` prop and ' +
|
|
'expected the `href` prop to be a non-empty string but ecountered an empty string instead. ' +
|
|
'If your intent was to have React hoist and deduplciate this stylesheet using the ' +
|
|
'`precedence` prop ensure there is a non-empty string `href` prop as well, ' +
|
|
'otherwise remove the `precedence` prop.\n' +
|
|
' in link (at **)',
|
|
'An empty string ("") was passed to the href attribute. ' +
|
|
'To fix this, either do not render the element at all or ' +
|
|
'pass null to href instead of an empty string.\n' +
|
|
' in link (at **)',
|
|
'React encountered a `<link rel="stylesheet" .../>` with a `precedence` prop and ' +
|
|
'`onLoad` and `onError` props. The presence of loading and error handlers indicates ' +
|
|
'an intent to manage the stylesheet loading state from your from your Component code ' +
|
|
'and React will not hoist or deduplicate this stylesheet. ' +
|
|
'If your intent was to have React hoist and deduplciate this stylesheet using the ' +
|
|
'`precedence` prop remove the `onLoad` and `onError` props, otherwise remove the `precedence` prop.\n' +
|
|
' in link (at **)',
|
|
'React encountered a `<link rel="stylesheet" .../>` with a `precedence` prop and ' +
|
|
'`onLoad` prop. The presence of loading and error handlers indicates an intent to ' +
|
|
'manage the stylesheet loading state from your from your Component code and ' +
|
|
'React will not hoist or deduplicate this stylesheet. ' +
|
|
'If your intent was to have React hoist and deduplciate this stylesheet using the ' +
|
|
'`precedence` prop remove the `onLoad` prop, otherwise remove the `precedence` prop.\n' +
|
|
' in link (at **)',
|
|
'React encountered a `<link rel="stylesheet" .../>` with a `precedence` prop and `onError` prop. ' +
|
|
'The presence of loading and error handlers indicates an intent to manage the stylesheet loading state ' +
|
|
'from your from your Component code and React will not hoist or deduplicate this stylesheet. ' +
|
|
'If your intent was to have React hoist and deduplciate this stylesheet using the `precedence` ' +
|
|
'prop remove the `onError` prop, otherwise remove the `precedence` prop.\n' +
|
|
' in link (at **)',
|
|
'React encountered a `<link rel="stylesheet" .../>` with a `precedence` prop and a `disabled` prop. ' +
|
|
'The presence of the `disabled` prop indicates an intent to manage the stylesheet active state from ' +
|
|
'your from your Component code and React will not hoist or deduplicate this stylesheet. ' +
|
|
'If your intent was to have React hoist and deduplciate this stylesheet using the `precedence` ' +
|
|
'prop remove the `disabled` prop, otherwise remove the `precedence` prop.\n' +
|
|
' in link (at **)',
|
|
].filter(Boolean),
|
|
);
|
|
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<link
|
|
rel="stylesheet"
|
|
href="foo"
|
|
precedence="default"
|
|
onLoad={() => {}}
|
|
onError={() => {}}
|
|
/>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
assertConsoleErrorDev([
|
|
'React encountered a <link rel="stylesheet" href="foo" ... /> with a `precedence` ' +
|
|
'prop that also included the `onLoad` and `onError` props. ' +
|
|
'The presence of loading and error handlers indicates an intent to manage the stylesheet ' +
|
|
'loading state from your from your Component code and React will not hoist or deduplicate this stylesheet. ' +
|
|
'If your intent was to have React hoist and deduplciate this stylesheet using the `precedence` ' +
|
|
'prop remove the `onLoad` and `onError` props, otherwise remove the `precedence` prop.\n' +
|
|
' in body (at **)',
|
|
]);
|
|
});
|
|
|
|
it('will not block displaying a Suspense boundary on a stylesheet with media that does not match', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="block">
|
|
foo
|
|
<link
|
|
rel="stylesheet"
|
|
href="print"
|
|
media="print"
|
|
precedence="print"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="all"
|
|
media="all"
|
|
precedence="all"
|
|
/>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="block">
|
|
bar
|
|
<link
|
|
rel="stylesheet"
|
|
href="print"
|
|
media="print"
|
|
precedence="print"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="all"
|
|
media="all"
|
|
precedence="all"
|
|
/>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
{'loading...'}
|
|
{'loading...'}
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('block');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="print"
|
|
media="print"
|
|
data-precedence="print"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="all"
|
|
media="all"
|
|
data-precedence="all"
|
|
/>
|
|
</head>
|
|
<body>
|
|
{'loading...'}
|
|
{'loading...'}
|
|
<link rel="preload" href="print" media="print" as="style" />
|
|
<link rel="preload" href="all" media="all" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
const allStyle = document.querySelector('link[href="all"]');
|
|
const event = document.createEvent('Events');
|
|
event.initEvent('load', true, true);
|
|
allStyle.dispatchEvent(event);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link
|
|
rel="stylesheet"
|
|
href="print"
|
|
media="print"
|
|
data-precedence="print"
|
|
/>
|
|
<link
|
|
rel="stylesheet"
|
|
href="all"
|
|
media="all"
|
|
data-precedence="all"
|
|
/>
|
|
</head>
|
|
<body>
|
|
{'foo'}
|
|
{'bar'}
|
|
<link rel="preload" href="print" media="print" as="style" />
|
|
<link rel="preload" href="all" media="all" as="style" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Style Resource', () => {
|
|
it('treats <style href="..." precedence="..."> elements as a style resource when server rendering', async () => {
|
|
const css = `
|
|
body {
|
|
background-color: red;
|
|
}`;
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<style href="foo" precedence="foo">
|
|
{css}
|
|
</style>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="foo" data-precedence="foo">
|
|
{css}
|
|
</style>
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can insert style resources as part of a boundary reveal', async () => {
|
|
const cssRed = `
|
|
body {
|
|
background-color: red;
|
|
}`;
|
|
const cssBlue = `
|
|
body {
|
|
background-color: blue;
|
|
}`;
|
|
const cssGreen = `
|
|
body {
|
|
background-color: green;
|
|
}`;
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="blocked">
|
|
<style href="foo" precedence="foo">
|
|
{cssRed}
|
|
</style>
|
|
loaded
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('blocked');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="foo" data-precedence="foo">
|
|
{cssRed}
|
|
</style>
|
|
</head>
|
|
<body>loaded</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<style href="foo" precedence="foo">
|
|
{cssRed}
|
|
</style>
|
|
loaded
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="foo" data-precedence="foo">
|
|
{cssRed}
|
|
</style>
|
|
</head>
|
|
<body>loaded</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<style href="foo" precedence="foo">
|
|
{cssRed}
|
|
</style>
|
|
loaded
|
|
</Suspense>
|
|
<style href="bar" precedence="bar">
|
|
{cssBlue}
|
|
</style>
|
|
<style href="baz" precedence="foo">
|
|
{cssGreen}
|
|
</style>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="foo" data-precedence="foo">
|
|
{cssRed}
|
|
</style>
|
|
<style data-href="baz" data-precedence="foo">
|
|
{cssGreen}
|
|
</style>
|
|
<style data-href="bar" data-precedence="bar">
|
|
{cssBlue}
|
|
</style>
|
|
</head>
|
|
<body>loaded</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can emit styles early when a partial boundary flushes', async () => {
|
|
const css = 'body { background-color: red; }';
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Suspense>
|
|
<BlockedOn value="first">
|
|
<div>first</div>
|
|
<style href="foo" precedence="default">
|
|
{css}
|
|
</style>
|
|
<BlockedOn value="second">
|
|
<div>second</div>
|
|
<style href="bar" precedence="default">
|
|
{css}
|
|
</style>
|
|
</BlockedOn>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('first');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<style data-href="foo" data-precedence="default" media="not all">
|
|
{css}
|
|
</style>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('second');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="foo" data-precedence="default">
|
|
{css}
|
|
</style>
|
|
<style data-href="bar" data-precedence="default">
|
|
{css}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div>first</div>
|
|
<div>second</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can hoist styles flushed early even when no other style dependencies are flushed on completion', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="first">
|
|
<style href="foo" precedence="default">
|
|
some css
|
|
</style>
|
|
<div>first</div>
|
|
<BlockedOn value="second">
|
|
<div>second</div>
|
|
</BlockedOn>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
// When we resolve first we flush the style tag because it is ready but we aren't yet ready to
|
|
// flush the entire boundary and reveal it.
|
|
await act(() => {
|
|
resolveText('first');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
loading...
|
|
<style data-href="foo" data-precedence="default" media="not all">
|
|
some css
|
|
</style>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// When we resolve second we flush the rest of the boundary segments and reveal the boundary. The style tag
|
|
// is hoisted during this reveal process even though no other styles flushed during this tick
|
|
await act(() => {
|
|
resolveText('second');
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="foo" data-precedence="default">
|
|
some css
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div>first</div>
|
|
<div>second</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can emit multiple style rules into a single style tag for a given precedence', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<style href="1" precedence="default">
|
|
1
|
|
</style>
|
|
<style href="2" precedence="foo">
|
|
foo2
|
|
</style>
|
|
<style href="3" precedence="default">
|
|
3
|
|
</style>
|
|
<style href="4" precedence="default">
|
|
4
|
|
</style>
|
|
<style href="5" precedence="foo">
|
|
foo5
|
|
</style>
|
|
<div>initial</div>
|
|
<Suspense fallback="loading...">
|
|
<BlockedOn value="first">
|
|
<style href="6" precedence="default">
|
|
6
|
|
</style>
|
|
<style href="7" precedence="foo">
|
|
foo7
|
|
</style>
|
|
<style href="8" precedence="default">
|
|
8
|
|
</style>
|
|
<style href="9" precedence="default">
|
|
9
|
|
</style>
|
|
<style href="10" precedence="foo">
|
|
foo10
|
|
</style>
|
|
<div>first</div>
|
|
<BlockedOn value="second">
|
|
<style href="11" precedence="default">
|
|
11
|
|
</style>
|
|
<style href="12" precedence="foo">
|
|
foo12
|
|
</style>
|
|
<style href="13" precedence="default">
|
|
13
|
|
</style>
|
|
<style href="14" precedence="default">
|
|
14
|
|
</style>
|
|
<style href="15" precedence="foo">
|
|
foo15
|
|
</style>
|
|
<div>second</div>
|
|
</BlockedOn>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="1 3 4" data-precedence="default">
|
|
134
|
|
</style>
|
|
<style data-href="2 5" data-precedence="foo">
|
|
foo2foo5
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div>initial</div>loading...
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// When we resolve first we flush the style tag because it is ready but we aren't yet ready to
|
|
// flush the entire boundary and reveal it.
|
|
await act(() => {
|
|
resolveText('first');
|
|
});
|
|
await act(() => {
|
|
resolveText('second');
|
|
});
|
|
|
|
// Some sets of styles were ready before the entire boundary and they got emitted as early as they were
|
|
// ready. The remaining styles were ready when the boundary finished and they got grouped as well
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="1 3 4" data-precedence="default">
|
|
134
|
|
</style>
|
|
<style data-href="6 8 9" data-precedence="default">
|
|
689
|
|
</style>
|
|
<style data-href="11 13 14" data-precedence="default">
|
|
111314
|
|
</style>
|
|
<style data-href="2 5" data-precedence="foo">
|
|
foo2foo5
|
|
</style>
|
|
<style data-href="7 10" data-precedence="foo">
|
|
foo7foo10
|
|
</style>
|
|
<style data-href="12 15" data-precedence="foo">
|
|
foo12foo15
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div>initial</div>
|
|
<div>first</div>
|
|
<div>second</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
// Client inserted style tags are not grouped together but can hydrate against a grouped set
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<style href="1" precedence="default">
|
|
1
|
|
</style>
|
|
<style href="2" precedence="foo">
|
|
foo2
|
|
</style>
|
|
<style href="16" precedence="default">
|
|
16
|
|
</style>
|
|
<style href="17" precedence="default">
|
|
17
|
|
</style>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="1 3 4" data-precedence="default">
|
|
134
|
|
</style>
|
|
<style data-href="6 8 9" data-precedence="default">
|
|
689
|
|
</style>
|
|
<style data-href="11 13 14" data-precedence="default">
|
|
111314
|
|
</style>
|
|
<style data-href="16" data-precedence="default">
|
|
16
|
|
</style>
|
|
<style data-href="17" data-precedence="default">
|
|
17
|
|
</style>
|
|
<style data-href="2 5" data-precedence="foo">
|
|
foo2foo5
|
|
</style>
|
|
<style data-href="7 10" data-precedence="foo">
|
|
foo7foo10
|
|
</style>
|
|
<style data-href="12 15" data-precedence="foo">
|
|
foo12foo15
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div>initial</div>
|
|
<div>first</div>
|
|
<div>second</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('warns if you render a <style> with an href with a space on the server', async () => {
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<style href="foo bar" precedence="default">
|
|
style
|
|
</style>
|
|
</body>
|
|
</html>,
|
|
).pipe(writable);
|
|
});
|
|
assertConsoleErrorDev([
|
|
'React expected the `href` prop for a <style> tag opting into hoisting semantics ' +
|
|
'using the `precedence` prop to not have any spaces but ecountered spaces instead. ' +
|
|
'using spaces in this prop will cause hydration of this style to fail on the client. ' +
|
|
'The href for the <style> where this ocurred is "foo bar".\n' +
|
|
' in style (at **)',
|
|
]);
|
|
});
|
|
|
|
it('can emit styles with nonce', async () => {
|
|
const nonce = 'R4nD0m';
|
|
const fooCss = '.foo { color: hotpink; }';
|
|
const barCss = '.bar { background-color: blue; }';
|
|
const bazCss = '.baz { border: 1px solid black; }';
|
|
await act(() => {
|
|
renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<Suspense>
|
|
<BlockedOn value="first">
|
|
<div>first</div>
|
|
<style href="foo" precedence="default" nonce={nonce}>
|
|
{fooCss}
|
|
</style>
|
|
<style href="bar" precedence="default" nonce={nonce}>
|
|
{barCss}
|
|
</style>
|
|
<BlockedOn value="second">
|
|
<div>second</div>
|
|
<style href="baz" precedence="default" nonce={nonce}>
|
|
{bazCss}
|
|
</style>
|
|
</BlockedOn>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>,
|
|
{nonce: {style: nonce}},
|
|
).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('first');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<style
|
|
data-href="foo bar"
|
|
data-precedence="default"
|
|
media="not all"
|
|
nonce={nonce}>
|
|
{`${fooCss}${barCss}`}
|
|
</style>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
await act(() => {
|
|
resolveText('second');
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<style data-href="foo bar" data-precedence="default" nonce={nonce}>
|
|
{`${fooCss}${barCss}`}
|
|
</style>
|
|
<style data-href="baz" data-precedence="default" nonce={nonce}>
|
|
{bazCss}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div>first</div>
|
|
<div>second</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Script Resources', () => {
|
|
it('treats async scripts without onLoad or onError as Resources', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<script src="foo" async={true} />
|
|
<script src="bar" async={true} onLoad={() => {}} />
|
|
<script src="baz" data-meaningful="" />
|
|
<script src="qux" defer={true} data-meaningful="" />
|
|
hello world
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
// 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 HostHoistable but it also will
|
|
// never hydrate
|
|
// The regular script is just a normal html that should hydrate with a HostComponent
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script src="foo" async="" />
|
|
</head>
|
|
<body>
|
|
<script src="bar" async="" />
|
|
<script src="baz" data-meaningful="" />
|
|
<script src="qux" defer="" data-meaningful="" />
|
|
hello world
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<script src="foo" async={true} />
|
|
<script src="bar" async={true} onLoad={() => {}} />
|
|
<script src="baz" data-meaningful="" />
|
|
<script src="qux" defer={true} data-meaningful="" />
|
|
hello world
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
// The async script with onLoad is inserted in the right place but does not cause the hydration
|
|
// to fail.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script src="foo" async="" />
|
|
</head>
|
|
<body>
|
|
<script src="bar" async="" />
|
|
<script src="baz" data-meaningful="" />
|
|
<script src="qux" defer="" data-meaningful="" />
|
|
hello world
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
root.unmount();
|
|
// When we unmount we expect to retain singletons and any content that is not cleared within them.
|
|
// The foo script is a resource so it sticks around. The other scripts are regular HostComponents
|
|
// so they unmount and are removed from the DOM.
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script src="foo" async="" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('does not create script resources when inside an <svg> context', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<svg>
|
|
<path>
|
|
<script async={true} src="foo" />
|
|
</path>
|
|
<foreignObject>
|
|
<script async={true} src="bar" />
|
|
</foreignObject>
|
|
</svg>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<script async="" src="bar" />
|
|
</head>
|
|
<body>
|
|
<svg>
|
|
<path>
|
|
<script async="" src="foo" />
|
|
</path>
|
|
<foreignobject />
|
|
</svg>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.createRoot(document.body);
|
|
root.render(
|
|
<div>
|
|
<svg>
|
|
<path>
|
|
<script async={true} src="foo" />
|
|
</path>
|
|
<foreignObject>
|
|
<script async={true} src="bar" />
|
|
</foreignObject>
|
|
</svg>
|
|
</div>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document.body)).toEqual(
|
|
<div>
|
|
<svg>
|
|
<path>
|
|
<script async="" src="foo" />
|
|
</path>
|
|
<foreignobject />
|
|
</svg>
|
|
</div>,
|
|
);
|
|
});
|
|
|
|
it('does not create script resources when inside a <noscript> context', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<noscript>
|
|
<script async={true} src="foo" />
|
|
</noscript>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<noscript>
|
|
<script async="" src="foo"></script>
|
|
</noscript>
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.createRoot(document.body);
|
|
root.render(
|
|
<div>
|
|
<noscript>
|
|
<script async={true} src="foo" />
|
|
</noscript>
|
|
</div>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document.body)).toEqual(
|
|
<div>
|
|
{/* On the client, <noscript> never renders children */}
|
|
<noscript />
|
|
</div>,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Hoistables', () => {
|
|
it('can hoist meta tags on the server and hydrate them on the client', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<meta name="foo" data-foo="data" content="bar" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta name="foo" data-foo="data" content="bar" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<meta name="foo" data-foo="data" content="bar" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta name="foo" data-foo="data" content="bar" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
root.render(
|
|
<html>
|
|
<body />
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body />
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can hoist meta tags on the client', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(
|
|
<div>
|
|
<meta name="foo" data-foo="data" content="bar" />
|
|
</div>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
|
|
expect(getMeaningfulChildren(document.head)).toEqual(
|
|
<meta name="foo" data-foo="data" content="bar" />,
|
|
);
|
|
expect(getMeaningfulChildren(container)).toEqual(<div />);
|
|
|
|
root.render(<div />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document.head)).toEqual(undefined);
|
|
});
|
|
|
|
it('can hoist link (non-stylesheet) tags on the server and hydrate them on the client', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<link rel="foo" data-foo="data" href="foo" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="foo" data-foo="data" href="foo" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<link rel="foo" data-foo="data" href="foo" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="foo" data-foo="data" href="foo" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
root.render(
|
|
<html>
|
|
<body />
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body />
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can hoist link (non-stylesheet) tags on the client', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(
|
|
<div>
|
|
<link rel="foo" data-foo="data" href="foo" />
|
|
</div>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
|
|
expect(getMeaningfulChildren(document.head)).toEqual(
|
|
<link rel="foo" data-foo="data" href="foo" />,
|
|
);
|
|
expect(getMeaningfulChildren(container)).toEqual(<div />);
|
|
|
|
root.render(<div />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document.head)).toEqual(undefined);
|
|
});
|
|
|
|
it('can hoist title tags on the server and hydrate them on the client', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<title data-foo="foo">a title</title>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<title data-foo="foo">a title</title>
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<html>
|
|
<body>
|
|
<title data-foo="foo">a title</title>
|
|
</body>
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<title data-foo="foo">a title</title>
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
|
|
root.render(
|
|
<html>
|
|
<body />
|
|
</html>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body />
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can hoist title tags on the client', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(
|
|
<div>
|
|
<title data-foo="foo">a title</title>
|
|
</div>,
|
|
);
|
|
});
|
|
await waitForAll([]);
|
|
|
|
expect(getMeaningfulChildren(document.head)).toEqual(
|
|
<title data-foo="foo">a title</title>,
|
|
);
|
|
expect(getMeaningfulChildren(container)).toEqual(<div />);
|
|
|
|
root.render(<div />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document.head)).toEqual(undefined);
|
|
});
|
|
|
|
it('prioritizes ordering for certain hoistables over others when rendering on the server', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<link rel="foo" href="foo" />
|
|
<meta name="bar" />
|
|
<title>a title</title>
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preconnect" href="bar" />
|
|
<link rel="dns-prefetch" href="baz" />
|
|
<meta name="viewport" />
|
|
<meta charSet="utf-8" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
{/* charset first */}
|
|
<meta charset="utf-8" />
|
|
{/* viewport meta next */}
|
|
<meta name="viewport" />
|
|
{/* Everything else last */}
|
|
<link rel="foo" href="foo" />
|
|
<meta name="bar" />
|
|
<title>a title</title>
|
|
<link rel="preload" href="foo" as="style" />
|
|
<link rel="preconnect" href="bar" />
|
|
<link rel="dns-prefetch" href="baz" />
|
|
</head>
|
|
<body />
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('supports rendering hoistables outside of <html> scope', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<>
|
|
<meta name="before" />
|
|
<html>
|
|
<body>foo</body>
|
|
</html>
|
|
<meta name="after" />
|
|
</>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta name="before" />
|
|
<meta name="after" />
|
|
</head>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<>
|
|
<meta name="before" />
|
|
<html>
|
|
<body>foo</body>
|
|
</html>
|
|
<meta name="after" />
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<meta name="before" />
|
|
<meta name="after" />
|
|
</head>
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
|
|
root.render(
|
|
<>
|
|
{null}
|
|
<html>
|
|
<body>foo</body>
|
|
</html>
|
|
{null}
|
|
</>,
|
|
);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>foo</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can hydrate hoistable tags inside late suspense boundaries', async () => {
|
|
function App() {
|
|
return (
|
|
<html>
|
|
<body>
|
|
<link rel="rel1" href="linkhref" />
|
|
<link rel="rel2" href="linkhref" />
|
|
<meta name="name1" content="metacontent" />
|
|
<meta name="name2" content="metacontent" />
|
|
<Suspense fallback="loading...">
|
|
<link rel="rel3" href="linkhref" />
|
|
<link rel="rel4" href="linkhref" />
|
|
<meta name="name3" content="metacontent" />
|
|
<meta name="name4" content="metacontent" />
|
|
<BlockedOn value="release">
|
|
<link rel="rel5" href="linkhref" />
|
|
<link rel="rel6" href="linkhref" />
|
|
<meta name="name5" content="metacontent" />
|
|
<meta name="name6" content="metacontent" />
|
|
<div>hello world</div>
|
|
</BlockedOn>
|
|
</Suspense>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
await act(() => {
|
|
renderToPipeableStream(<App />).pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="rel1" href="linkhref" />
|
|
<link rel="rel2" href="linkhref" />
|
|
<meta name="name1" content="metacontent" />
|
|
<meta name="name2" content="metacontent" />
|
|
<link rel="rel3" href="linkhref" />
|
|
<link rel="rel4" href="linkhref" />
|
|
<meta name="name3" content="metacontent" />
|
|
<meta name="name4" content="metacontent" />
|
|
</head>
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
const root = ReactDOMClient.hydrateRoot(document, <App />);
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="rel1" href="linkhref" />
|
|
<link rel="rel2" href="linkhref" />
|
|
<meta name="name1" content="metacontent" />
|
|
<meta name="name2" content="metacontent" />
|
|
<link rel="rel3" href="linkhref" />
|
|
<link rel="rel4" href="linkhref" />
|
|
<meta name="name3" content="metacontent" />
|
|
<meta name="name4" content="metacontent" />
|
|
</head>
|
|
<body>loading...</body>
|
|
</html>,
|
|
);
|
|
|
|
const thirdPartyLink = document.createElement('link');
|
|
thirdPartyLink.setAttribute('href', 'linkhref');
|
|
thirdPartyLink.setAttribute('rel', '3rdparty');
|
|
document.body.prepend(thirdPartyLink);
|
|
|
|
const thirdPartyMeta = document.createElement('meta');
|
|
thirdPartyMeta.setAttribute('content', 'metacontent');
|
|
thirdPartyMeta.setAttribute('name', '3rdparty');
|
|
document.body.prepend(thirdPartyMeta);
|
|
|
|
await act(() => {
|
|
resolveText('release');
|
|
});
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<link rel="rel1" href="linkhref" />
|
|
<link rel="rel2" href="linkhref" />
|
|
<meta name="name1" content="metacontent" />
|
|
<meta name="name2" content="metacontent" />
|
|
<link rel="rel3" href="linkhref" />
|
|
<link rel="rel4" href="linkhref" />
|
|
<meta name="name3" content="metacontent" />
|
|
<meta name="name4" content="metacontent" />
|
|
</head>
|
|
<body>
|
|
<meta name="3rdparty" content="metacontent" />
|
|
<link rel="3rdparty" href="linkhref" />
|
|
<div>hello world</div>
|
|
<link rel="rel5" href="linkhref" />
|
|
<link rel="rel6" href="linkhref" />
|
|
<meta name="name5" content="metacontent" />
|
|
<meta name="name6" content="metacontent" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
|
|
root.unmount();
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<meta name="3rdparty" content="metacontent" />
|
|
<link rel="3rdparty" href="linkhref" />
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('does not hoist inside an <svg> context', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<svg>
|
|
<title>svg title</title>
|
|
<link rel="svg link" href="a" />
|
|
<meta name="svg meta" />
|
|
<path>
|
|
<title>deep svg title</title>
|
|
<meta name="deep svg meta" />
|
|
<link rel="deep svg link" href="a" />
|
|
</path>
|
|
<foreignObject>
|
|
<title>hoistable title</title>
|
|
<meta name="hoistable" />
|
|
<link rel="hoistable" href="a" />
|
|
</foreignObject>
|
|
</svg>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document.head)).toEqual([
|
|
<title>hoistable title</title>,
|
|
<meta name="hoistable" />,
|
|
<link rel="hoistable" href="a" />,
|
|
]);
|
|
});
|
|
|
|
it('does not hoist inside noscript context', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<html>
|
|
<body>
|
|
<title>title</title>
|
|
<link rel="link" href="a" />
|
|
<meta name="meta" />
|
|
<noscript>
|
|
<title>noscript title</title>
|
|
<link rel="noscript link" href="a" />
|
|
<meta name="noscript meta" />
|
|
</noscript>
|
|
</body>
|
|
</html>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
expect(getMeaningfulChildren(document.head)).toEqual([
|
|
<title>title</title>,
|
|
<link rel="link" href="a" />,
|
|
<meta name="meta" />,
|
|
]);
|
|
});
|
|
|
|
it('can render a title before a singleton even if that singleton clears its contents', async () => {
|
|
await act(() => {
|
|
const {pipe} = renderToPipeableStream(
|
|
<>
|
|
<title>foo</title>
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>server</div>
|
|
</body>
|
|
</html>
|
|
</>,
|
|
);
|
|
pipe(writable);
|
|
});
|
|
|
|
const errors = [];
|
|
ReactDOMClient.hydrateRoot(
|
|
document,
|
|
<>
|
|
<title>foo</title>
|
|
<html>
|
|
<head />
|
|
<body>
|
|
<div>client</div>
|
|
</body>
|
|
</html>
|
|
</>,
|
|
{
|
|
onRecoverableError(err) {
|
|
errors.push(err.message);
|
|
},
|
|
},
|
|
);
|
|
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document)).toEqual(
|
|
<html>
|
|
<head>
|
|
<title>foo</title>
|
|
</head>
|
|
<body>
|
|
<div>client</div>
|
|
</body>
|
|
</html>,
|
|
);
|
|
});
|
|
|
|
it('can update title tags', async () => {
|
|
const root = ReactDOMClient.createRoot(container);
|
|
await act(() => {
|
|
root.render(<title data-foo="foo">a title</title>);
|
|
});
|
|
await waitForAll([]);
|
|
|
|
expect(getMeaningfulChildren(document.head)).toEqual(
|
|
<title data-foo="foo">a title</title>,
|
|
);
|
|
|
|
await act(() => {
|
|
root.render(<title data-foo="bar">another title</title>);
|
|
});
|
|
await waitForAll([]);
|
|
expect(getMeaningfulChildren(document.head)).toEqual(
|
|
<title data-foo="bar">another title</title>,
|
|
);
|
|
});
|
|
});
|
|
});
|