diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 47bced8a1274a..830b863633cd2 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -243,6 +243,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks'; +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources'; export function appendInitialChild(parentInstance, child) { if (typeof child === 'string') { diff --git a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js index ec547d8cbaa83..b9de7f0a6e868 100644 --- a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js @@ -487,6 +487,7 @@ describe('ReactDOMComponent', () => { expect(node.hasAttribute('src')).toBe(false); }); + // @gate !enableFloat it('should not add an empty href attribute', () => { const container = document.createElement('div'); expect(() => ReactDOM.render(, container)).toErrorDev( diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 60a3a3938df3e..a166ace5d4949 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -144,6 +144,30 @@ describe('ReactDOMFizzServer', () => { } } + async function actIntoEmptyDocument(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(bufferedContent, { + runScripts: 'dangerously', + }); + window = jsdom.window; + document = jsdom.window.document; + container = document; + buffer = ''; + } + function getVisibleChildren(element) { const children = []; let node = element.firstChild; @@ -301,6 +325,7 @@ describe('ReactDOMFizzServer', () => { ); pipe(writable); }); + expect(getVisibleChildren(container)).toEqual(
Loading...
@@ -3202,6 +3227,50 @@ describe('ReactDOMFizzServer', () => { ); }); + it('converts stylesheet links into preinit-as-style resources', async () => { + function App() { + return ( + <> + + + + a title + + a body + + + ); + } + + const chunks = []; + function listener(chunk) { + chunks.push(chunk); + } + + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + writable.on('data', listener); + pipe(writable); + }); + writable.removeListener('data', listener); + + expect( + chunks[0].startsWith( + '', + ), + ).toBe(true); + + expect(getVisibleChildren(container)).toEqual( + + + + a title + + a body + , + ); + }); + describe('error escaping', () => { it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => { window.__outlet = {}; diff --git a/packages/react-dom/src/__tests__/ReactDOMResources-test.js b/packages/react-dom/src/__tests__/ReactDOMResources-test.js new file mode 100644 index 0000000000000..89539858a247c --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMResources-test.js @@ -0,0 +1,1206 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +let JSDOM; +let Scheduler; +let React; +let ReactDOMClient; +let ReactDOMFizzServer; +let container; +let document; +let writable; +let buffer; +let hasErrored; +let Stream; +let fatalError; + +describe('ReactDOMResources', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + Scheduler = require('scheduler'); + Stream = require('stream'); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + ReactDOMFizzServer = require('react-dom/server'); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + function getVisibleChildren(element) { + const children = []; + let node = element.firstChild; + while (node) { + if (node.nodeType === 1) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { + const props = {}; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } + props[attributes[i].name] = attributes[i].value; + } + props.children = getVisibleChildren(node); + children.push(React.createElement(node.tagName.toLowerCase(), props)); + } + } else if (node.nodeType === 3) { + children.push(node.data); + } + node = node.nextSibling; + } + return children.length === 0 + ? undefined + : children.length === 1 + ? children[0] + : children; + } + + async function actIntoEmptyDocument(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(bufferedContent, { + runScripts: 'dangerously', + }); + document = jsdom.window.document; + container = document; + buffer = ''; + } + + async function actInto(callback, prelude, getContainer) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(prelude + bufferedContent, { + runScripts: 'dangerously', + }); + document = jsdom.window.document; + container = getContainer(document); + buffer = ''; + } + + // async function act(callback) { + // await callback(); + // // Await one turn around the event loop. + // // This assumes that we'll flush everything we have so far. + // await new Promise(resolve => { + // setImmediate(resolve); + // }); + // if (hasErrored) { + // throw fatalError; + // } + // // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // // We also want to execute any scripts that are embedded. + // // We assume that we have now received a proper fragment of HTML. + // const bufferedContent = buffer; + // buffer = ''; + // const fakeBody = document.createElement('body'); + // fakeBody.innerHTML = bufferedContent; + // while (fakeBody.firstChild) { + // const node = fakeBody.firstChild; + // if ( + // node.nodeName === 'SCRIPT' && + // (CSPnonce === null || node.getAttribute('nonce') === CSPnonce) + // ) { + // const script = document.createElement('script'); + // script.textContent = node.textContent; + // fakeBody.removeChild(node); + // container.appendChild(script); + // } else { + // container.appendChild(node); + // } + // } + // } + + function normalizeCodeLocInfo(str) { + return ( + str && + str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) { + return '\n in ' + name + ' (at **)'; + }) + ); + } + + // @gate enableFloat + it('hoists resources to the head if the container is a Document without hydration', async () => { + function App() { + return ( + <> + + + + +
hello world
+ + + + + ); + } + + await actInto( + async () => {}, + 'this will be replaced on root.render', + doc => doc, + ); + + expect(getVisibleChildren(document)).toEqual( + + + this will be replaced on root.render + , + ); + + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the head if the container is a Document with hydration', async () => { + function App() { + return ( + <> + + + + +
hello world
+ + + + + ); + } + + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the head if the container is the documentElement without hydration', async () => { + function App() { + return ( + <> + + + + +
hello world
+ + + ); + } + + await actInto( + async () => {}, + 'this will be replaced on root.render', + doc => doc.documentElement, + ); + + expect(getVisibleChildren(document)).toEqual( + + + this will be replaced on root.render + , + ); + + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the head if the container is the documentElement with hydration', async () => { + function App() { + return ( + <> + + + + +
hello world
+ + + ); + } + + await actInto( + async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }, + '', + doc => doc.documentElement, + ); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the head when the container is an Element (other than the documentElement) without hydration', async () => { + function App() { + return ( + <> + +
hello world
+ + + ); + } + + await actInto( + async () => {}, + '
', + doc => doc.getElementById('container'), + ); + expect(getVisibleChildren(document)).toEqual( + + + +
+ +
+ + , + ); + + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
+
hello world
+
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the container when it is an Element (other than the documentElement) with hydration', async () => { + function App() { + return ( + <> + +
hello world
+ + + ); + } + + await actInto( + async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }, + '
', + doc => doc.getElementById('container'), + ); + expect(getVisibleChildren(document)).toEqual( + + + + + +
+ + +
hello world
+
+ + , + ); + + // The resources are not relocated on hydration so they stay ahead of the hello world div + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + +
+ + +
hello world
+
+ + , + ); + }); + + // @gate enableFloat + it('removes resources that no longer have any referrers', async () => { + if (gate(flags => !flags.enableFloat)) { + throw new Error( + 'This test fails to fail properly when the flag is false. It errors but for some reason jest still thinks it did not fail properly', + ); + } + function App({exclude, isClient, multiple}) { + return ( + <> + {!isClient ? : null} + + + + + {exclude ? null : } +
hello world
+ {new Array(multiple || 0).fill(0).map((_, i) => ( + + ))} + + + + ); + } + + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
hello world
+ + , + ); + + const root = ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushWithoutYielding(); + // "serveronly" is removed because it is not referred to by any HostResource + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('dedupes resources across roots when not using hydration', async () => { + function App({extra}) { + return ( + <> + +
hello world
+ {extra ? : null} + + + ); + } + + await actInto( + async () => {}, + '
', + doc => [ + doc.getElementById('container1'), + doc.getElementById('container2'), + ], + ); + expect(getVisibleChildren(document)).toEqual( + + + +
+
+ + , + ); + + const root1 = ReactDOMClient.createRoot(container[0]); + root1.render(); + const root2 = ReactDOMClient.createRoot(container[1]); + root2.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
+
hello world
+
+
+
hello world
+
+ + , + ); + + root1.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + , + , + , + ]); + + root2.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + , + , + , + ]); + + root1.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + , + , + ]); + }); + + // @gate enableFloat + it('tracks resources separately when using hydrationRoots', async () => { + function App({extra}) { + return ( + <> + +
hello world
+ {extra ? : null} + + + ); + } + + const server1 = ReactDOMFizzServer.renderToString(); + const server2 = ReactDOMFizzServer.renderToString(); + + await actInto( + async () => {}, + `
${server1}
${server2}
`, + doc => [ + doc.getElementById('container1'), + doc.getElementById('container2'), + ], + ); + expect(getVisibleChildren(document)).toEqual( + + + +
+ + +
hello world
+
+
+ + + +
hello world
+
+ + , + ); + + const root1 = ReactDOMClient.hydrateRoot(container[0], ); + const root2 = ReactDOMClient.hydrateRoot( + container[1], + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + +
+ + +
hello world
+
+
+ + + +
hello world
+
+ + , + ); + + root1.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + +
+ + +
hello world
+ +
+
+ + + +
hello world
+
+ + , + ); + + root2.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + +
+ + +
hello world
+ +
+
+ + +
hello world
+
+ + , + ); + + root1.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + +
+ + +
hello world
+
+
+ + +
hello world
+
+ + , + ); + }); + + it('treats resource eligible elements with data-* attributes as components instead of resources', async () => { + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + +
hello world
+ + , + ); + pipe(writable); + }); + // data attribute links get their own individual representation in the stream because they are treated + // like regular HostComponents + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + +
hello world
+ + , + ); + + const root = ReactDOMClient.hydrateRoot( + container, + + + + + + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + +
hello world
+ + , + ); + + // Drop the foo crossorigin anonymous HostResource that might match if we weren't useing data attributes + // It is actually removed from the head because the body representation is a HostComponent and completely + // disconnected from the Resource runtime. + root.render( + + + + + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
hello world
+ + , + ); + }); + + describe('link resources', () => { + // @gate enableFloat + it('keys resources on href, crossOrigin, and referrerPolicy', async () => { + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + +
hello world
+ + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document.head)).toEqual([ + , + , + , + ]); + + const root = ReactDOMClient.hydrateRoot( + container, + + + + + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + , + , + , + ]); + + // Add the default referrer. This should not result in a new resource key because it is equivalent to no specified policy + root.render( + + + + + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + , + , + , + ]); + + // Change the referrerPolicy to something distinct and observe a new resource is emitted + root.render( + + + + + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + , + , + , + , + ]); + + // Update the other "foo" link to match the new referrerPolicy and observe the resource coalescing + root.render( + + + + + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + , + , + , + ]); + }); + + // @gate enableFloat + it('warns in Dev when two key-matched resources use different values for non-ignored divergent props', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + if (args.length > 1) { + if (typeof args[1] === 'object') { + mockError(args[0].split('\n')[0]); + return; + } + } + mockError(...args.map(normalizeCodeLocInfo)); + }; + + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
+ + + hello world +
+ + , + ); + pipe(writable); + }); + // The second link matches the key of the first link but disagrees on the media prop. this should warn but also only + // emit the first resource representation for that key + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + if (__DEV__) { + expect(mockError.mock.calls).toEqual([ + [ + 'Warning: A "%s" Resource (%s="%s") was %s with a different "%s" prop than the one used originally.' + + ' The original value was "%s" and the new value is "%s". Either the "%s" value should be the same' + + ' or this Resource should point to distinct "%s" location.%s', + 'stylesheet', + 'href', + 'foo', + 'created', + 'media', + 'print', + 'screen and (max-width: 600px)', + 'media', + 'href', + '\n' + + ' in link (at **)\n' + + ' in div (at **)\n' + + ' in body (at **)\n' + + ' in html (at **)', + ], + ]); + } + mockError.mock.calls.length = 0; + + const root = ReactDOMClient.hydrateRoot( + document, + + + +
+ + + hello world +
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + if (__DEV__) { + expect(mockError.mock.calls).toEqual([ + [ + 'Warning: A "%s" Resource (%s="%s") was %s with a different "%s" prop than the one used originally.' + + ' The original value was "%s" and the new value is "%s". Either the "%s" value should be the same' + + ' or this Resource should point to distinct "%s" location.%s', + 'stylesheet', + 'href', + 'foo', + 'created', + 'media', + 'print', + 'screen and (max-width: 600px)', + 'media', + 'href', + '\n' + + ' in div (at **)\n' + + ' in body (at **)\n' + + ' in html (at **)', + ], + ]); + } + mockError.mock.calls.length = 0; + + root.render( + + + +
+ + + hello world +
+ + , + ); + + expect(Scheduler).toFlushWithoutYielding(); + if (__DEV__) { + expect(mockError.mock.calls).toEqual([ + [ + 'Warning: A "%s" Resource (%s="%s") was %s with a different "%s" prop than the one used originally.' + + ' The original value was "%s" and the new value is "%s". Either the "%s" value should be the same' + + ' or this Resource should point to distinct "%s" location.%s', + 'stylesheet', + 'href', + 'foo', + 'updated', + 'integrity', + '', + 'some hash', + 'integrity', + 'href', + '\n' + + ' in div (at **)\n' + + ' in body (at **)\n' + + ' in html (at **)', + ], + ]); + } + } finally { + console.error = originalConsoleError; + } + }); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js new file mode 100644 index 0000000000000..8969b6b047dd8 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js @@ -0,0 +1,1077 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let JSDOM; +let Stream; +// let Scheduler; +let React; +let ReactDOM; +// let ReactDOMClient; +let ReactDOMFizzServer; +let Suspense; +// let TextDecoder; +let textCache; +// let window; +let document; +let writable; +let container; +let buffer = ''; +let hasErrored = false; +let fatalError = undefined; +const CSPnonce = null; + +describe('ReactDOMServerPreload', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + // Scheduler = require('scheduler'); + React = require('react'); + ReactDOM = require('react-dom'); + // ReactDOMClient = require('react-dom/client'); + ReactDOMFizzServer = require('react-dom/server'); + Stream = require('stream'); + Suspense = React.Suspense; + // TextDecoder = require('util').TextDecoder; + + textCache = new Map(); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + // window = jsdom.window; + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + function expectLinks(beforeLinks, separator, afterLinks) { + let selector = 'link'; + if (separator) { + selector += ', ' + separator; + } + const els = Array.from(document.querySelectorAll(selector)); + let index = 0; + const [foundBeforeLinks, foundAfterLinks] = els.reduce( + (linkGroups, nextEl) => { + switch (nextEl.tagName) { + case (separator && separator.toUpperCase()) || '': { + index = 1; + break; + } + case 'LINK': { + const descriptor = [nextEl.rel, nextEl.getAttribute('href')]; + if (nextEl.hasAttribute('as')) { + descriptor.push(nextEl.getAttribute('as')); + } + if (nextEl.hasAttribute('crossorigin')) { + descriptor.push( + nextEl.getAttribute('crossorigin') === 'use-credentials' + ? 'use-credentials' + : 'anonymous', + ); + } + linkGroups[index].push(descriptor); + break; + } + } + return linkGroups; + }, + [[], []], + ); + expect(foundBeforeLinks).toEqual(beforeLinks); + if (separator) { + expect(foundAfterLinks).toEqual(afterLinks); + } + } + + function expectBodyLinks(bodyLinks) { + return expectLinks([], 'body', bodyLinks); + } + + function expectScript(href, toBeScript) { + const script = document.querySelector(`script[data-src="${href}"]`); + const expected = [ + script.tagName.toLowerCase(), + script.getAttribute('data-src'), + ]; + if (script.hasAttribute('crossorigin')) { + expected.push( + script.getAttribute('crossorigin') === 'use-credentials' + ? 'use-credentials' + : 'anonymous', + ); + } + expect(expected).toEqual(toBeScript); + } + + async function act(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + buffer = ''; + const fakeBody = document.createElement('body'); + fakeBody.innerHTML = bufferedContent; + const appender = container === document ? document.body : container; + while (fakeBody.firstChild) { + const node = fakeBody.firstChild; + if ( + node.nodeName === 'SCRIPT' && + (CSPnonce === null || node.getAttribute('nonce') === CSPnonce) + ) { + const script = document.createElement('script'); + if (node.hasAttribute('src')) { + script.setAttribute('data-src', node.getAttribute('src')); + } + if (node.hasAttribute('crossorigin')) { + script.setAttribute('crossorigin', node.getAttribute('crossorigin')); + } + if (node.hasAttribute('async')) { + script.setAttribute('async', node.getAttribute('async')); + } + script.textContent = node.textContent; + fakeBody.removeChild(node); + appender.appendChild(script); + } else { + appender.appendChild(node); + } + } + const scripts = Array.from(document.getElementsByTagName('script')); + scripts.forEach(script => { + const srcAttr = script.getAttribute('src'); + if (srcAttr != null) { + script.dataset.src = srcAttr; + } + }); + } + + async function actIntoEmptyDocument(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(bufferedContent, { + runScripts: 'dangerously', + }); + // window = jsdom.window; + document = jsdom.window.document; + container = document; + buffer = ''; + } + + function getVisibleChildren(element) { + const children = []; + let node = element.firstChild; + while (node) { + if (node.nodeType === 1) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { + const props = {}; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } + props[attributes[i].name] = attributes[i].value; + } + props.children = getVisibleChildren(node); + children.push(React.createElement(node.tagName.toLowerCase(), props)); + } + } else if (node.nodeType === 3) { + children.push(node.data); + } + node = node.nextSibling; + } + return children.length === 0 + ? undefined + : children.length === 1 + ? children[0] + : children; + } + + function resolveText(text) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + } + + // function rejectText(text, error) { + // const record = textCache.get(text); + // if (record === undefined) { + // const newRecord = { + // status: 'rejected', + // value: error, + // }; + // textCache.set(text, newRecord); + // } else if (record.status === 'pending') { + // const thenable = record.value; + // record.status = 'rejected'; + // record.value = error; + // 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; + } + } + + xit('can flush a preload link for a stylesheet', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([['preload', 'foo', 'style']]); + }); + + xit('only emits 1 preload even if preload is called more than once for the same resource', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( + <> + + + + ); + } + + function Component1() { + ReactDOM.preload('bar', {as: 'style'}); + return
one
; + } + + function Component2() { + ReactDOM.preload('foo', {as: 'style'}); + return
two
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([ + ['preload', 'foo', 'style'], + ['preload', 'bar', 'style'], + ]); + }); + + xit('only emits resources once per priority', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( + + + + + + ); + } + + function Resource({href}) { + const text = readText(href); + ReactDOM.preload(text, {as: 'style'}); + return
{text}
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + await resolveText('foo'); + pipe(writable); + }); + expectBodyLinks([['preload', 'foo', 'style']]); + + await act(async () => { + resolveText('bar'); + }); + expectBodyLinks([ + ['preload', 'foo', 'style'], + ['preload', 'bar', 'style'], + ]); + + await act(async () => { + await resolveText('baz'); + }); + expectBodyLinks([ + ['preload', 'foo', 'style'], + ['preload', 'bar', 'style'], + ['preload', 'baz', 'style'], + ]); + }); + + xit('does not emit a preload if a resource has already been initialized', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( + + + + + ); + } + + function PreloadResource({href}) { + ReactDOM.preload(href, {as: 'style'}); + const text = readText(href); + ReactDOM.preinit(text, {as: 'style'}); + return
{text}
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + await resolveText('foo'); + pipe(writable); + }); + expectBodyLinks([ + ['stylesheet', 'foo'], + ['preload', 'bar', 'style'], + ]); + + await act(async () => { + resolveText('bar'); + }); + expectBodyLinks([ + ['stylesheet', 'foo'], + ['preload', 'bar', 'style'], + ['stylesheet', 'bar'], + ]); + }); + + xit('does not emit lower priority resource loaders when a higher priority loader is already known', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( + + + + ); + } + + function PreloadResource({href}) { + ReactDOM.preinit(href, {as: 'style'}); + const text = readText(href); + ReactDOM.preload(text, {as: 'style'}); + return
{text}
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expectBodyLinks([['stylesheet', 'foo']]); + + await act(async () => { + resolveText('foo'); + }); + expectBodyLinks([['stylesheet', 'foo']]); + }); + + xit('supports prefetching DNS', async () => { + function App() { + ReactDOM.prefetchDNS('foo'); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([['dns-prefetch', 'foo']]); + }); + + xit('supports preconnecting', async () => { + function App() { + ReactDOM.preconnect('foo'); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([['preconnect', 'foo']]); + }); + + xit('supports prefetching', async () => { + function App() { + ReactDOM.prefetch('foo', {as: 'font'}); + ReactDOM.prefetch('bar', {as: 'style'}); + ReactDOM.prefetch('baz', {as: 'script'}); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([ + ['prefetch', 'foo', 'font', 'anonymous'], + ['prefetch', 'bar', 'style'], + ['prefetch', 'baz', 'script'], + ]); + }); + + xit('supports preloading', async () => { + function App() { + ReactDOM.preload('foo', {as: 'font'}); + ReactDOM.preload('bar', {as: 'style'}); + ReactDOM.preload('baz', {as: 'script'}); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([ + ['preload', 'foo', 'font', 'anonymous'], + ['preload', 'bar', 'style'], + ['preload', 'baz', 'script'], + ]); + }); + + xit('supports initializing stylesheets and scripts', async () => { + function App() { + ReactDOM.preinit('foo', {as: 'style'}); + ReactDOM.preinit('bar', {as: 'script'}); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([['stylesheet', 'foo']]); + expectScript('bar', ['script', 'bar']); + }); + + it('converts links for preloading into resources for preloading', async () => { + function App() { + return ( +
+ + + + + + + + + + + +
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([ + // the stylesheet link is hoisted as a resource while the other links are left in place + ['stylesheet', 'stylesheet'], + + ['foo', 'this link is not a resource'], + ['dns-prefetch', 'dns-prefetch'], + ['preconnect', 'preconnect'], + ['prefetch', 'prefetchstyle', 'style'], + ['prefetch', 'prefetchscript', 'script'], + ['prefetch', 'prefetchfont', 'font'], + ['preload', 'preloadstyle', 'style'], + ['preload', 'preloadscript', 'script'], + ['preload', 'preloadfont', 'font'], + ['font', 'font'], + ]); + }); + + // @TODO restore this test once we support scripts + xit('captures resources for preloading when rendering a script', async () => { + function App() { + return ( +
+ +