diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index dbaba801b185a..9bf674bfc64ab 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -243,6 +243,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks'; +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources'; export function appendInitialChild(parentInstance, child) { if (typeof child === 'string') { diff --git a/packages/react-dom/index.experimental.js b/packages/react-dom/index.experimental.js index 44f7dcfe0b1e2..73f14f91953f1 100644 --- a/packages/react-dom/index.experimental.js +++ b/packages/react-dom/index.experimental.js @@ -22,3 +22,5 @@ export { unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. version, } from './src/client/ReactDOM'; + +export {preinit, preload} from './src/shared/ReactDOMFloat'; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 4d8e677e03739..8ec7e473c37b6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -329,6 +329,7 @@ describe('ReactDOMFizzServer', () => { ); pipe(writable); }); + expect(getVisibleChildren(container)).toEqual(
Loading...
@@ -4391,7 +4392,7 @@ describe('ReactDOMFizzServer', () => { }); // @gate enableFloat - it('recognizes stylesheet links as attributes during hydration', async () => { + it('recognizes stylesheet links as resources during hydration', async () => { await actIntoEmptyDocument(() => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream( <> @@ -4469,15 +4470,13 @@ describe('ReactDOMFizzServer', () => { ); try { expect(Scheduler).toFlushWithoutYielding(); + // The reason data-bar is not found on the link is props are only used to generate the resource instance + // if it does not already exist but in this case it was left behind in the document. In the future + // changing props on resources will warn in dev expect(getVisibleChildren(document)).toEqual( - + a body , @@ -4498,7 +4497,7 @@ describe('ReactDOMFizzServer', () => { // Temporarily this test is expected to fail everywhere. When we have resource hoisting // it should start to pass and we can adjust the gate accordingly - // @gate false && enableFloat + // @gate experimental && enableFloat it('should insert missing resources during hydration', async () => { await actIntoEmptyDocument(() => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream( @@ -4525,7 +4524,7 @@ describe('ReactDOMFizzServer', () => { expect(getVisibleChildren(document)).toEqual( - + foo , @@ -4546,111 +4545,6 @@ describe('ReactDOMFizzServer', () => { } }); - // @gate experimental && enableFloat - it('fail hydration if a suitable resource cannot be found in the DOM for a given location (href)', async () => { - gate(flags => { - if (!(__EXPERIMENTAL__ && flags.enableFloat)) { - throw new Error('bailing out of test'); - } - }); - await actIntoEmptyDocument(() => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - - - a body - , - ); - pipe(writable); - }); - - const errors = []; - ReactDOMClient.hydrateRoot( - document, - - - - - a body - , - { - onRecoverableError(err, errInfo) { - errors.push(err.message); - }, - }, - ); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev( - [ - 'Warning: A matching Hydratable Resource was not found in the DOM for ', - 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', - ], - {withoutStack: 1}, - ); - expect(errors).toEqual([ - 'Hydration failed because the initial UI does not match what was rendered on the server.', - 'Hydration failed because the initial UI does not match what was rendered on the server.', - 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', - ]); - }); - - // @gate experimental && enableFloat - it('should error in dev when rendering more than one resource for a given location (href)', async () => { - gate(flags => { - if (!(__EXPERIMENTAL__ && flags.enableFloat)) { - throw new Error('bailing out of test'); - } - }); - await actIntoEmptyDocument(() => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - <> - - - - - a body - - , - ); - pipe(writable); - }); - expect(getVisibleChildren(document)).toEqual( - - - - - - a body - , - ); - - const errors = []; - ReactDOMClient.hydrateRoot( - document, - <> - - - - - - a body - - , - { - onRecoverableError(err, errInfo) { - errors.push(err.message); - }, - }, - ); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev([ - 'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"', - 'Warning: Stylesheet resources need a unique representation in the DOM while hydrating and more than one matching DOM Node was found. To fix, ensure you are only rendering one stylesheet link with an href attribute of "foo"', - ]); - expect(errors).toEqual([]); - }); - describe('text separators', () => { // To force performWork to start before resolving AsyncText but before piping we need to wait until // after scheduleWork which currently uses setImmediate to delay performWork diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js new file mode 100644 index 0000000000000..19b523afcac65 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -0,0 +1,2652 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let JSDOM; +let Stream; +let Scheduler; +let React; +let ReactDOM; +let ReactDOMClient; +let ReactDOMFizzServer; +let Suspense; +let textCache; +let document; +let writable; +const CSPnonce = null; +let container; +let buffer = ''; +let hasErrored = false; +let fatalError = undefined; + +describe('ReactDOMFizzServer', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + Scheduler = require('scheduler'); + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMClient = require('react-dom/client'); + ReactDOMFizzServer = require('react-dom/server'); + Stream = require('stream'); + Suspense = React.Suspense; + + textCache = new Map(); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + function normalizeCodeLocInfo(str) { + if (typeof str !== 'string') { + console.log(str); + } + return ( + str && + str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) { + return '\n in ' + name + ' (at **)'; + }) + ); + } + + function componentStack(components) { + return components + .map(component => `\n in ${component} (at **)`) + .join(''); + } + + async function act(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + buffer = ''; + const fakeBody = document.createElement('body'); + fakeBody.innerHTML = bufferedContent; + const parent = + container.nodeName === '#document' ? container.body : container; + while (fakeBody.firstChild) { + const node = fakeBody.firstChild; + if ( + node.nodeName === 'SCRIPT' && + (CSPnonce === null || node.getAttribute('nonce') === CSPnonce) + ) { + const script = document.createElement('script'); + script.textContent = node.textContent; + fakeBody.removeChild(node); + parent.appendChild(script); + } else { + parent.appendChild(node); + } + } + } + + async function actIntoEmptyDocument(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(bufferedContent, { + runScripts: 'dangerously', + }); + document = jsdom.window.document; + container = document; + buffer = ''; + } + + function getVisibleChildren(element) { + const children = []; + let node = element.firstChild; + while (node) { + if (node.nodeType === 1) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { + const props = {}; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } + props[attributes[i].name] = attributes[i].value; + } + props.children = getVisibleChildren(node); + children.push(React.createElement(node.tagName.toLowerCase(), props)); + } + } else if (node.nodeType === 3) { + children.push(node.data); + } + node = node.nextSibling; + } + return children.length === 0 + ? undefined + : children.length === 1 + ? children[0] + : children; + } + + function resolveText(text) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + } + + function readText(text) { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + throw record.value; + case 'rejected': + throw record.value; + case 'resolved': + return record.value; + } + } else { + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + + throw thenable; + } + } + + function AsyncText({text}) { + return readText(text); + } + + // @gate enableFloat + it('treats stylesheet links with a precedence as a resource', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + Hello + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + Hello + , + ); + + ReactDOMClient.hydrateRoot( + document, + + + Hello + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + Hello + , + ); + }); + + // @gate enableFloat + it('inserts text separators following text when followed by an element that is converted to a resource and thus removed from the html inline', async () => { + // If you render many of these as siblings the values get emitted as a single text with no separator sometimes + // because the link gets elided as a resource + function AsyncTextWithResource({text, href, precedence}) { + const value = readText(text); + return ( + <> + {value} + + + ); + } + + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + , + ); + pipe(writable); + resolveText('foo'); + resolveText('bar'); + resolveText('baz'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + {'foo'} + {'bar'} + {'baz'} + + , + ); + }); + + // @gate enableFloat + it('hoists late stylesheets the correct precedence', async () => { + function AsyncListItemWithResource({text, href, precedence, ...rest}) { + const value = readText(text); + return ( +
  • + + {value} +
  • + ); + } + function BlockingChildren({text, children}) { + readText(text); + return children; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + +
    + + +
      +
    • + +
    • + +
    +
    +
    +
    + +
      + + + +
    +
    +
    +
    + + +
      + + + +
    +
    +
    +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    loading foo bar...
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + , + ); + + await act(() => { + resolveText('foo'); + resolveText('bar'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    loading foo bar...
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + , + ); + + 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(getVisibleChildren(document)).toEqual( + + + + + + + + +
    loading foo bar...
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + , + ); + + 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(getVisibleChildren(document)).toEqual( + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + , + ); + + await act(() => { + resolveText('baz'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + + , + ); + + await act(() => { + resolveText('qux'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    loading bar baz qux...
    +
    loading bar baz qux...
    + + + + + , + ); + + await act(() => { + const bazlink = document.querySelector( + 'link[rel="stylesheet"][href="baz"]', + ); + const quxlink = document.querySelector( + 'link[rel="stylesheet"][href="qux"]', + ); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + bazlink.dispatchEvent(event); + quxlink.dispatchEvent(event); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    +
      +
    • bar
    • +
    • baz
    • +
    • qux
    • +
    +
    +
    loading bar baz qux...
    + + + + + , + ); + + await act(() => { + resolveText('unblock'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + +
    +
      +
    • foo
    • +
    • bar
    • +
    +
    +
    +
      +
    • bar
    • +
    • baz
    • +
    • qux
    • +
    +
    +
    +
      +
    • bar
    • +
    • baz
    • +
    • qux
    • +
    +
    + + + + + , + ); + }); + + // @gate enableFloat + it('normalizes style resource precedence for all boundaries inlined as part of the shell flush', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + outer + + + + + +
    + middle + + + + + +
    + inner + + + + + +
    +
    +
    +
    + +
    middle
    + + + + +
    +
    + + , + ); + pipe(writable); + }); + + // The reason the href's aren't ordered linearly is that when boundaries complete their resources + // get hoisted to the shell directly so they can flush in the head. If a boundary doesn't suspend then + // child boundaries will complete before the parent boundary and thus have their resources hoist + // early. The reason precedences are still ordered correctly between child and parent is because + // the precedence ordering is determined upon first discovernig a resource rather than on hoist and + // so it follows render order + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + +
    + outer +
    + middle
    inner
    +
    +
    middle
    +
    + + , + ); + }); + + // @gate enableFloat + it('style resources are inserted according to precedence order on the client', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + + + Hello +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    + + , + ); + + const root = ReactDOMClient.hydrateRoot( + document, + + + +
    + + + Hello +
    + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    + + , + ); + + root.render( + + + +
    Hello
    + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
    Hello
    + + , + ); + }); + + xit('inserts preloads in render phase eagerly', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + foo + + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + foo + , + ); + + const root = ReactDOMClient.hydrateRoot( + document, + + + + + foo + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + foo + , + ); + + root.render( + + + + foo + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + + foo + , + ); + }); + + // @gate enableFloat + it('does not emit preinit stylesheets if they are invoked after the shell flushes', async () => { + function PreinitsBlockedOn({text}) { + readText(text); + ReactDOM.preinit('one', {precedence: 'one', as: 'style'}); + ReactDOM.preinit('two', {precedence: 'two', as: 'style'}); + return null; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + + + Hello +
    +
    + + + + +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    +
    loading...
    + + , + ); + + await act(() => { + resolveText('foo'); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    +
    loading...
    + + + + , + ); + + await act(() => { + resolveText('bar'); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    Hello
    +
    bar
    + + + + , + ); + }); + + // @gate enableFloat + it('will include child boundary style resources in the boundary reveal instruction', async () => { + function BlockedOn({text, children}) { + readText(text); + return children; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + + +
    foo
    + + + +
    bar
    + + + +
    baz
    + +
    +
    +
    +
    +
    +
    +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + +
    loading foo...
    + + , + ); + + await act(() => { + resolveText('bar'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading foo...
    + + , + ); + + await act(() => { + resolveText('baz'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading foo...
    + + , + ); + + await act(() => { + resolveText('foo'); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
    loading foo...
    + + + + + , + ); + + 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(getVisibleChildren(document)).toEqual( + + + + + + + +
    +
    foo
    +
    bar
    +
    baz
    +
    + + + + + , + ); + }); + + // @gate enableFloat + it('will hoist resources of child boundaries emitted as part of a partial boundary to the parent boundary', async () => { + function BlockedOn({text, children}) { + readText(text); + return children; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + +
    + +
    foo
    + + + +
    bar
    + + +
    + +
    baz
    + +
    +
    +
    +
    +
    +
    + +
    qux
    + +
    +
    +
    +
    + + , + ); + pipe(writable); + }); + + expect(getVisibleChildren(document)).toEqual( + + + +
    loading...
    + + , + ); + + // This will enqueue a style resource in a deep blocked boundary (loading baz...). + await act(() => { + resolveText('baz'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading...
    + + , + ); + + // This will enqueue a style resource in the intermediate blocked boundary (loading bar...). + await act(() => { + resolveText('bar'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading...
    + + , + ); + + // This will complete a segment in the top level boundary that is still blocked on another segment. + // It will flush the completed segment however the inner boundaries should not emit their style dependencies + // because they are not going to be revealed yet. instead their dependencies are hoisted to the blocked + // boundary (top level). + await act(() => { + resolveText('foo'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading...
    + + + + + , + ); + + // This resolves the last blocked segment on the top level boundary so we see all dependencies of the + // nested boundaries emitted at this level + await act(() => { + resolveText('qux'); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + +
    loading...
    + + + + + + , + ); + + // 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(getVisibleChildren(document)).toEqual( + + + + + + + + +
    +
    +
    foo
    +
    bar
    +
    +
    baz
    +
    +
    qux
    +
    +
    + + + + + + , + ); + }); + + // @gate enableFloat + it('encodes attributes consistently whether resources are flushed in shell or in late boundaries', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + function BlockedOn({text, children}) { + readText(text); + return children; + } + function App() { + return ( + + + +
    + {}} + norsymbols={Symbol('foo')} + /> + + + {}} + norsymbols={Symbol('foo')} + /> + + +
    + + + ); + } + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + +
    loading...
    + + , + ); + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(2); + expect(mockError).toHaveBeenCalledWith( + 'Warning: React does not recognize the `%s` prop on a DOM element.' + + ' If you intentionally want it to appear in the DOM as a custom attribute,' + + ' spell it as lowercase `%s` instead. If you accidentally passed it from a' + + ' parent component, remove it from the DOM element.%s', + 'nonStandardAttr', + 'nonstandardattr', + componentStack(['link', 'div', 'body', 'html', 'App']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: Invalid values for props %s on <%s> tag. Either remove them from' + + ' the element, or pass a string or number value to keep them in the DOM. For' + + ' details, see https://reactjs.org/link/attribute-behavior %s', + '`shouldnotincludefunctions`, `norsymbols`', + 'link', + componentStack(['link', 'div', 'body', 'html', 'App']), + ); + mockError.mockClear(); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + + // Now we flush the stylesheet with the boundary + await act(() => { + resolveText('unblock'); + }); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
    loading...
    + + , + ); + if (__DEV__) { + // The way the test is currently set up the props that would warn have already warned + // so no new warnings appear. This is really testing the same code pathway so + // exercising that more here isn't all that useful + expect(mockError).toHaveBeenCalledTimes(0); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('boundary style resource dependencies hoist to a parent boundary when flushed inline', async () => { + function BlockedOn({text, children}) { + readText(text); + return children; + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
    + + + + + + + + + + + + + + + + + + +
    + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading A...
    + + , + ); + + await act(() => { + resolveText('unblock'); + resolveText('AAAA'); + resolveText('AA'); + }); + expect(getVisibleChildren(document)).toEqual( + + + +
    loading A...
    + + + + + + , + ); + + 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(getVisibleChildren(document)).toEqual( + + + + + + +
    + {'A'} + {'AA'} + {'loading AAA...'} +
    + + + + + + , + ); + + 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(getVisibleChildren(document)).toEqual( + + + + + + + + +
    + {'A'} + {'AA'} + {'AAA'} + {'AAAA'} +
    + + + + + + , + ); + }); + + // @gate enableFloat + it('always enforces crossOrigin "anonymous" for font preloads', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + , + ); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + + + , + ); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(2); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" did not specify the crossOrigin prop. Font preloads must always use' + + ' crossOrigin "anonymous" to be functional for loading font files.%s', + 'preload Resource (as "font")', + 'foo', + componentStack(['link', 'body', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" specified a crossOrigin value of "use-credentials". Font preloads must always use' + + ' crossOrigin "anonymous" to be functional for loading font files.%s', + 'preload Resource (as "font")', + 'baz', + componentStack(['link', 'body', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + describe('pre-function input validation', () => { + function Preloads({scenarios}) { + for (let i = 0; i < scenarios.length; i++) { + const href = scenarios[i][0]; + const options = scenarios[i][1]; + ReactDOM.preload(href, options); + } + } + function Preinits({scenarios}) { + for (let i = 0; i < scenarios.length; i++) { + const href = scenarios[i][0]; + const options = scenarios[i][1]; + ReactDOM.preinit(href, options); + } + } + async function render(Component, scenarios) { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + , + ); + pipe(writable); + }); + for (let i = 0; i < scenarios.length; i++) { + const assertion = scenarios[i][2]; + assertion(mockError, i); + } + } finally { + console.error = originalConsoleError; + } + } + + // @gate enableFloat + it('warns when an invalid href argument is provided to ReactDOM.preload on the server', async () => { + const expectedMessage = + 'Warning: ReactDOM.preload expected the first argument to be a string representing an href but found %s instead.%s'; + const expectedStack = componentStack(['Preloads', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preloads, [ + [ + '', + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('an empty string'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + undefined, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + null, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 232132, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "number"'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + {}, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "object"'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }); + + // @gate enableFloat + it('warns when an invalid href argument is provided to ReactDOM.preinit on the server', async () => { + const expectedMessage = + 'Warning: ReactDOM.preinit expected the first argument to be a string representing an href but found %s instead.%s'; + const expectedStack = componentStack(['Preinits', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preinits, [ + [ + '', + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('an empty string'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + undefined, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + null, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 232132, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "number"'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + {}, + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "object"'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }); + + // @gate enableFloat + it('warns when an invalid options argument is provided to ReactDOM.preload on the server', async () => { + const expectedMessage = + 'Warning: ReactDOM.preload expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. The href for the preload call where this warning originated is "%s".%s'; + const expectedStack = componentStack(['Preloads', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preloads, [ + [ + 'foo', + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + null, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + 'bar', + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "string"', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + 123, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('something with type "number"', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }); + + // @gate enableFloat + it('warns when an invalid options argument is provided to ReactDOM.preinit on the server', async () => { + const expectedMessage = + 'Warning: ReactDOM.preinit expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. %s for preinit %s %s. The href for' + + ' the preinit call where this warning originated is "%s".%s'; + const expectedStack = componentStack(['Preinits', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preinits, [ + [ + 'foo', + undefined, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'undefined', + 'Currently, the only valid resource type', + 'is', + '"style"', + 'foo', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + null, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'null', + 'Currently, the only valid resource type', + 'is', + '"style"', + 'foo', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + 'bar', + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'something with type "string"', + 'Currently, the only valid resource type', + 'is', + '"style"', + 'foo', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'foo', + 123, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'something with type "number"', + 'Currently, the only valid resource type', + 'is', + '"style"', + 'foo', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }); + + // @gate enableFloat + it('warns when an invalid "as" option is provided to ReactDOM.preload on the server', async () => { + const expectedMessage = + 'Warning: ReactDOM.preload expected the a valid "as" type in the options (second) argument but found %s instead.' + + ' Please provide a type from one of the following valid values instead: %s. The href for the preload call' + + ' where this warning originated is "%s".%s'; + const expectedStack = componentStack(['Preloads', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preloads, [ + [ + 'foo', + {}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('undefined', '"style" and "font"', 'foo'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'bar', + {as: null}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('null', '"style" and "font"', 'bar'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'baz', + {as: 123}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'something with type "number"', + '"style" and "font"', + 'baz', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'qux', + {as: {}}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'something with type "object"', + '"style" and "font"', + 'qux', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'quux', + {as: 'bar'}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs('"bar"', '"style" and "font"', 'quux'), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }); + + // @gate enableFloat + it('warns when an invalid "as" option is provided to ReactDOM.preinit on the server', async () => { + const expectedMessage = + 'Warning: ReactDOM.preinit expected a valid "as" type in the options (second) argument but found %s instead.' + + ' %s for preinit %s %s. The href for the preinit call where this warning originated is "%s".%s'; + const expectedStack = componentStack(['Preinits', 'head', 'html']); + function makeArgs(...substitutions) { + return [expectedMessage, ...substitutions, expectedStack]; + } + await render(Preinits, [ + [ + 'foo', + {}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'undefined', + 'Currently, the only valid resource type', + 'is', + '"style"', + 'foo', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'bar', + {as: null}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'null', + 'Currently, the only valid resource type', + 'is', + '"style"', + 'bar', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'baz', + {as: 123}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'something with type "number"', + 'Currently, the only valid resource type', + 'is', + '"style"', + 'baz', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'qux', + {as: {}}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + 'something with type "object"', + 'Currently, the only valid resource type', + 'is', + '"style"', + 'qux', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + [ + 'quux', + {as: 'bar'}, + (mockError, scenarioNumber) => { + if (__DEV__) { + expect(mockError.mock.calls[scenarioNumber]).toEqual( + makeArgs( + '"bar"', + 'Currently, the only valid resource type', + 'is', + '"style"', + 'quux', + ), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + }, + ], + ]); + }); + }); + + describe('prop validation', () => { + // @gate enableFloat + it('warns when style Resource have different values for media for the same href', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + + + + , + ); + pipe(writable); + }); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(3); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' + + ' are encountered so this new value will be ignored. Please update the props to agree or change' + + ' the href to be distinct. %s.%s', + 'style Resource', + 'foo', + 'is missing the media prop found on', + 'an earlier instance of this Resource', + 'the original prop value was "all"', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' + + ' are encountered so this new value will be ignored. Please update the props to agree or change' + + ' the href to be distinct. %s.%s', + 'style Resource', + 'bar', + 'has a media prop not found on', + 'an earlier instance of this Resource', + 'the extra prop value is "all"', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' + + ' are encountered so this new value will be ignored. Please update the props to agree or change' + + ' the href to be distinct. %s.%s', + 'style Resource', + 'baz', + 'has a media prop value that is different from', + 'an earlier instance of this Resource', + 'the original value was "some". The new value is "all"', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('warns when style resource props differ or are added for the same href', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + + + + + , + ); + pipe(writable); + }); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(3); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' + + ' are encountered so this new value will be ignored. Please update the props to agree or change' + + ' the href to be distinct. %s.%s', + 'style Resource', + 'foo', + 'has a precedence prop value that is different from', + 'an earlier instance of this Resource', + 'the original value was "foo". The new value is "foonew"', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' + + ' are encountered so this new value will be ignored. Please update the props to agree or change' + + ' the href to be distinct. %s.%s', + 'style Resource', + 'bar', + 'has a data-foo prop not found on', + 'an earlier instance of this Resource', + 'the extra prop value is "a new value"', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' + + ' are encountered so this new value will be ignored. Please update the props to agree or change' + + ' the href to be distinct. %s.%s', + 'style Resource', + 'baz', + 'has a data-foo prop value that is different from', + 'an earlier instance of this Resource', + 'the original value was "an original value". The new value is "a new value"', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // At the moment there are no other resource types besides "style" + xit('errors creating a style Resource when there is a preload resource with a disagreeing as type', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + , + ); + pipe(writable); + }); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(1); + expect(mockError).toHaveBeenCalledWith( + 'While creating a style Resource for href "%s" a preload resource for this same' + + ' href was found with an "as" prop of "%s". When preloading a stylesheet the "as" prop must be of type' + + ' "style". This most likely ocurred by rending a preload link with an incorrect "as" prop or by calling' + + ' ReactDOM.preload with an incorrect "as" option.', + 'foo', + 'font', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('warns when style Resource includes onLoad and/or onError props', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + {}} + onError={() => {}} + /> + {}} + /> + {}} + /> + + , + ); + pipe(writable); + }); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(3); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included %s %s.' + + ' When using onLoad or onError React will opt out of Resource behavior. If you meant for this' + + ' element to be treated as a Resource remove the %s %s. Otherwise remove the precedence prop.%s', + 'foo', + 'onLoad and onError', + 'props', + 'onLoad and onError', + 'props', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included %s %s.' + + ' When using onLoad or onError React will opt out of Resource behavior. If you meant for this' + + ' element to be treated as a Resource remove the %s %s. Otherwise remove the precedence prop.%s', + 'bar', + 'onLoad', + 'prop', + 'onLoad', + 'prop', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A link (rel="stylesheet") element with href "%s" has the precedence prop but also included %s %s.' + + ' When using onLoad or onError React will opt out of Resource behavior. If you meant for this' + + ' element to be treated as a Resource remove the %s %s. Otherwise remove the precedence prop.%s', + 'baz', + 'onError', + 'prop', + 'onError', + 'prop', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('warns when preload Resources have new or different values for props', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + + + + , + ); + pipe(writable); + }); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(2); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' + + ' are encountered so this new value will be ignored. Please update the props to agree or change' + + ' the href to be distinct. %s.%s', + 'preload Resource (as "style")', + 'foo', + 'has a data-foo prop value that is different from', + 'an earlier instance of this Resource', + 'the original value was "a current value". The new value is "a new value"', + componentStack(['link', 'head', 'html']), + ); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s is using the same href "%s" as a %s. This is always an error and React will only keep the first preload' + + ' for any given href, discarding subsequent instances. Please the places where you are rendering a preload tag' + + ' or calling ReactDOM.preload with this href and either may the preload types agree or make the href distinct.%s', + 'preload Resource (as "font")', + 'bar', + 'preload Resource (as "style")', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + + // @gate enableFloat + it('warns when the an existing preload Resource has certain specific different props from a style Resource of the same href', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + mockError(...args.map(normalizeCodeLocInfo)); + }; + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + + + + , + ); + pipe(writable); + }); + + if (__DEV__) { + expect(mockError).toHaveBeenCalledTimes(1); + expect(mockError).toHaveBeenCalledWith( + 'Warning: A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' + + ' are encountered so this new value will be ignored. Please update the props to agree or change' + + ' the href to be distinct. %s.%s', + 'style Resource', + 'foo', + 'has a crossOrigin prop value that is different from', + 'a preload Resource (as "style") with the same href', + 'the original value was "preload value". The new value is "style value"', + componentStack(['link', 'head', 'html']), + ); + } else { + expect(mockError).not.toHaveBeenCalled(); + } + } finally { + console.error = originalConsoleError; + } + }); + }); +}); diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 406cda4f266a8..5dea15f2b8c70 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -71,6 +71,7 @@ import { enqueueStateRestore, restoreStateIfNeeded, } from '../events/ReactDOMControlledComponent'; +import {Dispatcher} from '../shared/ReactDOMDispatcher'; setAttemptSynchronousHydration(attemptSynchronousHydration); setAttemptDiscreteHydration(attemptDiscreteHydration); @@ -145,6 +146,7 @@ const Internals = { restoreStateIfNeeded, batchedUpdates, ], + Dispatcher, }; function createRoot( diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index defc3d93fc036..473dc6002d10a 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -1305,3 +1305,17 @@ export function restoreControlledState( return; } } + +export function isHostResourceType(type: string, props: Object): boolean { + switch (type) { + case 'link': { + return ( + props.rel === 'preload' || + (props.rel === 'stylesheet' && typeof props.precedence === 'string') + ); + } + default: { + return false; + } + } +} diff --git a/packages/react-dom/src/client/ReactDOMComponentTree.js b/packages/react-dom/src/client/ReactDOMComponentTree.js index 58e5d72acd581..87529594664f2 100644 --- a/packages/react-dom/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom/src/client/ReactDOMComponentTree.js @@ -23,6 +23,7 @@ import type { import { HostComponent, + HostResource, HostText, HostRoot, SuspenseComponent, @@ -30,7 +31,7 @@ import { import {getParentSuspenseInstance} from './ReactDOMHostConfig'; -import {enableScopeAPI} from 'shared/ReactFeatureFlags'; +import {enableScopeAPI, enableFloat} from 'shared/ReactFeatureFlags'; const randomKey = Math.random() .toString(36) @@ -166,7 +167,8 @@ export function getInstanceFromNode(node: Node): Fiber | null { inst.tag === HostComponent || inst.tag === HostText || inst.tag === SuspenseComponent || - inst.tag === HostRoot + inst.tag === HostRoot || + (enableFloat ? inst.tag === HostResource : false) ) { return inst; } else { @@ -181,7 +183,11 @@ export function getInstanceFromNode(node: Node): Fiber | null { * DOM node. */ export function getNodeFromInstance(inst: Fiber): Instance | TextInstance { - if (inst.tag === HostComponent || inst.tag === HostText) { + if ( + inst.tag === HostComponent || + inst.tag === HostText || + (enableFloat ? inst.tag === HostResource : false) + ) { // In Fiber this, is just the state node right now. We assume it will be // a host component or host text. return inst.stateNode; diff --git a/packages/react-dom/src/client/ReactDOMFloatClient.js b/packages/react-dom/src/client/ReactDOMFloatClient.js new file mode 100644 index 0000000000000..69cc58a764426 --- /dev/null +++ b/packages/react-dom/src/client/ReactDOMFloatClient.js @@ -0,0 +1,297 @@ +/** + * 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. + * + * @flow + */ +import type {Instance, Container} from './ReactDOMHostConfig'; +import {pushDispatcher, popDispatcher} from '../shared/ReactDOMDispatcher'; + +import { + createElement, + setInitialProperties, + getOwnerDocumentFromRootContainer, +} from './ReactDOMComponent'; +import {HTML_NAMESPACE} from '../shared/DOMNamespaces'; + +type PreloadResource = { + type: 'preload', + href: string, + insertionTime: number, + ownerDocument: Document, + props: Object, + instance: Element, +}; + +type StyleResource = { + type: 'style', + + // Ref count for resource + count: number, + + // Resource Descriptors + href: string, + precedence: string, + props: Object, + + // Insertion + preloaded: boolean, + loaded: boolean, + error: mixed, + instance: ?Element, + ownerDocument: Document, +}; +type Resource = StyleResource | PreloadResource; + +const preloadResources: Map = new Map(); +const styleResources: Map = new Map(); + +export function getResource( + type: string, + props: Object, + rootContainerInstance: Container, +): Resource { + switch (type) { + case 'link': { + switch (props.rel) { + case 'stylesheet': { + // We construct or get an existing resource for the style itself and return it + const styleResource = copyToStylesheetResource( + props, + rootContainerInstance, + ); + return styleResource; + } + case 'preload': { + const preload = copyToPreloadResource(props, rootContainerInstance); + insertPreloadInstance(preload.instance, preload.ownerDocument); + return preload; + } + default: { + throw new Error( + `getResource encountered a link type (rel) it did not expect: "${ + typeof props.rel === 'string' ? props.rel : '' + }". this is a bug in React.`, + ); + } + } + } + default: { + throw new Error( + `getResource encountered a resource type it did not expect: "${type}". this is a bug in React.`, + ); + } + } +} + +export function acquireResource(resource: Resource): Instance { + switch (resource.type) { + case 'style': { + return acquireStyleResource(resource); + } + case 'preload': { + return resource.instance; + } + default: { + throw new Error( + `acquireResource encountered a resource type it did not expect: "${resource.type}". this is a bug in React.`, + ); + } + } +} + +export function releaseResource(resource: Resource) { + switch (resource.type) { + case 'style': { + resource.count--; + } + } +} + +function createResourceInstance( + type: string, + props: Object, + ownerDocument: Document, +): Instance { + const element = createElement(type, props, ownerDocument, HTML_NAMESPACE); + setInitialProperties(element, type, props); + return element; +} + +function copyToStylesheetResource( + props: Object, + rootContainerInstance: Container, +): StyleResource { + let resource = styleResources.get(props.href); + if (!resource) { + const ownedProps = Object.assign({}, props); + delete ownedProps.precedence; + ownedProps['data-rprec'] = props.precedence; + + const ownerDocument = getOwnerDocumentFromRootContainer( + rootContainerInstance, + ); + resource = { + type: 'style', + count: 0, + href: props.href, + precedence: props.precedence, + props: ownedProps, + preloaded: false, + loaded: false, + error: false, + ownerDocument, + instance: null, + }; + styleResources.set(props.href, resource); + } + return (resource: any); +} + +function copyToPreloadResource( + props: Object, + rootContainerInstance, +): PreloadResource { + const resource = preloadResources.get(props.href); + if (resource) { + return resource; + } else { + const ownedProps = Object.assign({}, props); + return moveToPreloadResource(ownedProps, rootContainerInstance); + } +} + +function moveToPreloadResource( + props: Object, + rootContainerInstance: Container, +): PreloadResource { + let resource = preloadResources.get(props.href); + if (!resource) { + const ownerDocument = getOwnerDocumentFromRootContainer( + rootContainerInstance, + ); + let element = ownerDocument.querySelector( + `link[rel="preload"][href="${props.href}"]`, + ); + if (!element) { + element = createResourceInstance('link', props, ownerDocument); + } + resource = { + type: 'preload', + href: props.href, + ownerDocument, + insertionTime: Date.now(), + props, + instance: element, + }; + } + return resource; +} + +function acquireStyleResource(resource: StyleResource): Instance { + if (!resource.instance) { + const {props, ownerDocument, precedence} = resource; + const existingEl = ownerDocument.querySelector( + `link[rel="stylesheet"][data-rprec][href="${props.href}"]`, + ); + if (existingEl) { + resource.instance = existingEl; + resource.preloaded = true; + const loaded = (resource.loaded = existingEl.hasAttribute('data-rl')); + const errored = (resource.error = existingEl.hasAttribute('data-re')); + if (!loaded && !errored) { + (existingEl: any)._ReactLoad = onResourceLoad.bind(null, resource); + (existingEl: any)._ReactError = onResourceError.bind(null, resource); + } + } else { + const instance = (resource.instance = createResourceInstance( + 'link', + resource.props, + ownerDocument, + )); + + insertStyleInstance( + instance, + onResourceLoad.bind(null, resource, instance), + onResourceError.bind(null, resource, instance), + precedence, + ownerDocument, + ); + } + } + resource.count++; + return (resource.instance: any); +} + +function onResourceLoad(resource: StyleResource, instance: Element) { + resource.loaded = true; + (instance: any).onload = null; + (instance: any).onerror = null; +} + +function onResourceError(resource: StyleResource, instance: Element) { + resource.error = true; + (instance: any).onload = null; + (instance: any).onerror = null; +} + +function insertStyleInstance( + instance: Instance, + onLoad: () => void, + onError: () => void, + precedence: string, + ownerDocument: Document, +): void { + (instance: any).onload = onLoad; + (instance: any).onerror = onError; + const nodes = ownerDocument.querySelectorAll( + 'link[rel="stylesheet"][data-rprec]', + ); + let prior = null; + if (precedence === 'default') { + const len = nodes.length; + prior = len ? nodes[len - 1] : null; + } else { + let matched = false; + for (let i = 0, node = null; i < nodes.length; i++, prior = node) { + node = nodes[i]; + const nodePrecedence = node.dataset.rprec; + if (nodePrecedence === precedence) { + matched = true; + } else if (matched || nodePrecedence === 'default') { + break; + } + } + } + if (prior) { + (prior.parentNode: any).insertBefore(instance, prior.nextSibling); + } else { + (ownerDocument.head: any).insertBefore( + instance, + (ownerDocument.head: any).firstChild, + ); + } +} + +function insertPreloadInstance( + instance: Instance, + ownerDocument: Document, +): void { + if (!ownerDocument.contains(instance)) { + ((ownerDocument.head || ownerDocument.body: any): Element).appendChild( + instance, + ); + } +} + +export function prepareToRender() { + pushDispatcher(Dispatcher); +} + +export function cleanupAfterRender() { + popDispatcher(); +} + +const Dispatcher = {}; diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 64195ac7f5957..84c30a0e72945 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -40,7 +40,6 @@ import { warnForDeletedHydratableText, warnForInsertedHydratedElement, warnForInsertedHydratedText, - getOwnerDocumentFromRootContainer, } from './ReactDOMComponent'; import {getSelectionInformation, restoreSelection} from './ReactInputSelection'; import setTextContent from './setTextContent'; @@ -67,7 +66,11 @@ import { enableScopeAPI, enableFloat, } from 'shared/ReactFeatureFlags'; -import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; +import { + HostComponent, + HostResource, + HostText, +} from 'react-reconciler/src/ReactWorkTags'; import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem'; import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities'; @@ -680,14 +683,6 @@ export function clearContainer(container: Container): void { export const supportsHydration = true; -export function isHydratableResource(type: string, props: Props): boolean { - return ( - type === 'link' && - typeof (props: any).precedence === 'string' && - (props: any).rel === 'stylesheet' - ); -} - export function canHydrateInstance( instance: HydratableInstance, type: string, @@ -788,10 +783,7 @@ function getNextHydratable(node) { const nodeType = node.nodeType; if (enableFloat) { if (nodeType === ELEMENT_NODE) { - if ( - ((node: any): Element).tagName === 'LINK' && - ((node: any): Element).hasAttribute('data-rprec') - ) { + if (isHostResourceInstance(((node: any): Element))) { continue; } break; @@ -903,43 +895,6 @@ export function hydrateSuspenseInstance( precacheFiberNode(internalInstanceHandle, suspenseInstance); } -export function getMatchingResourceInstance( - type: string, - props: Props, - rootHostContainer: Container, -): ?Instance { - if (enableFloat) { - switch (type) { - case 'link': { - if (typeof (props: any).href !== 'string') { - return null; - } - const selector = `link[rel="stylesheet"][data-rprec][href="${ - (props: any).href - }"]`; - const link = getOwnerDocumentFromRootContainer( - rootHostContainer, - ).querySelector(selector); - if (__DEV__) { - const allLinks = getOwnerDocumentFromRootContainer( - rootHostContainer, - ).querySelectorAll(selector); - if (allLinks.length > 1) { - console.error( - 'Stylesheet resources need a unique representation in the DOM while hydrating' + - ' and more than one matching DOM Node was found. To fix, ensure you are only' + - ' rendering one stylesheet link with an href attribute of "%s".', - (props: any).href, - ); - } - } - return link; - } - } - } - return null; -} - export function getNextHydratableInstanceAfterSuspenseInstance( suspenseInstance: SuspenseInstance, ): null | HydratableInstance { @@ -1281,6 +1236,7 @@ export function matchAccessibilityRole(node: Instance, role: string): boolean { export function getTextContent(fiber: Fiber): string | null { switch (fiber.tag) { + case HostResource: case HostComponent: let textContent = ''; const childNodes = fiber.stateNode.childNodes; @@ -1390,3 +1346,45 @@ export function requestPostPaintCallback(callback: (time: number) => void) { localRequestAnimationFrame(time => callback(time)); }); } +// ------------------- +// Resources +// ------------------- + +export const supportsResources = true; + +export function isHostResourceInstance( + instance: Instance | Container, +): boolean { + if (instance.nodeType === ELEMENT_NODE) { + switch (instance.tagName.toLowerCase()) { + case 'link': { + const rel = ((instance: any): HTMLLinkElement).rel; + return ( + rel === 'preload' || + (rel === 'stylesheet' && instance.hasAttribute('data-rprec')) + ); + } + default: { + return false; + } + } + } + return false; +} + +export function prepareToRender() { + return; +} + +export function cleanupAfterRender() { + return; +} + +export { + getResource, + acquireResource, + releaseResource, +} from './ReactDOMFloatClient'; + +import {isHostResourceType} from './ReactDOMComponent'; +export {isHostResourceType}; diff --git a/packages/react-dom/src/events/DOMPluginEventSystem.js b/packages/react-dom/src/events/DOMPluginEventSystem.js index 0b1f1c498e024..06c42b82e2252 100644 --- a/packages/react-dom/src/events/DOMPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMPluginEventSystem.js @@ -33,6 +33,7 @@ import { HostRoot, HostPortal, HostComponent, + HostResource, HostText, ScopeComponent, } from 'react-reconciler/src/ReactWorkTags'; @@ -52,6 +53,7 @@ import { enableLegacyFBSupport, enableCreateEventHandleAPI, enableScopeAPI, + enableFloat, } from 'shared/ReactFeatureFlags'; import { invokeGuardedCallbackAndCatchFirstError, @@ -621,7 +623,11 @@ export function dispatchEventForPluginEventSystem( return; } const parentTag = parentNode.tag; - if (parentTag === HostComponent || parentTag === HostText) { + if ( + parentTag === HostComponent || + parentTag === HostText || + (enableFloat ? parentTag === HostResource : false) + ) { node = ancestorInst = parentNode; continue mainLoop; } @@ -675,7 +681,10 @@ export function accumulateSinglePhaseListeners( while (instance !== null) { const {stateNode, tag} = instance; // Handle listeners that are on HostComponents (i.e.
    ) - if (tag === HostComponent && stateNode !== null) { + if ( + (tag === HostComponent || (enableFloat ? tag === HostResource : false)) && + stateNode !== null + ) { lastHostComponent = stateNode; // createEventHandle listeners @@ -786,7 +795,10 @@ export function accumulateTwoPhaseListeners( while (instance !== null) { const {stateNode, tag} = instance; // Handle listeners that are on HostComponents (i.e.
    ) - if (tag === HostComponent && stateNode !== null) { + if ( + (tag === HostComponent || (enableFloat ? tag === HostResource : false)) && + stateNode !== null + ) { const currentTarget = stateNode; const captureListener = getListener(instance, captureName); if (captureListener != null) { @@ -883,7 +895,10 @@ function accumulateEnterLeaveListenersForEvent( if (alternate !== null && alternate === common) { break; } - if (tag === HostComponent && stateNode !== null) { + if ( + (tag === HostComponent || (enableFloat ? tag === HostResource : false)) && + stateNode !== null + ) { const currentTarget = stateNode; if (inCapturePhase) { const captureListener = getListener(instance, registrationName); diff --git a/packages/react-dom/src/server/ReactDOMFloatServer.js b/packages/react-dom/src/server/ReactDOMFloatServer.js new file mode 100644 index 0000000000000..f283997ce4420 --- /dev/null +++ b/packages/react-dom/src/server/ReactDOMFloatServer.js @@ -0,0 +1,925 @@ +/** + * 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. + * + * @flow + */ + +import {pushDispatcher, popDispatcher} from '../shared/ReactDOMDispatcher'; + +let currentResources: ?Resources = null; + +type ResourceType = 'style' | 'font'; +type PreloadResource = { + type: 'preload', + as: ResourceType, + href: string, + flushed: boolean, + props: Object, +}; + +type StyleResource = { + type: 'style', + href: string, + precedence: string, + flushed: boolean, + inShell: boolean, // flushedInShell + props: Object, + hint: PreloadResource, +}; + +export type Resource = PreloadResource | StyleResource; + +export type Resources = { + // Request local cache + preloadsMap: Map, + stylesMap: Map, + + // Flushing queues for Resource dependencies + explicitPreloads: Set, + implicitPreloads: Set, + precedences: Map>, + + // Module-global-like reference for current boundary resources + boundaryResources: ?BoundaryResources, +}; + +export function createResources(): Resources { + return { + // persistent + preloadsMap: new Map(), // preloadResources + stylesMap: new Map(), // styleResources + + // cleared on flush + explicitPreloads: new Set(), // explicitPreloads + implicitPreloads: new Set(), // add bootstrap script to implicit preloads + precedences: new Map(), + + // like a module global for currently rendering boundary + boundaryResources: null, + }; +} + +export type BoundaryResources = Set; + +export function createBoundaryResources(): BoundaryResources { + return new Set(); +} + +export function mergeBoundaryResources( + target: BoundaryResources, + source: BoundaryResources, +) { + const iter = source.values(); + let {value, done} = iter.next(); + for (; !done; {value, done} = iter.next()) { + target.add((value: any)); + } +} + +export function prepareToRender(resources: Resources) { + currentResources = resources; + + pushDispatcher(Dispatcher); +} + +export function setBoundaryResources( + resources: Resources, + boundaryResources: ?BoundaryResources, +) { + resources.boundaryResources = boundaryResources; +} + +export function cleanupAfterRender() { + currentResources = null; + + popDispatcher(); +} + +type PreloadAs = ResourceType; +type PreloadOptions = {as: PreloadAs, crossOrigin?: string}; +function preload(href: string, options: PreloadOptions) { + if (!currentResources) { + throw new Error( + '"currentResources" was expected to exist. This is a bug in React.', + ); + } + if (__DEV__) { + if (!href || typeof href !== 'string') { + const typeOfArg = getValueDescriptorExpectingObjectForWarning(href); + console.error( + 'ReactDOM.preload expected the first argument to be a string representing an href but found %s instead.', + typeOfArg, + ); + } else if (typeof options !== 'object' || options === null) { + const typeOfArg = getValueDescriptorExpectingObjectForWarning(options); + console.error( + 'ReactDOM.preload expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. The href for the preload call where this warning originated is "%s".', + typeOfArg, + href, + ); + } + } + if ( + typeof href === 'string' && + href && + typeof options === 'object' && + options !== null + ) { + const as = options.as; + if (__DEV__) { + switch (as) { + // Font specific validation of options + case 'font': { + if (options.crossOrigin === 'use-credentials') { + console.error( + 'ReactDOM.preload was called with an "as" type of "font" and with a "crossOrigin" option of "use-credentials".' + + ' Fonts preloading must use crossOrigin "anonymous" to be functional. Please update your font preload to omit' + + ' the crossOrigin option or change it to any other value than "use-credentials" (Browsers default all other values' + + ' to anonymous mode). The href for the preload call where this warning originated is "%s"', + href, + ); + } + // Intentionally fall through to any further validation for valid as types + } + + // The following block is for validation of all other resource types if any apply. + // eslint-disable-next-line-no-fallthrough + case 'style': { + break; + } + + // We have an invalid as type and need to warn + default: { + const typeOfAs = getValueDescriptorExpectingEnumForWarning(as); + console.error( + 'ReactDOM.preload expected the a valid "as" type in the options (second) argument but found %s instead.' + + ' Please provide a type from one of the following valid values instead: %s. The href for the preload call' + + ' where this warning originated is "%s".', + typeOfAs, + '"style" and "font"', + href, + ); + } + } + } + const ownedProps = preloadPropsFromPreloadOptions(href, as, options); + moveToExplicitPreloadResource(currentResources, href, as, ownedProps); + } +} + +type PreinitAs = 'style'; +type PreinitOptions = { + as: PreinitAs, + precedence?: string, + crossOrigin?: string, +}; +function preinit(href: string, options: PreinitOptions) { + if (!currentResources) { + throw new Error( + '"currentResources" was expected to exist. This is a bug in React.', + ); + } + if (__DEV__) { + if (!href || typeof href !== 'string') { + const typeOfArg = getValueDescriptorExpectingObjectForWarning(href); + console.error( + 'ReactDOM.preinit expected the first argument to be a string representing an href but found %s instead.', + typeOfArg, + ); + } else if (typeof options !== 'object' || options === null) { + const typeOfArg = getValueDescriptorExpectingObjectForWarning(options); + console.error( + 'ReactDOM.preinit expected the second argument to be an options argument containing at least an "as" property' + + ' specifying the Resource type. It found %s instead. %s for preinit %s %s. The href for' + + ' the preinit call where this warning originated is "%s".', + typeOfArg, + 'Currently, the only valid resource type', + 'is', + '"style"', + href, + ); + } + } + if ( + typeof href === 'string' && + href && + typeof options === 'object' && + options !== null + ) { + const as = options.as; + switch (as) { + case 'style': { + const precedence = options.precedence || 'default'; + const props = stylePropsFromPreinitOptions(href, precedence, options); + + // Do not associate preinit style resources with any specific boundary regardless of where it is called + const previousBoundaryResources = currentResources.boundaryResources; + currentResources.boundaryResources = null; + moveToStylesheetResource(currentResources, href, precedence, props); + currentResources.boundaryResources = previousBoundaryResources; + + return; + } + default: { + if (__DEV__) { + const typeOfAs = getValueDescriptorExpectingEnumForWarning(as); + console.error( + 'ReactDOM.preinit expected a valid "as" type in the options (second) argument but found %s instead.' + + ' %s for preinit %s %s. The href for the preinit call where this warning originated is "%s".', + typeOfAs, + 'Currently, the only valid resource type', + 'is', + '"style"', + href, + ); + } + } + } + } +} + +function preloadPropsFromPreloadOptions( + href: string, + as: ResourceType, + options: PreloadOptions, +): any { + return { + href, + rel: 'preload', + as, + crossOrigin: as === 'font' ? '' : options.crossOrigin, + }; +} + +function preloadPropsFromRawProps( + href: string, + as: ResourceType, + rawBorrowedProps: Object, +): any { + const ownedProps = Object.assign({}, rawBorrowedProps); + ownedProps.href = href; + ownedProps.rel = 'preload'; + ownedProps.as = as; + if (as === 'font') { + if (__DEV__) { + const name = getResourceNameForWarning('preload', ownedProps, false); + if (!('crossOrigin' in rawBorrowedProps)) { + console.error( + 'A %s with href "%s" did not specify the crossOrigin prop. Font preloads must always use' + + ' crossOrigin "anonymous" to be functional for loading font files.', + name, + href, + ); + } else if (rawBorrowedProps.crossOrigin === 'use-credentials') { + console.error( + 'A %s with href "%s" specified a crossOrigin value of "use-credentials". Font preloads must always use' + + ' crossOrigin "anonymous" to be functional for loading font files.', + name, + href, + ); + } + } + ownedProps.crossOrigin = ''; + } + return ownedProps; +} + +function preloadPropsFromStyleProps(props: Object): Object { + return { + rel: 'preload', + as: 'style', + href: props.href, + crossOrigin: props.crossOrigin, + integrity: props.integrity, + media: props.media, + hrefLang: props.hrefLang, + referrerPolicy: props.referrerPolicy, + }; +} + +function copyToExplicitPreloadResource( + resources: Resources, + href: string, + as: ResourceType, + rawBorrowedProps: Object, +): PreloadResource { + const {preloadsMap, explicitPreloads} = resources; + if (__DEV__) { + // In dev we do runtime validation of props so we always copy the rawProps and run through + // the move pathway. + const props = preloadPropsFromRawProps(href, as, rawBorrowedProps); + return moveToExplicitPreloadResource(resources, href, as, props); + } else { + // In prod, we use a resource if it exists and create it otherwise + let resource = preloadsMap.get(href); + if (!resource) { + const props = preloadPropsFromRawProps(href, as, rawBorrowedProps); + resource = createPreloadResource(resources, href, as, props); + } + // This block needs to be kept in sync with the moveToExplicitPreloadResource block otherwise + // behavior in Dev and Prod will diverge + explicitPreloads.add(resource); + return resource; + } +} + +function moveToExplicitPreloadResource( + resources: Resources, + href: string, + as: ResourceType, + ownedProps: Object, +): PreloadResource { + const {preloadsMap, explicitPreloads} = resources; + + const props = ownedProps; + + let resource = preloadsMap.get(href); + if (resource) { + if (__DEV__) { + const originalWarningName = getResourceNameForWarning( + 'preload', + resource.props, + (resource: any)._dev_implicit_construction === true, + ); + const latestWarningName = getResourceNameForWarning( + 'preload', + props, + false, + ); + if (ownedProps.as !== resource.props.as) { + console.error( + 'A %s is using the same href "%s" as a %s. This is always an error and React will only keep the first preload' + + ' for any given href, discarding subsequent instances. Please the places where you are rendering a preload tag' + + ' or calling ReactDOM.preload with this href and either may the preload types agree or make the href distinct.', + latestWarningName, + href, + originalWarningName, + ); + } else { + for (const propName in props) { + const propValue = props[propName]; + const originalValue = resource.props[propName]; + switch (propName) { + // special case "as", this was handled before the switch + case 'as': + break; + + // Check for changes or additions + default: + if (propValue != null && propValue !== originalValue) { + warnDifferentProps( + href, + propName, + originalWarningName, + originalValue, + latestWarningName, + propValue, + ); + } + } + } + } + } + } else { + resource = createPreloadResource(resources, href, as, props); + explicitPreloads.add(resource); + } + return resource; +} + +function moveToImplicitPreloadResource( + resources: Resources, + href: string, + as: ResourceType, + ownedProps: Object, +): PreloadResource { + const {preloadsMap, implicitPreloads} = resources; + + const props = ownedProps; + + let resource = preloadsMap.get(href); + if (resource) { + if (__DEV__) { + const originalWarningName = getResourceNameForWarning( + 'preload', + resource.props, + (resource: any)._dev_implicit_construction === true, + ); + const latestWarningName = getResourceNameForWarning( + 'preload', + props, + true, + ); + if ((resource: any)._dev_implicit_construction) { + // The existing resource was constructed implicitly. We do a simplified validation where we + // make sure the as types are the same. If they are we assume any props variance will be + // called out by the primary resource type. + if (ownedProps.as !== resource.props.as) { + console.error( + 'A %s is using the same href "%s" as a %s. This is always an error and React will only keep the first' + + ' preload for any given href. Please find where you are using the same href for two different kinds of' + + ' Resources and update them to either be the same resource type or to have distinct hrefs.', + latestWarningName, + href, + originalWarningName, + ); + } + } else { + for (const propName in props) { + const propValue = props[propName]; + const originalValue = resource.props[propName]; + switch (propName) { + // Check for difference + case 'as': + if ( + originalValue !== propValue && + // We only care about meaningful differences in value and thus exclude undefined !== null + !(originalValue == null && propValue == null) + ) + warnDifferentProps( + href, + propName, + originalWarningName, + originalValue, + latestWarningName, + propValue, + ); + break; + + // Check for changes or additions + default: + if (propValue != null && propValue !== originalValue) { + warnDifferentProps( + href, + propName, + originalWarningName, + originalValue, + latestWarningName, + propValue, + ); + } + } + } + } + } + } else { + resource = createPreloadResource(resources, href, as, ownedProps); + if (__DEV__) { + (resource: any)._dev_implicit_construction = true; + } + implicitPreloads.add(resource); + } + return resource; +} + +function createPreloadResource( + resources: Resources, + href: string, + as: ResourceType, + ownedProps: Object, +): PreloadResource { + const {preloadsMap} = resources; + if (__DEV__) { + if (preloadsMap.has(href)) { + console.error( + 'createPreloadResource was called when a preload Resource matching the same href already exists. This is a bug in React.', + ); + } + } + + const resource = { + type: 'preload', + as, + href, + flushed: false, + props: ownedProps, + }; + preloadsMap.set(href, resource); + return resource; +} + +function stylePropsFromRawProps( + href: string, + precedence: string, + rawProps: Object, +): any { + const props = Object.assign({}, rawProps); + props.href = href; + props.rel = 'stylesheet'; + props['data-rprec'] = precedence; + delete props.precedence; + + return props; +} + +function stylePropsFromPreinitOptions( + href: string, + precedence: string, + options: PreinitOptions, +): any { + return { + rel: 'stylesheet', + href, + 'data-rprec': precedence, + crossOrigin: options.crossOrigin, + }; +} + +function copyToStylesheetResource( + resources: Resources, + href: string, + precedence: string, + rawBorrowedProps: Object, +): void { + const {boundaryResources, precedences, stylesMap} = resources; + + if (__DEV__) { + // In dev we do runtime validation of props so we always copy the rawProps and run through + // the move pathway. + const props = stylePropsFromRawProps(href, precedence, rawBorrowedProps); + return moveToStylesheetResource(resources, href, precedence, props); + } else { + // In prod, we use a resource if it exists and create it otherwise + let resource = stylesMap.get(href); + if (!resource) { + const props = stylePropsFromRawProps(href, precedence, rawBorrowedProps); + resource = createStyleResource(resources, href, precedence, props); + } + // This block needs to be kept in sync with the moveToStylesheetResources block otherwise + // behavior in Dev and Prod will diverge + if (boundaryResources) { + boundaryResources.add(resource); + if (!precedences.has(precedence)) { + precedences.set(precedence, new Set()); + } + } else { + let set = precedences.get(precedence); + if (!set) { + set = new Set(); + precedences.set(precedence, set); + } + set.add(resource); + } + } +} + +function moveToStylesheetResource( + resources: Resources, + href: string, + precedence: string, + ownedProps: any, +): void { + const {stylesMap, boundaryResources, precedences} = resources; + + const props = ownedProps; + + let resource = stylesMap.get(href); + if (resource) { + if (__DEV__) { + // eslint-disable-next-line no-labels + warnOnce: { + const originalWarningName = getResourceNameForWarning( + resource.type, + resource.props, + false, + ); + const latestWarningName = getResourceNameForWarning( + 'style', + props, + false, + ); + if (resource.props.media != null && props.media == null) { + warnDifferentProps( + href, + 'media', + originalWarningName, + resource.props.media, + latestWarningName, + props.media, + ); + // eslint-disable-next-line no-labels + break warnOnce; + } + + for (let propName in props) { + const propValue = props[propName]; + const originalValue = resource.props[propName]; + + if (propValue != null && propValue !== originalValue) { + propName = propName === 'data-rprec' ? 'precedence' : propName; + warnDifferentProps( + href, + propName, + originalWarningName, + originalValue, + latestWarningName, + propValue, + ); + // eslint-disable-next-line no-labels + break warnOnce; + } + } + } + } + } else { + resource = createStyleResource(resources, href, precedence, props); + } + + if (boundaryResources) { + boundaryResources.add(resource); + if (!precedences.has(precedence)) { + precedences.set(precedence, new Set()); + } + } else { + let set = precedences.get(precedence); + if (!set) { + set = new Set(); + precedences.set(precedence, set); + } + set.add(resource); + } +} + +function createStyleResource( + resources: Resources, + href: string, + precedence: string, + ownedProps: any, +): StyleResource { + if (__DEV__) { + if (resources.stylesMap.has(href)) { + console.error( + 'createStyleResource was called when a style Resource matching the same href already exists. This is a bug in React.', + ); + } + } + const {stylesMap, preloadsMap} = resources; + + const props = ownedProps; + + let hint = preloadsMap.get(href); + if (hint) { + // If a preload for this style Resource already exists there are certain props we want to adopt + // on the style Resource, primarily focussed on making sure the style network pathways utilize + // the preload pathways. For instance if you have diffreent crossOrigin attributes for a preload + // and a stylesheet the stylesheet will make a new request even if the preload had already loaded + const preloadProps = hint.props; + if (props.crossOrigin == null) props.crossOrigin = preloadProps.crossOrigin; + if (props.referrerPolicy == null) + props.referrerPolicy = preloadProps.referrerPolicy; + if (props.media == null) props.media = preloadProps.media; + if (props.title == null) props.title = preloadProps.title; + + if (__DEV__) { + const originalWarningName = getResourceNameForWarning( + 'preload', + hint.props, + (hint: any)._dev_implicit_construction, + ); + const latestWarningName = getResourceNameForWarning( + 'style', + props, + false, + ); + + if (preloadProps.as !== 'style') { + console.error( + 'While creating a %s for href "%s" a %s for this same href was found. When preloading a stylesheet the' + + ' "as" prop must be of type "style". This most likely ocurred by rending a preload link with an incorrect' + + ' "as" prop or by calling ReactDOM.preload with an incorrect "as" option.', + latestWarningName, + href, + originalWarningName, + ); + } + + for (const propName in props) { + const propValue = props[propName]; + const originalValue = hint.props[propName]; + switch (propName) { + // Check for difference on specific props that cross over or influence + // the relationship between the preload and stylesheet + case 'crossOrigin': + case 'referrerPolicy': + case 'media': + case 'title': + if ( + originalValue !== propValue && + !(originalValue == null && propValue == null) + ) + warnDifferentProps( + href, + propName, + originalWarningName, + originalValue, + latestWarningName, + propValue, + ); + } + } + } + } else { + hint = moveToImplicitPreloadResource( + resources, + href, + 'style', + preloadPropsFromStyleProps(props), + ); + } + + const resource = { + type: 'style', + href, + precedence, + flushed: false, + inShell: false, + props, + hint, + }; + stylesMap.set(href, resource); + + return resource; +} + +// Construct a resource from link props. +export function resourcesFromLink(props: Object): boolean { + if (!currentResources) { + throw new Error( + '"currentResources" was expected to exist. This is a bug in React.', + ); + } + const {rel, href} = props; + if (!href || typeof href !== 'string') { + return false; + } + + switch (rel) { + case 'stylesheet': { + const {onLoad, onError, precedence} = props; + if (typeof precedence !== 'string' || onLoad || onError) { + if (__DEV__) { + if (typeof precedence === 'string' && (onLoad || onError)) { + const propNames = + onLoad && onError + ? 'onLoad and onError' + : onLoad + ? 'onLoad' + : 'onError'; + const propsReference = onLoad && onError ? 'props' : 'prop'; + console.error( + 'A link (rel="stylesheet") element with href "%s" has the precedence prop but also included %s %s.' + + ' When using onLoad or onError React will opt out of Resource behavior. If you meant for this' + + ' element to be treated as a Resource remove the %s %s. Otherwise remove the precedence prop.', + href, + propNames, + propsReference, + propNames, + propsReference, + ); + } + } + moveToImplicitPreloadResource( + currentResources, + href, + 'style', + preloadPropsFromStyleProps(props), + ); + return false; + } + copyToStylesheetResource(currentResources, href, precedence, props); + return true; + } + case 'preload': { + const {as, onLoad, onError} = props; + if (onLoad || onError) { + return false; + } + switch (as) { + case 'style': + case 'font': { + copyToExplicitPreloadResource(currentResources, href, as, props); + return true; + } + } + return false; + } + } + return false; +} + +export function hoistResources( + resources: Resources, + source: BoundaryResources, +): void { + if (resources.boundaryResources) { + mergeBoundaryResources(resources.boundaryResources, source); + source.clear(); + } +} + +export function hoistResourcesToRoot( + resources: Resources, + boundaryResources: BoundaryResources, +): void { + const iter = boundaryResources.values(); + while (true) { + const {value} = iter.next(); + if (!value) break; + // all precedences are set upon discovery. so we know we will have a set here + const set: Set = (resources.precedences.get( + value.precedence, + ): any); + set.add(value); + } + boundaryResources.clear(); +} + +const Dispatcher = { + preload, + preinit, +}; + +function warnDifferentProps( + href: string, + propName: string, + originalName: string, + originalValue: any, + latestName: string, + latestValue: any, +): void { + if (__DEV__) { + const juxtaposedNameStatement = + latestName === originalName + ? 'an earlier instance of this Resource' + : `a ${originalName} with the same href`; + + let comparisonStatement, propValueStatement; + if (originalValue == null) { + comparisonStatement = `has a ${propName} prop not found on`; + propValueStatement = `the extra prop value is "${latestValue}"`; + } else if (latestValue == null) { + comparisonStatement = `is missing the ${propName} prop found on`; + propValueStatement = `the original prop value was "${originalValue}"`; + } else { + comparisonStatement = `has a ${propName} prop value that is different from`; + propValueStatement = `the original value was "${originalValue}". The new value is "${latestValue}"`; + } + console.error( + 'A %s with href "%s" %s %s. Resources always use the props that were provided the first time they' + + ' are encountered so this new value will be ignored. Please update the props to agree or change' + + ' the href to be distinct. %s.', + latestName, + href, + comparisonStatement, + juxtaposedNameStatement, + propValueStatement, + ); + } +} + +function getResourceNameForWarning( + type: string, + props: Object, + implicit: boolean, +) { + if (__DEV__) { + switch (type) { + case 'style': { + return 'style Resource'; + } + case 'preload': { + if (implicit) { + return `preload for a ${props.as} Resource`; + } + return `preload Resource (as "${props.as}")`; + } + } + } + return 'Resource'; +} + +function getValueDescriptorExpectingObjectForWarning(thing: any): string { + return thing === null + ? 'null' + : thing === undefined + ? 'undefined' + : thing === '' + ? 'an empty string' + : `something with type "${typeof thing}"`; +} + +function getValueDescriptorExpectingEnumForWarning(thing: any): string { + return thing === null + ? 'null' + : thing === undefined + ? 'undefined' + : thing === '' + ? 'an empty string' + : typeof thing === 'string' + ? JSON.stringify(thing) + : `something with type "${typeof thing}"`; +} diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index c30d9673265c2..74cb582b722a9 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -8,6 +8,8 @@ */ import type {ReactNodeList} from 'shared/ReactTypes'; +import type {Resources, BoundaryResources} from './ReactDOMFloatServer'; +export type {Resources, BoundaryResources}; import { checkHtmlStringCoercion, @@ -58,6 +60,19 @@ import hasOwnProperty from 'shared/hasOwnProperty'; import sanitizeURL from '../shared/sanitizeURL'; import isArray from 'shared/isArray'; +import { + prepareToRender as prepareToRenderImpl, + cleanupAfterRender as cleanupAfterRenderImpl, + resourcesFromLink, +} from './ReactDOMFloatServer'; +export { + createResources, + createBoundaryResources, + setBoundaryResources, + hoistResources, + hoistResourcesToRoot, +} from './ReactDOMFloatServer'; + // Used to distinguish these contexts from ones used in other renderers. // E.g. this can be used to distinguish legacy renderers from this modern one. export const isPrimaryRenderer = true; @@ -73,7 +88,8 @@ export type ResponseState = { nextSuspenseID: number, sentCompleteSegmentFunction: boolean, sentCompleteBoundaryFunction: boolean, - sentClientRenderFunction: boolean, // We allow the legacy renderer to extend this object. + sentClientRenderFunction: boolean, + sentStyleInsertionFunction: boolean, // We allow the legacy renderer to extend this object. ... }; @@ -183,6 +199,7 @@ export function createResponseState( sentCompleteSegmentFunction: false, sentCompleteBoundaryFunction: false, sentClientRenderFunction: false, + sentStyleInsertionFunction: false, }; } @@ -206,6 +223,7 @@ type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; export type FormatContext = { insertionMode: InsertionMode, // root/svg/html/mathml/table selectedValue: null | string | Array, // the selected value(s) inside a + preambleOpen: boolean, }; function createFormatContext( @@ -215,6 +233,7 @@ function createFormatContext( return { insertionMode, selectedValue, + preambleOpen: true, }; } @@ -264,6 +283,25 @@ export function getChildFormatContext( return createFormatContext(HTML_MODE, null); } if (parentContext.insertionMode === ROOT_HTML_MODE) { + switch (type) { + case 'html': { + return parentContext; + } + case 'head': + case 'title': + case 'base': + case 'link': + case 'style': + case 'meta': + case 'script': + case 'noscript': + case 'template': { + break; + } + default: { + parentContext.preambleOpen = false; + } + } // We've emitted the root and is now in plain HTML mode. return createFormatContext(HTML_MODE, null); } @@ -1088,6 +1126,26 @@ function pushLink( target: Array, props: Object, responseState: ResponseState, + textEmbedded: boolean, +): ReactNodeList { + if (resourcesFromLink(props)) { + if (textEmbedded) { + // This link follows text but we aren't writing a tag. while not as efficient as possible we need + // to be safe and assume text will follow by inserting a textSeparator + target.push(textSeparator); + } + // We have converted this link exclusively to a resource and no longer + // need to emit it + return null; + } + + return pushLinkImpl(target, props, responseState); +} + +function pushLinkImpl( + target: Array, + props: Object, + responseState: ResponseState, ): ReactNodeList { const isStylesheet = props.rel === 'stylesheet'; target.push(startChunkForTag('link')); @@ -1269,9 +1327,15 @@ function pushStartHead( props: Object, tag: string, responseState: ResponseState, + formatContext: FormatContext, ): ReactNodeList { // Preamble type is nullable for feature off cases but is guaranteed when feature is on - target = enableFloat ? preamble : target; + target = + enableFloat && + formatContext.insertionMode === ROOT_HTML_MODE && + formatContext.preambleOpen + ? preamble + : target; return pushStartGenericElement(target, props, tag, responseState); } @@ -1281,13 +1345,11 @@ function pushStartHtml( preamble: Array, props: Object, tag: string, - formatContext: FormatContext, responseState: ResponseState, + formatContext: FormatContext, ): ReactNodeList { - // Preamble type is nullable for feature off cases but is guaranteed when feature is on - target = enableFloat ? preamble : target; - if (formatContext.insertionMode === ROOT_HTML_MODE) { + target = enableFloat && formatContext.preambleOpen ? preamble : target; // If we're rendering the html tag and we're at the root (i.e. not in foreignObject) // then we also emit the DOCTYPE as part of the root content as a convenience for // rendering the whole document. @@ -1517,6 +1579,7 @@ export function pushStartInstance( props: Object, responseState: ResponseState, formatContext: FormatContext, + textEmbedded: boolean, ): ReactNodeList { if (__DEV__) { validateARIAProperties(type, props); @@ -1570,7 +1633,7 @@ export function pushStartInstance( case 'title': return pushStartTitle(target, props, responseState); case 'link': - return pushLink(target, props, responseState); + return pushLink(target, props, responseState, textEmbedded); // Newline eating tags case 'listing': case 'pre': { @@ -1606,15 +1669,22 @@ export function pushStartInstance( } // Preamble start tags case 'head': - return pushStartHead(target, preamble, props, type, responseState); + return pushStartHead( + target, + preamble, + props, + type, + responseState, + formatContext, + ); case 'html': { return pushStartHtml( target, preamble, props, type, - formatContext, responseState, + formatContext, ); } default: { @@ -2079,8 +2149,176 @@ export function writeEndSegment( const completeSegmentFunction = 'function $RS(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)}'; +// const completeBoundaryFunction = +// 'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}};'; +// const completeBoundaryFunction = +// '$RC=function(){function h(e,f){var a=document.getElementById(e),c=document.getElementById(f);c.parentNode.removeChild(c);if(a){a=a.previousSibling;var k=a.parentNode,b=a.nextSibling,g=0;do{if(b&&8===b.nodeType){var d=b.data;if("/$"===d)if(0===g)break;else g--;else"$"!==d&&"$?"!==d&&"$!"!==d||g++}d=b.nextSibling;k.removeChild(b);b=d}while(b);for(;c.firstChild;)k.insertBefore(c.firstChild,b);a.data="$";a._reactRetry&&a._reactRetry()}}return function(e,f,a){if(a&&(a=$RR(a)))return Promise.all(a).then(h.bind(null,e,f))["catch"](function(c){return console.log("caught",c)});h(e,f)}}()' +const styleInsertionFunction = + '$RR=function(){function p(f){var e=(new Promise(function(d,a){f.onload=d;f.onerror=a})).then(function(){e.s="l"},function(){e.s="e"});return e}for(var n=new Map,k=new Map,h,l,m=document,q=m.querySelectorAll("link[data-rprec]"),r=0;l=q[r++];)k.set(l.dataset.rprec,h=l);return function(f){for(var e=0,d,a,g,b,c;a=f[e++];)if(b=n.get(a))"l"!==b.s&&(d?d.push(b):d=[b]);else{c=m.createElement("link");c.href=a;c.rel="stylesheet";for(c.dataset.rprec=g=f[e++];b=f[e++];)c.setAttribute(b,f[e++]);b=p(c);n.set(a,b);d?d.push(b):d=[b];a=k.get(g)||h;a===h&&(h=c);k.set(g,c);a?a.parentNode.insertBefore(c,a.nextSibling):(g=m.head,g.insertBefore(c,g.firstChild))}return d}}()'; +// const styleInsertionFunctionLong = `$RR = (function() { +// const resourceMap = new Map(); +// const precedences = new Map(); +// // We omit rel stylesheet because only stylesheets should have data-prec attribute +// // and we can concievably use another kind of link to act as a placeholder for a +// // precedence that does not yet have any resources. +// let lastResource, node; +// let thisDocument = document; + +// // Seed the precedence list with existing resources +// let nodes = thisDocument.querySelectorAll('link[data-rprec]'); +// for (let i = 0;node = nodes[i++];) { +// precedences.set(node.dataset.rprec, lastResource = node); +// } + +// function getPromise(resourceEl) { +// let p = new Promise((re, rj) => { +// resourceEl.onload = re; +// resourceEl.onerror = rj; +// }).then(() => { +// p.s = 'l'; +// }, () => { +// p.s = 'e'; +// }); +// return p; +// } + +// return function insertStyle(styles) { +// let i = 0; +// let dependencies, href, precedence, attr, loadingState, resourceEl; + +// // styles is a flat array of strings delimited with the number zero. a resource is described by +// // one of the following sequences +// // , +// // , , 0 +// // , , , , ... , 0 +// // The last resource will omit the zero terminal value +// // The reason we can omit the zero when sending only an href is because we will find that +// // resource in the resourceMap and can infer the next element will be another href +// while (href = styles[i++]) { + +// // We check if this resource is already in our resourceMap and reuse it if so. +// // If it is already loaded we don't return it as a depenendency since there is nothing +// // to wait for +// loadingState = resourceMap.get(href); +// if (loadingState) { +// if (loadingState.s !== 'l') { +// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; +// } +// continue; +// } + +// // We construct our new resource element, looping over remaining attributes if any +// // setting them to the Element. +// resourceEl = thisDocument.createElement("link"); +// resourceEl.href = href; +// resourceEl.rel = 'stylesheet'; +// resourceEl.dataset.rprec = precedence = styles[i++]; +// while(attr = styles[i++]) { +// resourceEl.setAttribute(attr, styles[i++]); +// } + +// // We stash a pending promise in our map by href which will resolve or reject +// // when the underlying resource loads or errors. We add it to the dependencies +// // array to be returned. +// loadingState = getPromise(resourceEl); +// resourceMap.set(href, loadingState); +// dependencies ? dependencies.push(loadingState) : dependencies = [loadingState]; + +// // The prior style resource is the last one placed at a given +// // precedence or the last resource itself which may be null. +// // We grab this value and then update the last resource for this +// // precedence to be the inserted element, updating the lastResource +// // pointer if needed. +// let prior = precedences.get(precedence) || lastResource; +// if (prior === lastResource) { +// lastResource = resourceEl +// } +// precedences.set(precedence, resourceEl) + +// // Finally, we insert the newly constructed instance at an appropriate location +// // in the Document. +// if (prior) { +// prior.parentNode.insertBefore(resourceEl, prior.nextSibling); +// } else { +// let head = thisDocument.head; +// head.insertBefore(resourceEl, head.firstChild); +// } +// } +// return dependencies; +// } +// })()`; const completeBoundaryFunction = - 'function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}}'; + '$RC=function(){function h(e,f){var a=document.getElementById(e),c=document.getElementById(f);c.parentNode.removeChild(c);if(a){a=a.previousSibling;var k=a.parentNode,b=a.nextSibling,g=0;do{if(b&&8===b.nodeType){var d=b.data;if("/$"===d)if(0===g)break;else g--;else"$"!==d&&"$?"!==d&&"$!"!==d||g++}d=b.nextSibling;k.removeChild(b);b=d}while(b);for(;c.firstChild;)k.insertBefore(c.firstChild,b);a.data="$";a._reactRetry&&a._reactRetry()}}return function(e,f,a){if(a&&(a=$RR(a)))return Promise.all(a).then(h.bind(null,e,f))["catch"](function(c){return console.log("caught",c)});h(e,f)}}()'; +// const completeBoundaryFunctionLong = `$RC = (function() { +// function flipBoundary(suspenseBoundaryID, contentID) { +// // Find the fallback's first element. +// const suspenseIdNode = document.getElementById(suspenseBoundaryID); +// const contentNode = document.getElementById(contentID); +// // We'll detach the content node so that regardless of what happens next we don't leave in the tree. +// // This might also help by not causing recalcing each time we move a child from here to the target. +// contentNode.parentNode.removeChild(contentNode); +// if (!suspenseIdNode) { +// // The user must have already navigated away from this tree. +// // E.g. because the parent was hydrated. That's fine there's nothing to do +// // but we have to make sure that we already deleted the container node. +// return; +// } +// // Find the boundary around the fallback. This is always the previous node. +// const suspenseNode = suspenseIdNode.previousSibling; + +// // Clear all the existing children. This is complicated because +// // there can be embedded Suspense boundaries in the fallback. +// // This is similar to clearSuspenseBoundary in ReactDOMHostConfig. +// // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. +// // They never hydrate anyway. However, currently we support incrementally loading the fallback. +// const parentInstance = suspenseNode.parentNode; +// let node = suspenseNode.nextSibling; +// let depth = 0; +// do { +// if (node && node.nodeType === 8) { +// const data = node.data; +// if (data === '/$') { +// if (depth === 0) { +// break; +// } else { +// depth--; +// } +// } else if ( +// data === '$' || +// data === '$?' || +// data === '$!' +// ) { +// depth++; +// } +// } + +// const nextNode = node.nextSibling; +// parentInstance.removeChild(node); +// node = nextNode; +// } while (node); + +// const endOfBoundary = node; + +// // Insert all the children from the contentNode between the start and end of suspense boundary. +// while (contentNode.firstChild) { +// parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); +// } + +// suspenseNode.data = '$'; +// if (suspenseNode._reactRetry) { +// suspenseNode._reactRetry(); +// } +// } +// return function completeBoundary(suspenseBoundaryID, contentID, styleResources){ +// if (styleResources) { +// const p = $RR(styleResources); +// if (p) { +// return Promise.all(p).then(flipBoundary.bind(null, suspenseBoundaryID, contentID)).catch(e => console.log('caught', e)); +// } +// } +// flipBoundary(suspenseBoundaryID, contentID); +// } +// })()`; const clientRenderFunction = 'function $RX(b,c,d,e){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),b._reactRetry&&b._reactRetry())}'; @@ -2114,25 +2352,55 @@ export function writeCompletedSegmentInstruction( return writeChunkAndReturn(destination, completeSegmentScript3); } +const completeBoundaryScript1FullWithStyleInsertion = stringToPrecomputedChunk( + styleInsertionFunction + ';' + completeBoundaryFunction + ';$RC("', +); const completeBoundaryScript1Full = stringToPrecomputedChunk( completeBoundaryFunction + ';$RC("', ); +const completeBoundaryScript1PartialWithStyleInsertion = stringToPrecomputedChunk( + styleInsertionFunction + ';$RC("', +); const completeBoundaryScript1Partial = stringToPrecomputedChunk('$RC("'); const completeBoundaryScript2 = stringToPrecomputedChunk('","'); -const completeBoundaryScript3 = stringToPrecomputedChunk('")'); +const completeBoundaryScript3 = stringToPrecomputedChunk('"'); +const completeBoundaryScript4 = stringToPrecomputedChunk(')'); export function writeCompletedBoundaryInstruction( destination: Destination, responseState: ResponseState, boundaryID: SuspenseBoundaryID, contentSegmentID: number, + boundaryResources: BoundaryResources, ): boolean { + let styleResourcesChunk; + if (enableFloat) { + styleResourcesChunk = getBoundaryStyleDependencies(boundaryResources); + } writeChunk(destination, responseState.startInlineScript); if (!responseState.sentCompleteBoundaryFunction) { - // The first time we write this, we'll need to include the full implementation. - responseState.sentCompleteBoundaryFunction = true; - writeChunk(destination, completeBoundaryScript1Full); + if (enableFloat) { + responseState.sentCompleteBoundaryFunction = true; + if (styleResourcesChunk && !responseState.sentStyleInsertionFunction) { + responseState.sentStyleInsertionFunction = true; + writeChunk(destination, completeBoundaryScript1FullWithStyleInsertion); + } else { + writeChunk(destination, completeBoundaryScript1Full); + } + } else { + // The first time we write this, we'll need to include the full implementation. + responseState.sentCompleteBoundaryFunction = true; + writeChunk(destination, completeBoundaryScript1Full); + } } else { + if ( + enableFloat && + styleResourcesChunk && + !responseState.sentStyleInsertionFunction + ) { + responseState.sentStyleInsertionFunction = true; + writeChunk(destination, completeBoundaryScript1PartialWithStyleInsertion); + } // Future calls can just reuse the same function. writeChunk(destination, completeBoundaryScript1Partial); } @@ -2148,7 +2416,13 @@ export function writeCompletedBoundaryInstruction( writeChunk(destination, completeBoundaryScript2); writeChunk(destination, responseState.segmentPrefix); writeChunk(destination, formattedContentID); - return writeChunkAndReturn(destination, completeBoundaryScript3); + if (enableFloat && styleResourcesChunk) { + writeChunk(destination, stringToChunk('",')); + writeChunk(destination, styleResourcesChunk); + } else { + writeChunk(destination, completeBoundaryScript3); + } + return writeChunkAndReturn(destination, completeBoundaryScript4); } const clientRenderScript1Full = stringToPrecomputedChunk( @@ -2209,10 +2483,10 @@ export function writeClientRenderBoundaryInstruction( return writeChunkAndReturn(destination, clientRenderScript2); } -const regexForJSStringsInScripts = /[<\u2028\u2029]/g; +const regexForJSStringsInInstructionScripts = /[<\u2028\u2029]/g; function escapeJSStringsForInstructionScripts(input: string): string { const escaped = JSON.stringify(input); - return escaped.replace(regexForJSStringsInScripts, match => { + return escaped.replace(regexForJSStringsInInstructionScripts, match => { switch (match) { // santizing breaking out of strings and script tags case '<': @@ -2230,3 +2504,379 @@ function escapeJSStringsForInstructionScripts(input: string): string { } }); } + +const regexForJSStringsInScripts = /[&><\u2028\u2029]/g; +function escapeJSObjectForInstructionScripts(input: Object): string { + const escaped = JSON.stringify(input); + return escaped.replace(regexForJSStringsInScripts, match => { + switch (match) { + // santizing breaking out of strings and script tags + case '&': + return '\\u0026'; + case '>': + return '\\u003e'; + case '<': + return '\\u003c'; + case '\u2028': + return '\\u2028'; + case '\u2029': + return '\\u2029'; + default: { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'escapeJSObjectForInstructionScripts encountered a match it does not know how to replace. this means the match regex and the replacement characters are no longer in sync. This is a bug in React', + ); + } + } + }); +} + +export function writeInitialResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): boolean { + const explicitPreloadsTarget = []; + const remainingTarget = []; + + const {precedences, explicitPreloads, implicitPreloads} = resources; + let iter = precedences.values(); + + // Flush stylesheets first by earliest precedence + while (true) { + const {value: styleResources} = iter.next(); + if (!styleResources) break; + + const jter = styleResources.values(); + + while (true) { + const {value: resource} = jter.next(); + if (!resource) break; + + // resources should not already be flushed so we elide this check + pushLinkImpl(remainingTarget, resource.props, responseState); + resource.flushed = true; + resource.inShell = true; + resource.hint.flushed = true; + } + } + + iter = explicitPreloads.values(); + while (true) { + const {value: resource} = iter.next(); + if (!resource) break; + + if (!resource.flushed) { + pushLinkImpl(explicitPreloadsTarget, resource.props, responseState); + resource.flushed = true; + } + } + explicitPreloads.clear(); + + iter = implicitPreloads.values(); + while (true) { + const {value: resource} = iter.next(); + if (!resource) break; + + if (!resource.flushed) { + pushLinkImpl(remainingTarget, resource.props, responseState); + resource.flushed = true; + } + } + implicitPreloads.clear(); + + let i; + let r = true; + for (i = 0; i < explicitPreloadsTarget.length - 1; i++) { + writeChunk(destination, explicitPreloadsTarget[i]); + } + if (i < explicitPreloadsTarget.length) { + r = writeChunkAndReturn(destination, explicitPreloadsTarget[i]); + } + + for (i = 0; i < remainingTarget.length - 1; i++) { + writeChunk(destination, remainingTarget[i]); + } + if (i < remainingTarget.length) { + r = writeChunkAndReturn(destination, remainingTarget[i]); + } + return r; +} + +export function writeImmediateResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): boolean { + const {explicitPreloads, implicitPreloads} = resources; + const target = []; + let iter; + + iter = explicitPreloads.values(); + while (true) { + const {value: resource} = iter.next(); + if (!resource) break; + + if (!resource.flushed) { + pushLinkImpl(target, resource.props, responseState); + resource.flushed = true; + } + } + explicitPreloads.clear(); + + iter = implicitPreloads.values(); + while (true) { + const {value: resource} = iter.next(); + if (!resource) break; + + if (!resource.flushed) { + pushLinkImpl(target, resource.props, responseState); + resource.flushed = true; + } + } + implicitPreloads.clear(); + + let i = 0; + for (; i < target.length - 1; i++) { + writeChunk(destination, target[i]); + } + if (i < target.length) { + return writeChunkAndReturn(destination, target[i]); + } + return false; +} + +// For density the format of style dependencies is flattened into an array +// of strings. If the stylesheet was already flushed a single string will +// be inserted representing the `href`. If this is the first time a stylesheet +// has flushed this way then the `href` and `precedence` will be inserted +// followed by a pair of strings representing the attribute name / value pairs +// for any additional attributes. To avoid ambiguities a number will be inserted +// between each dependency +type StyleDescriptorList = Array; +function getBoundaryStyleDependencies( + boundaryResources: BoundaryResources, +): ?Chunk { + const descriptors = []; + const iter = boundaryResources.values(); + let lastPushedDescriptor = false; + while (true) { + const {value: resource} = iter.next(); + if (!resource) break; + + if (resource.inShell) { + // We can elide this dependency because it was flushed in the shell and + // should be ready before content is shown on the client + } else if (resource.flushed) { + if (lastPushedDescriptor) { + descriptors.push(0); + } + descriptors.push(resource.href); + lastPushedDescriptor = false; + } else { + if (lastPushedDescriptor) { + descriptors.push(0); + } + pushStyleResourceDescriptor( + descriptors, + resource.href, + resource.precedence, + resource.props, + ); + resource.flushed = true; + resource.hint.flushed = true; + lastPushedDescriptor = true; + } + } + return descriptors.length + ? stringToChunk(escapeJSObjectForInstructionScripts(descriptors)) + : null; +} + +function pushStyleResourceDescriptor( + target: StyleDescriptorList, + href: string, + precedence: string, + props: Object, +): ReactNodeList { + if (__DEV__) { + checkAttributeStringCoercion(href, 'href'); + } + const coercedHref = '' + (href: any); + sanitizeURL(coercedHref); + if (__DEV__) { + checkAttributeStringCoercion(precedence, 'precedence'); + } + const coercedPrecedence = '' + (precedence: any); + target.push(coercedHref, coercedPrecedence); + + for (const propKey in props) { + if (hasOwnProperty.call(props, propKey)) { + const propValue = props[propKey]; + if (propValue == null) { + continue; + } + switch (propKey) { + case 'href': + case 'rel': + case 'precedence': + case 'data-rprec': { + break; + } + case 'children': + case 'dangerouslySetInnerHTML': + throw new Error( + `${'link'} is a self-closing tag and must neither have \`children\` nor ` + + 'use `dangerouslySetInnerHTML`.', + ); + // eslint-disable-next-line-no-fallthrough + default: + pushStyleResourcesAttribute(target, propKey, propValue); + break; + } + } + } + return null; +} + +function pushStyleResourcesAttribute( + target: StyleDescriptorList, + name: string, + value: string | boolean | number | Function | Object, // not null or undefined +): void { + let attributeName = name.toLowerCase(); + let attributeValue; + switch (typeof value) { + case 'function': + case 'symbol': + return; + } + + if ( + // shouldIgnoreAttribute + // We have already filtered out null/undefined and reserved words. + name.length > 2 && + (name[0] === 'o' || name[0] === 'O') && + (name[1] === 'n' || name[1] === 'N') + ) { + return; + } + + switch (name) { + // Reserved names + case 'defaultValue': // These shouldn't be set as attributes on generic HTML elements. + case 'defaultChecked': + case 'innerHTML': // Must use dangerouslySetInnerHTML instead. + case 'suppressContentEditableWarning': + case 'suppressHydrationWarning': + case 'style': + // Ignored. These are built-in to React on the client. + return; + + // Attribute renames + case 'acceptCharset': + attributeName = 'accept-charset'; + break; + case 'className': + attributeName = 'class'; + break; + case 'htmlFor': + attributeName = 'for'; + break; + case 'httpEquiv': + attributeName = 'http-equiv'; + break; + + // Booleans + case 'allowFullScreen': + case 'async': + case 'autoFocus': + case 'autoPlay': + case 'controls': + case 'default': + case 'defer': + case 'disabled': + case 'disablePictureInPicture': + case 'disableRemotePlayback': + case 'formNoValidate': + case 'hidden': + case 'loop': + case 'noModule': + case 'noValidate': + case 'open': + case 'playsInline': + case 'readOnly': + case 'required': + case 'reversed': + case 'scoped': + case 'seamless': + case 'itemScope': + case 'checked': + case 'multiple': + case 'muted': + case 'selected': + if (value === false) { + return; + } + attributeValue = ''; + break; + + // Overloaded Booleans + case 'capture': + case 'download': + if (value === false) { + return; + } else if (value === true) { + attributeValue = ''; + } + break; + + // Positive Numbers + case 'cols': + case 'rows': + case 'size': + case 'span': { + if (isNaN(value) || (value: any) < 1) { + return; + } + if (__DEV__) { + checkAttributeStringCoercion(value, name); + } + attributeValue = '' + (value: any); + break; + } + + // Numbers + case 'rowSpan': + case 'start': + if (isNaN(value)) return; + break; + + // Santized URLs + case 'src': + case 'href': + case 'action': + case 'formAction': { + if (__DEV__) { + checkAttributeStringCoercion(value, attributeName); + } + attributeValue = '' + (value: any); + sanitizeURL(attributeValue); + break; + } + } + + if (__DEV__) { + checkAttributeStringCoercion(value, attributeName); + } + attributeValue = '' + (value: any); + target.push(attributeName, attributeValue); +} + +export function prepareToRender(resources: Resources) { + prepareToRenderImpl(resources); +} + +export function cleanupAfterRender() { + cleanupAfterRenderImpl(); +} diff --git a/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js index 375562e80b56d..dcbe47730159e 100644 --- a/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js @@ -40,6 +40,7 @@ export type ResponseState = { sentCompleteSegmentFunction: boolean, sentCompleteBoundaryFunction: boolean, sentClientRenderFunction: boolean, + sentStyleInsertionFunction: boolean, // This is an extra field for the legacy renderer generateStaticMarkup: boolean, }; @@ -61,6 +62,7 @@ export function createResponseState( sentCompleteSegmentFunction: responseState.sentCompleteSegmentFunction, sentCompleteBoundaryFunction: responseState.sentCompleteBoundaryFunction, sentClientRenderFunction: responseState.sentClientRenderFunction, + sentStyleInsertionFunction: responseState.sentStyleInsertionFunction, // This is an extra field for the legacy renderer generateStaticMarkup, }; @@ -70,10 +72,13 @@ export function createRootFormatContext(): FormatContext { return { insertionMode: HTML_MODE, // We skip the root mode because we don't want to emit the DOCTYPE in legacy mode. selectedValue: null, + preambleOpen: true, }; } export type { + Resources, + BoundaryResources, FormatContext, SuspenseBoundaryID, } from './ReactDOMServerFormatConfig'; @@ -96,6 +101,15 @@ export { writeEndPendingSuspenseBoundary, writePlaceholder, writeCompletedRoot, + createResources, + createBoundaryResources, + writeInitialResources, + writeImmediateResources, + hoistResources, + hoistResourcesToRoot, + setBoundaryResources, + prepareToRender, + cleanupAfterRender, } from './ReactDOMServerFormatConfig'; import {stringToChunk} from 'react-server/src/ReactServerStreamConfig'; diff --git a/packages/react-dom/src/shared/ReactDOMDispatcher.js b/packages/react-dom/src/shared/ReactDOMDispatcher.js new file mode 100644 index 0000000000000..d072df60cca0d --- /dev/null +++ b/packages/react-dom/src/shared/ReactDOMDispatcher.js @@ -0,0 +1,25 @@ +/** + * 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. + * + * @flow + */ + +const Dispatcher = { + current: null, +}; + +const stack = []; + +function pushDispatcher(dispatcher: any) { + stack.push(Dispatcher.current); + Dispatcher.current = dispatcher; +} + +function popDispatcher() { + Dispatcher.current = stack.pop(); +} + +export {pushDispatcher, popDispatcher, Dispatcher}; diff --git a/packages/react-dom/src/shared/ReactDOMFloat.js b/packages/react-dom/src/shared/ReactDOMFloat.js new file mode 100644 index 0000000000000..b397df2729c64 --- /dev/null +++ b/packages/react-dom/src/shared/ReactDOMFloat.js @@ -0,0 +1,23 @@ +import {__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED as Internals} from '../client/ReactDOM'; + +export function preinit() { + const dispatcher = Internals.Dispatcher.current; + if (dispatcher) { + dispatcher.preinit.apply(this, arguments); + } else { + throw new Error( + `ReactDOM.preinit was when React did not expect it. If you called this function outside of a Component render, such as in an effect or event handler, refactor your code to call it while rendering. If it was called while React was rendering this is a bug in React.`, + ); + } +} + +export function preload() { + const dispatcher = Internals.Dispatcher.current; + if (dispatcher) { + dispatcher.preload.apply(this, arguments); + } else { + throw new Error( + `ReactDOM.preload was when React did not expect it. If you called this function outside of a Component render, such as in an effect or event handler, refactor your code to call it while rendering. If it was called while React was rendering this is a bug in React.`, + ); + } +} diff --git a/packages/react-dom/src/test-utils/ReactTestUtils.js b/packages/react-dom/src/test-utils/ReactTestUtils.js index 2c89baf398a90..afdc62f371725 100644 --- a/packages/react-dom/src/test-utils/ReactTestUtils.js +++ b/packages/react-dom/src/test-utils/ReactTestUtils.js @@ -13,6 +13,7 @@ import { ClassComponent, FunctionComponent, HostComponent, + HostResource, HostText, } from 'react-reconciler/src/ReactWorkTags'; import {SyntheticEvent} from '../events/SyntheticEvent'; @@ -21,6 +22,7 @@ import { rethrowCaughtError, invokeGuardedCallbackAndCatchFirstError, } from 'shared/ReactErrorUtils'; +import {enableFloat} from 'shared/ReactFeatureFlags'; import assign from 'shared/assign'; import isArray from 'shared/isArray'; @@ -59,7 +61,8 @@ function findAllInRenderedFiberTreeInternal(fiber, test) { node.tag === HostComponent || node.tag === HostText || node.tag === ClassComponent || - node.tag === FunctionComponent + node.tag === FunctionComponent || + (enableFloat ? node.tag === HostResource : false) ) { const publicInst = node.stateNode; if (test(publicInst)) { diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 3c66e0ad8cd0b..9b591ccf94baf 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -323,6 +323,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: Instance, diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index c26c5c389c1e9..8728f4daf5614 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -89,6 +89,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: Instance, diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index 92685d4f85bef..c18ca62379521 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -57,6 +57,9 @@ SUSPENSE_UPDATE_TO_COMPLETE[0] = SUSPENSE_UPDATE_TO_COMPLETE_TAG; const SUSPENSE_UPDATE_TO_CLIENT_RENDER = new Uint8Array(1); SUSPENSE_UPDATE_TO_CLIENT_RENDER[0] = SUSPENSE_UPDATE_TO_CLIENT_RENDER_TAG; +export type Resources = void; +export type BoundaryResources = void; + // Per response, export type ResponseState = { nextSuspenseID: number, @@ -142,6 +145,7 @@ export function pushStartInstance( props: Object, responseState: ResponseState, formatContext: FormatContext, + textEmbedded: boolean, ): ReactNodeList { target.push( INSTANCE, @@ -291,6 +295,7 @@ export function writeCompletedBoundaryInstruction( responseState: ResponseState, boundaryID: SuspenseBoundaryID, contentSegmentID: number, + resources: BoundaryResources, ): boolean { writeChunk(destination, SUSPENSE_UPDATE_TO_COMPLETE); writeChunk(destination, formatID(boundaryID)); @@ -309,3 +314,38 @@ export function writeClientRenderBoundaryInstruction( writeChunk(destination, SUSPENSE_UPDATE_TO_CLIENT_RENDER); return writeChunkAndReturn(destination, formatID(boundaryID)); } + +export function writeInitialResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): boolean { + return true; +} + +export function writeImmediateResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): boolean { + return true; +} + +export function hoistResources( + resources: Resources, + boundaryResources: BoundaryResources, +) {} + +export function hoistResourcesToRoot( + resources: Resources, + boundaryResources: BoundaryResources, +) {} + +export function prepareToRender(resources: Resources) {} +export function cleanupAfterRender() {} +export function createResources() {} +export function createBoundaryResources() {} +export function setBoundaryResources( + resources: Resources, + boundaryResources: ?BoundaryResources, +) {} diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 7e0a33b97942b..8200c317f3bb1 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -51,6 +51,9 @@ type Destination = { stack: Array, }; +type Resources = null; +type BoundaryResources = null; + const POP = Buffer.from('/', 'utf8'); function write(destination: Destination, buffer: Uint8Array): void { @@ -263,6 +266,22 @@ const ReactNoopServer = ReactFizzServer({ ): boolean { boundary.status = 'client-render'; }, + + writeInitialResources() {}, + writeImmediateResources() {}, + + createResources(): Resources { + return null; + }, + + createBoundaryResources(): BoundaryResources { + return null; + }, + + setBoundaryResources(resources: BoundaryResources) {}, + + prepareToRender() {}, + cleanupAfterRender() {}, }); type Options = { diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 66919fc89239d..7665b5d06dfba 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -478,6 +478,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { const endTime = Scheduler.unstable_now(); callback(endTime); }, + prepareToRender() {}, + cleanupAfterRender() {}, }; const hostConfig = useMutation diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index 484f954a6f4df..7dc5a4d0ff26b 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -21,6 +21,7 @@ import type { } from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new'; +import {supportsResources, isHostResourceType} from './ReactFiberHostConfig'; import { createRootStrictEffectsByDefault, enableCache, @@ -32,6 +33,7 @@ import { allowConcurrentByDefault, enableTransitionTracing, enableDebugTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import {NoFlags, Placement, StaticMask} from './ReactFiberFlags'; import {ConcurrentRoot} from './ReactRootTags'; @@ -42,6 +44,7 @@ import { HostComponent, HostText, HostPortal, + HostResource, ForwardRef, Fragment, Mode, @@ -494,7 +497,13 @@ export function createFiberFromTypeAndProps( } } } else if (typeof type === 'string') { - fiberTag = HostComponent; + if (enableFloat && supportsResources) { + fiberTag = isHostResourceType(type, pendingProps) + ? HostResource + : HostComponent; + } else { + fiberTag = HostComponent; + } } else { getTag: switch (type) { case REACT_FRAGMENT_TYPE: diff --git a/packages/react-reconciler/src/ReactFiber.old.js b/packages/react-reconciler/src/ReactFiber.old.js index 60d52c2d0b827..cab5f0e5502c4 100644 --- a/packages/react-reconciler/src/ReactFiber.old.js +++ b/packages/react-reconciler/src/ReactFiber.old.js @@ -21,6 +21,7 @@ import type { } from './ReactFiberOffscreenComponent'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old'; +import {supportsResources, isHostResourceType} from './ReactFiberHostConfig'; import { createRootStrictEffectsByDefault, enableCache, @@ -32,6 +33,7 @@ import { allowConcurrentByDefault, enableTransitionTracing, enableDebugTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import {NoFlags, Placement, StaticMask} from './ReactFiberFlags'; import {ConcurrentRoot} from './ReactRootTags'; @@ -42,6 +44,7 @@ import { HostComponent, HostText, HostPortal, + HostResource, ForwardRef, Fragment, Mode, @@ -494,7 +497,13 @@ export function createFiberFromTypeAndProps( } } } else if (typeof type === 'string') { - fiberTag = HostComponent; + if (enableFloat && supportsResources) { + fiberTag = isHostResourceType(type, pendingProps) + ? HostResource + : HostComponent; + } else { + fiberTag = HostComponent; + } } else { getTag: switch (type) { case REACT_FRAGMENT_TYPE: diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 913a054be2dd2..fb68a744f1c05 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -40,6 +40,7 @@ import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.new import { enableCPUSuspense, enableUseMutableSource, + enableFloat, } from 'shared/ReactFeatureFlags'; import checkPropTypes from 'shared/checkPropTypes'; @@ -54,6 +55,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, ForwardRef, @@ -162,10 +164,15 @@ import { registerSuspenseInstanceRetry, supportsHydration, isPrimaryRenderer, + getResource, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldError, shouldSuspend} from './ReactFiberReconciler'; -import {pushHostContext, pushHostContainer} from './ReactFiberHostContext.new'; +import { + pushHostContext, + pushHostContainer, + getRootHostContainer, +} from './ReactFiberHostContext.new'; import { suspenseStackCursor, pushSuspenseListContext, @@ -1578,6 +1585,24 @@ function updateHostComponent( return workInProgress.child; } +function updateHostResource(current, workInProgress, renderLanes) { + pushHostContext(workInProgress); + markRef(current, workInProgress); + const rootContainerInstance = getRootHostContainer(); + workInProgress.memoizedState = getResource( + workInProgress.type, + workInProgress.pendingProps, + rootContainerInstance, + ); + reconcileChildren( + current, + workInProgress, + workInProgress.pendingProps.children, + renderLanes, + ); + return workInProgress.child; +} + function updateHostText(current, workInProgress) { if (current === null) { tryToClaimNextHydratableInstance(workInProgress); @@ -3648,6 +3673,7 @@ function attemptEarlyBailoutIfNoScheduledUpdate( } resetHydrationState(); break; + case HostResource: case HostComponent: pushHostContext(workInProgress); break; @@ -3982,6 +4008,11 @@ function beginWork( } case HostRoot: return updateHostRoot(current, workInProgress, renderLanes); + case HostResource: + if (enableFloat) { + return updateHostResource(current, workInProgress, renderLanes); + } + // eslint-disable-next-line no-fallthrough case HostComponent: return updateHostComponent(current, workInProgress, renderLanes); case HostText: diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 382e9075716af..3074d6fd54006 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -40,6 +40,7 @@ import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent.old import { enableCPUSuspense, enableUseMutableSource, + enableFloat, } from 'shared/ReactFeatureFlags'; import checkPropTypes from 'shared/checkPropTypes'; @@ -54,6 +55,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, ForwardRef, @@ -162,10 +164,15 @@ import { registerSuspenseInstanceRetry, supportsHydration, isPrimaryRenderer, + getResource, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldError, shouldSuspend} from './ReactFiberReconciler'; -import {pushHostContext, pushHostContainer} from './ReactFiberHostContext.old'; +import { + pushHostContext, + pushHostContainer, + getRootHostContainer, +} from './ReactFiberHostContext.old'; import { suspenseStackCursor, pushSuspenseListContext, @@ -1578,6 +1585,24 @@ function updateHostComponent( return workInProgress.child; } +function updateHostResource(current, workInProgress, renderLanes) { + pushHostContext(workInProgress); + markRef(current, workInProgress); + const rootContainerInstance = getRootHostContainer(); + workInProgress.memoizedState = getResource( + workInProgress.type, + workInProgress.pendingProps, + rootContainerInstance, + ); + reconcileChildren( + current, + workInProgress, + workInProgress.pendingProps.children, + renderLanes, + ); + return workInProgress.child; +} + function updateHostText(current, workInProgress) { if (current === null) { tryToClaimNextHydratableInstance(workInProgress); @@ -3648,6 +3673,7 @@ function attemptEarlyBailoutIfNoScheduledUpdate( } resetHydrationState(); break; + case HostResource: case HostComponent: pushHostContext(workInProgress); break; @@ -3982,6 +4008,11 @@ function beginWork( } case HostRoot: return updateHostRoot(current, workInProgress, renderLanes); + case HostResource: + if (enableFloat) { + return updateHostResource(current, workInProgress, renderLanes); + } + // eslint-disable-next-line no-fallthrough case HostComponent: return updateHostComponent(current, workInProgress, renderLanes); case HostText: diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 825652939f5e4..1785ddbbe0bbe 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -48,7 +48,11 @@ import { enableUpdaterTracking, enableCache, enableTransitionTracing, +<<<<<<< HEAD enableUseEventHook, +======= + enableFloat, +>>>>>>> a958de177 ([Fizz/Float] Float for stylesheet resources) } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -56,6 +60,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, Profiler, @@ -71,7 +76,6 @@ import { CacheComponent, TracingMarkerComponent, } from './ReactWorkTags'; -import {detachDeletedInstance} from './ReactFiberHostConfig'; import { NoFlags, ContentReset, @@ -115,6 +119,7 @@ import { supportsMutation, supportsPersistence, supportsHydration, + supportsResources, commitMount, commitUpdate, resetTextContent, @@ -139,6 +144,9 @@ import { prepareScopeUpdate, prepareForCommit, beforeActiveInstanceBlur, + detachDeletedInstance, + acquireResource, + releaseResource, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -490,6 +498,7 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { clearContainer(root.containerInfo); } } +<<<<<<< HEAD break; } case HostComponent: @@ -500,6 +509,16 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { break; default: { if ((flags & Snapshot) !== NoFlags) { +======= + case HostComponent: + case HostResource: + case HostText: + case HostPortal: + case IncompleteClassComponent: + // Nothing to do for these component types + break; + default: { +>>>>>>> a958de177 ([Fizz/Float] Float for stylesheet resources) throw new Error( 'This unit of work tag should not have side-effects. This error is ' + 'likely caused by a bug in React. Please file an issue.', @@ -1049,6 +1068,21 @@ function commitLayoutEffectOnFiber( } break; } + case HostResource: { + if (enableFloat && supportsResources) { + recursivelyTraverseLayoutEffects( + finishedRoot, + finishedWork, + committedLanes, + ); + + if (flags & Ref) { + safelyAttachRef(finishedWork, finishedWork.return); + } + break; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { recursivelyTraverseLayoutEffects( finishedRoot, @@ -1427,7 +1461,10 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { // children to find all the terminal nodes. let node: Fiber = finishedWork; while (true) { - if (node.tag === HostComponent) { + if ( + node.tag === HostComponent || + (enableFloat ? node.tag === HostResource : false) + ) { if (hostSubtreeRoot === null) { hostSubtreeRoot = node; try { @@ -1499,6 +1536,7 @@ function commitAttachRef(finishedWork: Fiber) { const instance = finishedWork.stateNode; let instanceToUse; switch (finishedWork.tag) { + case HostResource: case HostComponent: instanceToUse = getPublicInstance(instance); break; @@ -1703,7 +1741,8 @@ function isHostParent(fiber: Fiber): boolean { return ( fiber.tag === HostComponent || fiber.tag === HostRoot || - fiber.tag === HostPortal + fiber.tag === HostPortal || + (enableFloat ? fiber.tag === HostResource : false) ); } @@ -1950,6 +1989,23 @@ function commitDeletionEffectsOnFiber( // into their subtree. There are simpler cases in the inner switch // that don't modify the stack. switch (deletedFiber.tag) { + case HostResource: { + if (enableFloat && supportsResources) { + if (!offscreenSubtreeWasHidden) { + safelyDetachRef(deletedFiber, nearestMountedAncestor); + } + + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber, + ); + + releaseResource(deletedFiber.memoizedState); + return; + } + } + // eslint-disable-next-line no-fallthrough case HostComponent: { if (!offscreenSubtreeWasHidden) { safelyDetachRef(deletedFiber, nearestMountedAncestor); @@ -2445,6 +2501,31 @@ function commitMutationEffectsOnFiber( } return; } + case HostResource: { + if (enableFloat && supportsResources) { + recursivelyTraverseMutationEffects(root, finishedWork, lanes); + commitReconciliationEffects(finishedWork); + + if (flags & Ref) { + if (current !== null) { + safelyDetachRef(current, current.return); + } + } + + if (flags & Update) { + const newResource = finishedWork.memoizedState; + if (current !== null) { + const currentResource = current.memoizedState; + if (currentResource !== newResource) { + releaseResource(currentResource); + } + } + finishedWork.stateNode = acquireResource(newResource); + } + return; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); @@ -2830,6 +2911,7 @@ export function disappearLayoutEffects(finishedWork: Fiber) { recursivelyTraverseDisappearLayoutEffects(finishedWork); break; } + case HostResource: case HostComponent: { // TODO (Offscreen) Check: flags & RefStatic safelyDetachRef(finishedWork, finishedWork.return); @@ -2928,6 +3010,7 @@ export function reappearLayoutEffects( // case HostRoot: { // ... // } + case HostResource: case HostComponent: { recursivelyTraverseReappearLayoutEffects( finishedRoot, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 57233404d6ff9..d7396305e3182 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -49,6 +49,7 @@ import { enableCache, enableTransitionTracing, enableUseEventHook, + enableFloat, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -56,6 +57,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, Profiler, @@ -71,7 +73,6 @@ import { CacheComponent, TracingMarkerComponent, } from './ReactWorkTags'; -import {detachDeletedInstance} from './ReactFiberHostConfig'; import { NoFlags, ContentReset, @@ -115,6 +116,7 @@ import { supportsMutation, supportsPersistence, supportsHydration, + supportsResources, commitMount, commitUpdate, resetTextContent, @@ -139,6 +141,9 @@ import { prepareScopeUpdate, prepareForCommit, beforeActiveInstanceBlur, + detachDeletedInstance, + acquireResource, + releaseResource, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -493,6 +498,7 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) { break; } case HostComponent: + case HostResource: case HostText: case HostPortal: case IncompleteClassComponent: @@ -1049,6 +1055,21 @@ function commitLayoutEffectOnFiber( } break; } + case HostResource: { + if (enableFloat && supportsResources) { + recursivelyTraverseLayoutEffects( + finishedRoot, + finishedWork, + committedLanes, + ); + + if (flags & Ref) { + safelyAttachRef(finishedWork, finishedWork.return); + } + break; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { recursivelyTraverseLayoutEffects( finishedRoot, @@ -1427,7 +1448,10 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { // children to find all the terminal nodes. let node: Fiber = finishedWork; while (true) { - if (node.tag === HostComponent) { + if ( + node.tag === HostComponent || + (enableFloat && supportsResources ? node.tag === HostResource : false) + ) { if (hostSubtreeRoot === null) { hostSubtreeRoot = node; try { @@ -1499,6 +1523,7 @@ function commitAttachRef(finishedWork: Fiber) { const instance = finishedWork.stateNode; let instanceToUse; switch (finishedWork.tag) { + case HostResource: case HostComponent: instanceToUse = getPublicInstance(instance); break; @@ -1703,7 +1728,8 @@ function isHostParent(fiber: Fiber): boolean { return ( fiber.tag === HostComponent || fiber.tag === HostRoot || - fiber.tag === HostPortal + fiber.tag === HostPortal || + (enableFloat && supportsResources ? fiber.tag === HostResource : false) ); } @@ -1950,6 +1976,23 @@ function commitDeletionEffectsOnFiber( // into their subtree. There are simpler cases in the inner switch // that don't modify the stack. switch (deletedFiber.tag) { + case HostResource: { + if (enableFloat && supportsResources) { + if (!offscreenSubtreeWasHidden) { + safelyDetachRef(deletedFiber, nearestMountedAncestor); + } + + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber, + ); + + releaseResource(deletedFiber.memoizedState); + return; + } + } + // eslint-disable-next-line no-fallthrough case HostComponent: { if (!offscreenSubtreeWasHidden) { safelyDetachRef(deletedFiber, nearestMountedAncestor); @@ -2445,6 +2488,31 @@ function commitMutationEffectsOnFiber( } return; } + case HostResource: { + if (enableFloat && supportsResources) { + recursivelyTraverseMutationEffects(root, finishedWork, lanes); + commitReconciliationEffects(finishedWork); + + if (flags & Ref) { + if (current !== null) { + safelyDetachRef(current, current.return); + } + } + + if (flags & Update) { + const newResource = finishedWork.memoizedState; + if (current !== null) { + const currentResource = current.memoizedState; + if (currentResource !== newResource) { + releaseResource(currentResource); + } + } + finishedWork.stateNode = acquireResource(newResource); + } + return; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); @@ -2830,6 +2898,7 @@ export function disappearLayoutEffects(finishedWork: Fiber) { recursivelyTraverseDisappearLayoutEffects(finishedWork); break; } + case HostResource: case HostComponent: { // TODO (Offscreen) Check: flags & RefStatic safelyDetachRef(finishedWork, finishedWork.return); @@ -2928,6 +2997,7 @@ export function reappearLayoutEffects( // case HostRoot: { // ... // } + case HostResource: case HostComponent: { recursivelyTraverseReappearLayoutEffects( finishedRoot, diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 592bef4fbd25d..eec23dfe83538 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -45,6 +45,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, ContextProvider, @@ -92,6 +93,7 @@ import { prepareUpdate, supportsMutation, supportsPersistence, + supportsResources, cloneInstance, cloneHiddenInstance, cloneHiddenTextInstance, @@ -144,6 +146,7 @@ import { enableProfilerTimer, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import { renderDidSuspend, @@ -954,6 +957,26 @@ function completeWork( } return null; } + case HostResource: { + if (enableFloat && supportsResources) { + popHostContext(workInProgress); + const currentRef = current ? current.ref : null; + if (currentRef !== workInProgress.ref) { + markRef(workInProgress); + } + if ( + current === null || + current.memoizedState !== workInProgress.memoizedState + ) { + // The workInProgress resource is different than the current one or the current + // one does not exist + markUpdate(workInProgress); + } + bubbleProperties(workInProgress); + return null; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { popHostContext(workInProgress); const type = workInProgress.type; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index f3ba7bf0799a0..10be55575c530 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -45,6 +45,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostText, HostPortal, ContextProvider, @@ -92,6 +93,7 @@ import { prepareUpdate, supportsMutation, supportsPersistence, + supportsResources, cloneInstance, cloneHiddenInstance, cloneHiddenTextInstance, @@ -144,6 +146,7 @@ import { enableProfilerTimer, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import { renderDidSuspend, @@ -954,6 +957,26 @@ function completeWork( } return null; } + case HostResource: { + if (enableFloat && supportsResources) { + popHostContext(workInProgress); + const currentRef = current ? current.ref : null; + if (currentRef !== workInProgress.ref) { + markRef(workInProgress); + } + if ( + current === null || + current.memoizedState !== workInProgress.memoizedState + ) { + // The workInProgress resource is different than the current one or the current + // one does not exist + markUpdate(workInProgress); + } + bubbleProperties(workInProgress); + return null; + } + } + // eslint-disable-next-line-no-fallthrough case HostComponent: { popHostContext(workInProgress); const type = workInProgress.type; diff --git a/packages/react-reconciler/src/ReactFiberComponentStack.js b/packages/react-reconciler/src/ReactFiberComponentStack.js index 43ad9fd3b542b..3821758c67a47 100644 --- a/packages/react-reconciler/src/ReactFiberComponentStack.js +++ b/packages/react-reconciler/src/ReactFiberComponentStack.js @@ -11,6 +11,7 @@ import type {Fiber} from './ReactInternalTypes'; import { HostComponent, + HostResource, LazyComponent, SuspenseComponent, SuspenseListComponent, @@ -34,6 +35,7 @@ function describeFiber(fiber: Fiber): string { : null; const source = __DEV__ ? fiber._debugSource : null; switch (fiber.tag) { + case HostResource: case HostComponent: return describeBuiltInComponentFrame(fiber.type, source, owner); case LazyComponent: diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js index 9eba0aad9dc21..f61c0eb7789e7 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js @@ -24,12 +24,10 @@ export const supportsHydration = false; export const canHydrateInstance = shim; export const canHydrateTextInstance = shim; export const canHydrateSuspenseInstance = shim; -export const isHydratableResource = shim; export const isSuspenseInstancePending = shim; export const isSuspenseInstanceFallback = shim; export const getSuspenseInstanceFallbackErrorDetails = shim; export const registerSuspenseInstanceRetry = shim; -export const getMatchingResourceInstance = shim; export const getNextHydratableSibling = shim; export const getFirstHydratableChild = shim; export const getFirstHydratableChildWithinContainer = shim; diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js new file mode 100644 index 0000000000000..4f25e12da71df --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js @@ -0,0 +1,29 @@ +/** + * 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. + * + * @flow + */ + +// Renderers that don't support hydration +// can re-export everything from this module. + +function shim(...args: any) { + throw new Error( + 'The current renderer does not support resources. ' + + 'This error is likely caused by a bug in React. ' + + 'Please file an issue.', + ); +} + +// Resources (when unsupported) +export const supportsResources = false; +export const isHostResourceInstance = shim; +export const isHostResourceType = shim; +export const getResource = shim; +export const acquireResource = shim; +export const releaseResource = shim; +export const prepareToRender = shim; +export const cleanupAfterRender = shim; diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.new.js b/packages/react-reconciler/src/ReactFiberHotReloading.new.js index 4f289fe4195e4..96eefa62f7905 100644 --- a/packages/react-reconciler/src/ReactFiberHotReloading.new.js +++ b/packages/react-reconciler/src/ReactFiberHotReloading.new.js @@ -37,6 +37,7 @@ import { FunctionComponent, ForwardRef, HostComponent, + HostResource, HostPortal, HostRoot, MemoComponent, @@ -47,6 +48,7 @@ import { REACT_MEMO_TYPE, REACT_LAZY_TYPE, } from 'shared/ReactSymbols'; +import {enableFloat} from 'shared/ReactFeatureFlags'; let resolveFamily: RefreshHandler | null = null; // $FlowFixMe Flow gets confused by a WeakSet feature check below. @@ -449,7 +451,10 @@ function findChildHostInstancesForFiberShallowly( let node: Fiber = fiber; let foundHostInstances = false; while (true) { - if (node.tag === HostComponent) { + if ( + node.tag === HostComponent || + (enableFloat ? node.tag === HostResource : false) + ) { // We got a match. foundHostInstances = true; hostInstances.add(node.stateNode); diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.old.js b/packages/react-reconciler/src/ReactFiberHotReloading.old.js index 55f0f94cf8a44..5b08bdf35f311 100644 --- a/packages/react-reconciler/src/ReactFiberHotReloading.old.js +++ b/packages/react-reconciler/src/ReactFiberHotReloading.old.js @@ -37,6 +37,7 @@ import { FunctionComponent, ForwardRef, HostComponent, + HostResource, HostPortal, HostRoot, MemoComponent, @@ -47,6 +48,7 @@ import { REACT_MEMO_TYPE, REACT_LAZY_TYPE, } from 'shared/ReactSymbols'; +import {enableFloat} from 'shared/ReactFeatureFlags'; let resolveFamily: RefreshHandler | null = null; // $FlowFixMe Flow gets confused by a WeakSet feature check below. @@ -449,7 +451,10 @@ function findChildHostInstancesForFiberShallowly( let node: Fiber = fiber; let foundHostInstances = false; while (true) { - if (node.tag === HostComponent) { + if ( + node.tag === HostComponent || + (enableFloat ? node.tag === HostResource : false) + ) { // We got a match. foundHostInstances = true; hostInstances.add(node.stateNode); diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 66c28274ae0cf..82df8620e0fcb 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -34,7 +34,6 @@ import { NoFlags, DidCapture, } from './ReactFiberFlags'; -import {enableFloat} from 'shared/ReactFeatureFlags'; import { createFiberFromHostInstanceForDeletion, @@ -46,9 +45,7 @@ import { canHydrateInstance, canHydrateTextInstance, canHydrateSuspenseInstance, - isHydratableResource, getNextHydratableSibling, - getMatchingResourceInstance, getFirstHydratableChild, getFirstHydratableChildWithinContainer, getFirstHydratableChildWithinSuspenseInstance, @@ -78,7 +75,6 @@ import { restoreSuspendedTreeContext, } from './ReactFiberTreeContext.new'; import {queueRecoverableErrors} from './ReactFiberWorkLoop.new'; -import {getRootHostContainer} from './ReactFiberHostContext.new'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -408,19 +404,6 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } - if (enableFloat) { - if ( - fiber.tag === HostComponent && - isHydratableResource(fiber.type, fiber.pendingProps) - ) { - fiber.stateNode = getMatchingResourceInstance( - fiber.type, - fiber.pendingProps, - getRootHostContainer(), - ); - return; - } - } let nextInstance = nextHydratableInstance; if (!nextInstance) { if (shouldClientRenderOnMismatch(fiber)) { @@ -613,30 +596,6 @@ function popHydrationState(fiber: Fiber): boolean { if (!supportsHydration) { return false; } - if ( - enableFloat && - isHydrating && - isHydratableResource(fiber.type, fiber.memoizedProps) - ) { - if (fiber.stateNode === null) { - if (__DEV__) { - const rel = fiber.memoizedProps.rel - ? `rel="${fiber.memoizedProps.rel}" ` - : ''; - const href = fiber.memoizedProps.href - ? `href="${fiber.memoizedProps.href}"` - : ''; - console.error( - 'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.', - fiber.type, - rel, - href, - ); - } - throwOnHydrationMismatch(fiber); - } - return true; - } if (fiber !== hydrationParentFiber) { // We're deeper than the current hydration context, inside an inserted // tree. diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 10e59b1d2bf0a..3f6ade7832a59 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -34,7 +34,6 @@ import { NoFlags, DidCapture, } from './ReactFiberFlags'; -import {enableFloat} from 'shared/ReactFeatureFlags'; import { createFiberFromHostInstanceForDeletion, @@ -46,9 +45,7 @@ import { canHydrateInstance, canHydrateTextInstance, canHydrateSuspenseInstance, - isHydratableResource, getNextHydratableSibling, - getMatchingResourceInstance, getFirstHydratableChild, getFirstHydratableChildWithinContainer, getFirstHydratableChildWithinSuspenseInstance, @@ -78,7 +75,6 @@ import { restoreSuspendedTreeContext, } from './ReactFiberTreeContext.old'; import {queueRecoverableErrors} from './ReactFiberWorkLoop.old'; -import {getRootHostContainer} from './ReactFiberHostContext.old'; // The deepest Fiber on the stack involved in a hydration context. // This may have been an insertion or a hydration. @@ -408,19 +404,6 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } - if (enableFloat) { - if ( - fiber.tag === HostComponent && - isHydratableResource(fiber.type, fiber.pendingProps) - ) { - fiber.stateNode = getMatchingResourceInstance( - fiber.type, - fiber.pendingProps, - getRootHostContainer(), - ); - return; - } - } let nextInstance = nextHydratableInstance; if (!nextInstance) { if (shouldClientRenderOnMismatch(fiber)) { @@ -613,30 +596,6 @@ function popHydrationState(fiber: Fiber): boolean { if (!supportsHydration) { return false; } - if ( - enableFloat && - isHydrating && - isHydratableResource(fiber.type, fiber.memoizedProps) - ) { - if (fiber.stateNode === null) { - if (__DEV__) { - const rel = fiber.memoizedProps.rel - ? `rel="${fiber.memoizedProps.rel}" ` - : ''; - const href = fiber.memoizedProps.href - ? `href="${fiber.memoizedProps.href}"` - : ''; - console.error( - 'A matching Hydratable Resource was not found in the DOM for <%s %s%s>.', - fiber.type, - rel, - href, - ); - } - throwOnHydrationMismatch(fiber); - } - return true; - } if (fiber !== hydrationParentFiber) { // We're deeper than the current hydration context, inside an inserted // tree. diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index 4aa5a203ae3a9..67600492d13ff 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -17,12 +17,14 @@ import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFrom import { ClassComponent, HostComponent, + HostResource, HostRoot, HostPortal, HostText, SuspenseComponent, } from './ReactWorkTags'; import {NoFlags, Placement, Hydrating} from './ReactFiberFlags'; +import {enableFloat} from 'shared/ReactFeatureFlags'; const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner; @@ -273,7 +275,11 @@ export function findCurrentHostFiber(parent: Fiber): Fiber | null { function findCurrentHostFiberImpl(node: Fiber) { // Next we'll drill down this component to find the first HostComponent/Text. - if (node.tag === HostComponent || node.tag === HostText) { + if ( + node.tag === HostComponent || + node.tag === HostText || + (enableFloat ? node.tag === HostResource : false) + ) { return node; } @@ -298,7 +304,11 @@ export function findCurrentHostFiberWithNoPortals(parent: Fiber): Fiber | null { function findCurrentHostFiberWithNoPortalsImpl(node: Fiber) { // Next we'll drill down this component to find the first HostComponent/Text. - if (node.tag === HostComponent || node.tag === HostText) { + if ( + node.tag === HostComponent || + node.tag === HostText || + (enableFloat ? node.tag === HostResource : false) + ) { return node; } diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js index 1de655dfb0264..a5c408681dd04 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.new.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.new.js @@ -19,6 +19,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostPortal, ContextProvider, SuspenseComponent, @@ -115,6 +116,7 @@ function unwindWork( // We unwound to the root without completing it. Exit. return null; } + case HostResource: case HostComponent: { // TODO: popHydrationState popHostContext(workInProgress); @@ -233,6 +235,7 @@ function unwindInterruptedWork( resetMutableSourceWorkInProgressVersions(); break; } + case HostResource: case HostComponent: { popHostContext(interruptedWork); break; diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js index 218d41919eb2a..870983968cc6f 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.old.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.old.js @@ -19,6 +19,7 @@ import { ClassComponent, HostRoot, HostComponent, + HostResource, HostPortal, ContextProvider, SuspenseComponent, @@ -115,6 +116,7 @@ function unwindWork( // We unwound to the root without completing it. Exit. return null; } + case HostResource: case HostComponent: { // TODO: popHydrationState popHostContext(workInProgress); @@ -233,6 +235,7 @@ function unwindInterruptedWork( resetMutableSourceWorkInProgressVersions(); break; } + case HostResource: case HostComponent: { popHostContext(interruptedWork); break; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index dc5efade1120d..35736f6f1221e 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -40,6 +40,7 @@ import { enableUpdaterTracking, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import is from 'shared/objectIs'; @@ -81,8 +82,11 @@ import { afterActiveInstanceBlur, getCurrentEventPriority, supportsMicrotasks, + supportsResources, errorHydratingContainer, scheduleMicrotask, + prepareToRender, + cleanupAfterRender, } from './ReactFiberHostConfig'; import { @@ -965,157 +969,167 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { // This is the entry point for every concurrent task, i.e. anything that // goes through Scheduler. function performConcurrentWorkOnRoot(root, didTimeout) { - if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { - resetNestedUpdateFlag(); + if (enableFloat && supportsResources) { + prepareToRender(); } - // Since we know we're in a React event, we can clear the current - // event time. The next update will compute a new event time. - currentEventTime = NoTimestamp; - currentEventTransitionLane = NoLanes; + try { + if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { + resetNestedUpdateFlag(); + } - if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { - throw new Error('Should not already be working.'); - } + // Since we know we're in a React event, we can clear the current + // event time. The next update will compute a new event time. + currentEventTime = NoTimestamp; + currentEventTransitionLane = NoLanes; - // Flush any pending passive effects before deciding which lanes to work on, - // in case they schedule additional work. - const originalCallbackNode = root.callbackNode; - const didFlushPassiveEffects = flushPassiveEffects(); - if (didFlushPassiveEffects) { - // Something in the passive effect phase may have canceled the current task. - // Check if the task node for this root was changed. - if (root.callbackNode !== originalCallbackNode) { - // The current task was canceled. Exit. We don't need to call - // `ensureRootIsScheduled` because the check above implies either that - // there's a new task, or that there's no remaining work on this root. - return null; - } else { - // Current task was not canceled. Continue. + if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { + throw new Error('Should not already be working.'); } - } - - // Determine the next lanes to work on, using the fields stored - // on the root. - let lanes = getNextLanes( - root, - root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, - ); - if (lanes === NoLanes) { - // Defensive coding. This is never expected to happen. - return null; - } - // We disable time-slicing in some cases: if the work has been CPU-bound - // for too long ("expired" work, to prevent starvation), or we're in - // sync-updates-by-default mode. - // TODO: We only check `didTimeout` defensively, to account for a Scheduler - // bug we're still investigating. Once the bug in Scheduler is fixed, - // we can remove this, since we track expiration ourselves. - const shouldTimeSlice = - !includesBlockingLane(root, lanes) && - !includesExpiredLane(root, lanes) && - (disableSchedulerTimeoutInWorkLoop || !didTimeout); - let exitStatus = shouldTimeSlice - ? renderRootConcurrent(root, lanes) - : renderRootSync(root, lanes); - if (exitStatus !== RootInProgress) { - if (exitStatus === RootErrored) { - // If something threw an error, try rendering one more time. We'll - // render synchronously to block concurrent data mutations, and we'll - // includes all pending updates are included. If it still fails after - // the second attempt, we'll give up and commit the resulting tree. - const originallyAttemptedLanes = lanes; - const errorRetryLanes = getLanesToRetrySynchronouslyOnError( - root, - originallyAttemptedLanes, - ); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError( - root, - originallyAttemptedLanes, - errorRetryLanes, - ); + // Flush any pending passive effects before deciding which lanes to work on, + // in case they schedule additional work. + const originalCallbackNode = root.callbackNode; + const didFlushPassiveEffects = flushPassiveEffects(); + if (didFlushPassiveEffects) { + // Something in the passive effect phase may have canceled the current task. + // Check if the task node for this root was changed. + if (root.callbackNode !== originalCallbackNode) { + // The current task was canceled. Exit. We don't need to call + // `ensureRootIsScheduled` because the check above implies either that + // there's a new task, or that there's no remaining work on this root. + return null; + } else { + // Current task was not canceled. Continue. } } - if (exitStatus === RootFatalErrored) { - const fatalError = workInProgressRootFatalError; - prepareFreshStack(root, NoLanes); - markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); - throw fatalError; + + // Determine the next lanes to work on, using the fields stored + // on the root. + let lanes = getNextLanes( + root, + root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, + ); + if (lanes === NoLanes) { + // Defensive coding. This is never expected to happen. + return null; } - if (exitStatus === RootDidNotComplete) { - // The render unwound without completing the tree. This happens in special - // cases where need to exit the current render without producing a - // consistent tree or committing. - // - // This should only happen during a concurrent render, not a discrete or - // synchronous update. We should have already checked for this when we - // unwound the stack. - markRootSuspended(root, lanes); - } else { - // The render completed. - - // Check if this render may have yielded to a concurrent event, and if so, - // confirm that any newly rendered stores are consistent. - // TODO: It's possible that even a concurrent render may never have yielded - // to the main thread, if it was fast enough, or if it expired. We could - // skip the consistency check in that case, too. - const renderWasConcurrent = !includesBlockingLane(root, lanes); - const finishedWork: Fiber = (root.current.alternate: any); - if ( - renderWasConcurrent && - !isRenderConsistentWithExternalStores(finishedWork) - ) { - // A store was mutated in an interleaved event. Render again, - // synchronously, to block further mutations. - exitStatus = renderRootSync(root, lanes); - - // We need to check again if something threw - if (exitStatus === RootErrored) { - const originallyAttemptedLanes = lanes; - const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + // We disable time-slicing in some cases: if the work has been CPU-bound + // for too long ("expired" work, to prevent starvation), or we're in + // sync-updates-by-default mode. + // TODO: We only check `didTimeout` defensively, to account for a Scheduler + // bug we're still investigating. Once the bug in Scheduler is fixed, + // we can remove this, since we track expiration ourselves. + const shouldTimeSlice = + !includesBlockingLane(root, lanes) && + !includesExpiredLane(root, lanes) && + (disableSchedulerTimeoutInWorkLoop || !didTimeout); + let exitStatus = shouldTimeSlice + ? renderRootConcurrent(root, lanes) + : renderRootSync(root, lanes); + if (exitStatus !== RootInProgress) { + if (exitStatus === RootErrored) { + // If something threw an error, try rendering one more time. We'll + // render synchronously to block concurrent data mutations, and we'll + // includes all pending updates are included. If it still fails after + // the second attempt, we'll give up and commit the resulting tree. + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError( root, originallyAttemptedLanes, + errorRetryLanes, ); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError( + } + } + if (exitStatus === RootFatalErrored) { + const fatalError = workInProgressRootFatalError; + prepareFreshStack(root, NoLanes); + markRootSuspended(root, lanes); + ensureRootIsScheduled(root, now()); + throw fatalError; + } + + if (exitStatus === RootDidNotComplete) { + // The render unwound without completing the tree. This happens in special + // cases where need to exit the current render without producing a + // consistent tree or committing. + // + // This should only happen during a concurrent render, not a discrete or + // synchronous update. We should have already checked for this when we + // unwound the stack. + markRootSuspended(root, lanes); + } else { + // The render completed. + + // Check if this render may have yielded to a concurrent event, and if so, + // confirm that any newly rendered stores are consistent. + // TODO: It's possible that even a concurrent render may never have yielded + // to the main thread, if it was fast enough, or if it expired. We could + // skip the consistency check in that case, too. + const renderWasConcurrent = !includesBlockingLane(root, lanes); + const finishedWork: Fiber = (root.current.alternate: any); + if ( + renderWasConcurrent && + !isRenderConsistentWithExternalStores(finishedWork) + ) { + // A store was mutated in an interleaved event. Render again, + // synchronously, to block further mutations. + exitStatus = renderRootSync(root, lanes); + + // We need to check again if something threw + if (exitStatus === RootErrored) { + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( root, originallyAttemptedLanes, - errorRetryLanes, ); - // We assume the tree is now consistent because we didn't yield to any - // concurrent events. + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); + // We assume the tree is now consistent because we didn't yield to any + // concurrent events. + } + } + if (exitStatus === RootFatalErrored) { + const fatalError = workInProgressRootFatalError; + prepareFreshStack(root, NoLanes); + markRootSuspended(root, lanes); + ensureRootIsScheduled(root, now()); + throw fatalError; } } - if (exitStatus === RootFatalErrored) { - const fatalError = workInProgressRootFatalError; - prepareFreshStack(root, NoLanes); - markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); - throw fatalError; - } - } - // We now have a consistent tree. The next step is either to commit it, - // or, if something suspended, wait to commit it after a timeout. - root.finishedWork = finishedWork; - root.finishedLanes = lanes; - finishConcurrentRender(root, exitStatus, lanes); + // We now have a consistent tree. The next step is either to commit it, + // or, if something suspended, wait to commit it after a timeout. + root.finishedWork = finishedWork; + root.finishedLanes = lanes; + finishConcurrentRender(root, exitStatus, lanes); + } } - } - ensureRootIsScheduled(root, now()); - if (root.callbackNode === originalCallbackNode) { - // The task node scheduled for this root is the same one that's - // currently executed. Need to return a continuation. - return performConcurrentWorkOnRoot.bind(null, root); + ensureRootIsScheduled(root, now()); + if (root.callbackNode === originalCallbackNode) { + // The task node scheduled for this root is the same one that's + // currently executed. Need to return a continuation. + return performConcurrentWorkOnRoot.bind(null, root); + } + return null; + } finally { + if (enableFloat && supportsResources) { + cleanupAfterRender(); + } } - return null; } function recoverFromConcurrentError( @@ -1412,72 +1426,82 @@ function markRootSuspended(root, suspendedLanes) { // This is the entry point for synchronous tasks that don't go // through Scheduler function performSyncWorkOnRoot(root) { - if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { - syncNestedUpdateFlag(); + if (enableFloat && supportsResources) { + prepareToRender(); } - if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { - throw new Error('Should not already be working.'); - } + try { + if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { + syncNestedUpdateFlag(); + } - flushPassiveEffects(); + if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { + throw new Error('Should not already be working.'); + } - let lanes = getNextLanes(root, NoLanes); - if (!includesSomeLane(lanes, SyncLane)) { - // There's no remaining sync work left. - ensureRootIsScheduled(root, now()); - return null; - } + flushPassiveEffects(); - let exitStatus = renderRootSync(root, lanes); - if (root.tag !== LegacyRoot && exitStatus === RootErrored) { - // If something threw an error, try rendering one more time. We'll render - // synchronously to block concurrent data mutations, and we'll includes - // all pending updates are included. If it still fails after the second - // attempt, we'll give up and commit the resulting tree. - const originallyAttemptedLanes = lanes; - const errorRetryLanes = getLanesToRetrySynchronouslyOnError( - root, - originallyAttemptedLanes, - ); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError( + let lanes = getNextLanes(root, NoLanes); + if (!includesSomeLane(lanes, SyncLane)) { + // There's no remaining sync work left. + ensureRootIsScheduled(root, now()); + return null; + } + + let exitStatus = renderRootSync(root, lanes); + if (root.tag !== LegacyRoot && exitStatus === RootErrored) { + // If something threw an error, try rendering one more time. We'll render + // synchronously to block concurrent data mutations, and we'll includes + // all pending updates are included. If it still fails after the second + // attempt, we'll give up and commit the resulting tree. + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( root, originallyAttemptedLanes, - errorRetryLanes, ); + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); + } } - } - if (exitStatus === RootFatalErrored) { - const fatalError = workInProgressRootFatalError; - prepareFreshStack(root, NoLanes); - markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); - throw fatalError; - } + if (exitStatus === RootFatalErrored) { + const fatalError = workInProgressRootFatalError; + prepareFreshStack(root, NoLanes); + markRootSuspended(root, lanes); + ensureRootIsScheduled(root, now()); + throw fatalError; + } - if (exitStatus === RootDidNotComplete) { - throw new Error('Root did not complete. This is a bug in React.'); - } + if (exitStatus === RootDidNotComplete) { + throw new Error('Root did not complete. This is a bug in React.'); + } - // We now have a consistent tree. Because this is a sync render, we - // will commit it even if something suspended. - const finishedWork: Fiber = (root.current.alternate: any); - root.finishedWork = finishedWork; - root.finishedLanes = lanes; - commitRoot( - root, - workInProgressRootRecoverableErrors, - workInProgressTransitions, - ); + // We now have a consistent tree. Because this is a sync render, we + // will commit it even if something suspended. + const finishedWork: Fiber = (root.current.alternate: any); + root.finishedWork = finishedWork; + root.finishedLanes = lanes; + commitRoot( + root, + workInProgressRootRecoverableErrors, + workInProgressTransitions, + ); - // Before exiting, make sure there's a callback scheduled for the next - // pending level. - ensureRootIsScheduled(root, now()); + // Before exiting, make sure there's a callback scheduled for the next + // pending level. + ensureRootIsScheduled(root, now()); - return null; + return null; + } finally { + if (enableFloat && supportsResources) { + cleanupAfterRender(); + } + } } export function flushRoot(root: FiberRoot, lanes: Lanes) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index d8d29c496495e..10946f6995b93 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -40,6 +40,7 @@ import { enableUpdaterTracking, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import is from 'shared/objectIs'; @@ -81,8 +82,11 @@ import { afterActiveInstanceBlur, getCurrentEventPriority, supportsMicrotasks, + supportsResources, errorHydratingContainer, scheduleMicrotask, + prepareToRender, + cleanupAfterRender, } from './ReactFiberHostConfig'; import { @@ -965,157 +969,167 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { // This is the entry point for every concurrent task, i.e. anything that // goes through Scheduler. function performConcurrentWorkOnRoot(root, didTimeout) { - if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { - resetNestedUpdateFlag(); + if (enableFloat && supportsResources) { + prepareToRender(); } - // Since we know we're in a React event, we can clear the current - // event time. The next update will compute a new event time. - currentEventTime = NoTimestamp; - currentEventTransitionLane = NoLanes; + try { + if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { + resetNestedUpdateFlag(); + } - if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { - throw new Error('Should not already be working.'); - } + // Since we know we're in a React event, we can clear the current + // event time. The next update will compute a new event time. + currentEventTime = NoTimestamp; + currentEventTransitionLane = NoLanes; - // Flush any pending passive effects before deciding which lanes to work on, - // in case they schedule additional work. - const originalCallbackNode = root.callbackNode; - const didFlushPassiveEffects = flushPassiveEffects(); - if (didFlushPassiveEffects) { - // Something in the passive effect phase may have canceled the current task. - // Check if the task node for this root was changed. - if (root.callbackNode !== originalCallbackNode) { - // The current task was canceled. Exit. We don't need to call - // `ensureRootIsScheduled` because the check above implies either that - // there's a new task, or that there's no remaining work on this root. - return null; - } else { - // Current task was not canceled. Continue. + if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { + throw new Error('Should not already be working.'); } - } - - // Determine the next lanes to work on, using the fields stored - // on the root. - let lanes = getNextLanes( - root, - root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, - ); - if (lanes === NoLanes) { - // Defensive coding. This is never expected to happen. - return null; - } - // We disable time-slicing in some cases: if the work has been CPU-bound - // for too long ("expired" work, to prevent starvation), or we're in - // sync-updates-by-default mode. - // TODO: We only check `didTimeout` defensively, to account for a Scheduler - // bug we're still investigating. Once the bug in Scheduler is fixed, - // we can remove this, since we track expiration ourselves. - const shouldTimeSlice = - !includesBlockingLane(root, lanes) && - !includesExpiredLane(root, lanes) && - (disableSchedulerTimeoutInWorkLoop || !didTimeout); - let exitStatus = shouldTimeSlice - ? renderRootConcurrent(root, lanes) - : renderRootSync(root, lanes); - if (exitStatus !== RootInProgress) { - if (exitStatus === RootErrored) { - // If something threw an error, try rendering one more time. We'll - // render synchronously to block concurrent data mutations, and we'll - // includes all pending updates are included. If it still fails after - // the second attempt, we'll give up and commit the resulting tree. - const originallyAttemptedLanes = lanes; - const errorRetryLanes = getLanesToRetrySynchronouslyOnError( - root, - originallyAttemptedLanes, - ); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError( - root, - originallyAttemptedLanes, - errorRetryLanes, - ); + // Flush any pending passive effects before deciding which lanes to work on, + // in case they schedule additional work. + const originalCallbackNode = root.callbackNode; + const didFlushPassiveEffects = flushPassiveEffects(); + if (didFlushPassiveEffects) { + // Something in the passive effect phase may have canceled the current task. + // Check if the task node for this root was changed. + if (root.callbackNode !== originalCallbackNode) { + // The current task was canceled. Exit. We don't need to call + // `ensureRootIsScheduled` because the check above implies either that + // there's a new task, or that there's no remaining work on this root. + return null; + } else { + // Current task was not canceled. Continue. } } - if (exitStatus === RootFatalErrored) { - const fatalError = workInProgressRootFatalError; - prepareFreshStack(root, NoLanes); - markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); - throw fatalError; + + // Determine the next lanes to work on, using the fields stored + // on the root. + let lanes = getNextLanes( + root, + root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, + ); + if (lanes === NoLanes) { + // Defensive coding. This is never expected to happen. + return null; } - if (exitStatus === RootDidNotComplete) { - // The render unwound without completing the tree. This happens in special - // cases where need to exit the current render without producing a - // consistent tree or committing. - // - // This should only happen during a concurrent render, not a discrete or - // synchronous update. We should have already checked for this when we - // unwound the stack. - markRootSuspended(root, lanes); - } else { - // The render completed. - - // Check if this render may have yielded to a concurrent event, and if so, - // confirm that any newly rendered stores are consistent. - // TODO: It's possible that even a concurrent render may never have yielded - // to the main thread, if it was fast enough, or if it expired. We could - // skip the consistency check in that case, too. - const renderWasConcurrent = !includesBlockingLane(root, lanes); - const finishedWork: Fiber = (root.current.alternate: any); - if ( - renderWasConcurrent && - !isRenderConsistentWithExternalStores(finishedWork) - ) { - // A store was mutated in an interleaved event. Render again, - // synchronously, to block further mutations. - exitStatus = renderRootSync(root, lanes); - - // We need to check again if something threw - if (exitStatus === RootErrored) { - const originallyAttemptedLanes = lanes; - const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + // We disable time-slicing in some cases: if the work has been CPU-bound + // for too long ("expired" work, to prevent starvation), or we're in + // sync-updates-by-default mode. + // TODO: We only check `didTimeout` defensively, to account for a Scheduler + // bug we're still investigating. Once the bug in Scheduler is fixed, + // we can remove this, since we track expiration ourselves. + const shouldTimeSlice = + !includesBlockingLane(root, lanes) && + !includesExpiredLane(root, lanes) && + (disableSchedulerTimeoutInWorkLoop || !didTimeout); + let exitStatus = shouldTimeSlice + ? renderRootConcurrent(root, lanes) + : renderRootSync(root, lanes); + if (exitStatus !== RootInProgress) { + if (exitStatus === RootErrored) { + // If something threw an error, try rendering one more time. We'll + // render synchronously to block concurrent data mutations, and we'll + // includes all pending updates are included. If it still fails after + // the second attempt, we'll give up and commit the resulting tree. + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( + root, + originallyAttemptedLanes, + ); + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError( root, originallyAttemptedLanes, + errorRetryLanes, ); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError( + } + } + if (exitStatus === RootFatalErrored) { + const fatalError = workInProgressRootFatalError; + prepareFreshStack(root, NoLanes); + markRootSuspended(root, lanes); + ensureRootIsScheduled(root, now()); + throw fatalError; + } + + if (exitStatus === RootDidNotComplete) { + // The render unwound without completing the tree. This happens in special + // cases where need to exit the current render without producing a + // consistent tree or committing. + // + // This should only happen during a concurrent render, not a discrete or + // synchronous update. We should have already checked for this when we + // unwound the stack. + markRootSuspended(root, lanes); + } else { + // The render completed. + + // Check if this render may have yielded to a concurrent event, and if so, + // confirm that any newly rendered stores are consistent. + // TODO: It's possible that even a concurrent render may never have yielded + // to the main thread, if it was fast enough, or if it expired. We could + // skip the consistency check in that case, too. + const renderWasConcurrent = !includesBlockingLane(root, lanes); + const finishedWork: Fiber = (root.current.alternate: any); + if ( + renderWasConcurrent && + !isRenderConsistentWithExternalStores(finishedWork) + ) { + // A store was mutated in an interleaved event. Render again, + // synchronously, to block further mutations. + exitStatus = renderRootSync(root, lanes); + + // We need to check again if something threw + if (exitStatus === RootErrored) { + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( root, originallyAttemptedLanes, - errorRetryLanes, ); - // We assume the tree is now consistent because we didn't yield to any - // concurrent events. + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); + // We assume the tree is now consistent because we didn't yield to any + // concurrent events. + } + } + if (exitStatus === RootFatalErrored) { + const fatalError = workInProgressRootFatalError; + prepareFreshStack(root, NoLanes); + markRootSuspended(root, lanes); + ensureRootIsScheduled(root, now()); + throw fatalError; } } - if (exitStatus === RootFatalErrored) { - const fatalError = workInProgressRootFatalError; - prepareFreshStack(root, NoLanes); - markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); - throw fatalError; - } - } - // We now have a consistent tree. The next step is either to commit it, - // or, if something suspended, wait to commit it after a timeout. - root.finishedWork = finishedWork; - root.finishedLanes = lanes; - finishConcurrentRender(root, exitStatus, lanes); + // We now have a consistent tree. The next step is either to commit it, + // or, if something suspended, wait to commit it after a timeout. + root.finishedWork = finishedWork; + root.finishedLanes = lanes; + finishConcurrentRender(root, exitStatus, lanes); + } } - } - ensureRootIsScheduled(root, now()); - if (root.callbackNode === originalCallbackNode) { - // The task node scheduled for this root is the same one that's - // currently executed. Need to return a continuation. - return performConcurrentWorkOnRoot.bind(null, root); + ensureRootIsScheduled(root, now()); + if (root.callbackNode === originalCallbackNode) { + // The task node scheduled for this root is the same one that's + // currently executed. Need to return a continuation. + return performConcurrentWorkOnRoot.bind(null, root); + } + return null; + } finally { + if (enableFloat && supportsResources) { + cleanupAfterRender(); + } } - return null; } function recoverFromConcurrentError( @@ -1412,72 +1426,82 @@ function markRootSuspended(root, suspendedLanes) { // This is the entry point for synchronous tasks that don't go // through Scheduler function performSyncWorkOnRoot(root) { - if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { - syncNestedUpdateFlag(); + if (enableFloat && supportsResources) { + prepareToRender(); } - if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { - throw new Error('Should not already be working.'); - } + try { + if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { + syncNestedUpdateFlag(); + } - flushPassiveEffects(); + if ((executionContext & (RenderContext | CommitContext)) !== NoContext) { + throw new Error('Should not already be working.'); + } - let lanes = getNextLanes(root, NoLanes); - if (!includesSomeLane(lanes, SyncLane)) { - // There's no remaining sync work left. - ensureRootIsScheduled(root, now()); - return null; - } + flushPassiveEffects(); - let exitStatus = renderRootSync(root, lanes); - if (root.tag !== LegacyRoot && exitStatus === RootErrored) { - // If something threw an error, try rendering one more time. We'll render - // synchronously to block concurrent data mutations, and we'll includes - // all pending updates are included. If it still fails after the second - // attempt, we'll give up and commit the resulting tree. - const originallyAttemptedLanes = lanes; - const errorRetryLanes = getLanesToRetrySynchronouslyOnError( - root, - originallyAttemptedLanes, - ); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError( + let lanes = getNextLanes(root, NoLanes); + if (!includesSomeLane(lanes, SyncLane)) { + // There's no remaining sync work left. + ensureRootIsScheduled(root, now()); + return null; + } + + let exitStatus = renderRootSync(root, lanes); + if (root.tag !== LegacyRoot && exitStatus === RootErrored) { + // If something threw an error, try rendering one more time. We'll render + // synchronously to block concurrent data mutations, and we'll includes + // all pending updates are included. If it still fails after the second + // attempt, we'll give up and commit the resulting tree. + const originallyAttemptedLanes = lanes; + const errorRetryLanes = getLanesToRetrySynchronouslyOnError( root, originallyAttemptedLanes, - errorRetryLanes, ); + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError( + root, + originallyAttemptedLanes, + errorRetryLanes, + ); + } } - } - if (exitStatus === RootFatalErrored) { - const fatalError = workInProgressRootFatalError; - prepareFreshStack(root, NoLanes); - markRootSuspended(root, lanes); - ensureRootIsScheduled(root, now()); - throw fatalError; - } + if (exitStatus === RootFatalErrored) { + const fatalError = workInProgressRootFatalError; + prepareFreshStack(root, NoLanes); + markRootSuspended(root, lanes); + ensureRootIsScheduled(root, now()); + throw fatalError; + } - if (exitStatus === RootDidNotComplete) { - throw new Error('Root did not complete. This is a bug in React.'); - } + if (exitStatus === RootDidNotComplete) { + throw new Error('Root did not complete. This is a bug in React.'); + } - // We now have a consistent tree. Because this is a sync render, we - // will commit it even if something suspended. - const finishedWork: Fiber = (root.current.alternate: any); - root.finishedWork = finishedWork; - root.finishedLanes = lanes; - commitRoot( - root, - workInProgressRootRecoverableErrors, - workInProgressTransitions, - ); + // We now have a consistent tree. Because this is a sync render, we + // will commit it even if something suspended. + const finishedWork: Fiber = (root.current.alternate: any); + root.finishedWork = finishedWork; + root.finishedLanes = lanes; + commitRoot( + root, + workInProgressRootRecoverableErrors, + workInProgressTransitions, + ); - // Before exiting, make sure there's a callback scheduled for the next - // pending level. - ensureRootIsScheduled(root, now()); + // Before exiting, make sure there's a callback scheduled for the next + // pending level. + ensureRootIsScheduled(root, now()); - return null; + return null; + } finally { + if (enableFloat && supportsResources) { + cleanupAfterRender(); + } + } } export function flushRoot(root: FiberRoot, lanes: Lanes) { diff --git a/packages/react-reconciler/src/ReactTestSelectors.js b/packages/react-reconciler/src/ReactTestSelectors.js index 48640fd6c70b6..3ce6720c85a77 100644 --- a/packages/react-reconciler/src/ReactTestSelectors.js +++ b/packages/react-reconciler/src/ReactTestSelectors.js @@ -10,7 +10,11 @@ import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type {Instance} from './ReactFiberHostConfig'; -import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; +import { + HostComponent, + HostResource, + HostText, +} from 'react-reconciler/src/ReactWorkTags'; import getComponentNameFromType from 'shared/getComponentNameFromType'; import { findFiberRoot, @@ -150,7 +154,7 @@ function matchSelector(fiber: Fiber, selector: Selector): boolean { ((selector: any): HasPseudoClassSelector).value, ); case ROLE_TYPE: - if (fiber.tag === HostComponent) { + if (fiber.tag === HostComponent || fiber.tag === HostResource) { const node = fiber.stateNode; if ( matchAccessibilityRole(node, ((selector: any): RoleSelector).value) @@ -160,7 +164,11 @@ function matchSelector(fiber: Fiber, selector: Selector): boolean { } break; case TEXT_TYPE: - if (fiber.tag === HostComponent || fiber.tag === HostText) { + if ( + fiber.tag === HostComponent || + fiber.tag === HostText || + fiber.tag === HostResource + ) { const textContent = getTextContent(fiber); if ( textContent !== null && @@ -171,7 +179,7 @@ function matchSelector(fiber: Fiber, selector: Selector): boolean { } break; case TEST_NAME_TYPE: - if (fiber.tag === HostComponent) { + if (fiber.tag === HostComponent || fiber.tag === HostResource) { const dataTestID = fiber.memoizedProps['data-testname']; if ( typeof dataTestID === 'string' && @@ -217,7 +225,10 @@ function findPaths(root: Fiber, selectors: Array): Array { let selectorIndex = ((stack[index++]: any): number); let selector = selectors[selectorIndex]; - if (fiber.tag === HostComponent && isHiddenSubtree(fiber)) { + if ( + (fiber.tag === HostComponent || fiber.tag === HostResource) && + isHiddenSubtree(fiber) + ) { continue; } else { while (selector != null && matchSelector(fiber, selector)) { @@ -249,7 +260,10 @@ function hasMatchingPaths(root: Fiber, selectors: Array): boolean { let selectorIndex = ((stack[index++]: any): number); let selector = selectors[selectorIndex]; - if (fiber.tag === HostComponent && isHiddenSubtree(fiber)) { + if ( + (fiber.tag === HostComponent || fiber.tag === HostResource) && + isHiddenSubtree(fiber) + ) { continue; } else { while (selector != null && matchSelector(fiber, selector)) { @@ -289,7 +303,7 @@ export function findAllNodes( let index = 0; while (index < stack.length) { const node = ((stack[index++]: any): Fiber); - if (node.tag === HostComponent) { + if (node.tag === HostComponent || node.tag === HostResource) { if (isHiddenSubtree(node)) { continue; } @@ -327,7 +341,10 @@ export function getFindAllNodesFailureDescription( let selectorIndex = ((stack[index++]: any): number); const selector = selectors[selectorIndex]; - if (fiber.tag === HostComponent && isHiddenSubtree(fiber)) { + if ( + (fiber.tag === HostComponent || fiber.tag === HostResource) && + isHiddenSubtree(fiber) + ) { continue; } else if (matchSelector(fiber, selector)) { matchedNames.push(selectorToString(selector)); @@ -479,7 +496,7 @@ export function focusWithin( if (isHiddenSubtree(fiber)) { continue; } - if (fiber.tag === HostComponent) { + if (fiber.tag === HostComponent || fiber.tag === HostResource) { const node = fiber.stateNode; if (setFocusIfFocusable(node)) { return true; diff --git a/packages/react-reconciler/src/ReactWorkTags.js b/packages/react-reconciler/src/ReactWorkTags.js index 00d2d93794e9a..0a62312ce87a0 100644 --- a/packages/react-reconciler/src/ReactWorkTags.js +++ b/packages/react-reconciler/src/ReactWorkTags.js @@ -33,7 +33,8 @@ export type WorkTag = | 22 | 23 | 24 - | 25; + | 25 + | 26; export const FunctionComponent = 0; export const ClassComponent = 1; @@ -60,3 +61,4 @@ export const OffscreenComponent = 22; export const LegacyHiddenComponent = 23; export const CacheComponent = 24; export const TracingMarkerComponent = 25; +export const HostResource = 26; diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index 19ddc6c8262f4..f41296621ba9a 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -66,6 +66,8 @@ describe('ReactFiberHostContext', () => { getCurrentEventPriority: function() { return DefaultEventPriority; }, + prepareToRender: function() {}, + cleanupAfterRender: function() {}, supportsMutation: true, requestPostPaintCallback: function() {}, }); @@ -131,6 +133,8 @@ describe('ReactFiberHostContext', () => { return DefaultEventPriority; }, requestPostPaintCallback: function() {}, + prepareToRender: function() {}, + cleanupAfterRender: function() {}, supportsMutation: true, }); diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 517b45ead8fc1..261d96400cd9a 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -187,6 +187,16 @@ export const didNotFindHydratableTextInstance = export const didNotFindHydratableSuspenseInstance = $$$hostConfig.didNotFindHydratableSuspenseInstance; export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer; -export const isHydratableResource = $$$hostConfig.isHydratableResource; -export const getMatchingResourceInstance = - $$$hostConfig.getMatchingResourceInstance; + +// ------------------- +// Resources +// (optional) +// ------------------- +export const supportsResources = $$$hostConfig.supportsResources; +export const isHostResourceInstance = $$$hostConfig.isHostResourceInstance; +export const isHostResourceType = $$$hostConfig.isHostResourceType; +export const getResource = $$$hostConfig.getResource; +export const acquireResource = $$$hostConfig.acquireResource; +export const releaseResource = $$$hostConfig.releaseResource; +export const prepareToRender = $$$hostConfig.prepareToRender; +export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; diff --git a/packages/react-reconciler/src/getComponentNameFromFiber.js b/packages/react-reconciler/src/getComponentNameFromFiber.js index 883ac838b975d..4b14f01014714 100644 --- a/packages/react-reconciler/src/getComponentNameFromFiber.js +++ b/packages/react-reconciler/src/getComponentNameFromFiber.js @@ -19,6 +19,7 @@ import { HostRoot, HostPortal, HostComponent, + HostResource, HostText, Fragment, Mode, @@ -77,6 +78,7 @@ export default function getComponentNameFromFiber(fiber: Fiber): string | null { return getWrappedName(type, type.render, 'ForwardRef'); case Fragment: return 'Fragment'; + case HostResource: case HostComponent: // Host component type is the display name (e.g. "div", "View") return type; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 5f44458e7889c..50ddb33b86348 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -24,6 +24,8 @@ import type { SuspenseBoundaryID, ResponseState, FormatContext, + Resources, + BoundaryResources, } from './ReactServerFormatConfig'; import type {ContextSnapshot} from './ReactFizzNewContext'; import type {ComponentStackNode} from './ReactFizzComponentStack'; @@ -63,6 +65,15 @@ import { UNINITIALIZED_SUSPENSE_BOUNDARY_ID, assignSuspenseBoundaryID, getChildFormatContext, + writeInitialResources, + writeImmediateResources, + hoistResources, + hoistResourcesToRoot, + prepareToRender, + cleanupAfterRender, + setBoundaryResources, + createResources, + createBoundaryResources, } from './ReactServerFormatConfig'; import { constructClassInstance, @@ -147,6 +158,7 @@ type SuspenseBoundary = { completedSegments: Array, // completed but not yet flushed segments. byteSize: number, // used to determine whether to inline children boundaries. fallbackAbortableTasks: Set, // used to cancel task on the fallback if the boundary completes or gets canceled. + resources: BoundaryResources, }; export type Task = { @@ -199,9 +211,11 @@ export opaque type Request = { nextSegmentId: number, allPendingTasks: number, // when it reaches zero, we can close the connection. pendingRootTasks: number, // when this reaches zero, we've finished at least the root boundary. + resources: Resources, completedRootSegment: null | Segment, // Completed but not yet flushed root segments. + rootDidFlush: boolean, abortableTasks: Set, - pingedTasks: Array, + pingedTasks: Array, // High priority tasks that should be worked on first. // Queues to flush in order of priority clientRenderedBoundaries: Array, // Errored or client rendered but not yet flushed. completedBoundaries: Array, // Completed but not yet fully flushed boundaries to show. @@ -262,6 +276,7 @@ export function createRequest( ): Request { const pingedTasks = []; const abortSet: Set = new Set(); + const resources: Resources = createResources(); const request = { destination: null, responseState, @@ -274,7 +289,9 @@ export function createRequest( nextSegmentId: 0, allPendingTasks: 0, pendingRootTasks: 0, + resources, completedRootSegment: null, + rootDidFlush: false, abortableTasks: abortSet, pingedTasks: pingedTasks, clientRenderedBoundaries: [], @@ -337,6 +354,7 @@ function createSuspenseBoundary( byteSize: 0, fallbackAbortableTasks, errorDigest: null, + resources: createBoundaryResources(), }; } @@ -562,6 +580,9 @@ function renderSuspenseBoundary( // we're writing to. If something suspends, it'll spawn new suspended task with that context. task.blockedBoundary = newBoundary; task.blockedSegment = contentRootSegment; + if (enableFloat) { + setBoundaryResources(request.resources, newBoundary.resources); + } try { // We use the safe form because we don't handle suspending here. Only error handling. renderNode(request, task, content); @@ -572,6 +593,11 @@ function renderSuspenseBoundary( contentRootSegment.textEmbedded, ); contentRootSegment.status = COMPLETED; + if (enableFloat) { + if (newBoundary.pendingTasks === 0) { + hoistCompletedBoundaryResources(request, newBoundary); + } + } queueCompletedSegment(newBoundary, contentRootSegment); if (newBoundary.pendingTasks === 0) { // This must have been the last segment we were waiting on. This boundary is now complete. @@ -592,6 +618,12 @@ function renderSuspenseBoundary( // We don't need to schedule any task because we know the parent has written yet. // We do need to fallthrough to create the fallback though. } finally { + if (enableFloat) { + setBoundaryResources( + request.resources, + parentBoundary ? parentBoundary.resources : null, + ); + } task.blockedBoundary = parentBoundary; task.blockedSegment = parentSegment; } @@ -619,6 +651,19 @@ function renderSuspenseBoundary( popComponentStackInDEV(task); } +function hoistCompletedBoundaryResources( + request: Request, + completedBoundary: SuspenseBoundary, +): void { + if (!request.rootDidFlush) { + // The Shell has not flushed yet. we can hoist Resources for this boundary + // all the way to the Root. + hoistResourcesToRoot(request.resources, completedBoundary.resources); + } + // We don't hoist if the root already flushed because late resources will be hoisted + // as boundaries flush +} + function renderBackupSuspenseBoundary( request: Request, task: Task, @@ -644,6 +689,7 @@ function renderHostElement( ): void { pushBuiltInComponentStackInDEV(task, type); const segment = task.blockedSegment; + const children = pushStartInstance( segment.chunks, request.preamble, @@ -651,10 +697,12 @@ function renderHostElement( props, request.responseState, segment.formatContext, + segment.lastPushedText, ); segment.lastPushedText = false; const prevContext = segment.formatContext; segment.formatContext = getChildFormatContext(prevContext, type, props); + // We use the non-destructive form because if something suspends, we still // need to pop back up and finish this subtree of HTML. renderNode(request, task, children); @@ -1733,11 +1781,15 @@ function finishedTask( queueCompletedSegment(boundary, segment); } } + if (enableFloat) { + hoistCompletedBoundaryResources(request, boundary); + } if (boundary.parentFlushed) { // The segment might be part of a segment that didn't flush yet, but if the boundary's // parent flushed, we need to schedule the boundary to be emitted. request.completedBoundaries.push(boundary); } + // We can now cancel any pending task on the fallback since we won't need to show it anymore. // This needs to happen after we read the parentFlushed flags because aborting can finish // work which can trigger user code, which can start flushing, which can change those flags. @@ -1774,6 +1826,14 @@ function finishedTask( } function retryTask(request: Request, task: Task): void { + prepareToRender(request.resources); + if (enableFloat) { + const blockedBoundary = task.blockedBoundary; + setBoundaryResources( + request.resources, + blockedBoundary ? blockedBoundary.resources : null, + ); + } const segment = task.blockedSegment; if (segment.status !== PENDING) { // We completed this by other means before we had a chance to retry it. @@ -1825,9 +1885,13 @@ function retryTask(request: Request, task: Task): void { erroredTask(request, task.blockedBoundary, segment, x); } } finally { + if (enableFloat) { + setBoundaryResources(request.resources, null); + } if (__DEV__) { currentTaskInDEV = prevTaskInDEV; } + cleanupAfterRender(); } } @@ -1900,6 +1964,7 @@ function flushSubtree( const chunks = segment.chunks; let chunkIdx = 0; const children = segment.children; + for (let childIdx = 0; childIdx < children.length; childIdx++) { const nextChild = children[childIdx]; // Write all the chunks up until the next child. @@ -1998,6 +2063,9 @@ function flushSegment( return writeEndPendingSuspenseBoundary(destination, request.responseState); } else { + if (enableFloat) { + hoistResources(request.resources, boundary.resources); + } // We can inline this boundary's content as a complete boundary. writeStartCompletedSuspenseBoundary(destination, request.responseState); @@ -2019,6 +2087,25 @@ function flushSegment( } } +function flushInitialResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, +): void { + writeInitialResources(destination, resources, responseState); +} + +function flushImmediateResources( + destination: Destination, + request: Request, +): void { + writeImmediateResources( + destination, + request.resources, + request.responseState, + ); +} + function flushClientRenderedBoundary( request: Request, destination: Destination, @@ -2054,6 +2141,9 @@ function flushCompletedBoundary( destination: Destination, boundary: SuspenseBoundary, ): boolean { + if (enableFloat) { + setBoundaryResources(request.resources, boundary.resources); + } const completedSegments = boundary.completedSegments; let i = 0; for (; i < completedSegments.length; i++) { @@ -2067,6 +2157,7 @@ function flushCompletedBoundary( request.responseState, boundary.id, boundary.rootSegmentID, + boundary.resources, ); } @@ -2075,6 +2166,9 @@ function flushPartialBoundary( destination: Destination, boundary: SuspenseBoundary, ): boolean { + if (enableFloat) { + setBoundaryResources(request.resources, boundary.resources); + } const completedSegments = boundary.completedSegments; let i = 0; for (; i < completedSegments.length; i++) { @@ -2138,8 +2232,6 @@ function flushCompletedQueues( // that item fully and then yield. At that point we remove the already completed // items up until the point we completed them. - // TODO: Emit preloading. - let i; const completedRootSegment = request.completedRootSegment; if (completedRootSegment !== null) { @@ -2150,15 +2242,24 @@ function flushCompletedQueues( // we expect the preamble to be tiny and will ignore backpressure writeChunk(destination, preamble[i]); } + + flushInitialResources( + destination, + request.resources, + request.responseState, + ); } flushSegment(request, destination, completedRootSegment); request.completedRootSegment = null; + request.rootDidFlush = true; writeCompletedRoot(destination, request.responseState); } else { - // We haven't flushed the root yet so we don't need to check boundaries further down + // We haven't flushed the root yet so we don't need to check any other branches further down return; } + } else if (enableFloat && request.rootDidFlush) { + flushImmediateResources(destination, request); } // We emit client rendering instructions for already emitted boundaries first. diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index ecb3218ea1dad..cf259dd050f78 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -26,6 +26,8 @@ declare var $$$hostConfig: any; export opaque type Destination = mixed; // eslint-disable-line no-undef export opaque type ResponseState = mixed; +export opaque type Resources = mixed; +export opaque type BoundaryResources = mixed; export opaque type FormatContext = mixed; export opaque type SuspenseBoundaryID = mixed; @@ -66,3 +68,16 @@ export const writeCompletedBoundaryInstruction = $$$hostConfig.writeCompletedBoundaryInstruction; export const writeClientRenderBoundaryInstruction = $$$hostConfig.writeClientRenderBoundaryInstruction; + +// ------------------------- +// Resources +// ------------------------- +export const writeInitialResources = $$$hostConfig.writeInitialResources; +export const writeImmediateResources = $$$hostConfig.writeImmediateResources; +export const hoistResources = $$$hostConfig.hoistResources; +export const hoistResourcesToRoot = $$$hostConfig.hoistResourcesToRoot; +export const prepareToRender = $$$hostConfig.prepareToRender; +export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; +export const createResources = $$$hostConfig.createResources; +export const createBoundaryResources = $$$hostConfig.createBoundaryResources; +export const setBoundaryResources = $$$hostConfig.setBoundaryResources; diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index dfccf3e9ef0fc..8140fe7a0bbd9 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -46,6 +46,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoPersistence'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks'; +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources'; const NO_CONTEXT = {}; const UPDATE_SIGNAL = {}; diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index ae2e532b270eb..f8d8b8ca3a37a 100644 --- a/packages/react-test-renderer/src/ReactTestRenderer.js +++ b/packages/react-test-renderer/src/ReactTestRenderer.js @@ -27,6 +27,7 @@ import { FunctionComponent, ClassComponent, HostComponent, + HostResource, HostPortal, HostText, HostRoot, @@ -196,6 +197,7 @@ function toTree(node: ?Fiber) { instance: null, rendered: childrenToTree(node.child), }; + case HostResource: case HostComponent: { return { nodeType: 'host', @@ -302,7 +304,7 @@ class ReactTestInstance { } get instance() { - if (this._fiber.tag === HostComponent) { + if (this._fiber.tag === HostComponent || this._fiber.tag === HostResource) { return getPublicInstance(this._fiber.stateNode); } else { return this._fiber.stateNode; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 7a23215cc332a..7576b1d3f38d0 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -425,5 +425,13 @@ "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead.", "438": "An unsupported type was passed to use(): %s", "439": "We didn't expect to see a forward reference. This is a bug in the React Server.", - "440": "An event from useEvent was called during render." + "440": "An event from useEvent was called during render.", + "441": "The current renderer does not support Resources. This error is likely caused by a bug in React. Please file an issue.", + "442": "React encountered a Resource type it did not recognize (\"%s\"). this is a bug in React.", + "443": "acquireResource encountered a resource type it did not expect: \"%s\". this is a bug in React.", + "444": "getResource encountered a link type (rel) it did not expect: \"%s\". this is a bug in React.", + "445": "getResource encountered a resource type it did not expect: \"%s\". this is a bug in React.", + "446": "\"currentResources\" was expected to exist. This is a bug in React.", + "447": "ReactDOM.preinit was when React did not expect it. If you called this function outside of a Component render, such as in an effect or event handler, refactor your code to call it while rendering. If it was called while React was rendering this is a bug in React.", + "448": "ReactDOM.preload was when React did not expect it. If you called this function outside of a Component render, such as in an effect or event handler, refactor your code to call it while rendering. If it was called while React was rendering this is a bug in React." }