From 75686aa287ba86f475f2cce5374a06fd83d7d04d Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 10 Jun 2022 09:50:31 -0700 Subject: [PATCH 1/7] [Fizz/Float] Float for stylesheet resources This change implements Float for a minimal use case of hoisting stylesheet resources to the head and ensuring the flush in the appropriate spot in the stream. Subsequent commits will add support for client stylesheet hoisting and Flight resources. While there is some additional buildout of Float capabilities in general the public APIs have all been removed. The intent with this first implementation is to opt in use the more useful semantics of resources --- packages/react-art/src/ReactARTHostConfig.js | 3 + .../src/__tests__/ReactDOMFizzServer-test.js | 69 ++ .../__tests__/ReactDOMServerPreload-test.js | 1079 +++++++++++++++++ .../src/client/ReactDOMHostConfig.js | 12 +- .../src/server/ReactDOMFloatServer.js | 432 +++++++ .../src/server/ReactDOMServerFormatConfig.js | 240 +++- .../ReactDOMServerLegacyFormatConfig.js | 5 + .../src/shared/ReactDOMDispatcher.js | 25 + .../src/ReactFabricHostConfig.js | 8 + .../src/ReactNativeHostConfig.js | 8 + .../server/ReactNativeServerFormatConfig.js | 12 + .../src/ReactNoopServer.js | 13 + .../src/createReactNoop.js | 3 + .../src/ReactFiberWorkLoop.new.js | 257 ++-- .../src/ReactFiberWorkLoop.old.js | 255 ++-- .../ReactFiberHostContext-test.internal.js | 4 + .../src/forks/ReactFiberHostConfig.custom.js | 2 + packages/react-server/src/ReactFizzServer.js | 79 +- .../forks/ReactServerFormatConfig.custom.js | 5 + .../src/ReactTestHostConfig.js | 3 + packages/shared/ReactFeatureFlags.js | 3 + .../forks/ReactFeatureFlags.native-fb.js | 2 + .../forks/ReactFeatureFlags.native-oss.js | 2 + .../forks/ReactFeatureFlags.test-renderer.js | 2 + .../ReactFeatureFlags.test-renderer.www.js | 2 + .../shared/forks/ReactFeatureFlags.testing.js | 2 + .../forks/ReactFeatureFlags.testing.www.js | 2 + .../shared/forks/ReactFeatureFlags.www.js | 3 + 28 files changed, 2272 insertions(+), 260 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js create mode 100644 packages/react-dom/src/server/ReactDOMFloatServer.js create mode 100644 packages/react-dom/src/shared/ReactDOMDispatcher.js diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 47bced8a1274a..c21c2dfb7e087 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -451,3 +451,6 @@ export function preparePortalMount(portalInstance: any): void { export function detachDeletedInstance(node: Instance): void { // noop } + +export function prepareToRender() {} +export function cleanupAfterRender() {} diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 60a3a3938df3e..a166ace5d4949 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -144,6 +144,30 @@ describe('ReactDOMFizzServer', () => { } } + async function actIntoEmptyDocument(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(bufferedContent, { + runScripts: 'dangerously', + }); + window = jsdom.window; + document = jsdom.window.document; + container = document; + buffer = ''; + } + function getVisibleChildren(element) { const children = []; let node = element.firstChild; @@ -301,6 +325,7 @@ describe('ReactDOMFizzServer', () => { ); pipe(writable); }); + expect(getVisibleChildren(container)).toEqual(
Loading...
@@ -3202,6 +3227,50 @@ describe('ReactDOMFizzServer', () => { ); }); + it('converts stylesheet links into preinit-as-style resources', async () => { + function App() { + return ( + <> + + + + a title + + a body + + + ); + } + + const chunks = []; + function listener(chunk) { + chunks.push(chunk); + } + + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + writable.on('data', listener); + pipe(writable); + }); + writable.removeListener('data', listener); + + expect( + chunks[0].startsWith( + '', + ), + ).toBe(true); + + expect(getVisibleChildren(container)).toEqual( + + + + a title + + a body + , + ); + }); + describe('error escaping', () => { it('escapes error hash, message, and component stack values in directly flushed errors (html escaping)', async () => { window.__outlet = {}; diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js new file mode 100644 index 0000000000000..09a4614d35c62 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js @@ -0,0 +1,1079 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let JSDOM; +let Stream; +// let Scheduler; +let React; +let ReactDOM; +// let ReactDOMClient; +let ReactDOMFizzServer; +let Suspense; +// let TextDecoder; +let textCache; +// let window; +let document; +let writable; +let container; +let buffer = ''; +let hasErrored = false; +let fatalError = undefined; +const CSPnonce = null; + +describe('ReactDOMServerPreload', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + // Scheduler = require('scheduler'); + React = require('react'); + ReactDOM = require('react-dom'); + // ReactDOMClient = require('react-dom/client'); + if (__EXPERIMENTAL__) { + ReactDOMFizzServer = require('react-dom/server'); + } + Stream = require('stream'); + Suspense = React.Suspense; + // TextDecoder = require('util').TextDecoder; + + textCache = new Map(); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + // window = jsdom.window; + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + function expectLinks(beforeLinks, separator, afterLinks) { + let selector = 'link'; + if (separator) { + selector += ', ' + separator; + } + const els = Array.from(document.querySelectorAll(selector)); + let index = 0; + const [foundBeforeLinks, foundAfterLinks] = els.reduce( + (linkGroups, nextEl) => { + switch (nextEl.tagName) { + case (separator && separator.toUpperCase()) || '': { + index = 1; + break; + } + case 'LINK': { + const descriptor = [nextEl.rel, nextEl.getAttribute('href')]; + if (nextEl.hasAttribute('as')) { + descriptor.push(nextEl.getAttribute('as')); + } + if (nextEl.hasAttribute('crossorigin')) { + descriptor.push( + nextEl.getAttribute('crossorigin') === 'use-credentials' + ? 'use-credentials' + : 'anonymous', + ); + } + linkGroups[index].push(descriptor); + break; + } + } + return linkGroups; + }, + [[], []], + ); + expect(foundBeforeLinks).toEqual(beforeLinks); + if (separator) { + expect(foundAfterLinks).toEqual(afterLinks); + } + } + + function expectBodyLinks(bodyLinks) { + return expectLinks([], 'body', bodyLinks); + } + + function expectScript(href, toBeScript) { + const script = document.querySelector(`script[data-src="${href}"]`); + const expected = [ + script.tagName.toLowerCase(), + script.getAttribute('data-src'), + ]; + if (script.hasAttribute('crossorigin')) { + expected.push( + script.getAttribute('crossorigin') === 'use-credentials' + ? 'use-credentials' + : 'anonymous', + ); + } + expect(expected).toEqual(toBeScript); + } + + async function act(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + buffer = ''; + const fakeBody = document.createElement('body'); + fakeBody.innerHTML = bufferedContent; + const appender = container === document ? document.body : container; + while (fakeBody.firstChild) { + const node = fakeBody.firstChild; + if ( + node.nodeName === 'SCRIPT' && + (CSPnonce === null || node.getAttribute('nonce') === CSPnonce) + ) { + const script = document.createElement('script'); + if (node.hasAttribute('src')) { + script.setAttribute('data-src', node.getAttribute('src')); + } + if (node.hasAttribute('crossorigin')) { + script.setAttribute('crossorigin', node.getAttribute('crossorigin')); + } + if (node.hasAttribute('async')) { + script.setAttribute('async', node.getAttribute('async')); + } + script.textContent = node.textContent; + fakeBody.removeChild(node); + appender.appendChild(script); + } else { + appender.appendChild(node); + } + } + const scripts = Array.from(document.getElementsByTagName('script')); + scripts.forEach(script => { + const srcAttr = script.getAttribute('src'); + if (srcAttr != null) { + script.dataset.src = srcAttr; + } + }); + } + + async function actIntoEmptyDocument(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(bufferedContent, { + runScripts: 'dangerously', + }); + // window = jsdom.window; + document = jsdom.window.document; + container = document; + buffer = ''; + } + + function getVisibleChildren(element) { + const children = []; + let node = element.firstChild; + while (node) { + if (node.nodeType === 1) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { + const props = {}; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } + props[attributes[i].name] = attributes[i].value; + } + props.children = getVisibleChildren(node); + children.push(React.createElement(node.tagName.toLowerCase(), props)); + } + } else if (node.nodeType === 3) { + children.push(node.data); + } + node = node.nextSibling; + } + return children.length === 0 + ? undefined + : children.length === 1 + ? children[0] + : children; + } + + function resolveText(text) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + } + + // function rejectText(text, error) { + // const record = textCache.get(text); + // if (record === undefined) { + // const newRecord = { + // status: 'rejected', + // value: error, + // }; + // textCache.set(text, newRecord); + // } else if (record.status === 'pending') { + // const thenable = record.value; + // record.status = 'rejected'; + // record.value = error; + // thenable.pings.forEach(t => t()); + // } + // } + + function readText(text) { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + throw record.value; + case 'rejected': + throw record.value; + case 'resolved': + return record.value; + } + } else { + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + + throw thenable; + } + } + + xit('can flush a preload link for a stylesheet', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([['preload', 'foo', 'style']]); + }); + + xit('only emits 1 preload even if preload is called more than once for the same resource', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( + <> + + + + ); + } + + function Component1() { + ReactDOM.preload('bar', {as: 'style'}); + return
one
; + } + + function Component2() { + ReactDOM.preload('foo', {as: 'style'}); + return
two
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([ + ['preload', 'foo', 'style'], + ['preload', 'bar', 'style'], + ]); + }); + + xit('only emits resources once per priority', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( + + + + + + ); + } + + function Resource({href}) { + const text = readText(href); + ReactDOM.preload(text, {as: 'style'}); + return
{text}
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + await resolveText('foo'); + pipe(writable); + }); + expectBodyLinks([['preload', 'foo', 'style']]); + + await act(async () => { + resolveText('bar'); + }); + expectBodyLinks([ + ['preload', 'foo', 'style'], + ['preload', 'bar', 'style'], + ]); + + await act(async () => { + await resolveText('baz'); + }); + expectBodyLinks([ + ['preload', 'foo', 'style'], + ['preload', 'bar', 'style'], + ['preload', 'baz', 'style'], + ]); + }); + + xit('does not emit a preload if a resource has already been initialized', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( + + + + + ); + } + + function PreloadResource({href}) { + ReactDOM.preload(href, {as: 'style'}); + const text = readText(href); + ReactDOM.preinit(text, {as: 'style'}); + return
{text}
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + await resolveText('foo'); + pipe(writable); + }); + expectBodyLinks([ + ['stylesheet', 'foo'], + ['preload', 'bar', 'style'], + ]); + + await act(async () => { + resolveText('bar'); + }); + expectBodyLinks([ + ['stylesheet', 'foo'], + ['preload', 'bar', 'style'], + ['stylesheet', 'bar'], + ]); + }); + + xit('does not emit lower priority resource loaders when a higher priority loader is already known', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( + + + + ); + } + + function PreloadResource({href}) { + ReactDOM.preinit(href, {as: 'style'}); + const text = readText(href); + ReactDOM.preload(text, {as: 'style'}); + return
{text}
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expectBodyLinks([['stylesheet', 'foo']]); + + await act(async () => { + resolveText('foo'); + }); + expectBodyLinks([['stylesheet', 'foo']]); + }); + + xit('supports prefetching DNS', async () => { + function App() { + ReactDOM.prefetchDNS('foo'); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([['dns-prefetch', 'foo']]); + }); + + xit('supports preconnecting', async () => { + function App() { + ReactDOM.preconnect('foo'); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([['preconnect', 'foo']]); + }); + + xit('supports prefetching', async () => { + function App() { + ReactDOM.prefetch('foo', {as: 'font'}); + ReactDOM.prefetch('bar', {as: 'style'}); + ReactDOM.prefetch('baz', {as: 'script'}); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([ + ['prefetch', 'foo', 'font', 'anonymous'], + ['prefetch', 'bar', 'style'], + ['prefetch', 'baz', 'script'], + ]); + }); + + xit('supports preloading', async () => { + function App() { + ReactDOM.preload('foo', {as: 'font'}); + ReactDOM.preload('bar', {as: 'style'}); + ReactDOM.preload('baz', {as: 'script'}); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([ + ['preload', 'foo', 'font', 'anonymous'], + ['preload', 'bar', 'style'], + ['preload', 'baz', 'script'], + ]); + }); + + xit('supports initializing stylesheets and scripts', async () => { + function App() { + ReactDOM.preinit('foo', {as: 'style'}); + ReactDOM.preinit('bar', {as: 'script'}); + return
hi
; + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([['stylesheet', 'foo']]); + expectScript('bar', ['script', 'bar']); + }); + + it('converts links for preloading into resources for preloading', async () => { + function App() { + return ( +
+ + + + + + + + + + + +
+ ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + expectBodyLinks([ + // the stylesheet link is hoisted as a resource while the other links are left in place + ['stylesheet', 'stylesheet'], + + ['foo', 'this link is not a resource'], + ['dns-prefetch', 'dns-prefetch'], + ['preconnect', 'preconnect'], + ['prefetch', 'prefetchstyle', 'style'], + ['prefetch', 'prefetchscript', 'script'], + ['prefetch', 'prefetchfont', 'font'], + ['preload', 'preloadstyle', 'style'], + ['preload', 'preloadscript', 'script'], + ['preload', 'preloadfont', 'font'], + ['font', 'font'], + ]); + }); + + // @TODO restore this test once we support scripts + xit('captures resources for preloading when rendering a script', async () => { + function App() { + return ( +
+ + '); + +function writeGenericResource( + destination: Destination, + resource: Resource, + start: PrecomputedChunk, + end: PrecomputedChunk, +) { + writeChunk(destination, start); + writeChunk(destination, stringToChunk(escapeTextForBrowser(resource.href))); + if (resource.crossorigin === CORS_ANON) { + writeChunk(destination, crossOriginAnon); + } else if (resource.crossorigin === CORS_CREDS) { + writeChunk(destination, crossOriginCredentials); + } + writeChunk(destination, end); +} + +function writeAsResource( + destination: Destination, + resource: Resource, + start: PrecomputedChunk, + end: PrecomputedChunk, +) { + writeChunk(destination, start); + switch (resource.as) { + case STYLE_RESOURCE: { + writeChunk(destination, preAsStyle); + break; + } + case SCRIPT_RESOURCE: { + writeChunk(destination, preAsScript); + break; + } + case FONT_RESOURCE: { + writeChunk(destination, preAsFont); + break; + } + } + writeChunk(destination, stringToChunk(escapeTextForBrowser(resource.href))); + if (resource.crossorigin === CORS_ANON) { + writeChunk(destination, crossOriginAnon); + } else if (resource.crossorigin === CORS_CREDS) { + writeChunk(destination, crossOriginCredentials); + } + writeChunk(destination, end); +} + +function writeInitializingResource( + destination: Destination, + resource: Resource, +) { + switch (resource.as) { + case STYLE_RESOURCE: { + return writeGenericResource( + destination, + resource, + initStyleStart, + linkEnd, + ); + } + case SCRIPT_RESOURCE: { + return writeGenericResource( + destination, + resource, + initScriptStart, + initScriptEnd, + ); + } + } +} + +export function prepareToRender(resources: Resources) { + prepareToRenderImpl(resources); +} + +export function cleanupAfterRender() { + cleanupAfterRenderImpl(); +} + +export type Resources = Map; +export function createResources(): Resources { + return new Map(); +} diff --git a/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js index 375562e80b56d..d5ab3e26b8999 100644 --- a/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js @@ -74,6 +74,7 @@ export function createRootFormatContext(): FormatContext { } export type { + Resources, FormatContext, SuspenseBoundaryID, } from './ReactDOMServerFormatConfig'; @@ -96,6 +97,10 @@ export { writeEndPendingSuspenseBoundary, writePlaceholder, writeCompletedRoot, + createResources, + writeResources, + 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-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 515c4e0f9665c..842ae52835a0d 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -612,3 +612,11 @@ export function preparePortalMount(portalInstance: Instance): void { export function detachDeletedInstance(node: Instance): void { // noop } + +export function prepareToRender(): void { + // noop +} + +export function cleanupAfterRender(): void { + // noop +} diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 10c5e37f41bcc..ac12a562718bf 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -513,3 +513,11 @@ export function preparePortalMount(portalInstance: Instance): void { export function detachDeletedInstance(node: Instance): void { // noop } + +export function prepareToRender(): void { + // noop +} + +export function cleanupAfterRender(): void { + // noop +} diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index 3c2c23c911faf..0e85d8d8e706a 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -57,6 +57,8 @@ 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; + // Per response, export type ResponseState = { nextSuspenseID: number, @@ -137,6 +139,7 @@ export function pushTextInstance( export function pushStartInstance( target: Array, + prelude: mixed, type: string, props: Object, responseState: ResponseState, @@ -153,6 +156,7 @@ export function pushStartInstance( export function pushEndInstance( target: Array, + postlude: mixed, type: string, props: Object, ): void { @@ -307,3 +311,11 @@ export function writeClientRenderBoundaryInstruction( writeChunk(destination, SUSPENSE_UPDATE_TO_CLIENT_RENDER); return writeChunkAndReturn(destination, formatID(boundaryID)); } + +export function writeResources( + destination: Destination, + resources: Resources, +) {} +export function prepareToRender(resources: Resources) {} +export function cleanupAfterRender() {} +export function createResources() {} diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 14003b8291b37..5dabb8b180af9 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -51,6 +51,8 @@ type Destination = { stack: Array, }; +type Resources = null; + const POP = Buffer.from('/', 'utf8'); function write(destination: Destination, buffer: Uint8Array): void { @@ -113,6 +115,7 @@ const ReactNoopServer = ReactFizzServer({ }, pushStartInstance( target: Array, + prelude: mixed, type: string, props: Object, ): ReactNodeList { @@ -128,6 +131,7 @@ const ReactNoopServer = ReactFizzServer({ pushEndInstance( target: Array, + postlude: mixed, type: string, props: Object, ): void { @@ -261,6 +265,15 @@ const ReactNoopServer = ReactFizzServer({ ): boolean { boundary.status = 'client-render'; }, + + writeResources() {}, + + createResources(): Resources { + return null; + }, + + prepareToRender() {}, + cleanupAfterRender() {}, }); type Options = { diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index df2b3a839a521..a797f274acae1 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -477,6 +477,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { logRecoverableError() { // no-op }, + + prepareToRender() {}, + cleanupAfterRender() {}, }; const hostConfig = useMutation diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 4e965bb3f42d4..986f54004dc37 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -38,6 +38,7 @@ import { enableUpdaterTracking, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import is from 'shared/objectIs'; @@ -81,6 +82,8 @@ import { supportsMicrotasks, errorHydratingContainer, scheduleMicrotask, + prepareToRender, + cleanupAfterRender, } from './ReactFiberHostConfig'; import { @@ -900,141 +903,151 @@ 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(); - } + try { + if (enableFloat) { + 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; + 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 errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, 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; - } - - 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 errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, 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; + + // 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 errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError(root, errorRetryLanes); } } + 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 errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError(root, 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; + } + } - // 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) { + cleanupAfterRender(); + } } - return null; } function recoverFromConcurrentError(root, errorRetryLanes) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 4b2e523d40a21..f23ad4663c966 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -38,6 +38,7 @@ import { enableUpdaterTracking, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import is from 'shared/objectIs'; @@ -81,6 +82,8 @@ import { supportsMicrotasks, errorHydratingContainer, scheduleMicrotask, + prepareToRender, + cleanupAfterRender, } from './ReactFiberHostConfig'; import { @@ -900,141 +903,151 @@ 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) { + 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 errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, 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; - } - - 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 errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); - if (errorRetryLanes !== NoLanes) { - lanes = errorRetryLanes; - exitStatus = recoverFromConcurrentError(root, 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; + + // 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 errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError(root, errorRetryLanes); } } + 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); + 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 errorRetryLanes = getLanesToRetrySynchronouslyOnError(root); + if (errorRetryLanes !== NoLanes) { + lanes = errorRetryLanes; + exitStatus = recoverFromConcurrentError(root, 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; + } + } + + // 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) { + cleanupAfterRender(); + } } - return null; } function recoverFromConcurrentError(root, errorRetryLanes) { diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index 82e23de9965da..30c227c17d8ab 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, }); @@ -129,6 +131,8 @@ describe('ReactFiberHostContext', () => { getCurrentEventPriority: function() { return DefaultEventPriority; }, + 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 8afc9a3aa2cb9..42755ec9ac7dd 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -186,3 +186,5 @@ export const didNotFindHydratableTextInstance = export const didNotFindHydratableSuspenseInstance = $$$hostConfig.didNotFindHydratableSuspenseInstance; export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer; +export const prepareToRender = $$$hostConfig.prepareToRender; +export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index a8a3fe6932072..b919d3153173e 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -22,6 +22,7 @@ import type { SuspenseBoundaryID, ResponseState, FormatContext, + Resources, } from './ReactServerFormatConfig'; import type {ContextSnapshot} from './ReactFizzNewContext'; import type {ComponentStackNode} from './ReactFizzComponentStack'; @@ -60,6 +61,10 @@ import { UNINITIALIZED_SUSPENSE_BOUNDARY_ID, assignSuspenseBoundaryID, getChildFormatContext, + writeResources, + prepareToRender, + cleanupAfterRender, + createResources, } from './ReactServerFormatConfig'; import { constructClassInstance, @@ -191,13 +196,16 @@ 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. 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. partialBoundaries: Array, // Partially completed boundaries that can flush its segments early. + +prelude: Array, // chunks that need to be emitted before any segment chunks + +postlude: Array, // chunks that need to be emitted before after segments, waiting even for pending tasks before flushing // onError is called when an error happens anywhere in the tree. It might recover. // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes. // Returning null/undefined will cause a defualt error message in production @@ -252,6 +260,7 @@ export function createRequest( ): Request { const pingedTasks = []; const abortSet: Set = new Set(); + const resources: Resources = createResources(); const request = { destination: null, responseState, @@ -264,12 +273,15 @@ export function createRequest( nextSegmentId: 0, allPendingTasks: 0, pendingRootTasks: 0, + resources, completedRootSegment: null, abortableTasks: abortSet, pingedTasks: pingedTasks, clientRenderedBoundaries: [], completedBoundaries: [], partialBoundaries: [], + prelude: [], + postlude: [], onError: onError === undefined ? defaultErrorHandler : onError, onAllReady: onAllReady === undefined ? noop : onAllReady, onShellReady: onShellReady === undefined ? noop : onShellReady, @@ -628,8 +640,10 @@ function renderHostElement( ): void { pushBuiltInComponentStackInDEV(task, type); const segment = task.blockedSegment; + const children = pushStartInstance( segment.chunks, + request.prelude, type, props, request.responseState, @@ -638,6 +652,7 @@ function renderHostElement( 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); @@ -645,7 +660,13 @@ function renderHostElement( // We expect that errors will fatal the whole task and that we don't need // the correct context. Therefore this is not in a finally. segment.formatContext = prevContext; - pushEndInstance(segment.chunks, type, props); + pushEndInstance( + segment.chunks, + request.postlude, + type, + props, + segment.formatContext, + ); segment.lastPushedText = false; popComponentStackInDEV(task); } @@ -1689,6 +1710,7 @@ function finishedTask( } function retryTask(request: Request, task: Task): void { + prepareToRender(request.resources); const segment = task.blockedSegment; if (segment.status !== PENDING) { // We completed this by other means before we had a chance to retry it. @@ -1732,6 +1754,7 @@ function retryTask(request: Request, task: Task): void { if (__DEV__) { currentTaskInDEV = prevTaskInDEV; } + cleanupAfterRender(); } } @@ -1804,6 +1827,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. @@ -2035,6 +2059,7 @@ function flushCompletedQueues( request: Request, destination: Destination, ): void { + let allComplete = false; beginWriting(destination); try { // The structure of this is to go through each queue one by one and write @@ -2042,22 +2067,37 @@ 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. - - // TODO: It's kind of unfortunate to keep checking this array after we've already - // emitted the root. + let i; const completedRootSegment = request.completedRootSegment; - if (completedRootSegment !== null && request.pendingRootTasks === 0) { - flushSegment(request, destination, completedRootSegment); - request.completedRootSegment = null; - writeCompletedRoot(destination, request.responseState); + if (completedRootSegment !== null) { + if (request.pendingRootTasks === 0) { + // Flush the prelude if it exists + const prelude = request.prelude; + for (i = 0; i < prelude.length; i++) { + // we expect the prelude to be tiny and will ignore backpressure + writeChunk(destination, prelude[i]); + } + prelude.length = 0; + + // Flush resources + writeResources(destination, request.resources); + + // Flush the root segment + flushSegment(request, destination, completedRootSegment); + request.completedRootSegment = null; + writeCompletedRoot(destination, request.responseState); + } else { + // We haven't flushed the root yet so we don't need to check any other branches further down + return; + } + } else { + writeResources(destination, request.resources); } // We emit client rendering instructions for already emitted boundaries first. // This is so that we can signal to the client to start client rendering them as // soon as possible. const clientRenderedBoundaries = request.clientRenderedBoundaries; - let i; for (i = 0; i < clientRenderedBoundaries.length; i++) { const boundary = clientRenderedBoundaries[i]; if (!flushClientRenderedBoundary(request, destination, boundary)) { @@ -2119,9 +2159,8 @@ function flushCompletedQueues( } } largeBoundaries.splice(0, i); - } finally { - completeWriting(destination); - flushBuffered(destination); + + // Finally we check if there is no remaining work and flush the postlude if ( request.allPendingTasks === 0 && request.pingedTasks.length === 0 && @@ -2130,6 +2169,18 @@ function flushCompletedQueues( // We don't need to check any partially completed segments because // either they have pending task or they're complete. ) { + allComplete = true; + const postlude = request.postlude; + for (i = 0; i < postlude.length; i++) { + // we expect the postlude to be tiny and will ignore backpressure + writeChunk(destination, postlude[i]); + } + postlude.length = 0; + } + } finally { + completeWriting(destination); + flushBuffered(destination); + if (allComplete) { if (__DEV__) { if (request.abortableTasks.size !== 0) { console.error( diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index ecb3218ea1dad..f98d0e566e82b 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -26,6 +26,7 @@ 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 FormatContext = mixed; export opaque type SuspenseBoundaryID = mixed; @@ -66,3 +67,7 @@ export const writeCompletedBoundaryInstruction = $$$hostConfig.writeCompletedBoundaryInstruction; export const writeClientRenderBoundaryInstruction = $$$hostConfig.writeClientRenderBoundaryInstruction; +export const writeResources = $$$hostConfig.writeResources; +export const prepareToRender = $$$hostConfig.prepareToRender; +export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; +export const createResources = $$$hostConfig.createResources; diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 840912db30886..cedc00bf72d93 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -318,3 +318,6 @@ export function detachDeletedInstance(node: Instance): void { export function logRecoverableError(error: mixed): void { // noop } + +export function prepareToRender() {} +export function cleanupAfterRender() {} diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 33d233f92e4ef..1ec677d479933 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -254,3 +254,6 @@ export const enableGetInspectorDataForInstanceInProduction = false; export const enableProfilerNestedUpdateScheduledHook = false; export const consoleManagedByDevToolsDuringStrictMode = true; + +// enables preloading apis for React-dom server/client +export const enableFloat = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 0e07c9f67d994..9eddf89765abb 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -81,6 +81,8 @@ export const enableUseMutableSource = true; export const enableTransitionTracing = false; export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; + // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 933d914ce5a62..7aa7db4f29b7e 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -70,6 +70,8 @@ export const enableUseMutableSource = false; export const enableTransitionTracing = false; export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; + // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index b218c53470bda..3a02a754b6981 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -70,6 +70,8 @@ export const enableUseMutableSource = false; export const enableTransitionTracing = false; export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; + // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 63ba329f01a90..85b23b8e3dcb8 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -72,6 +72,8 @@ export const enableUseMutableSource = true; export const enableTransitionTracing = false; export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; + // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index 9e5a9107ab2b1..b6257863e18c7 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -70,6 +70,8 @@ export const enableUseMutableSource = false; export const enableTransitionTracing = false; export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; + // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index a4c3e1ba32678..b4855dc382c12 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -71,6 +71,8 @@ export const enableUseMutableSource = true; export const enableTransitionTracing = false; export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; + // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 50d078d3f0fd9..5f2fe666f4446 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -108,6 +108,9 @@ export const enableUseMutableSource = true; export const enableCustomElementPropertySupport = __EXPERIMENTAL__; export const enableSymbolFallbackForWWW = true; + +export const enableFloat = true; + // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; From 2a6a8f82db82a36fa45b40d9a37cc506718a0b3e Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 12 Jul 2022 17:40:52 -0700 Subject: [PATCH 2/7] Implement FloatResources in react-dom Resources are implemented as a new fiber type HostResource which expect ref counting and hoisting semantics. Currently only react-dom supports this and it is only enabled if the `enableFloat` flag is true. When encountering a resource we don't append it to the parent host component like usual. In the commit phase the resource is "acquired" and if necessary a domElement is created and inserted into the Document head. If a HostResource fiber is deleted the resource is "released" and if that was the last referrer to that resource is is removed from the document. Hydration poses a special challenge. We want to skip over resources in hydration because they may not have a matching element during render. This will become more prevalent when Float apis like `preinit(...)` are available. However if the resource exists because of a component rendering we should ideally opt into ref counting semnatics. The current approach is to consume hydratable resources in commit (if any are acquired we opt into ref counting mode) and if there are any remaining we assume they are static resources and hoist them permanently. --- packages/react-art/src/ReactARTHostConfig.js | 4 +- .../src/__tests__/ReactDOMComponent-test.js | 3 + .../src/__tests__/ReactDOMResources-test.js | 575 ++++++++++++++++++ .../__tests__/ReactDOMServerPreload-test.js | 4 +- .../react-dom/src/client/ReactDOMComponent.js | 18 +- .../src/client/ReactDOMFloatResources.js | 209 +++++++ .../src/client/ReactDOMHostConfig.js | 46 +- .../src/server/ReactDOMFloatServer.js | 1 - .../src/ReactFabricHostConfig.js | 9 +- .../src/ReactNativeHostConfig.js | 9 +- .../src/createReactNoop.js | 1 + .../react-reconciler/src/ReactFiber.new.js | 13 +- .../react-reconciler/src/ReactFiber.old.js | 13 +- .../src/ReactFiberBeginWork.new.js | 24 + .../src/ReactFiberBeginWork.old.js | 24 + .../src/ReactFiberCommitWork.new.js | 47 ++ .../src/ReactFiberCommitWork.old.js | 47 ++ .../src/ReactFiberCompleteWork.new.js | 12 + .../src/ReactFiberCompleteWork.old.js | 12 + .../ReactFiberHostConfigWithNoResources.js | 30 + .../src/ReactFiberRoot.new.js | 12 +- .../src/ReactFiberRoot.old.js | 12 +- .../src/ReactFiberWorkLoop.new.js | 10 +- .../src/ReactFiberWorkLoop.old.js | 16 +- .../src/ReactInternalTypes.js | 2 + .../react-reconciler/src/ReactWorkTags.js | 4 +- .../src/forks/ReactFiberHostConfig.custom.js | 12 + packages/react-server/src/ReactFizzServer.js | 8 +- .../src/ReactTestHostConfig.js | 4 +- packages/shared/ReactFeatureFlags.js | 6 +- .../ReactFeatureFlags.test-renderer.native.js | 2 + .../shared/forks/ReactFeatureFlags.www.js | 2 +- scripts/error-codes/codes.json | 7 +- 33 files changed, 1142 insertions(+), 56 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMResources-test.js create mode 100644 packages/react-dom/src/client/ReactDOMFloatResources.js create mode 100644 packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index c21c2dfb7e087..830b863633cd2 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -243,6 +243,7 @@ export * from 'react-reconciler/src/ReactFiberHostConfigWithNoHydration'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoScopes'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoTestSelectors'; export * from 'react-reconciler/src/ReactFiberHostConfigWithNoMicrotasks'; +export * from 'react-reconciler/src/ReactFiberHostConfigWithNoResources'; export function appendInitialChild(parentInstance, child) { if (typeof child === 'string') { @@ -451,6 +452,3 @@ export function preparePortalMount(portalInstance: any): void { export function detachDeletedInstance(node: Instance): void { // noop } - -export function prepareToRender() {} -export function cleanupAfterRender() {} diff --git a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js index ec547d8cbaa83..3e2c593bf977b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js @@ -487,6 +487,7 @@ describe('ReactDOMComponent', () => { expect(node.hasAttribute('src')).toBe(false); }); + // @gate !enableFloat it('should not add an empty href attribute', () => { const container = document.createElement('div'); expect(() => ReactDOM.render(, container)).toErrorDev( @@ -1501,6 +1502,7 @@ describe('ReactDOMComponent', () => { } }); + // @gate !enableFloat it('should receive a load event on elements', () => { const container = document.createElement('div'); const onLoad = jest.fn(); @@ -1519,6 +1521,7 @@ describe('ReactDOMComponent', () => { expect(onLoad).toHaveBeenCalledTimes(1); }); + // @gate !enableFloat it('should receive an error event on elements', () => { const container = document.createElement('div'); const onError = jest.fn(); diff --git a/packages/react-dom/src/__tests__/ReactDOMResources-test.js b/packages/react-dom/src/__tests__/ReactDOMResources-test.js new file mode 100644 index 0000000000000..c8819d7b09a23 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMResources-test.js @@ -0,0 +1,575 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +let JSDOM; +let Scheduler; +let React; +let ReactDOMClient; +let ReactDOMFizzServer; +let container; +let document; +let writable; +let buffer; +let hasErrored; +let Stream; +let fatalError; + +describe('ReactDOMResources', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + Scheduler = require('scheduler'); + Stream = require('stream'); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + ReactDOMFizzServer = require('react-dom/server'); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + function getVisibleChildren(element) { + const children = []; + let node = element.firstChild; + while (node) { + if (node.nodeType === 1) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { + const props = {}; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } + props[attributes[i].name] = attributes[i].value; + } + props.children = getVisibleChildren(node); + children.push(React.createElement(node.tagName.toLowerCase(), props)); + } + } else if (node.nodeType === 3) { + children.push(node.data); + } + node = node.nextSibling; + } + return children.length === 0 + ? undefined + : children.length === 1 + ? children[0] + : children; + } + + async function actIntoEmptyDocument(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(bufferedContent, { + runScripts: 'dangerously', + }); + document = jsdom.window.document; + container = document; + buffer = ''; + } + + async function actInto(callback, prelude, getContainer) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + // Test Environment + const jsdom = new JSDOM(prelude + bufferedContent, { + runScripts: 'dangerously', + }); + document = jsdom.window.document; + container = getContainer(document); + buffer = ''; + } + + // async function act(callback) { + // await callback(); + // // Await one turn around the event loop. + // // This assumes that we'll flush everything we have so far. + // await new Promise(resolve => { + // setImmediate(resolve); + // }); + // if (hasErrored) { + // throw fatalError; + // } + // // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // // We also want to execute any scripts that are embedded. + // // We assume that we have now received a proper fragment of HTML. + // const bufferedContent = buffer; + // buffer = ''; + // const fakeBody = document.createElement('body'); + // fakeBody.innerHTML = bufferedContent; + // while (fakeBody.firstChild) { + // const node = fakeBody.firstChild; + // if ( + // node.nodeName === 'SCRIPT' && + // (CSPnonce === null || node.getAttribute('nonce') === CSPnonce) + // ) { + // const script = document.createElement('script'); + // script.textContent = node.textContent; + // fakeBody.removeChild(node); + // container.appendChild(script); + // } else { + // container.appendChild(node); + // } + // } + // } + + // @gate enableFloat + it('hoists resources to the head if the container is a Document without hydration', async () => { + function App() { + return ( + <> + + + + +
hello world
+ + + + + ); + } + + await actInto( + async () => {}, + 'this will be replaced on root.render', + doc => doc, + ); + + expect(getVisibleChildren(document)).toEqual( + + + this will be replaced on root.render + , + ); + + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the head if the container is a Document with hydration', async () => { + function App() { + return ( + <> + + + + +
hello world
+ + + + + ); + } + + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the head if the container is the documentElement without hydration', async () => { + function App() { + return ( + <> + + + + +
hello world
+ + + ); + } + + await actInto( + async () => {}, + 'this will be replaced on root.render', + doc => doc.documentElement, + ); + + expect(getVisibleChildren(document)).toEqual( + + + this will be replaced on root.render + , + ); + + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the head if the container is the documentElement with hydration', async () => { + function App() { + return ( + <> + + + + +
hello world
+ + + ); + } + + await actInto( + async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }, + '', + doc => doc.documentElement, + ); + + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the head when the container is an Element (other than the documentElement) without hydration', async () => { + function App() { + return ( + <> + +
hello world
+ + + ); + } + + await actInto( + async () => {}, + '
', + doc => doc.getElementById('container'), + ); + expect(getVisibleChildren(document)).toEqual( + + + +
+ +
+ + , + ); + + const root = ReactDOMClient.createRoot(container); + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
+
hello world
+
+ + , + ); + }); + + // @gate enableFloat + it('hoists resources to the container when it is an Element (other than the documentElement) with hydration', async () => { + function App() { + return ( + <> + +
hello world
+ + + ); + } + + await actInto( + async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }, + '
', + doc => doc.getElementById('container'), + ); + expect(getVisibleChildren(document)).toEqual( + + + + + +
+ + +
hello world
+
+ + , + ); + + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + +
+
hello world
+ + +
+ + , + ); + }); + + // @gate enableFloat + it('removes resources that no longer have any referrers', async () => { + if (gate(flags => !flags.enableFloat)) { + throw new Error( + 'This test fails to fail properly when the flag is false. It errors but for some reason jest still thinks it did not fail properly', + ); + } + function App({exclude, isClient, multiple}) { + return ( + <> + {!isClient ? : null} + + + + + {exclude ? null : } +
hello world
+ {new Array(multiple || 0).fill(0).map((_, i) => ( + + ))} + + + + ); + } + + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(document)).toEqual( + + + + + + + +
hello world
+ + , + ); + + const root = ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushWithoutYielding(); + // "serveronly" is removed because it is not referred to by any HostResource + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + root.render(); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + + + + + +
hello world
+ + , + ); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js b/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js index 09a4614d35c62..8969b6b047dd8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPreload-test.js @@ -36,9 +36,7 @@ describe('ReactDOMServerPreload', () => { React = require('react'); ReactDOM = require('react-dom'); // ReactDOMClient = require('react-dom/client'); - if (__EXPERIMENTAL__) { - ReactDOMFizzServer = require('react-dom/server'); - } + ReactDOMFizzServer = require('react-dom/server'); Stream = require('stream'); Suspense = React.Suspense; // TextDecoder = require('util').TextDecoder; diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index 19eb70e583b39..0de2eac1c6261 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -257,7 +257,7 @@ export function checkForUnmatchedText( } } -function getOwnerDocumentFromRootContainer( +export function getOwnerDocumentFromRootContainer( rootContainerElement: Element | Document | DocumentFragment, ): Document { return rootContainerElement.nodeType === DOCUMENT_NODE @@ -483,6 +483,22 @@ export function createTextNode( ); } +export function setInitialResourceProperties( + domElement: Element, + tag: string, + props: Object, + rootContainerElement: Element | Document | DocumentFragment, +): void { + if (__DEV__) { + // @TODO replace with resource specific validation + validatePropertiesInDevelopment(tag, props); + } + + assertValidProps(tag, props); + + setInitialDOMProperties(tag, domElement, rootContainerElement, props, false); +} + export function setInitialProperties( domElement: Element, tag: string, diff --git a/packages/react-dom/src/client/ReactDOMFloatResources.js b/packages/react-dom/src/client/ReactDOMFloatResources.js new file mode 100644 index 0000000000000..366f6b2f3e93d --- /dev/null +++ b/packages/react-dom/src/client/ReactDOMFloatResources.js @@ -0,0 +1,209 @@ +/** + * 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 {Container, ResourceHost} from './ReactDOMHostConfig'; + +import {createElement, setInitialResourceProperties} from './ReactDOMComponent'; +import {HTML_NAMESPACE} from '../shared/DOMNamespaces'; +import { + DOCUMENT_NODE, + DOCUMENT_FRAGMENT_NODE, + ELEMENT_NODE, +} from '../shared/HTMLNodeType'; + +export type Resource = { + key: string, + type: string, + props: Object, + count: number, + instance: Element, +}; + +const CORS_NONE = ''; +const CORS_ANON = 'anonymous'; +const CORS_CREDS = 'use-credentials'; + +const resourceMap: Map = new Map(); +const embeddedResourceElementMap: Map = new Map(); +let pendingInsertionFragment: ?DocumentFragment = null; + +export function resourceFromElement(domElement: Element): void { + const href = domElement.getAttribute('href'); + const crossOriginAttr = domElement.getAttribute('crossorigin'); + if (!href) { + return; + } + + let crossOrigin; + if (crossOriginAttr === 'use-credentials') { + crossOrigin = CORS_CREDS; + } else if (crossOriginAttr === null) { + crossOrigin = CORS_NONE; + } else { + crossOrigin = CORS_ANON; + } + const key = href + crossOrigin; + embeddedResourceElementMap.set(key, domElement); +} + +export function acquireResource( + type: string, + props: Object, + rootContainerInstance: Container, + resourceHost: ResourceHost, +): Resource { + const href = props.href; + let crossOrigin; + const crossOriginProp = props.crossOrigin; + if (crossOriginProp === 'use-credentials') { + crossOrigin = CORS_CREDS; + } else if (typeof crossOriginProp === 'string' || crossOriginProp === true) { + crossOrigin = CORS_ANON; + } else { + crossOrigin = CORS_NONE; + } + const key = href + crossOrigin; + let resource = resourceMap.get(key); + if (!resource) { + let domElement = embeddedResourceElementMap.get(key); + if (domElement) { + embeddedResourceElementMap.delete(key); + } else { + domElement = createElement( + type, + props, + rootContainerInstance, + HTML_NAMESPACE, + ); + setInitialResourceProperties( + domElement, + type, + props, + rootContainerInstance, + ); + } + insertResource(domElement, resourceHost); + resource = { + key, + type, + props, + count: 0, + instance: domElement, + }; + resourceMap.set(key, resource); + } + resource.count++; + return resource; +} + +export function releaseResource(resource: Resource): void { + if (--resource.count === 0) { + const instance = resource.instance; + if (instance && instance.parentNode != null) { + instance.parentNode.removeChild(instance); + } + } + if (resource.count < 0) { + throw new Error( + 'HostResource count should never get below zero. this is a bug in React', + ); + } +} + +export function reconcileHydratedResources(rootContainerInstance: Container) { + embeddedResourceElementMap.forEach((domElement, key) => { + if (domElement.parentNode) { + domElement.parentNode.removeChild(domElement); + } + embeddedResourceElementMap.clear(); + }); +} + +function insertResource(element: Element, resourceHost: ResourceHost) { + switch (resourceHost.nodeType) { + case DOCUMENT_NODE: { + const head = ((resourceHost: any): Document).head; + if (!head) { + // If we do not have a head we are likely in the middle of replacing it on client render. + // stash the insertions in a fragment. They will be inserted after mutation effects + if (pendingInsertionFragment === null) { + pendingInsertionFragment = ((resourceHost: any): Document).createDocumentFragment(); + } + ((pendingInsertionFragment: any): DocumentFragment).append(element); + } else { + head.appendChild(element); + } + break; + } + case DOCUMENT_FRAGMENT_NODE: { + resourceHost.append(element); + break; + } + case ELEMENT_NODE: { + resourceHost.appendChild(element); + break; + } + default: { + throw new Error( + `${'insertResource'} was called with a rootContainer with an unexpected nodeType.`, + ); + } + } +} + +export function insertPendingResources(resourceHost: ResourceHost) { + if (pendingInsertionFragment !== null) { + if (resourceHost.nodeType === DOCUMENT_NODE) { + const head = ((resourceHost: any): Document).head; + if (!head) { + pendingInsertionFragment = null; + throw new Error( + `${'insertPendingResources'} expected the containing Document to have a head element and one was not found.`, + ); + } + head.appendChild(((pendingInsertionFragment: any): DocumentFragment)); + } + pendingInsertionFragment = null; + } +} + +export function prepareToHydrateResources() { + embeddedResourceElementMap.clear(); +} + +export function getRootResourceHost( + rootContainer: Container, + hydration: boolean, +): ResourceHost { + switch (rootContainer.nodeType) { + case DOCUMENT_NODE: + case DOCUMENT_FRAGMENT_NODE: { + return rootContainer; + } + case ELEMENT_NODE: { + if (hydration) { + // If we are hydrating we use the container as the ResourceHost unless that is not + // possible given the tag type of the container + const tagName = rootContainer.tagName; + if (tagName !== 'HTML' && tagName !== 'HEAD') { + return rootContainer; + } + } + // If we are not hydrating or we have a container tag name that is incompatible with being + // a ResourceHost we get the Root Node. Note that this intenitonally does not use ownerDocument + // because we want to use a shadoRoot if this rootContainer is embedded within one. + return rootContainer.getRootNode(); + } + default: { + throw new Error( + `${'getRootResourceHost'} was called with a rootContainer with an unexpected nodeType.`, + ); + } + } +} diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index a2e3a350ace54..32c380bf80164 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -64,9 +64,15 @@ import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying'; import { enableCreateEventHandleAPI, enableScopeAPI, + enableFloat, } from 'shared/ReactFeatureFlags'; import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem'; +import { + resourceFromElement, + reconcileHydratedResources, + prepareToHydrateResources, +} from './ReactDOMFloatResources'; import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities'; @@ -108,6 +114,7 @@ export type Container = | (Element & {_reactRootContainer?: FiberRoot, ...}) | (Document & {_reactRootContainer?: FiberRoot, ...}) | (DocumentFragment & {_reactRootContainer?: FiberRoot, ...}); +export type ResourceHost = Element | Document | DocumentFragment; export type Instance = Element; export type TextInstance = Text; export type SuspenseInstance = Comment & {_reactRetry?: () => void, ...}; @@ -275,6 +282,7 @@ export function createInstance( ); precacheFiberNode(internalInstanceHandle, domElement); updateFiberProps(domElement, props); + return domElement; } @@ -781,11 +789,12 @@ function getNextHydratable(node) { for (; node != null; node = ((node: any): Node).nextSibling) { const nodeType = node.nodeType; if (nodeType === ELEMENT_NODE) { - if ( - ((node: any): HTMLElement).tagName.toLowerCase() === 'link' && - ((node: any): HTMLElement).getAttribute('rel') === 'preload' - ) { - continue; + if (enableFloat) { + // @TODO replace with isResource logic + if (((node: any): HTMLElement).tagName.toLowerCase() === 'link') { + resourceFromElement(((node: any): HTMLElement)); + continue; + } } break; } else if (nodeType === TEXT_NODE) { @@ -1336,3 +1345,30 @@ export function setupIntersectionObserver( }, }; } + +// ------------------- +// Resources +// ------------------- + +export const supportsResources = true; + +export function prepareToRender() { + prepareToHydrateResources(); +} + +export function cleanupAfterRender() {} + +export function isResource(type: string) { + return type === 'link'; +} + +export function hoistStaticResource(rootContainerInstance: Container) { + reconcileHydratedResources(rootContainerInstance); +} + +export { + acquireResource, + releaseResource, + getRootResourceHost, + insertPendingResources, +} from './ReactDOMFloatResources'; diff --git a/packages/react-dom/src/server/ReactDOMFloatServer.js b/packages/react-dom/src/server/ReactDOMFloatServer.js index 1fe7cb06d9a84..2b753206a91a3 100644 --- a/packages/react-dom/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom/src/server/ReactDOMFloatServer.js @@ -59,7 +59,6 @@ export function prepareToRender(resourceMap: ResourceMap) { } export function cleanupAfterRender() { - return; currentResourceMap = null; popDispatcher(); diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 842ae52835a0d..7dffbb583069a 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -321,6 +321,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, @@ -612,11 +613,3 @@ export function preparePortalMount(portalInstance: Instance): void { export function detachDeletedInstance(node: Instance): void { // noop } - -export function prepareToRender(): void { - // noop -} - -export function cleanupAfterRender(): void { - // noop -} diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index ac12a562718bf..ff6f7aad24cad 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, @@ -513,11 +514,3 @@ export function preparePortalMount(portalInstance: Instance): void { export function detachDeletedInstance(node: Instance): void { // noop } - -export function prepareToRender(): void { - // noop -} - -export function cleanupAfterRender(): void { - // noop -} diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index a797f274acae1..6a45bd6e1bb07 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -449,6 +449,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { isPrimaryRenderer: true, warnsIfNotActing: true, supportsHydration: false, + supportsResources: false, getInstanceFromNode() { throw new Error('Not yet implemented.'); diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index b8bbb0d07ac85..24306cfaa4481 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, isResource} 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'; @@ -41,6 +43,7 @@ import { HostRoot, HostComponent, HostText, + HostResource, HostPortal, ForwardRef, Fragment, @@ -489,7 +492,15 @@ export function createFiberFromTypeAndProps( } } } else if (typeof type === 'string') { - fiberTag = HostComponent; + if (supportsResources && enableFloat) { + if (isResource(type)) { + fiberTag = HostResource; + } else { + fiberTag = 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 da24de2f63ab6..54cd841f6e518 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, isResource} 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'; @@ -41,6 +43,7 @@ import { HostRoot, HostComponent, HostText, + HostResource, HostPortal, ForwardRef, Fragment, @@ -489,7 +492,15 @@ export function createFiberFromTypeAndProps( } } } else if (typeof type === 'string') { - fiberTag = HostComponent; + if (supportsResources && enableFloat) { + if (isResource(type)) { + fiberTag = HostResource; + } else { + fiberTag = 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 1d3ca68b05b5b..51ab2c58bc552 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -55,6 +55,7 @@ import { HostRoot, HostComponent, HostText, + HostResource, HostPortal, ForwardRef, Fragment, @@ -1568,6 +1569,27 @@ function updateHostComponent( return workInProgress.child; } +function updateHostResource( + current: Fiber | null, + workInProgress: Fiber, + renderLanes: Lanes, +) { + const nextProps = workInProgress.pendingProps; + + const nextChildren = nextProps.children; + + if (__DEV__) { + if (nextChildren != null) { + console.error( + 'A "%s" element is being treated like a HostResource but it contains children. HostResources should not have any children', + workInProgress.type, + ); + } + } + markRef(current, workInProgress); + return null; +} + function updateHostText(current, workInProgress) { if (current === null) { tryToClaimNextHydratableInstance(workInProgress); @@ -3976,6 +3998,8 @@ function beginWork( return updateHostComponent(current, workInProgress, renderLanes); case HostText: return updateHostText(current, workInProgress); + case HostResource: + return updateHostResource(current, workInProgress, renderLanes); case SuspenseComponent: return updateSuspenseComponent(current, workInProgress, renderLanes); case HostPortal: diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 1fa498e29a929..98ee03da25010 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -55,6 +55,7 @@ import { HostRoot, HostComponent, HostText, + HostResource, HostPortal, ForwardRef, Fragment, @@ -1568,6 +1569,27 @@ function updateHostComponent( return workInProgress.child; } +function updateHostResource( + current: Fiber | null, + workInProgress: Fiber, + renderLanes: Lanes, +) { + const nextProps = workInProgress.pendingProps; + + const nextChildren = nextProps.children; + + if (__DEV__) { + if (nextChildren != null) { + console.error( + 'A "%s" element is being treated like a HostResource but it contains children. HostResources should not have any children', + workInProgress.type, + ); + } + } + markRef(current, workInProgress); + return null; +} + function updateHostText(current, workInProgress) { if (current === null) { tryToClaimNextHydratableInstance(workInProgress); @@ -3976,6 +3998,8 @@ function beginWork( return updateHostComponent(current, workInProgress, renderLanes); case HostText: return updateHostText(current, workInProgress); + case HostResource: + return updateHostResource(current, workInProgress, renderLanes); case SuspenseComponent: return updateSuspenseComponent(current, workInProgress, renderLanes); case HostPortal: diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 200e661c7017a..243d4ecd64378 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -45,6 +45,7 @@ import { enableUpdaterTracking, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -53,6 +54,7 @@ import { HostRoot, HostComponent, HostText, + HostResource, HostPortal, Profiler, SuspenseComponent, @@ -111,6 +113,7 @@ import { supportsMutation, supportsPersistence, supportsHydration, + supportsResources, commitMount, commitUpdate, resetTextContent, @@ -135,6 +138,9 @@ import { prepareScopeUpdate, prepareForCommit, beforeActiveInstanceBlur, + acquireResource, + releaseResource, + insertPendingResources, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -1819,6 +1825,18 @@ function commitDeletionEffectsOnFiber( } return; } + case HostResource: { + if (supportsMutation) { + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber, + ); + const resource = deletedFiber.stateNode; + releaseResource(resource); + } + return; + } case DehydratedFragment: { if (enableSuspenseCallback) { const hydrationCallbacks = finishedRoot.hydrationCallbacks; @@ -2149,6 +2167,9 @@ export function commitMutationEffects( setCurrentDebugFiberInDEV(finishedWork); commitMutationEffectsOnFiber(finishedWork, root, committedLanes); + if (supportsResources && enableFloat) { + insertPendingResources(root.resourceHost); + } setCurrentDebugFiberInDEV(finishedWork); inProgressLanes = null; @@ -2366,6 +2387,32 @@ function commitMutationEffectsOnFiber( } return; } + case HostResource: { + recursivelyTraverseMutationEffects(root, finishedWork, lanes); + commitReconciliationEffects(finishedWork); + if (flags & Update) { + if (supportsResources && supportsMutation) { + if (current !== null) { + const oldResource = current.stateNode; + if (oldResource) { + releaseResource(oldResource); + } + } + // We only acquire the resource in commit because acquisition is a mutation + // and we only want to do that during mutationEffects. We could refactor + // this to acquire without mutation in render and then use Update flags to + // do the insertion in commit but this seems simpler for now. + finishedWork.stateNode = acquireResource( + finishedWork.type, + finishedWork.memoizedProps, + // For HostResource memoizedState holds the rootContainerInstance + finishedWork.memoizedState, + root.resourceHost, + ); + } + } + return; + } case HostRoot: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 4b979151da999..40d5c52160079 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -45,6 +45,7 @@ import { enableUpdaterTracking, enableCache, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -53,6 +54,7 @@ import { HostRoot, HostComponent, HostText, + HostResource, HostPortal, Profiler, SuspenseComponent, @@ -111,6 +113,7 @@ import { supportsMutation, supportsPersistence, supportsHydration, + supportsResources, commitMount, commitUpdate, resetTextContent, @@ -135,6 +138,9 @@ import { prepareScopeUpdate, prepareForCommit, beforeActiveInstanceBlur, + acquireResource, + releaseResource, + insertPendingResources, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -1819,6 +1825,18 @@ function commitDeletionEffectsOnFiber( } return; } + case HostResource: { + if (supportsMutation) { + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber, + ); + const resource = deletedFiber.stateNode; + releaseResource(resource); + } + return; + } case DehydratedFragment: { if (enableSuspenseCallback) { const hydrationCallbacks = finishedRoot.hydrationCallbacks; @@ -2149,6 +2167,9 @@ export function commitMutationEffects( setCurrentDebugFiberInDEV(finishedWork); commitMutationEffectsOnFiber(finishedWork, root, committedLanes); + if (supportsResources && enableFloat) { + insertPendingResources(root.resourceHost); + } setCurrentDebugFiberInDEV(finishedWork); inProgressLanes = null; @@ -2366,6 +2387,32 @@ function commitMutationEffectsOnFiber( } return; } + case HostResource: { + recursivelyTraverseMutationEffects(root, finishedWork, lanes); + commitReconciliationEffects(finishedWork); + if (flags & Update) { + if (supportsResources && supportsMutation) { + if (current !== null) { + const oldResource = current.stateNode; + if (oldResource) { + releaseResource(oldResource); + } + } + // We only acquire the resource in commit because acquisition is a mutation + // and we only want to do that during mutationEffects. We could refactor + // this to acquire without mutation in render and then use Update flags to + // do the insertion in commit but this seems simpler for now. + finishedWork.stateNode = acquireResource( + finishedWork.type, + finishedWork.memoizedProps, + // For HostResource memoizedState holds the rootContainerInstance + finishedWork.memoizedState, + root.resourceHost, + ); + } + } + return; + } case HostRoot: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 058a14d802aaa..9a893bab21110 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -46,6 +46,7 @@ import { HostRoot, HostComponent, HostText, + HostResource, HostPortal, ContextProvider, ContextConsumer, @@ -1085,6 +1086,17 @@ function completeWork( bubbleProperties(workInProgress); return null; } + case HostResource: { + const rootContainerInstance = getRootHostContainer(); + workInProgress.memoizedState = rootContainerInstance; + if (current !== null && workInProgress.stateNode != null) { + // noop for now + } else { + markUpdate(workInProgress); + } + bubbleProperties(workInProgress); + return null; + } case SuspenseComponent: { popSuspenseHandler(workInProgress); const nextState: null | SuspenseState = workInProgress.memoizedState; diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index d21f726693dc9..434048936d3e1 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -46,6 +46,7 @@ import { HostRoot, HostComponent, HostText, + HostResource, HostPortal, ContextProvider, ContextConsumer, @@ -1085,6 +1086,17 @@ function completeWork( bubbleProperties(workInProgress); return null; } + case HostResource: { + const rootContainerInstance = getRootHostContainer(); + workInProgress.memoizedState = rootContainerInstance; + if (current !== null && workInProgress.stateNode != null) { + // noop for now + } else { + markUpdate(workInProgress); + } + bubbleProperties(workInProgress); + return null; + } case SuspenseComponent: { popSuspenseHandler(workInProgress); const nextState: null | SuspenseState = workInProgress.memoizedState; diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js new file mode 100644 index 0000000000000..b81e6397ad543 --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js @@ -0,0 +1,30 @@ +/** + * 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 resources +// 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 prepareToRender = shim; +export const cleanupAfterRender = shim; +export const isResource = shim; +export const hoistStaticResource = shim; +export const acquireResource = shim; +export const releaseResource = shim; +export const getRootResourceHost = shim; +export const insertPendingResources = shim; diff --git a/packages/react-reconciler/src/ReactFiberRoot.new.js b/packages/react-reconciler/src/ReactFiberRoot.new.js index f171ca0de3943..441f510ddf4e0 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.new.js +++ b/packages/react-reconciler/src/ReactFiberRoot.new.js @@ -16,7 +16,12 @@ import type { import type {RootTag} from './ReactRootTags'; import type {Cache} from './ReactFiberCacheComponent.new'; -import {noTimeout, supportsHydration} from './ReactFiberHostConfig'; +import { + noTimeout, + supportsHydration, + supportsResources, + getRootResourceHost, +} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber.new'; import { NoLane, @@ -32,6 +37,7 @@ import { enableProfilerTimer, enableUpdaterTracking, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import {initializeUpdateQueue} from './ReactFiberClassUpdateQueue.new'; import {LegacyRoot, ConcurrentRoot} from './ReactRootTags'; @@ -114,6 +120,10 @@ function FiberRootNode( } } + if (supportsResources && enableFloat && tag === ConcurrentRoot) { + this.resourceHost = getRootResourceHost(containerInfo, hydrate); + } + if (__DEV__) { switch (tag) { case ConcurrentRoot: diff --git a/packages/react-reconciler/src/ReactFiberRoot.old.js b/packages/react-reconciler/src/ReactFiberRoot.old.js index 9b37cee41edab..8c304b3e37b05 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.old.js +++ b/packages/react-reconciler/src/ReactFiberRoot.old.js @@ -16,7 +16,12 @@ import type { import type {RootTag} from './ReactRootTags'; import type {Cache} from './ReactFiberCacheComponent.old'; -import {noTimeout, supportsHydration} from './ReactFiberHostConfig'; +import { + noTimeout, + supportsHydration, + supportsResources, + getRootResourceHost, +} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber.old'; import { NoLane, @@ -32,6 +37,7 @@ import { enableProfilerTimer, enableUpdaterTracking, enableTransitionTracing, + enableFloat, } from 'shared/ReactFeatureFlags'; import {initializeUpdateQueue} from './ReactFiberClassUpdateQueue.old'; import {LegacyRoot, ConcurrentRoot} from './ReactRootTags'; @@ -114,6 +120,10 @@ function FiberRootNode( } } + if (supportsResources && enableFloat && tag === ConcurrentRoot) { + this.resourceHost = getRootResourceHost(containerInfo, hydrate); + } + if (__DEV__) { switch (tag) { case ConcurrentRoot: diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 986f54004dc37..19a19f6dab0f3 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -82,8 +82,10 @@ import { supportsMicrotasks, errorHydratingContainer, scheduleMicrotask, + supportsResources, prepareToRender, cleanupAfterRender, + hoistStaticResource, } from './ReactFiberHostConfig'; import { @@ -904,7 +906,7 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { // goes through Scheduler. function performConcurrentWorkOnRoot(root, didTimeout) { try { - if (enableFloat) { + if (supportsResources && enableFloat) { prepareToRender(); } @@ -1044,7 +1046,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) { } return null; } finally { - if (enableFloat) { + if (supportsResources && enableFloat) { cleanupAfterRender(); } } @@ -2246,6 +2248,10 @@ function commitRootImpl( // The next phase is the mutation phase, where we mutate the host tree. commitMutationEffects(root, finishedWork, lanes); + if (supportsResources && enableFloat) { + hoistStaticResource(root.containerInfo); + } + if (enableCreateEventHandleAPI) { if (shouldFireAfterActiveInstanceBlur) { afterActiveInstanceBlur(); diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index f23ad4663c966..adfb0bc5813bd 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -82,8 +82,10 @@ import { supportsMicrotasks, errorHydratingContainer, scheduleMicrotask, + supportsResources, prepareToRender, cleanupAfterRender, + hoistStaticResource, } from './ReactFiberHostConfig'; import { @@ -903,11 +905,11 @@ 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 (enableFloat) { - prepareToRender(); - } - try { + if (supportsResources && enableFloat) { + prepareToRender(); + } + if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { resetNestedUpdateFlag(); } @@ -1044,7 +1046,7 @@ function performConcurrentWorkOnRoot(root, didTimeout) { } return null; } finally { - if (enableFloat) { + if (supportsResources && enableFloat) { cleanupAfterRender(); } } @@ -2246,6 +2248,10 @@ function commitRootImpl( // The next phase is the mutation phase, where we mutate the host tree. commitMutationEffects(root, finishedWork, lanes); + if (supportsResources && enableFloat) { + hoistStaticResource(root.containerInfo); + } + if (enableCreateEventHandleAPI) { if (shouldFireAfterActiveInstanceBlur) { afterActiveInstanceBlur(); diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index e017674dda205..c11de3abfafa2 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -204,6 +204,8 @@ type BaseFiberRootProperties = {| // Any additional information from the host associated with this root. containerInfo: any, + // Used only by Hosts that support Resources + resourceHost: any, // Used only by persistent updates. pendingChildren: any, // The currently active root fiber. This is the mutable root of the tree. 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/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 42755ec9ac7dd..4ca032879666e 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -59,6 +59,7 @@ export const warnsIfNotActing = $$$hostConfig.warnsIfNotActing; export const supportsMutation = $$$hostConfig.supportsMutation; export const supportsPersistence = $$$hostConfig.supportsPersistence; export const supportsHydration = $$$hostConfig.supportsHydration; +export const supportsResources = $$$hostConfig.supportsResources; export const getInstanceFromNode = $$$hostConfig.getInstanceFromNode; export const beforeActiveInstanceBlur = $$$hostConfig.beforeActiveInstanceBlur; export const afterActiveInstanceBlur = $$$hostConfig.afterActiveInstanceBlur; @@ -186,5 +187,16 @@ export const didNotFindHydratableTextInstance = export const didNotFindHydratableSuspenseInstance = $$$hostConfig.didNotFindHydratableSuspenseInstance; export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer; + +// ------------------- +// Resources +// (optional) +// ------------------- export const prepareToRender = $$$hostConfig.prepareToRender; export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; +export const isResource = $$$hostConfig.isResource; +export const hoistStaticResource = $$$hostConfig.hoistStaticResource; +export const acquireResource = $$$hostConfig.acquireResource; +export const releaseResource = $$$hostConfig.releaseResource; +export const getRootResourceHost = $$$hostConfig.getRootResourceHost; +export const insertPendingResources = $$$hostConfig.insertPendingResources; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index b919d3153173e..3d69cfcb197f6 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -660,13 +660,7 @@ function renderHostElement( // We expect that errors will fatal the whole task and that we don't need // the correct context. Therefore this is not in a finally. segment.formatContext = prevContext; - pushEndInstance( - segment.chunks, - request.postlude, - type, - props, - segment.formatContext, - ); + pushEndInstance(segment.chunks, request.postlude, type, props); segment.lastPushedText = false; popComponentStackInDEV(task); } diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index cedc00bf72d93..c5baec2507395 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 = {}; @@ -318,6 +319,3 @@ export function detachDeletedInstance(node: Instance): void { export function logRecoverableError(error: mixed): void { // noop } - -export function prepareToRender() {} -export function cleanupAfterRender() {} diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 1ec677d479933..7bfaacdea1637 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -116,6 +116,9 @@ export const enableCPUSuspense = __EXPERIMENTAL__; // aggressiveness. export const deletedTreeCleanUpLevel = 3; +// enables preloading apis for React-dom server/client +export const enableFloat = __EXPERIMENTAL__; + // ----------------------------------------------------------------------------- // Chopping Block // @@ -254,6 +257,3 @@ export const enableGetInspectorDataForInstanceInProduction = false; export const enableProfilerNestedUpdateScheduledHook = false; export const consoleManagedByDevToolsDuringStrictMode = true; - -// enables preloading apis for React-dom server/client -export const enableFloat = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index b76a1b8506d13..4e52ad7917985 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -68,6 +68,8 @@ export const enableUseMutableSource = false; export const enableTransitionTracing = false; export const enableSymbolFallbackForWWW = false; +export const enableFloat = false; + // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 5f2fe666f4446..4ce609d7778e3 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -109,7 +109,7 @@ export const enableCustomElementPropertySupport = __EXPERIMENTAL__; export const enableSymbolFallbackForWWW = true; -export const enableFloat = true; +export const enableFloat = false; // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 010afa06e70f3..a72de7a3b6f43 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -420,5 +420,10 @@ "432": "The render was aborted by the server without a reason.", "433": "useId can only be used while React is rendering", "434": "`dangerouslySetInnerHTML` does not make sense on .", - "435": "Unexpected Suspense handler tag (%s). This is a bug in React." + "435": "Unexpected Suspense handler tag (%s). This is a bug in React.", + "436": "HostResource count should never get below zero. this is a bug in React", + "437": "writeResource received a resource it did not know how to write. This is a bug in React.", + "438": "The current renderer does not support Resources. This error is likely caused by a bug in React. Please file an issue.", + "439": "%s was called with a rootContainer with an unexpected nodeType.", + "440": "%s expected the containing Document to have a head element and one was not found." } From 533aba8a29cbfcb95802ebc1ec68e407c1681f23 Mon Sep 17 00:00:00 2001 From: Josh Story <story@hey.com> Date: Tue, 19 Jul 2022 20:02:02 -0700 Subject: [PATCH 3/7] share resources with other roots using the same resourceContainer --- .../src/__tests__/ReactDOMResources-test.js | 210 ++++++++++++++++++ .../react-dom/src/client/ReactDOMComponent.js | 2 +- .../src/client/ReactDOMFloatResources.js | 54 ++++- .../src/client/ReactDOMHostConfig.js | 1 - .../src/ReactFiberBeginWork.new.js | 23 +- .../src/ReactFiberBeginWork.old.js | 23 +- .../src/ReactFiberCommitWork.new.js | 12 +- .../src/ReactFiberCommitWork.old.js | 12 +- .../src/ReactFiberCompleteWork.new.js | 17 +- .../src/ReactFiberCompleteWork.old.js | 17 +- 10 files changed, 311 insertions(+), 60 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMResources-test.js b/packages/react-dom/src/__tests__/ReactDOMResources-test.js index c8819d7b09a23..a80f3665b4177 100644 --- a/packages/react-dom/src/__tests__/ReactDOMResources-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMResources-test.js @@ -572,4 +572,214 @@ describe('ReactDOMResources', () => { </html>, ); }); + + // @gate enableFloat + it('dedupes resources across roots when not using hydration', async () => { + function App({extra}) { + return ( + <> + <link rel="stylesheet" href="foo" /> + <div>hello world</div> + {extra ? <link rel="stylesheet" href="extra" /> : null} + <link rel="stylesheet" href="bar" /> + </> + ); + } + + await actInto( + async () => {}, + '<!DOCTYPE html><html><head></head><body><div id="container1"></div><div id="container2"></div>', + doc => [ + doc.getElementById('container1'), + doc.getElementById('container2'), + ], + ); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div id="container1" /> + <div id="container2" /> + </body> + </html>, + ); + + const root1 = ReactDOMClient.createRoot(container[0]); + root1.render(<App />); + const root2 = ReactDOMClient.createRoot(container[1]); + root2.render(<App extra={true} />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="bar" /> + <link rel="stylesheet" href="extra" /> + </head> + <body> + <div id="container1"> + <div>hello world</div> + </div> + <div id="container2"> + <div>hello world</div> + </div> + </body> + </html>, + ); + + root1.render(<App extra={true} />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + <link rel="stylesheet" href="foo" />, + <link rel="stylesheet" href="bar" />, + <link rel="stylesheet" href="extra" />, + ]); + + root2.render(<App />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + <link rel="stylesheet" href="foo" />, + <link rel="stylesheet" href="bar" />, + <link rel="stylesheet" href="extra" />, + ]); + + root1.render(<App />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + <link rel="stylesheet" href="foo" />, + <link rel="stylesheet" href="bar" />, + ]); + }); + + // @gate enableFloat + it('tracks resources separately when using hydrationRoots', async () => { + function App({extra}) { + return ( + <> + <link rel="stylesheet" href="foo" /> + <div>hello world</div> + {extra ? <link rel="stylesheet" href="extra" /> : null} + <link rel="stylesheet" href="bar" /> + </> + ); + } + + const server1 = ReactDOMFizzServer.renderToString(<App />); + const server2 = ReactDOMFizzServer.renderToString(<App extra={true} />); + + await actInto( + async () => {}, + `<!DOCTYPE html><html><head></head><body><div id="container1">${server1}</div><div id="container2">${server2}</div>`, + doc => [ + doc.getElementById('container1'), + doc.getElementById('container2'), + ], + ); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div id="container1"> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="bar" /> + <div>hello world</div> + </div> + <div id="container2"> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="extra" /> + <link rel="stylesheet" href="bar" /> + <div>hello world</div> + </div> + </body> + </html>, + ); + + const root1 = ReactDOMClient.hydrateRoot(container[0], <App />); + const root2 = ReactDOMClient.hydrateRoot( + container[1], + <App extra={true} />, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div id="container1"> + <div>hello world</div> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="bar" /> + </div> + <div id="container2"> + <div>hello world</div> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="extra" /> + <link rel="stylesheet" href="bar" /> + </div> + </body> + </html>, + ); + + root1.render(<App extra={true} />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div id="container1"> + <div>hello world</div> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="bar" /> + <link rel="stylesheet" href="extra" /> + </div> + <div id="container2"> + <div>hello world</div> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="extra" /> + <link rel="stylesheet" href="bar" /> + </div> + </body> + </html>, + ); + + root2.render(<App />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div id="container1"> + <div>hello world</div> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="bar" /> + <link rel="stylesheet" href="extra" /> + </div> + <div id="container2"> + <div>hello world</div> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="bar" /> + </div> + </body> + </html>, + ); + + root1.render(<App />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head /> + <body> + <div id="container1"> + <div>hello world</div> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="bar" /> + </div> + <div id="container2"> + <div>hello world</div> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="bar" /> + </div> + </body> + </html>, + ); + }); }); diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index 0de2eac1c6261..22675b53ce3c6 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -257,7 +257,7 @@ export function checkForUnmatchedText( } } -export function getOwnerDocumentFromRootContainer( +function getOwnerDocumentFromRootContainer( rootContainerElement: Element | Document | DocumentFragment, ): Document { return rootContainerElement.nodeType === DOCUMENT_NODE diff --git a/packages/react-dom/src/client/ReactDOMFloatResources.js b/packages/react-dom/src/client/ReactDOMFloatResources.js index 366f6b2f3e93d..f99e866048150 100644 --- a/packages/react-dom/src/client/ReactDOMFloatResources.js +++ b/packages/react-dom/src/client/ReactDOMFloatResources.js @@ -7,7 +7,7 @@ * @flow */ -import type {Container, ResourceHost} from './ReactDOMHostConfig'; +import type {Container} from './ReactDOMHostConfig'; import {createElement, setInitialResourceProperties} from './ReactDOMComponent'; import {HTML_NAMESPACE} from '../shared/DOMNamespaces'; @@ -17,6 +17,13 @@ import { ELEMENT_NODE, } from '../shared/HTMLNodeType'; +type ResourceContainer = Element | Document | DocumentFragment; + +export type ResourceHost = { + map: ResourceMap, + container: ResourceContainer, +}; + export type Resource = { key: string, type: string, @@ -29,7 +36,9 @@ const CORS_NONE = ''; const CORS_ANON = 'anonymous'; const CORS_CREDS = 'use-credentials'; -const resourceMap: Map<string, Resource> = new Map(); +type ResourceMap = Map<string, Resource>; +type MapOfResourceMaps = Map<ResourceContainer, ResourceMap>; +let resourceMaps: ?MapOfResourceMaps = null; const embeddedResourceElementMap: Map<string, Element> = new Map(); let pendingInsertionFragment: ?DocumentFragment = null; @@ -69,6 +78,8 @@ export function acquireResource( crossOrigin = CORS_NONE; } const key = href + crossOrigin; + + const resourceMap = resourceHost.map; let resource = resourceMap.get(key); if (!resource) { let domElement = embeddedResourceElementMap.get(key); @@ -126,14 +137,15 @@ export function reconcileHydratedResources(rootContainerInstance: Container) { } function insertResource(element: Element, resourceHost: ResourceHost) { - switch (resourceHost.nodeType) { + const resourceContainer = resourceHost.container; + switch (resourceContainer.nodeType) { case DOCUMENT_NODE: { - const head = ((resourceHost: any): Document).head; + const head = ((resourceContainer: any): Document).head; if (!head) { // If we do not have a head we are likely in the middle of replacing it on client render. // stash the insertions in a fragment. They will be inserted after mutation effects if (pendingInsertionFragment === null) { - pendingInsertionFragment = ((resourceHost: any): Document).createDocumentFragment(); + pendingInsertionFragment = ((resourceContainer: any): Document).createDocumentFragment(); } ((pendingInsertionFragment: any): DocumentFragment).append(element); } else { @@ -142,11 +154,11 @@ function insertResource(element: Element, resourceHost: ResourceHost) { break; } case DOCUMENT_FRAGMENT_NODE: { - resourceHost.append(element); + resourceContainer.append(element); break; } case ELEMENT_NODE: { - resourceHost.appendChild(element); + resourceContainer.appendChild(element); break; } default: { @@ -158,9 +170,10 @@ function insertResource(element: Element, resourceHost: ResourceHost) { } export function insertPendingResources(resourceHost: ResourceHost) { + const resourceContainer = resourceHost.container; if (pendingInsertionFragment !== null) { - if (resourceHost.nodeType === DOCUMENT_NODE) { - const head = ((resourceHost: any): Document).head; + if (resourceContainer.nodeType === DOCUMENT_NODE) { + const head = ((resourceContainer: any): Document).head; if (!head) { pendingInsertionFragment = null; throw new Error( @@ -181,10 +194,15 @@ export function getRootResourceHost( rootContainer: Container, hydration: boolean, ): ResourceHost { + if (resourceMaps === null) { + resourceMaps = new Map(); + } + let resourceContainer; switch (rootContainer.nodeType) { case DOCUMENT_NODE: case DOCUMENT_FRAGMENT_NODE: { - return rootContainer; + resourceContainer = rootContainer; + break; } case ELEMENT_NODE: { if (hydration) { @@ -192,13 +210,15 @@ export function getRootResourceHost( // possible given the tag type of the container const tagName = rootContainer.tagName; if (tagName !== 'HTML' && tagName !== 'HEAD') { - return rootContainer; + resourceContainer = rootContainer; + break; } } // If we are not hydrating or we have a container tag name that is incompatible with being // a ResourceHost we get the Root Node. Note that this intenitonally does not use ownerDocument // because we want to use a shadoRoot if this rootContainer is embedded within one. - return rootContainer.getRootNode(); + resourceContainer = rootContainer.getRootNode(); + break; } default: { throw new Error( @@ -206,4 +226,14 @@ export function getRootResourceHost( ); } } + + let map = ((resourceMaps: any): MapOfResourceMaps).get(resourceContainer); + if (!map) { + map = new Map(); + ((resourceMaps: any): MapOfResourceMaps).set(resourceContainer, map); + } + return { + map, + container: resourceContainer, + }; } diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 32c380bf80164..9b5a021aa5c8a 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -114,7 +114,6 @@ export type Container = | (Element & {_reactRootContainer?: FiberRoot, ...}) | (Document & {_reactRootContainer?: FiberRoot, ...}) | (DocumentFragment & {_reactRootContainer?: FiberRoot, ...}); -export type ResourceHost = Element | Document | DocumentFragment; export type Instance = Element; export type TextInstance = Text; export type SuspenseInstance = Comment & {_reactRetry?: () => void, ...}; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 51ab2c58bc552..75094ea18e09a 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'; @@ -161,6 +162,7 @@ import { getSuspenseInstanceFallbackErrorDetails, registerSuspenseInstanceRetry, supportsHydration, + supportsResources, isPrimaryRenderer, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; @@ -1574,19 +1576,20 @@ function updateHostResource( workInProgress: Fiber, renderLanes: Lanes, ) { - const nextProps = workInProgress.pendingProps; - - const nextChildren = nextProps.children; + if (supportsResources && enableFloat) { + const nextProps = workInProgress.pendingProps; + const nextChildren = nextProps.children; - if (__DEV__) { - if (nextChildren != null) { - console.error( - 'A "%s" element is being treated like a HostResource but it contains children. HostResources should not have any children', - workInProgress.type, - ); + if (__DEV__) { + if (nextChildren != null) { + console.error( + 'A "%s" element is being treated like a HostResource but it contains children. HostResources should not have any children', + workInProgress.type, + ); + } } + markRef(current, workInProgress); } - markRef(current, workInProgress); return null; } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 98ee03da25010..3d69eddc67211 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'; @@ -161,6 +162,7 @@ import { getSuspenseInstanceFallbackErrorDetails, registerSuspenseInstanceRetry, supportsHydration, + supportsResources, isPrimaryRenderer, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; @@ -1574,19 +1576,20 @@ function updateHostResource( workInProgress: Fiber, renderLanes: Lanes, ) { - const nextProps = workInProgress.pendingProps; - - const nextChildren = nextProps.children; + if (supportsResources && enableFloat) { + const nextProps = workInProgress.pendingProps; + const nextChildren = nextProps.children; - if (__DEV__) { - if (nextChildren != null) { - console.error( - 'A "%s" element is being treated like a HostResource but it contains children. HostResources should not have any children', - workInProgress.type, - ); + if (__DEV__) { + if (nextChildren != null) { + console.error( + 'A "%s" element is being treated like a HostResource but it contains children. HostResources should not have any children', + workInProgress.type, + ); + } } + markRef(current, workInProgress); } - markRef(current, workInProgress); return null; } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 243d4ecd64378..fb4aea3f6f91c 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -1826,7 +1826,7 @@ function commitDeletionEffectsOnFiber( return; } case HostResource: { - if (supportsMutation) { + if (supportsResources) { recursivelyTraverseDeletionEffects( finishedRoot, nearestMountedAncestor, @@ -2167,7 +2167,7 @@ export function commitMutationEffects( setCurrentDebugFiberInDEV(finishedWork); commitMutationEffectsOnFiber(finishedWork, root, committedLanes); - if (supportsResources && enableFloat) { + if (supportsResources && enableFloat && root.resourceHost) { insertPendingResources(root.resourceHost); } setCurrentDebugFiberInDEV(finishedWork); @@ -2388,10 +2388,10 @@ function commitMutationEffectsOnFiber( return; } case HostResource: { - recursivelyTraverseMutationEffects(root, finishedWork, lanes); - commitReconciliationEffects(finishedWork); - if (flags & Update) { - if (supportsResources && supportsMutation) { + if (supportsResources && enableFloat) { + recursivelyTraverseMutationEffects(root, finishedWork, lanes); + commitReconciliationEffects(finishedWork); + if (flags & Update) { if (current !== null) { const oldResource = current.stateNode; if (oldResource) { diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 40d5c52160079..8ae71268a9f8a 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -1826,7 +1826,7 @@ function commitDeletionEffectsOnFiber( return; } case HostResource: { - if (supportsMutation) { + if (supportsResources) { recursivelyTraverseDeletionEffects( finishedRoot, nearestMountedAncestor, @@ -2167,7 +2167,7 @@ export function commitMutationEffects( setCurrentDebugFiberInDEV(finishedWork); commitMutationEffectsOnFiber(finishedWork, root, committedLanes); - if (supportsResources && enableFloat) { + if (supportsResources && enableFloat && root.resourceHost) { insertPendingResources(root.resourceHost); } setCurrentDebugFiberInDEV(finishedWork); @@ -2388,10 +2388,10 @@ function commitMutationEffectsOnFiber( return; } case HostResource: { - recursivelyTraverseMutationEffects(root, finishedWork, lanes); - commitReconciliationEffects(finishedWork); - if (flags & Update) { - if (supportsResources && supportsMutation) { + if (supportsResources && enableFloat) { + recursivelyTraverseMutationEffects(root, finishedWork, lanes); + commitReconciliationEffects(finishedWork); + if (flags & Update) { if (current !== null) { const oldResource = current.stateNode; if (oldResource) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 9a893bab21110..d56fce542129e 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -93,6 +93,7 @@ import { prepareUpdate, supportsMutation, supportsPersistence, + supportsResources, cloneInstance, cloneHiddenInstance, cloneHiddenTextInstance, @@ -1087,14 +1088,16 @@ function completeWork( return null; } case HostResource: { - const rootContainerInstance = getRootHostContainer(); - workInProgress.memoizedState = rootContainerInstance; - if (current !== null && workInProgress.stateNode != null) { - // noop for now - } else { - markUpdate(workInProgress); + if (supportsResources) { + const rootContainerInstance = getRootHostContainer(); + workInProgress.memoizedState = rootContainerInstance; + if (current !== null && workInProgress.stateNode != null) { + // noop for now + } else { + markUpdate(workInProgress); + } + bubbleProperties(workInProgress); } - bubbleProperties(workInProgress); return null; } case SuspenseComponent: { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index 434048936d3e1..bbc8566c36b6d 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -93,6 +93,7 @@ import { prepareUpdate, supportsMutation, supportsPersistence, + supportsResources, cloneInstance, cloneHiddenInstance, cloneHiddenTextInstance, @@ -1087,14 +1088,16 @@ function completeWork( return null; } case HostResource: { - const rootContainerInstance = getRootHostContainer(); - workInProgress.memoizedState = rootContainerInstance; - if (current !== null && workInProgress.stateNode != null) { - // noop for now - } else { - markUpdate(workInProgress); + if (supportsResources) { + const rootContainerInstance = getRootHostContainer(); + workInProgress.memoizedState = rootContainerInstance; + if (current !== null && workInProgress.stateNode != null) { + // noop for now + } else { + markUpdate(workInProgress); + } + bubbleProperties(workInProgress); } - bubbleProperties(workInProgress); return null; } case SuspenseComponent: { From a7b939d02eeadaa9b80a7f523924ec3a5ab1b9cb Mon Sep 17 00:00:00 2001 From: Josh Story <story@hey.com> Date: Wed, 20 Jul 2022 11:50:06 -0700 Subject: [PATCH 4/7] implement proper keying for stylesheet resources --- .../src/__tests__/ReactDOMComponent-test.js | 2 - .../src/__tests__/ReactDOMResources-test.js | 142 ++++++++++++++++++ .../src/client/ReactDOMFloatResources.js | 116 ++++++++------ .../src/client/ReactDOMHostConfig.js | 35 +++-- .../react-reconciler/src/ReactFiber.new.js | 4 +- .../react-reconciler/src/ReactFiber.old.js | 4 +- .../src/ReactFiberBeginWork.new.js | 11 +- .../src/ReactFiberBeginWork.old.js | 11 +- .../src/ReactFiberCommitWork.new.js | 6 +- .../src/ReactFiberCommitWork.old.js | 6 +- .../src/ReactFiberCompleteWork.new.js | 12 +- .../src/ReactFiberCompleteWork.old.js | 12 +- .../ReactFiberHostConfigWithNoResources.js | 3 +- .../src/ReactFiberWorkLoop.new.js | 6 +- .../src/ReactFiberWorkLoop.old.js | 6 +- .../src/forks/ReactFiberHostConfig.custom.js | 5 +- 16 files changed, 286 insertions(+), 95 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js index 3e2c593bf977b..b9de7f0a6e868 100644 --- a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js @@ -1502,7 +1502,6 @@ describe('ReactDOMComponent', () => { } }); - // @gate !enableFloat it('should receive a load event on <link> elements', () => { const container = document.createElement('div'); const onLoad = jest.fn(); @@ -1521,7 +1520,6 @@ describe('ReactDOMComponent', () => { expect(onLoad).toHaveBeenCalledTimes(1); }); - // @gate !enableFloat it('should receive an error event on <link> elements', () => { const container = document.createElement('div'); const onError = jest.fn(); diff --git a/packages/react-dom/src/__tests__/ReactDOMResources-test.js b/packages/react-dom/src/__tests__/ReactDOMResources-test.js index a80f3665b4177..33558ac078df8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMResources-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMResources-test.js @@ -782,4 +782,146 @@ describe('ReactDOMResources', () => { </html>, ); }); + + describe('link resources', () => { + // @gate enableFloat + it('keys resources on href, crossOrigin, and referrerPolicy', async () => { + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossOrigin="" /> + <link rel="stylesheet" href="foo" crossOrigin="anonymous" /> + <link rel="stylesheet" href="foo" crossOrigin="use-credentials" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + pipe(writable); + }); + expect(getVisibleChildren(document.head)).toEqual([ + <link rel="stylesheet" href="foo" />, + <link rel="stylesheet" href="foo" crossorigin="" />, + <link rel="stylesheet" href="foo" crossorigin="use-credentials" />, + ]); + + const root = ReactDOMClient.hydrateRoot( + container, + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossOrigin="" /> + <link rel="stylesheet" href="foo" crossOrigin="anonymous" /> + <link rel="stylesheet" href="foo" crossOrigin="use-credentials" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + <link rel="stylesheet" href="foo" />, + <link rel="stylesheet" href="foo" crossorigin="" />, + <link rel="stylesheet" href="foo" crossorigin="use-credentials" />, + ]); + + // Add the default referrer. This should not result in a new resource key because it is equivalent to no specified policy + root.render( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossOrigin="" /> + <link + rel="stylesheet" + href="foo" + crossOrigin="anonymous" + referrerPolicy="strict-origin-when-cross-origin" + /> + <link rel="stylesheet" href="foo" crossOrigin="use-credentials" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + <link rel="stylesheet" href="foo" />, + <link rel="stylesheet" href="foo" crossorigin="" />, + <link rel="stylesheet" href="foo" crossorigin="use-credentials" />, + ]); + + // Change the referrerPolicy to something distinct and observe a new resource is emitted + root.render( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossOrigin="" /> + <link + rel="stylesheet" + href="foo" + crossOrigin="anonymous" + referrerPolicy="no-origin" + /> + <link rel="stylesheet" href="foo" crossOrigin="use-credentials" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + <link rel="stylesheet" href="foo" />, + <link rel="stylesheet" href="foo" crossorigin="" />, + <link rel="stylesheet" href="foo" crossorigin="use-credentials" />, + <link + rel="stylesheet" + href="foo" + crossorigin="anonymous" + referrerpolicy="no-origin" + />, + ]); + + // Update the other "foo" link to match the new referrerPolicy and observe the resource coalescing + root.render( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link + rel="stylesheet" + href="foo" + crossOrigin="" + referrerPolicy="no-origin" + /> + <link + rel="stylesheet" + href="foo" + crossOrigin="anonymous" + referrerPolicy="no-origin" + /> + <link rel="stylesheet" href="foo" crossOrigin="use-credentials" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document.head)).toEqual([ + <link rel="stylesheet" href="foo" />, + <link rel="stylesheet" href="foo" crossorigin="use-credentials" />, + <link + rel="stylesheet" + href="foo" + crossorigin="anonymous" + referrerpolicy="no-origin" + />, + ]); + }); + }); }); diff --git a/packages/react-dom/src/client/ReactDOMFloatResources.js b/packages/react-dom/src/client/ReactDOMFloatResources.js index f99e866048150..4e590873f4ef9 100644 --- a/packages/react-dom/src/client/ReactDOMFloatResources.js +++ b/packages/react-dom/src/client/ReactDOMFloatResources.js @@ -8,7 +8,9 @@ */ import type {Container} from './ReactDOMHostConfig'; +import type {RootTag} from 'react-reconciler/src/ReactRootTags'; +import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags'; import {createElement, setInitialResourceProperties} from './ReactDOMComponent'; import {HTML_NAMESPACE} from '../shared/DOMNamespaces'; import { @@ -27,7 +29,6 @@ export type ResourceHost = { export type Resource = { key: string, type: string, - props: Object, count: number, instance: Element, }; @@ -41,69 +42,35 @@ type MapOfResourceMaps = Map<ResourceContainer, ResourceMap>; let resourceMaps: ?MapOfResourceMaps = null; const embeddedResourceElementMap: Map<string, Element> = new Map(); let pendingInsertionFragment: ?DocumentFragment = null; - -export function resourceFromElement(domElement: Element): void { - const href = domElement.getAttribute('href'); - const crossOriginAttr = domElement.getAttribute('crossorigin'); - if (!href) { - return; - } - - let crossOrigin; - if (crossOriginAttr === 'use-credentials') { - crossOrigin = CORS_CREDS; - } else if (crossOriginAttr === null) { - crossOrigin = CORS_NONE; - } else { - crossOrigin = CORS_ANON; - } - const key = href + crossOrigin; - embeddedResourceElementMap.set(key, domElement); -} +let rootIsUsingResources = false; export function acquireResource( + key: string, type: string, props: Object, - rootContainerInstance: Container, resourceHost: ResourceHost, ): Resource { - const href = props.href; - let crossOrigin; - const crossOriginProp = props.crossOrigin; - if (crossOriginProp === 'use-credentials') { - crossOrigin = CORS_CREDS; - } else if (typeof crossOriginProp === 'string' || crossOriginProp === true) { - crossOrigin = CORS_ANON; - } else { - crossOrigin = CORS_NONE; - } - const key = href + crossOrigin; - - const resourceMap = resourceHost.map; + const {map: resourceMap, container: resourceContainer} = resourceHost; let resource = resourceMap.get(key); if (!resource) { let domElement = embeddedResourceElementMap.get(key); if (domElement) { embeddedResourceElementMap.delete(key); } else { + // We cheat somewhat and substitute the resourceHost container instead of the rootContainer. + // Sometimes they are the same but even when they are not, the ownerDocument should be. domElement = createElement( type, props, - rootContainerInstance, + resourceContainer, HTML_NAMESPACE, ); - setInitialResourceProperties( - domElement, - type, - props, - rootContainerInstance, - ); + setInitialResourceProperties(domElement, type, props, resourceContainer); } insertResource(domElement, resourceHost); resource = { key, type, - props, count: 0, instance: domElement, }; @@ -186,7 +153,8 @@ export function insertPendingResources(resourceHost: ResourceHost) { } } -export function prepareToHydrateResources() { +export function prepareToHydrateResources(rootTag: RootTag) { + rootIsUsingResources = rootTag === ConcurrentRoot; embeddedResourceElementMap.clear(); } @@ -237,3 +205,65 @@ export function getRootResourceHost( container: resourceContainer, }; } + +export function getResourceKeyFromTypeAndProps( + type: string, + props: Object, +): ?string { + switch (type) { + case 'link': { + const {rel, href, crossOrigin, referrerPolicy} = props; + if (!href) { + return undefined; + } + + let cors; + if (crossOrigin === 'use-credentials') { + cors = CORS_CREDS; + } else if (typeof crossOrigin === 'string' || crossOrigin === true) { + cors = CORS_ANON; + } else { + cors = CORS_NONE; + } + + const referrer = + referrerPolicy === 'strict-origin-when-cross-origin' + ? '' + : referrerPolicy || ''; + + // We use new-lines in the key because they are not valid in urls and thus there should + // never be a collision between a href with no cors/referrer and another href with particular + // cors & referrer. + switch (rel) { + case 'stylesheet': { + return href + '\n' + cors + referrer; + } + default: + return undefined; + } + } + default: + return undefined; + } +} + +export function resourceFromElement(domElement: Element): boolean { + if (rootIsUsingResources) { + const type = domElement.tagName.toLowerCase(); + const props = { + rel: domElement.getAttribute('rel'), + href: domElement.getAttribute('href'), + crossOrigin: domElement.getAttribute('crossorigin'), + referrerPolicy: domElement.getAttribute('referrerpolicy'), + }; + + const key = getResourceKeyFromTypeAndProps(type, props); + + if (key) { + embeddedResourceElementMap.set(key, domElement); + return true; + } + } + + return false; +} diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 9b5a021aa5c8a..e7b55bdf85c05 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -15,6 +15,7 @@ import type { ObserveVisibleRectsCallback, } from 'react-reconciler/src/ReactTestSelectors'; import type {ReactScopeInstance} from 'shared/ReactTypes'; +import type {RootTag} from 'react-reconciler/src/ReactRootTags'; import { precacheFiberNode, @@ -69,8 +70,8 @@ import { import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem'; import { + getResourceKeyFromTypeAndProps, resourceFromElement, - reconcileHydratedResources, prepareToHydrateResources, } from './ReactDOMFloatResources'; @@ -787,16 +788,15 @@ function getNextHydratable(node) { // Skip non-hydratable nodes. for (; node != null; node = ((node: any): Node).nextSibling) { const nodeType = node.nodeType; - if (nodeType === ELEMENT_NODE) { - if (enableFloat) { - // @TODO replace with isResource logic - if (((node: any): HTMLElement).tagName.toLowerCase() === 'link') { - resourceFromElement(((node: any): HTMLElement)); - continue; - } - } - break; - } else if (nodeType === TEXT_NODE) { + if ( + enableFloat && + nodeType === ELEMENT_NODE && + resourceFromElement(((node: any): HTMLElement)) + ) { + // This node was a resource, we advance to the next node + continue; + } + if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) { break; } if (nodeType === COMMENT_NODE) { @@ -1351,23 +1351,22 @@ export function setupIntersectionObserver( export const supportsResources = true; -export function prepareToRender() { - prepareToHydrateResources(); +export function prepareToRender(rootTag: RootTag) { + prepareToHydrateResources(rootTag); } export function cleanupAfterRender() {} -export function isResource(type: string) { - return type === 'link'; +export function isResource(type: string, props: Props) { + return !!getResourceKeyFromTypeAndProps(type, props); } -export function hoistStaticResource(rootContainerInstance: Container) { - reconcileHydratedResources(rootContainerInstance); -} +export {getResourceKeyFromTypeAndProps}; export { acquireResource, releaseResource, getRootResourceHost, insertPendingResources, + reconcileHydratedResources, } from './ReactDOMFloatResources'; diff --git a/packages/react-reconciler/src/ReactFiber.new.js b/packages/react-reconciler/src/ReactFiber.new.js index 24306cfaa4481..ccf1cb969007a 100644 --- a/packages/react-reconciler/src/ReactFiber.new.js +++ b/packages/react-reconciler/src/ReactFiber.new.js @@ -492,8 +492,8 @@ export function createFiberFromTypeAndProps( } } } else if (typeof type === 'string') { - if (supportsResources && enableFloat) { - if (isResource(type)) { + if (supportsResources && enableFloat && mode & ConcurrentMode) { + if (isResource(type, pendingProps)) { fiberTag = HostResource; } else { fiberTag = HostComponent; diff --git a/packages/react-reconciler/src/ReactFiber.old.js b/packages/react-reconciler/src/ReactFiber.old.js index 54cd841f6e518..fe49570cc7e89 100644 --- a/packages/react-reconciler/src/ReactFiber.old.js +++ b/packages/react-reconciler/src/ReactFiber.old.js @@ -492,8 +492,8 @@ export function createFiberFromTypeAndProps( } } } else if (typeof type === 'string') { - if (supportsResources && enableFloat) { - if (isResource(type)) { + if (supportsResources && enableFloat && mode & ConcurrentMode) { + if (isResource(type, pendingProps)) { fiberTag = HostResource; } else { fiberTag = HostComponent; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 75094ea18e09a..a58a82c0f5c7c 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -164,6 +164,7 @@ import { supportsHydration, supportsResources, isPrimaryRenderer, + getResourceKeyFromTypeAndProps, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldError, shouldSuspend} from './ReactFiberReconciler'; @@ -1577,17 +1578,23 @@ function updateHostResource( renderLanes: Lanes, ) { if (supportsResources && enableFloat) { + const type = workInProgress.type; const nextProps = workInProgress.pendingProps; - const nextChildren = nextProps.children; + const nextChildren = nextProps.children; if (__DEV__) { if (nextChildren != null) { console.error( 'A "%s" element is being treated like a HostResource but it contains children. HostResources should not have any children', - workInProgress.type, + type, ); } } + + workInProgress.memoizedState = getResourceKeyFromTypeAndProps( + type, + nextProps, + ); markRef(current, workInProgress); } return null; diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 3d69eddc67211..6e113aa365055 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -164,6 +164,7 @@ import { supportsHydration, supportsResources, isPrimaryRenderer, + getResourceKeyFromTypeAndProps, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; import {shouldError, shouldSuspend} from './ReactFiberReconciler'; @@ -1577,17 +1578,23 @@ function updateHostResource( renderLanes: Lanes, ) { if (supportsResources && enableFloat) { + const type = workInProgress.type; const nextProps = workInProgress.pendingProps; - const nextChildren = nextProps.children; + const nextChildren = nextProps.children; if (__DEV__) { if (nextChildren != null) { console.error( 'A "%s" element is being treated like a HostResource but it contains children. HostResources should not have any children', - workInProgress.type, + type, ); } } + + workInProgress.memoizedState = getResourceKeyFromTypeAndProps( + type, + nextProps, + ); markRef(current, workInProgress); } return null; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index fb4aea3f6f91c..462632e530c71 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -2403,10 +2403,12 @@ function commitMutationEffectsOnFiber( // this to acquire without mutation in render and then use Update flags to // do the insertion in commit but this seems simpler for now. finishedWork.stateNode = acquireResource( + // The precomputed resource Key + finishedWork.memoizedState, + // Type and Props to construct an element if necessary finishedWork.type, finishedWork.memoizedProps, - // For HostResource memoizedState holds the rootContainerInstance - finishedWork.memoizedState, + // The resourceMap and resourceContainer root.resourceHost, ); } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 8ae71268a9f8a..d560728fc5810 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -2403,10 +2403,12 @@ function commitMutationEffectsOnFiber( // this to acquire without mutation in render and then use Update flags to // do the insertion in commit but this seems simpler for now. finishedWork.stateNode = acquireResource( + // The precomputed resource Key + finishedWork.memoizedState, + // Type and Props to construct an element if necessary finishedWork.type, finishedWork.memoizedProps, - // For HostResource memoizedState holds the rootContainerInstance - finishedWork.memoizedState, + // The resourceMap and resourceContainer root.resourceHost, ); } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index d56fce542129e..d709b372eca50 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -33,6 +33,7 @@ import type {Cache} from './ReactFiberCacheComponent.new'; import { enableSuspenseAvoidThisFallback, enableLegacyHidden, + enableFloat, } from 'shared/ReactFeatureFlags'; import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.new'; @@ -1088,12 +1089,11 @@ function completeWork( return null; } case HostResource: { - if (supportsResources) { - const rootContainerInstance = getRootHostContainer(); - workInProgress.memoizedState = rootContainerInstance; - if (current !== null && workInProgress.stateNode != null) { - // noop for now - } else { + if (supportsResources && enableFloat) { + const previousKey = current !== null ? current.memoizedState : ''; + const currentKey = workInProgress.memoizedState; + + if (currentKey !== previousKey) { markUpdate(workInProgress); } bubbleProperties(workInProgress); diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index bbc8566c36b6d..808117302249f 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -33,6 +33,7 @@ import type {Cache} from './ReactFiberCacheComponent.old'; import { enableSuspenseAvoidThisFallback, enableLegacyHidden, + enableFloat, } from 'shared/ReactFeatureFlags'; import {resetWorkInProgressVersions as resetMutableSourceWorkInProgressVersions} from './ReactMutableSource.old'; @@ -1088,12 +1089,11 @@ function completeWork( return null; } case HostResource: { - if (supportsResources) { - const rootContainerInstance = getRootHostContainer(); - workInProgress.memoizedState = rootContainerInstance; - if (current !== null && workInProgress.stateNode != null) { - // noop for now - } else { + if (supportsResources && enableFloat) { + const previousKey = current !== null ? current.memoizedState : ''; + const currentKey = workInProgress.memoizedState; + + if (currentKey !== previousKey) { markUpdate(workInProgress); } bubbleProperties(workInProgress); diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js index b81e6397ad543..de27aa081bd07 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js @@ -23,8 +23,9 @@ export const supportsResources = false; export const prepareToRender = shim; export const cleanupAfterRender = shim; export const isResource = shim; -export const hoistStaticResource = shim; +export const reconcileHydratedResources = shim; export const acquireResource = shim; export const releaseResource = shim; export const getRootResourceHost = shim; export const insertPendingResources = shim; +export const getResourceKeyFromTypeAndProps = shim; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 19a19f6dab0f3..dded10fdef503 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -85,7 +85,7 @@ import { supportsResources, prepareToRender, cleanupAfterRender, - hoistStaticResource, + reconcileHydratedResources, } from './ReactFiberHostConfig'; import { @@ -907,7 +907,7 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { function performConcurrentWorkOnRoot(root, didTimeout) { try { if (supportsResources && enableFloat) { - prepareToRender(); + prepareToRender(root.tag); } if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { @@ -2249,7 +2249,7 @@ function commitRootImpl( commitMutationEffects(root, finishedWork, lanes); if (supportsResources && enableFloat) { - hoistStaticResource(root.containerInfo); + reconcileHydratedResources(root.containerInfo); } if (enableCreateEventHandleAPI) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index adfb0bc5813bd..e45a1cc4819d8 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -85,7 +85,7 @@ import { supportsResources, prepareToRender, cleanupAfterRender, - hoistStaticResource, + reconcileHydratedResources, } from './ReactFiberHostConfig'; import { @@ -907,7 +907,7 @@ function ensureRootIsScheduled(root: FiberRoot, currentTime: number) { function performConcurrentWorkOnRoot(root, didTimeout) { try { if (supportsResources && enableFloat) { - prepareToRender(); + prepareToRender(root.tag); } if (enableProfilerTimer && enableProfilerNestedUpdatePhase) { @@ -2249,7 +2249,7 @@ function commitRootImpl( commitMutationEffects(root, finishedWork, lanes); if (supportsResources && enableFloat) { - hoistStaticResource(root.containerInfo); + reconcileHydratedResources(root.containerInfo); } if (enableCreateEventHandleAPI) { diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 4ca032879666e..7d29deded0b96 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -195,8 +195,11 @@ export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer; export const prepareToRender = $$$hostConfig.prepareToRender; export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; export const isResource = $$$hostConfig.isResource; -export const hoistStaticResource = $$$hostConfig.hoistStaticResource; +export const reconcileHydratedResources = + $$$hostConfig.reconcileHydratedResources; export const acquireResource = $$$hostConfig.acquireResource; export const releaseResource = $$$hostConfig.releaseResource; export const getRootResourceHost = $$$hostConfig.getRootResourceHost; export const insertPendingResources = $$$hostConfig.insertPendingResources; +export const getResourceKeyFromTypeAndProps = + $$$hostConfig.getResourceKeyFromTypeAndProps; From f5953d43ef78c288e5009c5959cab1068d37a189 Mon Sep 17 00:00:00 2001 From: Josh Story <story@hey.com> Date: Thu, 21 Jul 2022 08:58:53 -0700 Subject: [PATCH 5/7] opt-out of resource semantics when using data attributes --- .../src/__tests__/ReactDOMResources-test.js | 110 ++++++++++++++++++ .../src/client/ReactDOMFloatResources.js | 36 +++++- .../src/client/ReactDOMHostConfig.js | 5 +- .../src/server/ReactDOMFloatServer.js | 16 ++- 4 files changed, 159 insertions(+), 8 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMResources-test.js b/packages/react-dom/src/__tests__/ReactDOMResources-test.js index 33558ac078df8..689814ed750f5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMResources-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMResources-test.js @@ -783,6 +783,116 @@ describe('ReactDOMResources', () => { ); }); + it('treats resource eligible elements with data-* attributes as components instead of resources', async () => { + await actIntoEmptyDocument(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossOrigin="" /> + <link rel="stylesheet" href="foo" crossOrigin="use-credentials" /> + </head> + <body> + <link rel="stylesheet" href="foo" crossOrigin="" data-foo="" /> + <link rel="stylesheet" href="foo" crossOrigin="" data-foo="" /> + <div>hello world</div> + </body> + </html>, + ); + pipe(writable); + }); + // data attribute links get their own individual representation in the stream because they are treated + // like regular HostComponents + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossorigin="" /> + <link rel="stylesheet" href="foo" crossorigin="use-credentials" /> + </head> + <body> + <link rel="stylesheet" href="foo" crossorigin="" data-foo="" /> + <link rel="stylesheet" href="foo" crossorigin="" data-foo="" /> + <div>hello world</div> + </body> + </html>, + ); + + const root = ReactDOMClient.hydrateRoot( + container, + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossOrigin="" /> + <link rel="stylesheet" href="foo" crossOrigin="use-credentials" /> + </head> + <body> + <link rel="stylesheet" href="foo" crossOrigin="" data-foo="" /> + <link rel="stylesheet" href="foo" crossOrigin="" data-foo="" /> + <div>hello world</div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossorigin="" /> + <link rel="stylesheet" href="foo" crossorigin="use-credentials" /> + </head> + <body> + <link rel="stylesheet" href="foo" crossorigin="" data-foo="" /> + <link rel="stylesheet" href="foo" crossorigin="" data-foo="" /> + <div>hello world</div> + </body> + </html>, + ); + + // Drop the foo crossorigin anonymous HostResource that might match if we weren't useing data attributes + // It is actually removed from the head because the body representation is a HostComponent and completely + // disconnected from the Resource runtime. + root.render( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossOrigin="use-credentials" /> + </head> + <body> + <link + rel="stylesheet" + href="foo" + crossOrigin="" + data-bar="baz" + data-foo="" + /> + <link rel="stylesheet" href="foo" crossOrigin="" data-foo="" /> + <div>hello world</div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" /> + <link rel="stylesheet" href="foo" crossorigin="use-credentials" /> + </head> + <body> + <link + rel="stylesheet" + href="foo" + crossorigin="" + data-bar="baz" + data-foo="" + /> + <link rel="stylesheet" href="foo" crossorigin="" data-foo="" /> + <div>hello world</div> + </body> + </html>, + ); + }); + describe('link resources', () => { // @gate enableFloat it('keys resources on href, crossOrigin, and referrerPolicy', async () => { diff --git a/packages/react-dom/src/client/ReactDOMFloatResources.js b/packages/react-dom/src/client/ReactDOMFloatResources.js index 4e590873f4ef9..1d8cf30aa8134 100644 --- a/packages/react-dom/src/client/ReactDOMFloatResources.js +++ b/packages/react-dom/src/client/ReactDOMFloatResources.js @@ -67,7 +67,6 @@ export function acquireResource( ); setInitialResourceProperties(domElement, type, props, resourceContainer); } - insertResource(domElement, resourceHost); resource = { key, type, @@ -76,7 +75,9 @@ export function acquireResource( }; resourceMap.set(key, resource); } - resource.count++; + if (resource.count++ === 0) { + insertResource(resource.instance, resourceHost); + } return resource; } @@ -206,6 +207,20 @@ export function getRootResourceHost( }; } +export function isResource(type: string, props: Object): boolean { + const key = getResourceKeyFromTypeAndProps(type, props); + if (key) { + // This is potentially a Resource. We need to check if props contain + // data attributes. Resources do not support data attributes. + for (const prop in props) { + if (prop.startsWith('data-')) { + return false; + } + } + } + return !!key; +} + export function getResourceKeyFromTypeAndProps( type: string, props: Object, @@ -213,6 +228,7 @@ export function getResourceKeyFromTypeAndProps( switch (type) { case 'link': { const {rel, href, crossOrigin, referrerPolicy} = props; + if (!href) { return undefined; } @@ -247,9 +263,23 @@ export function getResourceKeyFromTypeAndProps( } } -export function resourceFromElement(domElement: Element): boolean { +export function resourceFromElement(domElement: HTMLElement): boolean { if (rootIsUsingResources) { + if (Object.keys(domElement.dataset).length) { + // This element has data attributes. Managing data attributes for resources is impractical + // because they suggest an intention to query / manipulate DOM Elmenets directly and + // turning the React representation into a deduped reference is incongruent with such + // intention. + return false; + } const type = domElement.tagName.toLowerCase(); + // This is really unfortunate that we need to create this intermediate props object. + // Originally I tried to just pass the domElement as the props object since jsx prop names + // match HTMLElement property names. However interface for the values passed to props more + // closely matches attributes. crossOrigin in particular is a pain where the attribute being + // missing is supposed to encode no cors but the property returns an empty string. However + // when the attribute is the empty string we are supped to be in cors anonymous mode but the property + // can also return empty string in this case (at least in JSDOM); const props = { rel: domElement.getAttribute('rel'), href: domElement.getAttribute('href'), diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index e7b55bdf85c05..6000ead53ea0a 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -1357,13 +1357,10 @@ export function prepareToRender(rootTag: RootTag) { export function cleanupAfterRender() {} -export function isResource(type: string, props: Props) { - return !!getResourceKeyFromTypeAndProps(type, props); -} - export {getResourceKeyFromTypeAndProps}; export { + isResource, acquireResource, releaseResource, getRootResourceHost, diff --git a/packages/react-dom/src/server/ReactDOMFloatServer.js b/packages/react-dom/src/server/ReactDOMFloatServer.js index 2b753206a91a3..db658ea4c4a6c 100644 --- a/packages/react-dom/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom/src/server/ReactDOMFloatServer.js @@ -400,13 +400,27 @@ export function resourcesFromLink(props: Object): boolean { // } case 'stylesheet': { preinit(href, {as: 'style', crossOrigin}); - return true; + // If this component is a valid resource, meaning it does not have anything that would + // cause it to need to be treated like a component we can omit it and return true here. + // If it is in fact a component it will need to be inserted and we return false here. + return validateResourceProps(props); } default: return false; } } +// returns false if props object contains any props which invalidate +// treating the element entirely as a Resource +function validateResourceProps(props: Object): boolean { + for (const prop in props) { + if (prop.toLowerCase().startsWith('data-')) { + return false; + } + } + return true; +} + // Construct a resource from script props. export function resourcesFromScript(props: Object) { // const src = props.src; From cad5c22f5625d61fcdceeda063b21387967a8fba Mon Sep 17 00:00:00 2001 From: Josh Story <story@hey.com> Date: Thu, 21 Jul 2022 14:10:26 -0700 Subject: [PATCH 6/7] leave found resources in place --- .../src/__tests__/ReactDOMResources-test.js | 19 ++++++++++--------- .../src/client/ReactDOMFloatResources.js | 6 +++--- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMResources-test.js b/packages/react-dom/src/__tests__/ReactDOMResources-test.js index 689814ed750f5..a53267b7b54bb 100644 --- a/packages/react-dom/src/__tests__/ReactDOMResources-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMResources-test.js @@ -442,6 +442,7 @@ describe('ReactDOMResources', () => { </html>, ); + // The resources are not relocated on hydration so they stay ahead of the hello world div ReactDOMClient.hydrateRoot(container, <App />); expect(Scheduler).toFlushWithoutYielding(); expect(getVisibleChildren(document)).toEqual( @@ -451,9 +452,9 @@ describe('ReactDOMResources', () => { </head> <body> <div id="container"> - <div>hello world</div> <link rel="stylesheet" href="foo" /> <link rel="stylesheet" href="bar" /> + <div>hello world</div> </div> </body> </html>, @@ -705,15 +706,15 @@ describe('ReactDOMResources', () => { <head /> <body> <div id="container1"> - <div>hello world</div> <link rel="stylesheet" href="foo" /> <link rel="stylesheet" href="bar" /> + <div>hello world</div> </div> <div id="container2"> - <div>hello world</div> <link rel="stylesheet" href="foo" /> <link rel="stylesheet" href="extra" /> <link rel="stylesheet" href="bar" /> + <div>hello world</div> </div> </body> </html>, @@ -726,16 +727,16 @@ describe('ReactDOMResources', () => { <head /> <body> <div id="container1"> - <div>hello world</div> <link rel="stylesheet" href="foo" /> <link rel="stylesheet" href="bar" /> + <div>hello world</div> <link rel="stylesheet" href="extra" /> </div> <div id="container2"> - <div>hello world</div> <link rel="stylesheet" href="foo" /> <link rel="stylesheet" href="extra" /> <link rel="stylesheet" href="bar" /> + <div>hello world</div> </div> </body> </html>, @@ -748,15 +749,15 @@ describe('ReactDOMResources', () => { <head /> <body> <div id="container1"> - <div>hello world</div> <link rel="stylesheet" href="foo" /> <link rel="stylesheet" href="bar" /> + <div>hello world</div> <link rel="stylesheet" href="extra" /> </div> <div id="container2"> - <div>hello world</div> <link rel="stylesheet" href="foo" /> <link rel="stylesheet" href="bar" /> + <div>hello world</div> </div> </body> </html>, @@ -769,14 +770,14 @@ describe('ReactDOMResources', () => { <head /> <body> <div id="container1"> - <div>hello world</div> <link rel="stylesheet" href="foo" /> <link rel="stylesheet" href="bar" /> + <div>hello world</div> </div> <div id="container2"> - <div>hello world</div> <link rel="stylesheet" href="foo" /> <link rel="stylesheet" href="bar" /> + <div>hello world</div> </div> </body> </html>, diff --git a/packages/react-dom/src/client/ReactDOMFloatResources.js b/packages/react-dom/src/client/ReactDOMFloatResources.js index 1d8cf30aa8134..531c46c6c60a8 100644 --- a/packages/react-dom/src/client/ReactDOMFloatResources.js +++ b/packages/react-dom/src/client/ReactDOMFloatResources.js @@ -66,16 +66,16 @@ export function acquireResource( HTML_NAMESPACE, ); setInitialResourceProperties(domElement, type, props, resourceContainer); + insertResource(domElement, resourceHost); } resource = { key, type, - count: 0, + count: 1, instance: domElement, }; resourceMap.set(key, resource); - } - if (resource.count++ === 0) { + } else if (resource.count++ === 0) { insertResource(resource.instance, resourceHost); } return resource; From b633ab08b97c1723064b516ee08a5447a5f33480 Mon Sep 17 00:00:00 2001 From: Josh Story <story@hey.com> Date: Fri, 22 Jul 2022 15:48:36 -0700 Subject: [PATCH 7/7] Support validation for props on duplicate resource and on client resource update` --- .../src/__tests__/ReactDOMResources-test.js | 170 +++++- .../src/client/ReactDOMFloatResources.js | 94 +++- .../src/client/ReactDOMHostConfig.js | 1 + .../src/server/ReactDOMFloatServer.js | 492 +++++------------- .../src/server/ReactDOMServerFormatConfig.js | 139 +---- .../src/ReactFiberCompleteWork.new.js | 9 + .../src/ReactFiberCompleteWork.old.js | 9 + .../ReactFiberHostConfigWithNoResources.js | 1 + .../src/forks/ReactFiberHostConfig.custom.js | 1 + scripts/error-codes/codes.json | 3 +- 10 files changed, 433 insertions(+), 486 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMResources-test.js b/packages/react-dom/src/__tests__/ReactDOMResources-test.js index a53267b7b54bb..89539858a247c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMResources-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMResources-test.js @@ -172,6 +172,15 @@ describe('ReactDOMResources', () => { // } // } + function normalizeCodeLocInfo(str) { + return ( + str && + str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) { + return '\n in ' + name + ' (at **)'; + }) + ); + } + // @gate enableFloat it('hoists resources to the head if the container is a Document without hydration', async () => { function App() { @@ -950,7 +959,7 @@ describe('ReactDOMResources', () => { rel="stylesheet" href="foo" crossOrigin="anonymous" - referrerPolicy="strict-origin-when-cross-origin" + referrerPolicy="" /> <link rel="stylesheet" href="foo" crossOrigin="use-credentials" /> </head> @@ -1034,5 +1043,164 @@ describe('ReactDOMResources', () => { />, ]); }); + + // @gate enableFloat + it('warns in Dev when two key-matched resources use different values for non-ignored divergent props', async () => { + const originalConsoleError = console.error; + const mockError = jest.fn(); + console.error = (...args) => { + if (args.length > 1) { + if (typeof args[1] === 'object') { + mockError(args[0].split('\n')[0]); + return; + } + } + mockError(...args.map(normalizeCodeLocInfo)); + }; + + try { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <html> + <head /> + <body> + <div> + <link rel="stylesheet" href="foo" media="print" /> + <link + rel="stylesheet" + href="foo" + media="screen and (max-width: 600px)" + /> + hello world + </div> + </body> + </html>, + ); + pipe(writable); + }); + // The second link matches the key of the first link but disagrees on the media prop. this should warn but also only + // emit the first resource representation for that key + expect(getVisibleChildren(document)).toEqual( + <html> + <head> + <link rel="stylesheet" href="foo" media="print" /> + </head> + <body> + <div>hello world</div> + </body> + </html>, + ); + if (__DEV__) { + expect(mockError.mock.calls).toEqual([ + [ + 'Warning: A "%s" Resource (%s="%s") was %s with a different "%s" prop than the one used originally.' + + ' The original value was "%s" and the new value is "%s". Either the "%s" value should be the same' + + ' or this Resource should point to distinct "%s" location.%s', + 'stylesheet', + 'href', + 'foo', + 'created', + 'media', + 'print', + 'screen and (max-width: 600px)', + 'media', + 'href', + '\n' + + ' in link (at **)\n' + + ' in div (at **)\n' + + ' in body (at **)\n' + + ' in html (at **)', + ], + ]); + } + mockError.mock.calls.length = 0; + + const root = ReactDOMClient.hydrateRoot( + document, + <html> + <head /> + <body> + <div> + <link rel="stylesheet" href="foo" media="print" /> + <link + rel="stylesheet" + href="foo" + media="screen and (max-width: 600px)" + /> + hello world + </div> + </body> + </html>, + ); + expect(Scheduler).toFlushWithoutYielding(); + if (__DEV__) { + expect(mockError.mock.calls).toEqual([ + [ + 'Warning: A "%s" Resource (%s="%s") was %s with a different "%s" prop than the one used originally.' + + ' The original value was "%s" and the new value is "%s". Either the "%s" value should be the same' + + ' or this Resource should point to distinct "%s" location.%s', + 'stylesheet', + 'href', + 'foo', + 'created', + 'media', + 'print', + 'screen and (max-width: 600px)', + 'media', + 'href', + '\n' + + ' in div (at **)\n' + + ' in body (at **)\n' + + ' in html (at **)', + ], + ]); + } + mockError.mock.calls.length = 0; + + root.render( + <html> + <head /> + <body> + <div> + <link rel="stylesheet" href="foo" media="print" /> + <link + rel="stylesheet" + href="foo" + media="print" + integrity="some hash" + /> + hello world + </div> + </body> + </html>, + ); + + expect(Scheduler).toFlushWithoutYielding(); + if (__DEV__) { + expect(mockError.mock.calls).toEqual([ + [ + 'Warning: A "%s" Resource (%s="%s") was %s with a different "%s" prop than the one used originally.' + + ' The original value was "%s" and the new value is "%s". Either the "%s" value should be the same' + + ' or this Resource should point to distinct "%s" location.%s', + 'stylesheet', + 'href', + 'foo', + 'updated', + 'integrity', + '', + 'some hash', + 'integrity', + 'href', + '\n' + + ' in div (at **)\n' + + ' in body (at **)\n' + + ' in html (at **)', + ], + ]); + } + } finally { + console.error = originalConsoleError; + } + }); }); }); diff --git a/packages/react-dom/src/client/ReactDOMFloatResources.js b/packages/react-dom/src/client/ReactDOMFloatResources.js index 531c46c6c60a8..ae95d225549da 100644 --- a/packages/react-dom/src/client/ReactDOMFloatResources.js +++ b/packages/react-dom/src/client/ReactDOMFloatResources.js @@ -26,11 +26,15 @@ export type ResourceHost = { container: ResourceContainer, }; +const STYLE_RESOURCE = 'stylesheet'; + +type ResourceElementType = 'link'; export type Resource = { key: string, - type: string, + elementType: ResourceElementType, count: number, instance: Element, + props: Object, }; const CORS_NONE = ''; @@ -46,7 +50,7 @@ let rootIsUsingResources = false; export function acquireResource( key: string, - type: string, + elementType: ResourceElementType, props: Object, resourceHost: ResourceHost, ): Resource { @@ -56,28 +60,43 @@ export function acquireResource( let domElement = embeddedResourceElementMap.get(key); if (domElement) { embeddedResourceElementMap.delete(key); + setInitialResourceProperties( + domElement, + elementType, + props, + resourceContainer, + ); } else { // We cheat somewhat and substitute the resourceHost container instead of the rootContainer. // Sometimes they are the same but even when they are not, the ownerDocument should be. domElement = createElement( - type, + elementType, props, resourceContainer, HTML_NAMESPACE, ); - setInitialResourceProperties(domElement, type, props, resourceContainer); + setInitialResourceProperties( + domElement, + elementType, + props, + resourceContainer, + ); insertResource(domElement, resourceHost); } resource = { key, - type, + elementType, count: 1, instance: domElement, + props, }; resourceMap.set(key, resource); } else if (resource.count++ === 0) { insertResource(resource.instance, resourceHost); } + if (__DEV__) { + validateResourceAndProps(resource, props, true); + } return resource; } @@ -242,17 +261,14 @@ export function getResourceKeyFromTypeAndProps( cors = CORS_NONE; } - const referrer = - referrerPolicy === 'strict-origin-when-cross-origin' - ? '' - : referrerPolicy || ''; + const referrer = referrerPolicy || ''; // We use new-lines in the key because they are not valid in urls and thus there should // never be a collision between a href with no cors/referrer and another href with particular // cors & referrer. switch (rel) { case 'stylesheet': { - return href + '\n' + cors + referrer; + return STYLE_RESOURCE + '\n' + href + '\n' + cors + referrer; } default: return undefined; @@ -297,3 +313,61 @@ export function resourceFromElement(domElement: HTMLElement): boolean { return false; } + +// @TODO figure out how to utilize existing prop validation to do this instead of reinventing +let warnOnDivergentPropsStylesheet = null; +if (__DEV__) { + warnOnDivergentPropsStylesheet = ['media', 'integrity', 'title'].map( + propName => { + return { + propName, + type: 'string', + }; + }, + ); +} + +// This coercion is to normalize across semantically identical values (such as missing being equivalent to empty string) +// If the user used an improper type for a prop that will be warned on using the normal prop validation mechanism +function getPropertyValue(props, propertyInfo): any { + switch (propertyInfo.type) { + case 'string': { + return props[propertyInfo.propName] || ''; + } + } +} + +export function validateResourceAndProps( + resource: Resource, + props: Object, + created: boolean, +) { + if (__DEV__) { + switch (resource.elementType) { + case 'link': { + (warnOnDivergentPropsStylesheet: any).forEach(propertyInfo => { + const currentProp = getPropertyValue(resource.props, propertyInfo); + const nextProp = getPropertyValue(props, propertyInfo); + if (currentProp !== nextProp) { + const locationPropName = 'href'; + const propName = propertyInfo.propName; + console.error( + 'A "%s" Resource (%s="%s") was %s with a different "%s" prop than the one used originally.' + + ' The original value was "%s" and the new value is "%s". Either the "%s" value should' + + ' be the same or this Resource should point to distinct "%s" location.', + 'stylesheet', + locationPropName, + props.href, + created ? 'created' : 'updated', + propName, + currentProp, + nextProp, + propName, + locationPropName, + ); + } + }); + } + } + } +} diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 6000ead53ea0a..d60bc641ab5a2 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -1366,4 +1366,5 @@ export { getRootResourceHost, insertPendingResources, reconcileHydratedResources, + validateResourceAndProps, } from './ReactDOMFloatResources'; diff --git a/packages/react-dom/src/server/ReactDOMFloatServer.js b/packages/react-dom/src/server/ReactDOMFloatServer.js index db658ea4c4a6c..496cfcf69834a 100644 --- a/packages/react-dom/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom/src/server/ReactDOMFloatServer.js @@ -7,6 +7,11 @@ * @flow */ +import type { + Chunk, + PrecomputedChunk, +} from 'react-server/src/ReactServerStreamConfig'; + import {pushDispatcher, popDispatcher} from '../shared/ReactDOMDispatcher'; let currentResourceMap = null; @@ -21,36 +26,24 @@ type Priority = 0 | 1 | 2 | 3 | 4; export const CORS_NONE = 0; export const CORS_ANON = 1; export const CORS_CREDS = 2; -type CrossOrigin = 0 | 1 | 2; - -export const NO_RESOURCE = /* */ 0b0000; -export const HOST_RESOURCE = /* */ 0b0001; -export const INITIALIZABLE_RESOURCE = /* */ 0b0110; -export const STYLE_RESOURCE = /* */ 0b0010; -export const SCRIPT_RESOURCE = /* */ 0b0100; - -export const FONT_RESOURCE = /* */ 0b1000; -type ResourceAsType = number; +export const STYLE_RESOURCE = 'stylesheet'; +export const FONT_RESOURCE = 'font'; +export const SCRIPT_RESOURCE = 'script'; +type ResourceType = 'stylesheet' | 'script'; +type ResourceElementType = 'link' | 'script'; export type Resource = { - // "priority" and "as" define the branching for how to flush these resources. - // Not all combinations are valid but flow isn't smart enought to allow for disjoint unions - // while keeping object allocations and mutations as small as possible in code - priority: Priority, - as: ResourceAsType, - // "href" is the resource location but also the key to define uniquenes of a given resource - href: string, - // "flushed" is how we track whether we need to emit the resource in the next flush flushed: boolean, - // Certain resources have module variants. only applies to 'script' resources - module: boolean, - crossorigin: CrossOrigin, - type: MimeType, + priority: Priority, + type: ResourceType, + elementType: ResourceElementType, + key: string, + props: Object, + chunks: Array<Chunk | PrecomputedChunk>, }; -type ResourceMap = Map<string, Resource>; -type MimeType = string; +type ResourceMap = Map<string, Resource>; export function prepareToRender(resourceMap: ResourceMap) { currentResourceMap = resourceMap; @@ -64,349 +57,68 @@ export function cleanupAfterRender() { popDispatcher(); } -type CrossOriginOption = boolean | string | 'use-credentials'; -type PrefetchDNSOptions = {crossOrigin?: CrossOriginOption}; -function prefetchDNS(href: string, options?: PrefetchDNSOptions) { - if (currentResourceMap === null) { - // @TODO excluding these errors since these methods are not exported (yet) and the error logic is going to evolve - // eslint-disable-next-line react-internal/prod-error-codes - throw new Error( - 'prefetchDNS was called while currentResourceMap is null. this is a bug in React', - ); - } - const crossOriginOption = options ? options.crossOrigin : false; - let crossorigin; - if (crossOriginOption === 'use-credentials') { - crossorigin = CORS_CREDS; - } else if ( - typeof crossOriginOption === 'string' || - crossOriginOption === true - ) { - crossorigin = CORS_ANON; - } else { - crossorigin = CORS_NONE; - } - const key = href + crossorigin; - const currentResource = currentResourceMap.get(key); - if (currentResource) { - // In this Float function we can avoid checking priority because DNS_PREFETCH is the lowest priority - return; - } else { - const resource: Resource = { - priority: DNS_PREFETCH, - as: HOST_RESOURCE, - href, - flushed: false, - module: false, - crossorigin, - type: '', - }; - currentResourceMap.set(key, resource); - } -} - -type PreconnectOptions = {crossOrigin?: CrossOriginOption}; -function preconnect(href: string, options?: PreconnectOptions) { +function preinitStylesheet(props: Object): Resource { if (currentResourceMap === null) { // @TODO excluding these errors since these methods are not exported (yet) and the error logic is going to evolve // eslint-disable-next-line react-internal/prod-error-codes throw new Error( - 'preconnect was called while currentResourceMap is null. this is a bug in React', + `${'preinitStylesheet'} was called while currentResourceMap is null. this is a bug in React`, ); } - const crossOriginOption = options ? options.crossOrigin : false; - let crossorigin; - if (crossOriginOption === 'use-credentials') { - crossorigin = CORS_CREDS; - } else if ( - typeof crossOriginOption === 'string' || - crossOriginOption === true - ) { - crossorigin = CORS_ANON; - } else { - crossorigin = CORS_NONE; - } - const key = href + crossorigin; - const currentResource = currentResourceMap.get(key); - if (currentResource) { - if (currentResource.priority >= PRECONNECT) { - return; - } else { - currentResource.priority = PRECONNECT; - // We are upgrading from prefetchDNS which also has an "as" of "host" so we don't need to reset it here - currentResource.flushed = false; - } - } else { - const resource: Resource = { - priority: PRECONNECT, - as: HOST_RESOURCE, - href, - flushed: false, - module: false, - crossorigin, - type: '', - }; - currentResourceMap.set(key, resource); - } -} -type PrefetchAs = 'style' | 'font' | 'script'; -type PrefetchOptions = {as: PrefetchAs, crossOrigin?: CrossOriginOption}; -function prefetch(href: string, options: PrefetchOptions) { - if (currentResourceMap === null) { - // @TODO excluding these errors since these methods are not exported (yet) and the error logic is going to evolve - // eslint-disable-next-line react-internal/prod-error-codes + const key = getResourceKeyFromTypeAndProps('link', props); + if (!key) { throw new Error( - 'prefetch was called while currentResourceMap is null. this is a bug in React', + `${'preinitStylesheet'} was called with props that are not valid for a Resource. This is a bug in React.`, ); } - if (!options) { - return; - } - let as; - switch (options.as) { - case 'style': - as = STYLE_RESOURCE; - break; - case 'script': - as = SCRIPT_RESOURCE; - break; - case 'font': - as = FONT_RESOURCE; - break; - default: - return; - } - const crossOriginOption = options.crossOrigin; - let crossorigin; - if (as === FONT_RESOURCE) { - crossorigin = CORS_ANON; - } else if (crossOriginOption === 'use-credentials') { - crossorigin = CORS_CREDS; - } else if ( - typeof crossOriginOption === 'string' || - crossOriginOption === true - ) { - crossorigin = CORS_ANON; - } else { - crossorigin = CORS_NONE; - } - const key = href + crossorigin; - const currentResource = currentResourceMap.get(key); - if (currentResource) { - if (currentResource.priority >= PREFETCH) { - return; + let resource = currentResourceMap.get(key); + if (resource) { + if (resource.priority < PREINIT) { + // We are upgrading a lower priority resource to this priority. replace props without + // validating differences + resource.priority = PREINIT; + resource.flushed = false; + resource.props = props; + resource.chunks = []; } else { - currentResource.priority = PREFETCH; - currentResource.as = as; - currentResource.flushed = false; + // @TODO validate prop differences + validateResourceAndProps(resource, props); } } else { - const resource: Resource = { - priority: PREFETCH, - as, - href, + resource = { flushed: false, - module: false, - crossorigin, - type: '', - }; - currentResourceMap.set(key, resource); - } -} - -type PreloadAs = 'style' | 'font' | 'script'; -type PreloadOptions = {as: PreloadAs, crossOrigin?: CrossOriginOption}; -function preload(href: string, options: PreloadOptions) { - if (currentResourceMap === null) { - // @TODO excluding these errors since these methods are not exported (yet) and the error logic is going to evolve - // eslint-disable-next-line react-internal/prod-error-codes - throw new Error( - 'preload was called while currentResourceMap is null. this is a bug in React', - ); - } - if (!options) { - return; - } - let as; - switch (options.as) { - case 'style': - as = STYLE_RESOURCE; - break; - case 'script': - as = SCRIPT_RESOURCE; - break; - case 'font': - as = FONT_RESOURCE; - break; - default: - return; - } - const crossOriginOption = options.crossOrigin; - let crossorigin; - if (as === FONT_RESOURCE) { - crossorigin = CORS_ANON; - } else if (crossOriginOption === 'use-credentials') { - crossorigin = CORS_CREDS; - } else if ( - typeof crossOriginOption === 'string' || - crossOriginOption === true - ) { - crossorigin = CORS_ANON; - } else { - crossorigin = CORS_NONE; - } - const key = href + crossorigin; - const currentResource = currentResourceMap.get(key); - if (currentResource) { - if (currentResource.priority >= PRELOAD) { - return; - } else { - currentResource.priority = PRELOAD; - currentResource.as = as; - currentResource.flushed = false; - } - } else { - const resource: Resource = { - priority: PRELOAD, - as, - href, - flushed: false, - module: false, - crossorigin, - type: '', - }; - currentResourceMap.set(key, resource); - } -} - -type PreinitAs = 'style' | 'script'; -type PreinitOptions = {as: PreinitAs, crossOrigin?: CrossOriginOption}; -function preinit(href: string, options: PreinitOptions) { - if (__DEV__) { - if (!options || (options.as !== 'style' && options.as !== 'script')) { - const reason = !options - ? 'no option argument was provided' - : !('as' in options) - ? `no "as" property was provided in the options argument` - : // eslint-disable-next-line react-internal/safe-string-coercion - `the "as" type provided was ${String(options.as)}`; - // @TODO excluding these errors since these methods are not exported (yet) and the error logic is going to evolve - // eslint-disable-next-line react-internal/prod-error-codes - throw new Error( - `preinit was called without specifying a valid "as" type in the options argument. preinit supports style and script resources only and ${reason}`, - ); - } - } - if (currentResourceMap === null) { - // @TODO excluding these errors since these methods are not exported (yet) and the error logic is going to evolve - // eslint-disable-next-line react-internal/prod-error-codes - throw new Error( - 'preinit was called while currentResourceMap is null. this is a bug in React', - ); - } - if (!options) { - return; - } - let as; - switch (options.as) { - case 'style': - as = STYLE_RESOURCE; - break; - case 'script': - as = SCRIPT_RESOURCE; - break; - default: - return; - } - const crossOriginOption = options.crossOrigin; - let crossorigin; - if (crossOriginOption === 'use-credentials') { - crossorigin = CORS_CREDS; - } else if ( - typeof crossOriginOption === 'string' || - crossOriginOption === true - ) { - crossorigin = CORS_ANON; - } else { - crossorigin = CORS_NONE; - } - const key = href + crossorigin; - const currentResource = currentResourceMap.get(key); - if (currentResource) { - if (currentResource.priority >= PREINIT) { - return; - } else { - currentResource.priority = PREINIT; - currentResource.as = as; - currentResource.flushed = false; - } - } else { - const resource: Resource = { priority: PREINIT, - as, - href, - flushed: false, - module: false, - crossorigin, - type: '', + type: STYLE_RESOURCE, + elementType: 'link', + key, + props, + chunks: [], }; currentResourceMap.set(key, resource); } + return resource; } // Construct a resource from link props. // Returns true if the link was fully described by the resource and the link can omitted from the stream. // Returns false if the link should still be emitted to the stream -export function resourcesFromLink(props: Object): boolean { +export function resourceFromLink(props: Object): ?Resource { const rel = props.rel; const href = props.href; - if (typeof rel !== 'string' && typeof href !== 'string') { - return false; + if (typeof rel !== 'string' || typeof href !== 'string') { + return; } - const crossOrigin = props.hasOwnProperty('crossOrigin') - ? props.crossOrigin - : false; - switch (rel) { - // case 'dns-prefetch': { - // crossOrigin == null || crossOrigin === false - // ? prefetchDNS(href) - // : prefetchDNS(href, {crossOrigin}); - // return true; - // } - // case 'preconnect': { - // crossOrigin == null || crossOrigin === false - // ? preconnect(href) - // : preconnect(href, {crossOrigin}); - // return true; - // } - // case 'prefetch': { - // let as = props.as; - // if (as === 'style' || as === 'script' || as === 'font') { - // prefetch(href, {as, crossOrigin}); - // return true; - // } - // return false; - // } - // case 'preload': { - // let as = props.as; - // if (as === 'style' || as === 'script' || as === 'font') { - // preload(href, {as, crossOrigin}); - // return true; - // } - // return false; - // } case 'stylesheet': { - preinit(href, {as: 'style', crossOrigin}); - // If this component is a valid resource, meaning it does not have anything that would - // cause it to need to be treated like a component we can omit it and return true here. - // If it is in fact a component it will need to be inserted and we return false here. - return validateResourceProps(props); + if (validateResourceProps(props)) { + return preinitStylesheet(props); + } + return null; } default: - return false; + return null; } } @@ -421,25 +133,97 @@ function validateResourceProps(props: Object): boolean { return true; } -// Construct a resource from script props. -export function resourcesFromScript(props: Object) { - // const src = props.src; - // if (typeof src !== 'string') { - // return; - // } +function getResourceKeyFromTypeAndProps( + type: ResourceElementType, + props: Object, +): ?string { + switch (type) { + case 'link': { + const {rel, href, crossOrigin, referrerPolicy} = props; + + if (!href) { + return undefined; + } + + let cors; + if (crossOrigin === 'use-credentials') { + cors = CORS_CREDS; + } else if (typeof crossOrigin === 'string' || crossOrigin === true) { + cors = CORS_ANON; + } else { + cors = CORS_NONE; + } + + const referrer = referrerPolicy || ''; + + // We use new-lines in the key because they are not valid in urls and thus there should + // never be a collision between a href with no cors/referrer and another href with particular + // cors & referrer. + switch (rel) { + case 'stylesheet': { + return STYLE_RESOURCE + '\n' + href + '\n' + cors + referrer; + } + default: + return undefined; + } + } + default: + return undefined; + } +} - // let crossOrigin = props.hasOwnProperty('crossOrigin') - // ? props.crossOrigin - // : false; +// @TODO figure out how to utilize existing prop validation to do this instead of reinventing +let warnOnDivergentPropsStylesheet = null; +if (__DEV__) { + warnOnDivergentPropsStylesheet = ['media', 'integrity', 'title'].map( + propName => { + return { + propName, + type: 'string', + }; + }, + ); +} + +// This coercion is to normalize across semantically identical values (such as missing being equivalent to empty string) +// If the user used an improper type for a prop that will be warned on using the normal prop validation mechanism +function getPropertyValue(props, propertyInfo): any { + switch (propertyInfo.type) { + case 'string': { + return props[propertyInfo.propName] || ''; + } + } +} - // preload(src, {as: 'script', crossOrigin}); - return; +function validateResourceAndProps(resource: Resource, props: Object) { + if (__DEV__) { + switch (resource.type) { + case STYLE_RESOURCE: { + (warnOnDivergentPropsStylesheet: any).forEach(propertyInfo => { + const currentProp = getPropertyValue(resource.props, propertyInfo); + const nextProp = getPropertyValue(props, propertyInfo); + if (currentProp !== nextProp) { + const locationPropName = 'href'; + const propName = propertyInfo.propName; + console.error( + 'A "%s" Resource (%s="%s") was %s with a different "%s" prop than the one used originally.' + + ' The original value was "%s" and the new value is "%s". Either the "%s" value should' + + ' be the same or this Resource should point to distinct "%s" location.', + 'stylesheet', + locationPropName, + props.href, + 'created', + propName, + currentProp, + nextProp, + propName, + locationPropName, + ); + } + }); + } + } + } } -const Dispatcher = { - prefetchDNS, - preconnect, - prefetch, - preload, - preinit, -}; +const Dispatcher = {}; diff --git a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js index 7e5708875256d..c1ef1015d61c0 100644 --- a/packages/react-dom/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom/src/server/ReactDOMServerFormatConfig.js @@ -61,17 +61,9 @@ import { type Resource, prepareToRender as prepareToRenderImpl, cleanupAfterRender as cleanupAfterRenderImpl, - resourcesFromLink, - DNS_PREFETCH, - PRECONNECT, - PREFETCH, - PRELOAD, + resourceFromLink, PREINIT, STYLE_RESOURCE, - SCRIPT_RESOURCE, - FONT_RESOURCE, - CORS_ANON, - CORS_CREDS, } from './ReactDOMFloatServer'; // Used to distinguish these contexts from ones used in other renderers. @@ -1131,12 +1123,23 @@ function pushLink( props: Object, responseState: ResponseState, ): ReactNodeList { - if (resourcesFromLink(props)) { + const linkResource = resourceFromLink(props); + if (linkResource) { + if (linkResource.chunks.length === 0) { + pushLinkImpl(linkResource.chunks, props, responseState); + } // 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<Chunk | PrecomputedChunk>, + props: Object, + responseState: ResponseState, +): ReactNodeList { target.push(startChunkForTag('link')); for (const propKey in props) { @@ -2207,28 +2210,6 @@ export function writeResources(destination: Destination, resources: Resources) { function writeResource(destination: Destination, resource: Resource) { switch (resource.priority) { - case DNS_PREFETCH: { - return writeGenericResource( - destination, - resource, - prefetchDNSStart, - linkEnd, - ); - } - case PRECONNECT: { - return writeGenericResource( - destination, - resource, - preconnectStart, - linkEnd, - ); - } - case PREFETCH: { - return writeAsResource(destination, resource, prefetchStart, linkEnd); - } - case PRELOAD: { - return writeAsResource(destination, resource, preloadStart, linkEnd); - } case PREINIT: { return writeInitializingResource(destination, resource); } @@ -2240,99 +2221,17 @@ function writeResource(destination: Destination, resource: Resource) { } } -const prefetchDNSStart = stringToPrecomputedChunk( - '<link rel="dns-prefetch" href="', -); -const preconnectStart = stringToPrecomputedChunk( - '<link rel="preconnect" href="', -); -const prefetchStart = stringToPrecomputedChunk('<link rel="prefetch"'); -const preloadStart = stringToPrecomputedChunk('<link rel="preload"'); - -const preAsStyle = stringToPrecomputedChunk(' as="style" href="'); -const preAsScript = stringToPrecomputedChunk(' as="script" href="'); -const preAsFont = stringToPrecomputedChunk(' as="font" href="'); - -const crossOriginAnon = stringToPrecomputedChunk('" crossorigin="'); -const crossOriginCredentials = stringToPrecomputedChunk( - '" crossorigin="use-credentials', -); - -const linkEnd = stringToPrecomputedChunk('">'); - -const initStyleStart = stringToPrecomputedChunk( - '<link rel="stylesheet" href="', -); - -const initScriptStart = stringToPrecomputedChunk('<script src="'); -const initScriptEnd = stringToPrecomputedChunk('" async=""></script>'); - -function writeGenericResource( - destination: Destination, - resource: Resource, - start: PrecomputedChunk, - end: PrecomputedChunk, -) { - writeChunk(destination, start); - writeChunk(destination, stringToChunk(escapeTextForBrowser(resource.href))); - if (resource.crossorigin === CORS_ANON) { - writeChunk(destination, crossOriginAnon); - } else if (resource.crossorigin === CORS_CREDS) { - writeChunk(destination, crossOriginCredentials); - } - writeChunk(destination, end); -} - -function writeAsResource( - destination: Destination, - resource: Resource, - start: PrecomputedChunk, - end: PrecomputedChunk, -) { - writeChunk(destination, start); - switch (resource.as) { - case STYLE_RESOURCE: { - writeChunk(destination, preAsStyle); - break; - } - case SCRIPT_RESOURCE: { - writeChunk(destination, preAsScript); - break; - } - case FONT_RESOURCE: { - writeChunk(destination, preAsFont); - break; - } - } - writeChunk(destination, stringToChunk(escapeTextForBrowser(resource.href))); - if (resource.crossorigin === CORS_ANON) { - writeChunk(destination, crossOriginAnon); - } else if (resource.crossorigin === CORS_CREDS) { - writeChunk(destination, crossOriginCredentials); - } - writeChunk(destination, end); -} - function writeInitializingResource( destination: Destination, resource: Resource, ) { - switch (resource.as) { + switch (resource.type) { case STYLE_RESOURCE: { - return writeGenericResource( - destination, - resource, - initStyleStart, - linkEnd, - ); - } - case SCRIPT_RESOURCE: { - return writeGenericResource( - destination, - resource, - initScriptStart, - initScriptEnd, - ); + const chunks = resource.chunks; + for (let i = 0; i < chunks.length; i++) { + writeChunk(destination, chunks[i]); + } + return; } } } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index d709b372eca50..b90146bed89e3 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -103,6 +103,7 @@ import { finalizeContainerChildren, preparePortalMount, prepareScopeUpdate, + validateResourceAndProps, } from './ReactFiberHostConfig'; import { getRootHostContainer, @@ -1095,6 +1096,14 @@ function completeWork( if (currentKey !== previousKey) { markUpdate(workInProgress); + } else { + if (__DEV__ && current !== null && current.stateNode) { + validateResourceAndProps( + current.stateNode, + workInProgress.memoizedProps, + false, + ); + } } bubbleProperties(workInProgress); } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index 808117302249f..17329a8e064a5 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -103,6 +103,7 @@ import { finalizeContainerChildren, preparePortalMount, prepareScopeUpdate, + validateResourceAndProps, } from './ReactFiberHostConfig'; import { getRootHostContainer, @@ -1095,6 +1096,14 @@ function completeWork( if (currentKey !== previousKey) { markUpdate(workInProgress); + } else { + if (__DEV__ && current !== null && current.stateNode) { + validateResourceAndProps( + current.stateNode, + workInProgress.memoizedProps, + false, + ); + } } bubbleProperties(workInProgress); } diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js index de27aa081bd07..016a6bccca182 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js @@ -29,3 +29,4 @@ export const releaseResource = shim; export const getRootResourceHost = shim; export const insertPendingResources = shim; export const getResourceKeyFromTypeAndProps = shim; +export const validateResourceAndProps = shim; diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 7d29deded0b96..671671299e893 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -203,3 +203,4 @@ export const getRootResourceHost = $$$hostConfig.getRootResourceHost; export const insertPendingResources = $$$hostConfig.insertPendingResources; export const getResourceKeyFromTypeAndProps = $$$hostConfig.getResourceKeyFromTypeAndProps; +export const validateResourceAndProps = $$$hostConfig.validateResourceAndProps; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index a72de7a3b6f43..d9fce4693a7a6 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -425,5 +425,6 @@ "437": "writeResource received a resource it did not know how to write. This is a bug in React.", "438": "The current renderer does not support Resources. This error is likely caused by a bug in React. Please file an issue.", "439": "%s was called with a rootContainer with an unexpected nodeType.", - "440": "%s expected the containing Document to have a head element and one was not found." + "440": "%s expected the containing Document to have a head element and one was not found.", + "441": "%s was called with props that are not valid for a Resource. This is a bug in React." }