[Tests][Fizz] Better HTML parsing behavior for Fizz tests (#26570)

In anticipation of making Fiber use the document global for dispatching
Float methods that arrive from Flight I needed to update some tests that
commonly recreated the JSDOM instance after importing react.

This change updates a few tests to only create JSDOM once per test,
before importing react-dom/client.

Additionally the current act implementation for server streaming did not
adequately model streaming semantics so I rewrite the act implementation
in a way that better mirrors how a browser would parse incoming HTML.

The new act implementation does the following

1. the first time it processes meaningful streamed content it figures
out whether it is rendering into the existing document container or if
it needs to reset the document. this is based on whether the streamed
content contains tags `<html>` or `<body>` etc...
2. Once the streaming container is set it will typically continue to
stream into that container for future calls to act. The exception is if
the streaming container is the `<head>` in which case it will switch to
streaming into the body once it receives a `<body>` tag.

This means for tests that render something like a `<div>...</div>` it
will naturally stream into the default `<div id="container">...` and for
tests that render a full document the HTML will parse like a real
browser would (with some very minor edge case differences)

I also refactored the way we move nodes from buffered content into the
document and execute any scripts we find. Previously we were using
window.eval and I switched this to just setting the external script
content as script text. Additionally the nonce logic is reworked to be a
bit simpler.
This commit is contained in:
Josh Story 2023-04-20 14:27:02 -07:00 committed by GitHub
parent d73d7d5908
commit e5708b3ea9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 778 additions and 578 deletions

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@
'use strict'; 'use strict';
import { import {
replaceScriptsAndMove, insertNodesAndExecuteScripts,
mergeOptions, mergeOptions,
withLoadingReadyState, withLoadingReadyState,
} from '../test-utils/FizzTestUtils'; } from '../test-utils/FizzTestUtils';
@ -24,8 +24,6 @@ let ReactDOMFizzServer;
let Suspense; let Suspense;
let textCache; let textCache;
let loadCache; let loadCache;
let window;
let document;
let writable; let writable;
const CSPnonce = null; const CSPnonce = null;
let container; let container;
@ -38,28 +36,32 @@ let waitForThrow;
let assertLog; let assertLog;
let Scheduler; let Scheduler;
let clientAct; let clientAct;
let streamingContainer;
function resetJSDOM(markup) {
// Test Environment
const jsdom = new JSDOM(markup, {
runScripts: 'dangerously',
});
// We mock matchMedia. for simplicity it only matches 'all' or '' and misses everything else
Object.defineProperty(jsdom.window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: query === 'all' || query === '',
media: query,
})),
});
window = jsdom.window;
document = jsdom.window.document;
}
describe('ReactDOMFloat', () => { describe('ReactDOMFloat', () => {
beforeEach(() => { beforeEach(() => {
jest.resetModules(); jest.resetModules();
JSDOM = require('jsdom').JSDOM; 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 = jsdom.window.document;
container = document.getElementById('container');
React = require('react'); React = require('react');
ReactDOM = require('react-dom'); ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client'); ReactDOMClient = require('react-dom/client');
@ -77,9 +79,6 @@ describe('ReactDOMFloat', () => {
textCache = new Map(); textCache = new Map();
loadCache = new Set(); loadCache = new Set();
resetJSDOM('<!DOCTYPE html><html><head></head><body><div id="container">');
container = document.getElementById('container');
buffer = ''; buffer = '';
hasErrored = false; hasErrored = false;
@ -100,6 +99,9 @@ describe('ReactDOMFloat', () => {
} }
}); });
const bodyStartMatch = /<body(?:>| .*?>)/;
const headStartMatch = /<head(?:>| .*?>)/;
async function act(callback) { async function act(callback) {
await callback(); await callback();
// Await one turn around the event loop. // Await one turn around the event loop.
@ -113,44 +115,123 @@ describe('ReactDOMFloat', () => {
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. // 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 also want to execute any scripts that are embedded.
// We assume that we have now received a proper fragment of HTML. // We assume that we have now received a proper fragment of HTML.
const bufferedContent = buffer; let bufferedContent = buffer;
buffer = ''; buffer = '';
const fakeBody = document.createElement('body');
fakeBody.innerHTML = bufferedContent;
const parent =
container.nodeName === '#document' ? container.body : container;
await withLoadingReadyState(async () => {
while (fakeBody.firstChild) {
const node = fakeBody.firstChild;
await replaceScriptsAndMove(
document.defaultView,
CSPnonce,
node,
parent,
);
}
}, document);
}
async function actIntoEmptyDocument(callback) { if (!bufferedContent) {
await callback(); return;
// Await one turn around the event loop.
// This assumes that we'll flush everything we have so far.
await new Promise(resolve => {
setImmediate(resolve);
});
if (hasErrored) {
throw fatalError;
} }
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
// We also want to execute any scripts that are embedded.
// We assume that we have now received a proper fragment of HTML.
const bufferedContent = buffer;
resetJSDOM(bufferedContent);
container = document;
buffer = '';
await withLoadingReadyState(async () => { await withLoadingReadyState(async () => {
await replaceScriptsAndMove(window, null, document.documentElement); const bodyMatch = bufferedContent.match(bodyStartMatch);
const headMatch = bufferedContent.match(headStartMatch);
if (streamingContainer === null) {
// This is the first streamed content. We decide here where to insert it. If we get <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);
}
}, document); }, document);
} }
@ -350,7 +431,7 @@ describe('ReactDOMFloat', () => {
// @gate enableFloat // @gate enableFloat
it('can hydrate non Resources in head when Resources are also inserted there', async () => { it('can hydrate non Resources in head when Resources are also inserted there', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head> <head>
@ -375,7 +456,7 @@ describe('ReactDOMFloat', () => {
<meta property="foo" content="bar" /> <meta property="foo" content="bar" />
<title>foo</title> <title>foo</title>
<link rel="foo" href="bar" /> <link rel="foo" href="bar" />
<noscript>&lt;link rel="icon" href="icon"/&gt;</noscript> <noscript>&lt;link rel="icon" href="icon"&gt;</noscript>
<base target="foo" href="bar" /> <base target="foo" href="bar" />
</head> </head>
<body>foo</body> <body>foo</body>
@ -406,7 +487,7 @@ describe('ReactDOMFloat', () => {
<meta property="foo" content="bar" /> <meta property="foo" content="bar" />
<title>foo</title> <title>foo</title>
<link rel="foo" href="bar" /> <link rel="foo" href="bar" />
<noscript>&lt;link rel="icon" href="icon"/&gt;</noscript> <noscript>&lt;link rel="icon" href="icon"&gt;</noscript>
<base target="foo" href="bar" /> <base target="foo" href="bar" />
<script async="" src="foo" /> <script async="" src="foo" />
</head> </head>
@ -598,7 +679,7 @@ describe('ReactDOMFloat', () => {
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
buffer = `<!DOCTYPE html><html><head>${ReactDOMFizzServer.renderToString( buffer = `<!DOCTYPE html><html><head>${ReactDOMFizzServer.renderToString(
<App />, <App />,
)}</head><body>foo</body></html>`; )}</head><body>foo</body></html>`;
@ -625,7 +706,7 @@ describe('ReactDOMFloat', () => {
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
buffer = `<!DOCTYPE html><html>${ReactDOMFizzServer.renderToString( buffer = `<!DOCTYPE html><html>${ReactDOMFizzServer.renderToString(
<App />, <App />,
)}<body>foo</body></html>`; )}<body>foo</body></html>`;
@ -649,7 +730,7 @@ describe('ReactDOMFloat', () => {
chunks.push(chunk); chunks.push(chunk);
}); });
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<> <>
<title>foo</title> <title>foo</title>
@ -681,7 +762,7 @@ describe('ReactDOMFloat', () => {
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -703,7 +784,7 @@ describe('ReactDOMFloat', () => {
// @gate enableFloat // @gate enableFloat
it('can avoid inserting a late stylesheet if it already rendered on the client', async () => { it('can avoid inserting a late stylesheet if it already rendered on the client', async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -829,7 +910,7 @@ body {
background-color: red; background-color: red;
}`; }`;
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -1125,7 +1206,7 @@ body {
</html> </html>
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -1211,7 +1292,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('treats stylesheet links with a precedence as a resource', async () => { it('treats stylesheet links with a precedence as a resource', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -1264,7 +1345,7 @@ body {
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -1302,7 +1383,7 @@ body {
function PresetPrecedence() { function PresetPrecedence() {
ReactDOM.preinit('preset', {as: 'style', precedence: 'preset'}); ReactDOM.preinit('preset', {as: 'style', precedence: 'preset'});
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -1584,7 +1665,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('normalizes stylesheet resource precedence for all boundaries inlined as part of the shell flush', async () => { it('normalizes stylesheet resource precedence for all boundaries inlined as part of the shell flush', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -1668,7 +1749,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('stylesheet resources are inserted according to precedence order on the client', async () => { it('stylesheet resources are inserted according to precedence order on the client', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -1791,7 +1872,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('will include child boundary stylesheet resources in the boundary reveal instruction', async () => { it('will include child boundary stylesheet resources in the boundary reveal instruction', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -1910,7 +1991,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('will hoist resources of child boundaries emitted as part of a partial boundary to the parent boundary', async () => { it('will hoist resources of child boundaries emitted as part of a partial boundary to the parent boundary', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -2132,7 +2213,7 @@ body {
); );
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -2218,7 +2299,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('boundary stylesheet resource dependencies hoist to a parent boundary when flushed inline', async () => { it('boundary stylesheet resource dependencies hoist to a parent boundary when flushed inline', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -2353,7 +2434,7 @@ body {
</html> </html>
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -2417,7 +2498,7 @@ body {
</html> </html>
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream(<App />).pipe(writable); renderToPipeableStream(<App />).pipe(writable);
}); });
@ -2573,7 +2654,7 @@ body {
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream(<App />).pipe(writable); renderToPipeableStream(<App />).pipe(writable);
}); });
@ -2593,7 +2674,7 @@ body {
<link rel="stylesheet" href="stylesheet" /> <link rel="stylesheet" href="stylesheet" />
<script src="sync rendered" data-meaningful="" /> <script src="sync rendered" data-meaningful="" />
<style>{'body { background-color: red; }'}</style> <style>{'body { background-color: red; }'}</style>
<noscript>&lt;meta name="noscript" content="noscript"/&gt;</noscript> <noscript>&lt;meta name="noscript" content="noscript"&gt;</noscript>
<link rel="foo" href="foo" /> <link rel="foo" href="foo" />
</head> </head>
<body> <body>
@ -2659,7 +2740,7 @@ body {
<script src="sync rendered" data-meaningful="" /> <script src="sync rendered" data-meaningful="" />
<style>{'body { background-color: red; }'}</style> <style>{'body { background-color: red; }'}</style>
<script src="async rendered" async="" /> <script src="async rendered" async="" />
<noscript>&lt;meta name="noscript" content="noscript"/&gt;</noscript> <noscript>&lt;meta name="noscript" content="noscript"&gt;</noscript>
<link rel="foo" href="foo" /> <link rel="foo" href="foo" />
<style>{'body { background-color: blue; }'}</style> <style>{'body { background-color: blue; }'}</style>
<div /> <div />
@ -2713,7 +2794,7 @@ body {
}); });
it('does not preload nomodule scripts', async () => { it('does not preload nomodule scripts', async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -2839,7 +2920,7 @@ body {
}); });
it('assumes stylesheets that load in the shell loaded already', async () => { it('assumes stylesheets that load in the shell loaded already', async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -3321,7 +3402,7 @@ body {
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream(<App url="foo" />).pipe(writable); renderToPipeableStream(<App url="foo" />).pipe(writable);
}); });
}).toErrorDev([ }).toErrorDev([
@ -3390,7 +3471,7 @@ body {
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream(<App url="foo" />).pipe(writable); renderToPipeableStream(<App url="foo" />).pipe(writable);
}); });
}).toErrorDev( }).toErrorDev(
@ -3469,7 +3550,7 @@ body {
return <div>hello</div>; return <div>hello</div>;
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -3555,7 +3636,7 @@ body {
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -3620,7 +3701,7 @@ body {
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -3645,7 +3726,7 @@ body {
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -3689,7 +3770,7 @@ body {
return <div>hello</div>; return <div>hello</div>;
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -3799,7 +3880,7 @@ body {
return <div>hello</div>; return <div>hello</div>;
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -3916,7 +3997,7 @@ body {
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -3952,7 +4033,7 @@ body {
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -3977,7 +4058,7 @@ body {
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -4007,7 +4088,7 @@ body {
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -4025,7 +4106,7 @@ body {
describe('Stylesheet Resources', () => { describe('Stylesheet Resources', () => {
// @gate enableFloat // @gate enableFloat
it('treats link rel stylesheet elements as a stylesheet resource when it includes a precedence when server rendering', async () => { it('treats link rel stylesheet elements as a stylesheet resource when it includes a precedence when server rendering', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -4078,7 +4159,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('treats link rel stylesheet elements as a stylesheet resource when it includes a precedence when hydrating', async () => { it('treats link rel stylesheet elements as a stylesheet resource when it includes a precedence when hydrating', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -4116,7 +4197,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('preloads stylesheets without a precedence prop when server rendering', async () => { it('preloads stylesheets without a precedence prop when server rendering', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -4144,7 +4225,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('hoists stylesheet resources to the correct precedence', async () => { it('hoists stylesheet resources to the correct precedence', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -4239,7 +4320,7 @@ body {
// @gate enableFloat && enableHostSingletons && enableClientRenderFallbackOnTextMismatch // @gate enableFloat && enableHostSingletons && enableClientRenderFallbackOnTextMismatch
it('retains styles even when a new html, head, and/body mount', async () => { it('retains styles even when a new html, head, and/body mount', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -4291,7 +4372,7 @@ body {
// @gate enableFloat && !enableHostSingletons // @gate enableFloat && !enableHostSingletons
it('retains styles even when a new html, head, and/body mount - without HostSingleton', async () => { it('retains styles even when a new html, head, and/body mount - without HostSingleton', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -4521,7 +4602,7 @@ body {
</html> </html>
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -4575,7 +4656,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('escapes hrefs when selecting matching elements in the document when using preload and preinit', async () => { it('escapes hrefs when selecting matching elements in the document when using preload and preinit', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -4638,7 +4719,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('does not create stylesheet resources when inside an <svg> context', async () => { it('does not create stylesheet resources when inside an <svg> context', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -4699,7 +4780,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('does not create stylesheet resources when inside a <noscript> context', async () => { it('does not create stylesheet resources when inside a <noscript> context', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -4716,7 +4797,7 @@ body {
<head /> <head />
<body> <body>
<noscript> <noscript>
&lt;link rel="stylesheet" href="foo" precedence="default"/&gt; &lt;link rel="stylesheet" href="foo" precedence="default"&gt;
</noscript> </noscript>
</body> </body>
</html>, </html>,
@ -4742,7 +4823,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('warns if you provide a `precedence` prop with other props that invalidate the creation of a stylesheet resource', async () => { it('warns if you provide a `precedence` prop with other props that invalidate the creation of a stylesheet resource', async () => {
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -4826,7 +4907,7 @@ body {
); );
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -4854,7 +4935,7 @@ body {
); );
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -4865,7 +4946,7 @@ body {
// @gate enableFloat // @gate enableFloat
it('will not block displaying a Suspense boundary on a stylesheet with media that does not match', async () => { it('will not block displaying a Suspense boundary on a stylesheet with media that does not match', async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -4986,7 +5067,7 @@ body {
body { body {
background-color: red; background-color: red;
}`; }`;
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -5024,7 +5105,7 @@ background-color: blue;
body { body {
background-color: green; background-color: green;
}`; }`;
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -5129,7 +5210,7 @@ background-color: green;
// @gate enableFloat // @gate enableFloat
it('can emit styles early when a partial boundary flushes', async () => { it('can emit styles early when a partial boundary flushes', async () => {
const css = 'body { background-color: red; }'; const css = 'body { background-color: red; }';
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -5197,7 +5278,7 @@ background-color: green;
}); });
it('can hoist styles flushed early even when no other style dependencies are flushed on completion', async () => { it('can hoist styles flushed early even when no other style dependencies are flushed on completion', async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -5261,7 +5342,7 @@ background-color: green;
}); });
it('can emit multiple style rules into a single style tag for a given precedence', async () => { it('can emit multiple style rules into a single style tag for a given precedence', async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -5440,7 +5521,7 @@ background-color: green;
it('warns if you render a <style> with an href with a space on the server', async () => { it('warns if you render a <style> with an href with a space on the server', async () => {
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream( renderToPipeableStream(
<html> <html>
<body> <body>
@ -5460,7 +5541,7 @@ background-color: green;
describe('Script Resources', () => { describe('Script Resources', () => {
// @gate enableFloat // @gate enableFloat
it('treats async scripts without onLoad or onError as Resources', async () => { it('treats async scripts without onLoad or onError as Resources', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<head /> <head />
@ -5526,7 +5607,7 @@ background-color: green;
// @gate enableFloat // @gate enableFloat
it('does not create script resources when inside an <svg> context', async () => { it('does not create script resources when inside an <svg> context', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -5587,7 +5668,7 @@ background-color: green;
// @gate enableFloat // @gate enableFloat
it('does not create script resources when inside a <noscript> context', async () => { it('does not create script resources when inside a <noscript> context', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -5646,7 +5727,7 @@ background-color: green;
); );
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -5679,7 +5760,7 @@ background-color: green;
); );
} }
await expect(async () => { await expect(async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream(<App />); const {pipe} = renderToPipeableStream(<App />);
pipe(writable); pipe(writable);
}); });
@ -5692,7 +5773,7 @@ background-color: green;
describe('Hoistables', () => { describe('Hoistables', () => {
// @gate enableFloat // @gate enableFloat
it('can hoist meta tags on the server and hydrate them on the client', async () => { it('can hoist meta tags on the server and hydrate them on the client', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -5768,7 +5849,7 @@ background-color: green;
// @gate enableFloat // @gate enableFloat
it('can hoist link (non-stylesheet) tags on the server and hydrate them on the client', async () => { it('can hoist link (non-stylesheet) tags on the server and hydrate them on the client', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -5844,7 +5925,7 @@ background-color: green;
// @gate enableFloat // @gate enableFloat
it('can hoist title tags on the server and hydrate them on the client', async () => { it('can hoist title tags on the server and hydrate them on the client', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -5920,7 +6001,7 @@ background-color: green;
// @gate enableFloat // @gate enableFloat
it('prioritizes ordering for certain hoistables over others when rendering on the server', async () => { it('prioritizes ordering for certain hoistables over others when rendering on the server', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -5962,7 +6043,7 @@ background-color: green;
let content = ''; let content = '';
writable.on('data', chunk => (content += chunk)); writable.on('data', chunk => (content += chunk));
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -6010,7 +6091,7 @@ background-color: green;
// @gate enableFloat // @gate enableFloat
it('supports rendering hoistables outside of <html> scope', async () => { it('supports rendering hoistables outside of <html> scope', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<> <>
<meta name="before" /> <meta name="before" />
@ -6098,7 +6179,7 @@ background-color: green;
</html> </html>
); );
} }
await actIntoEmptyDocument(() => { await act(() => {
renderToPipeableStream(<App />).pipe(writable); renderToPipeableStream(<App />).pipe(writable);
}); });
@ -6188,7 +6269,7 @@ background-color: green;
// @gate enableFloat // @gate enableFloat
it('does not hoist inside an <svg> context', async () => { it('does not hoist inside an <svg> context', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -6222,7 +6303,7 @@ background-color: green;
// @gate enableFloat // @gate enableFloat
it('does not hoist inside noscript context', async () => { it('does not hoist inside noscript context', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<html> <html>
<body> <body>
@ -6249,7 +6330,7 @@ background-color: green;
// @gate enableFloat && enableHostSingletons && (enableClientRenderFallbackOnTextMismatch || !__DEV__) // @gate enableFloat && enableHostSingletons && (enableClientRenderFallbackOnTextMismatch || !__DEV__)
it('can render a title before a singleton even if that singleton clears its contents', async () => { it('can render a title before a singleton even if that singleton clears its contents', async () => {
await actIntoEmptyDocument(() => { await act(() => {
const {pipe} = renderToPipeableStream( const {pipe} = renderToPipeableStream(
<> <>
<title>foo</title> <title>foo</title>

View File

@ -70,66 +70,98 @@ async function getRollupResult(scriptSrc: string): Promise<string | null> {
} }
} }
// Utility function to process received HTML nodes and execute async function insertNodesAndExecuteScripts(
// embedded scripts by: source: Document | Element,
// 1. Matching nonce attributes and moving node into an existing target: Node,
// parent container (if passed)
// 2. Resolving scripts with sources
// 3. Moving data attribute nodes to the body
async function replaceScriptsAndMove(
window: any,
CSPnonce: string | null, CSPnonce: string | null,
node: Node,
parent: Node | null,
) { ) {
if ( const ownerDocument = target.ownerDocument || target;
node.nodeType === 1 &&
(node.nodeName === 'SCRIPT' || node.nodeName === 'script') // We need to remove the script content for any scripts that would not run based on CSP
) { // We restore the script content after moving the nodes into the target
// $FlowFixMe[incompatible-cast] const badNonceScriptNodes: Map<Element, string> = new Map();
const element = (node: HTMLElement); if (CSPnonce) {
const script = window.document.createElement('SCRIPT'); const scripts = source.querySelectorAll('script');
const scriptSrc = element.getAttribute('src'); for (let i = 0; i < scripts.length; i++) {
if (scriptSrc) { const script = scripts[i];
const rollupOutput = await getRollupResult(scriptSrc); if (
if (rollupOutput) { !script.hasAttribute('src') &&
// Manually call eval(...) here, since changing the HTML text content script.getAttribute('nonce') !== CSPnonce
// may interfere with hydration ) {
window.eval(rollupOutput); badNonceScriptNodes.set(script, script.textContent);
script.textContent = '';
} }
for (let i = 0; i < element.attributes.length; i++) {
const attr = element.attributes.item(i);
script.setAttribute(attr.name, attr.value);
}
} else if (element === null || element.getAttribute('nonce') === CSPnonce) {
script.textContent = node.textContent;
} }
if (parent) { }
element.parentNode?.removeChild(element); let lastChild = null;
parent.appendChild(script); while (source.firstChild) {
const node = source.firstChild;
if (lastChild === node) {
throw new Error('Infinite loop.');
}
lastChild = node;
if (node.nodeType === 1) {
const element: Element = (node: any);
if (
// $FlowFixMe[prop-missing]
element.dataset != null &&
(element.dataset.rxi != null ||
element.dataset.rri != null ||
element.dataset.rci != null ||
element.dataset.rsi != null)
) {
// Fizz external runtime instructions are expected to be in the body.
// When we have renderIntoContainer and renderDocument this will be
// more enforceable. At the moment you can misconfigure your stream and end up
// with instructions that are deep in the document
(ownerDocument.body: any).appendChild(element);
} else {
target.appendChild(element);
if (element.nodeName === 'SCRIPT') {
await executeScript(element);
} else {
const scripts = element.querySelectorAll('script');
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
await executeScript(script);
}
}
}
} else { } else {
element.parentNode?.replaceChild(script, element); target.appendChild(node);
}
}
// restore the textContent now that we have finished attempting to execute scripts
badNonceScriptNodes.forEach((scriptContent, script) => {
script.textContent = scriptContent;
});
}
async function executeScript(script: Element) {
const ownerDocument = script.ownerDocument;
if (script.parentNode == null) {
throw new Error(
'executeScript expects to be called on script nodes that are currently in a document',
);
}
const parent = script.parentNode;
const scriptSrc = script.getAttribute('src');
if (scriptSrc) {
const rollupOutput = await getRollupResult(scriptSrc);
if (rollupOutput) {
const transientScript = ownerDocument.createElement('script');
transientScript.textContent = rollupOutput;
parent.appendChild(transientScript);
parent.removeChild(transientScript);
} }
} else if (
node.nodeType === 1 &&
// $FlowFixMe[prop-missing]
node.dataset != null &&
(node.dataset.rxi != null ||
node.dataset.rri != null ||
node.dataset.rci != null ||
node.dataset.rsi != null)
) {
// External runtime assumes that instruction data nodes are eventually
// appended to the body
window.document.body.appendChild(node);
} else { } else {
for (let i = 0; i < node.childNodes.length; i++) { const newScript = ownerDocument.createElement('script');
const inner = node.childNodes[i]; newScript.textContent = script.textContent;
await replaceScriptsAndMove(window, CSPnonce, inner, null); parent.insertBefore(newScript, script);
} parent.removeChild(script);
if (parent != null) {
parent.appendChild(node);
}
} }
} }
@ -191,7 +223,7 @@ async function withLoadingReadyState<T>(
} }
export { export {
replaceScriptsAndMove, insertNodesAndExecuteScripts,
mergeOptions, mergeOptions,
stripExternalRuntimeInNodes, stripExternalRuntimeInNodes,
withLoadingReadyState, withLoadingReadyState,