diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 5dd228795ca87..9c82cb6579231 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -148,18 +148,22 @@ export type RenderState = { hoistableChunks: Array, // Flushing queues for Resource dependencies - preconnects: Set, - fontPreloads: Set, - highImagePreloads: Set, + preconnects: Set, + fontPreloads: Set, + highImagePreloads: Set, // usedImagePreloads: Set, - precedences: Map>, - stylePrecedences: Map, - bootstrapScripts: Set, - scripts: Set, - bulkPreloads: Set, + styles: Map, + bootstrapScripts: Set, + scripts: Set, + bulkPreloads: Set, // Temporarily keeps track of key to preload resources before shell flushes. - preloadsMap: Map, + preloads: { + images: Map, + stylesheets: Map, + scripts: Map, + moduleScripts: Map, + }, // Module-global-like reference for current boundary resources boundaryResources: ?BoundaryResources, @@ -174,6 +178,30 @@ export type RenderState = { ... }; +type Exists = null; +type Preloaded = []; +// Credentials here are things that affect whether a browser will make a request +// as well as things that affect which connection the browser will use for that request. +// We want these to be aligned across preloads and resources because otherwise the preload +// will be wasted. +// We investigated whether referrerPolicy should be included here but from experimentation +// it seems that browsers do not treat this as part of the http cache key and does not affect +// which connection is used. +type PreloadedWithCredentials = [ + /* crossOrigin */ ?string, + /* integrity */ ?string, +]; + +const EXISTS: Exists = null; +// This constant is to mark preloads that have no unique credentials +// to convey. It should never be checked by identity and we should not +// assume Preload values in ResumableState equal this value because they +// will have come from some parsed input. +const PRELOAD_NO_CREDS: Preloaded = []; +if (__DEV__) { + Object.freeze(PRELOAD_NO_CREDS); +} + // Per response, global state that is not contextual to the rendering subtree. // This is resumable and therefore should be serializable. export type ResumableState = { @@ -189,10 +217,34 @@ export type ResumableState = { hasHtml: boolean, // Resources - Request local cache - preloadsMap: {[key: string]: PreloadProps}, - preconnectsMap: {[key: string]: null}, - stylesMap: {[key: string]: null}, - scriptsMap: {[key: string]: null}, + unknownResources: { + [asType: string]: { + [href: string]: Preloaded, + }, + }, + dnsResources: {[key: string]: Exists}, + connectResources: { + default: {[key: string]: Exists}, + anonymous: {[key: string]: Exists}, + credentials: {[key: string]: Exists}, + }, + imageResources: { + [key: string]: Preloaded, + }, + styleResources: { + [key: string]: Exists | Preloaded | PreloadedWithCredentials, + }, + scriptResources: { + [key: string]: Exists | Preloaded | PreloadedWithCredentials, + }, + moduleUnknownResources: { + [asType: string]: { + [href: string]: Preloaded, + }, + }, + moduleScriptResources: { + [key: string]: Exists | Preloaded | PreloadedWithCredentials, + }, }; const dataElementQuotedEnd = stringToPrecomputedChunk('">'); @@ -342,13 +394,17 @@ export function createRenderState( fontPreloads: new Set(), highImagePreloads: new Set(), // usedImagePreloads: new Set(), - precedences: new Map(), - stylePrecedences: new Map(), + styles: new Map(), bootstrapScripts: new Set(), scripts: new Set(), bulkPreloads: new Set(), - preloadsMap: new Map(), + preloads: { + images: new Map(), + stylesheets: new Map(), + scripts: new Map(), + moduleScripts: new Map(), + }, nonce, // like a module global for currently rendering boundary @@ -359,25 +415,30 @@ export function createRenderState( if (bootstrapScripts !== undefined) { for (let i = 0; i < bootstrapScripts.length; i++) { const scriptConfig = bootstrapScripts[i]; - const src = - typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src; - const integrity = - typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity; - const crossOrigin = - typeof scriptConfig === 'string' || scriptConfig.crossOrigin == null - ? undefined - : scriptConfig.crossOrigin === 'use-credentials' - ? 'use-credentials' - : ''; - - preloadBootstrapScript( - resumableState, - renderState, - src, + let src, crossOrigin, integrity; + const props: PreloadAsProps = ({ + rel: 'preload', + as: 'script', + fetchPriority: 'low', nonce, - integrity, - crossOrigin, - ); + }: any); + if (typeof scriptConfig === 'string') { + props.href = src = scriptConfig; + } else { + props.href = src = scriptConfig.src; + props.integrity = integrity = + typeof scriptConfig.integrity === 'string' + ? scriptConfig.integrity + : undefined; + props.crossOrigin = crossOrigin = + typeof scriptConfig === 'string' || scriptConfig.crossOrigin == null + ? undefined + : scriptConfig.crossOrigin === 'use-credentials' + ? 'use-credentials' + : ''; + } + + preloadBootstrapScriptOrModule(resumableState, renderState, src, props); bootstrapChunks.push( startScriptSrc, @@ -389,7 +450,7 @@ export function createRenderState( stringToChunk(escapeTextForBrowser(nonce)), ); } - if (integrity) { + if (typeof integrity === 'string') { bootstrapChunks.push( scriptIntegirty, stringToChunk(escapeTextForBrowser(integrity)), @@ -407,25 +468,29 @@ export function createRenderState( if (bootstrapModules !== undefined) { for (let i = 0; i < bootstrapModules.length; i++) { const scriptConfig = bootstrapModules[i]; - const src = - typeof scriptConfig === 'string' ? scriptConfig : scriptConfig.src; - const integrity = - typeof scriptConfig === 'string' ? undefined : scriptConfig.integrity; - const crossOrigin = - typeof scriptConfig === 'string' || scriptConfig.crossOrigin == null - ? undefined - : scriptConfig.crossOrigin === 'use-credentials' - ? 'use-credentials' - : ''; - - preloadBootstrapModule( - resumableState, - renderState, - src, + let src, crossOrigin, integrity; + const props: PreloadModuleProps = ({ + rel: 'modulepreload', + fetchPriority: 'low', nonce, - integrity, - crossOrigin, - ); + }: any); + if (typeof scriptConfig === 'string') { + props.href = src = scriptConfig; + } else { + props.href = src = scriptConfig.src; + props.integrity = integrity = + typeof scriptConfig.integrity === 'string' + ? scriptConfig.integrity + : undefined; + props.crossOrigin = crossOrigin = + typeof scriptConfig === 'string' || scriptConfig.crossOrigin == null + ? undefined + : scriptConfig.crossOrigin === 'use-credentials' + ? 'use-credentials' + : ''; + } + + preloadBootstrapScriptOrModule(resumableState, renderState, src, props); bootstrapChunks.push( startModuleSrc, @@ -438,7 +503,7 @@ export function createRenderState( stringToChunk(escapeTextForBrowser(nonce)), ); } - if (integrity) { + if (typeof integrity === 'string') { bootstrapChunks.push( scriptIntegirty, stringToChunk(escapeTextForBrowser(integrity)), @@ -496,10 +561,18 @@ export function createResumableState( // @TODO add bootstrap script to implicit preloads // persistent - preloadsMap: {}, - preconnectsMap: {}, - stylesMap: {}, - scriptsMap: {}, + unknownResources: {}, + dnsResources: {}, + connectResources: { + default: {}, + anonymous: {}, + credentials: {}, + }, + imageResources: {}, + styleResources: {}, + scriptResources: {}, + moduleUnknownResources: {}, + moduleScriptResources: {}, }; } @@ -2102,7 +2175,7 @@ function pushLink( if (props.rel === 'stylesheet') { // This may hoistable as a Stylesheet Resource, otherwise it will emit in place - const key = getResourceKey('style', href); + const key = getResourceKey(href); if ( typeof precedence !== 'string' || props.disabled != null || @@ -2136,60 +2209,62 @@ function pushLink( return pushLinkImpl(target, props); } else { // This stylesheet refers to a Resource and we create a new one if necessary - let stylesInPrecedence = renderState.precedences.get(precedence); - if (!resumableState.stylesMap.hasOwnProperty(key)) { - const resourceProps = stylesheetPropsFromRawProps(props); - let state = NoState; - if (resumableState.preloadsMap.hasOwnProperty(key)) { - const preloadProps: PreloadProps = resumableState.preloadsMap[key]; - adoptPreloadPropsForStylesheetProps(resourceProps, preloadProps); - const preloadResource = renderState.preloadsMap.get(key); - if (preloadResource) { - // If we already had a preload we don't want that resource to flush directly. - // We let the newly created resource govern flushing. - preloadResource.state |= Blocked; - if (preloadResource.state & Flushed) { - state = PreloadFlushed; - } - } else { - // If we resumed then we assume that this was already flushed - // by the shell. - state = PreloadFlushed; - } + let styleQueue = renderState.styles.get(precedence); + const hasKey = resumableState.styleResources.hasOwnProperty(key); + const resourceState = hasKey + ? resumableState.styleResources[key] + : undefined; + if (resourceState !== EXISTS) { + // We are going to create this resource now so it is marked as Exists + resumableState.styleResources[key] = EXISTS; + + // If this is the first time we've encountered this precedence we need + // to create a StyleQueue + if (!styleQueue) { + styleQueue = { + precedence: stringToChunk(escapeTextForBrowser(precedence)), + rules: ([]: Array), + hrefs: ([]: Array), + sheets: (new Map(): Map), + }; + renderState.styles.set(precedence, styleQueue); } - const resource = { - type: 'stylesheet', - chunks: ([]: Array), - state, - props: resourceProps, + + const resource: StylesheetResource = { + state: PENDING, + props: stylesheetPropsFromRawProps(props), }; - resumableState.stylesMap[key] = null; - if (!stylesInPrecedence) { - stylesInPrecedence = new Map(); - renderState.precedences.set(precedence, stylesInPrecedence); - const emptyStyleResource = { - type: 'style', - chunks: ([]: Array), - state: NoState, - props: { - precedence, - hrefs: ([]: Array), - }, - }; - stylesInPrecedence.set('', emptyStyleResource); - if (__DEV__) { - if (renderState.stylePrecedences.has(precedence)) { - console.error( - 'React constructed an empty style resource when a style resource already exists for this precedence: "%s". This is a bug in React.', - precedence, - ); - } + + if (resourceState) { + // When resourceState is truty it is a Preload state. We cast it for clarity + const preloadState: Preloaded | PreloadedWithCredentials = + resourceState; + if (preloadState.length === 2) { + adoptPreloadCredentials(resource.props, preloadState); + } + + const preloadResource = renderState.preloads.stylesheets.get(key); + if (preloadResource && preloadResource.length > 0) { + // The Preload for this resource was created in this render pass and has not flushed yet so + // we need to clear it to avoid it flushing. + preloadResource.length = 0; + } else { + // Either the preload resource from this render already flushed in this render pass + // or the preload flushed in a prior pass (prerender). In either case we need to mark + // this resource as already having been preloaded. + resource.state = PRELOADED; } - renderState.stylePrecedences.set(precedence, emptyStyleResource); + } else { + // We don't need to check whether a preloadResource exists in the renderState + // because if it did exist then the resourceState would also exist and we would + // have hit the primary if condition above. } - stylesInPrecedence.set(key, resource); + + // We add the newly created resource to our StyleQueue and if necessary + // track the resource with the currently rendering boundary + styleQueue.sheets.set(key, resource); if (renderState.boundaryResources) { - renderState.boundaryResources.add(resource); + renderState.boundaryResources.stylesheets.add(resource); } } else { // We need to track whether this boundary should wait on this resource or not. @@ -2197,11 +2272,11 @@ function pushLink( // it. However, it's possible when you resume that the style has already been emitted // and then it wouldn't be recreated in the RenderState and there's no need to track // it again since we should've hoisted it to the shell already. - if (stylesInPrecedence) { - const resource = stylesInPrecedence.get(key); + if (styleQueue) { + const resource = styleQueue.sheets.get(key); if (resource) { if (renderState.boundaryResources) { - renderState.boundaryResources.add(resource); + renderState.boundaryResources.stylesheets.add(resource); } } } @@ -2334,45 +2409,49 @@ function pushStyle( } } - const key = getResourceKey('style', href); - let resource = renderState.stylePrecedences.get(precedence); - if (!resumableState.stylesMap.hasOwnProperty(key)) { - if (!resource) { - resource = { - type: 'style', - chunks: [], - state: NoState, - props: { - precedence, - hrefs: [href], - }, - }; - renderState.stylePrecedences.set(precedence, resource); - const stylesInPrecedence: Map = new Map(); - stylesInPrecedence.set('', resource); - if (__DEV__) { - if (renderState.precedences.has(precedence)) { - console.error( - 'React constructed a new style precedence set when one already exists for this precedence: "%s". This is a bug in React.', - precedence, - ); - } + const key = getResourceKey(href); + let styleQueue = renderState.styles.get(precedence); + const hasKey = resumableState.styleResources.hasOwnProperty(key); + const resourceState = hasKey + ? resumableState.styleResources[key] + : undefined; + if (resourceState !== EXISTS) { + // We are going to create this resource now so it is marked as Exists + resumableState.styleResources[key] = EXISTS; + + if (__DEV__) { + if (resourceState) { + console.error( + 'React encountered a hoistable style tag for the same href as a preload: "%s". When using a style tag to inline styles you should not also preload it as a stylsheet.', + href, + ); } - renderState.precedences.set(precedence, stylesInPrecedence); + } + + if (!styleQueue) { + // This is the first time we've encountered this precedence we need + // to create a StyleQueue. + styleQueue = { + precedence: stringToChunk(escapeTextForBrowser(precedence)), + rules: ([]: Array), + hrefs: [stringToChunk(escapeTextForBrowser(href))], + sheets: (new Map(): Map), + }; + renderState.styles.set(precedence, styleQueue); } else { - resource.props.hrefs.push(href); + // We have seen this precedence before and need to track this href + styleQueue.hrefs.push(stringToChunk(escapeTextForBrowser(href))); } - resumableState.stylesMap[key] = null; - pushStyleContents(resource.chunks, props); + pushStyleContents(styleQueue.rules, props); } - if (resource) { + if (styleQueue) { // We need to track whether this boundary should wait on this resource or not. // Typically this resource should always exist since we either had it or just created // it. However, it's possible when you resume that the style has already been emitted // and then it wouldn't be recreated in the RenderState and there's no need to track // it again since we should've hoisted it to the shell already. if (renderState.boundaryResources) { - renderState.boundaryResources.add(resource); + renderState.boundaryResources.styles.add(styleQueue); } } @@ -2475,23 +2554,6 @@ function pushStyleContents( return; } -function getImagePreloadKey( - href: string, - imageSrcSet: ?string, - imageSizes: ?string, -) { - let uniquePart = ''; - if (typeof imageSrcSet === 'string' && imageSrcSet !== '') { - uniquePart += '[' + imageSrcSet + ']'; - if (typeof imageSizes === 'string') { - uniquePart += '[' + imageSizes + ']'; - } - } else { - uniquePart += '[][]' + href; - } - return getResourceKey('image', uniquePart); -} - function pushImg( target: Array, props: Object, @@ -2502,7 +2564,9 @@ function pushImg( const {src, srcSet} = props; if ( props.loading !== 'lazy' && - (typeof src === 'string' || typeof srcSet === 'string') && + (src || srcSet) && + (typeof src === 'string' || src == null) && + (typeof srcSet === 'string' || srcSet == null) && props.fetchPriority !== 'low' && pictureTagInScope === false && // We exclude data URIs in src and srcSet since these should not be preloaded @@ -2525,39 +2589,50 @@ function pushImg( ) { // We have a suspensey image and ought to preload it to optimize the loading of display blocking // resumableState. - const {sizes} = props; - const key = getImagePreloadKey(src, srcSet, sizes); - let resource: void | PreloadResource; - if (!resumableState.preloadsMap.hasOwnProperty(key)) { - const preloadProps: PreloadProps = { - rel: 'preload', - as: 'image', - // There is a bug in Safari where imageSrcSet is not respected on preload links - // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image. - // This harms older browers that do not support imageSrcSet by making their preloads not work - // but this population is shrinking fast and is already small so we accept this tradeoff. - href: srcSet ? undefined : src, - imageSrcSet: srcSet, - imageSizes: sizes, - crossOrigin: props.crossOrigin, - integrity: props.integrity, - type: props.type, - fetchPriority: props.fetchPriority, - referrerPolicy: props.referrerPolicy, - }; - resource = { - type: 'preload', - chunks: [], - state: NoState, - props: preloadProps, - }; - resumableState.preloadsMap[key] = preloadProps; - renderState.preloadsMap.set(key, resource); - pushLinkImpl(resource.chunks, preloadProps); - } else { - resource = renderState.preloadsMap.get(key); - } + const sizes = typeof props.sizes === 'string' ? props.sizes : undefined; + const key = getImageResourceKey(src, srcSet, sizes); + + const promotablePreloads = renderState.preloads.images; + + let resource = promotablePreloads.get(key); if (resource) { + // We consider whether this preload can be promoted to higher priority flushing queue. + // The only time a resource will exist here is if it was created during this render + // and was not already in the high priority queue. + if ( + props.fetchPriority === 'high' || + renderState.highImagePreloads.size < 10 + ) { + // Delete the resource from the map since we are promoting it and don't want to + // reenter this branch in a second pass for duplicate img hrefs. + promotablePreloads.delete(key); + + // $FlowFixMe - Flow should understand that this is a Resource if the condition was true + renderState.highImagePreloads.add(resource); + } + } else if (!resumableState.imageResources.hasOwnProperty(key)) { + // We must construct a new preload resource + resumableState.imageResources[key] = PRELOAD_NO_CREDS; + resource = []; + pushLinkImpl( + resource, + ({ + rel: 'preload', + as: 'image', + // There is a bug in Safari where imageSrcSet is not respected on preload links + // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image. + // This harms older browers that do not support imageSrcSet by making their preloads not work + // but this population is shrinking fast and is already small so we accept this tradeoff. + href: srcSet ? undefined : src, + imageSrcSet: srcSet, + imageSizes: sizes, + crossOrigin: props.crossOrigin, + integrity: props.integrity, + type: props.type, + fetchPriority: props.fetchPriority, + referrerPolicy: props.referrerPolicy, + }: PreloadProps), + ); if ( props.fetchPriority === 'high' || renderState.highImagePreloads.size < 10 @@ -2565,6 +2640,9 @@ function pushImg( renderState.highImagePreloads.add(resource); } else { renderState.bulkPreloads.add(resource); + // We can bump the priority up if the same img is rendered later + // with fetchPriority="high" + promotablePreloads.set(key, resource); } } } @@ -2899,33 +2977,48 @@ function pushScript( } const src = props.src; - const key = getResourceKey('script', src); + const key = getResourceKey(src); // We can make this "`, + `"
hello world
"`, ); }); @@ -505,7 +505,7 @@ describe('ReactDOMFizzServerBrowser', () => { ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js index 4a20f1be57f77..f7bab722bb386 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js @@ -98,7 +98,7 @@ describe('ReactDOMFizzServerNode', () => { pipe(writable); jest.runAllTimers(); expect(output.result).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 58e34316b0782..9ec0f2c97c9fb 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -145,7 +145,7 @@ describe('ReactDOMFizzStaticBrowser', () => { }); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js new file mode 100644 index 0000000000000..9a825bf1e3871 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticFloat-test.js @@ -0,0 +1,275 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +import { + getVisibleChildren, + insertNodesAndExecuteScripts, +} from '../test-utils/FizzTestUtils'; + +// Polyfills for test environment +global.ReadableStream = + require('web-streams-polyfill/ponyfill/es6').ReadableStream; +global.TextEncoder = require('util').TextEncoder; + +let React; +let ReactDOM; +let ReactDOMFizzServer; +let ReactDOMFizzStatic; +let Suspense; +let container; + +describe('ReactDOMFizzStaticFloat', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMFizzServer = require('react-dom/server.browser'); + if (__EXPERIMENTAL__) { + ReactDOMFizzStatic = require('react-dom/static.browser'); + } + Suspense = React.Suspense; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + async function readIntoContainer(stream) { + const reader = stream.getReader(); + let result = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + break; + } + result += Buffer.from(value).toString('utf8'); + } + const temp = document.createElement('div'); + temp.innerHTML = result; + await insertNodesAndExecuteScripts(temp, container, null); + } + + // @gate enablePostpone + it('should transfer connection credentials across prerender and resume for stylesheets, scripts, and moduleScripts', async () => { + let prerendering = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return ( + <> + + "`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index bff5b6b262e87..8fc6ef45ab2e2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -4014,6 +4014,87 @@ body { ); }); + it('can promote images to high priority when at least one instance specifies a high fetchPriority', async () => { + function App() { + // If a ends up in a higher priority queue than b it will flush first + ReactDOM.preload('a', {as: 'image'}); + ReactDOM.preload('b', {as: 'image'}); + return ( + + + + + + + + + + + + + + + + + + + + + + + ); + } + + await act(() => { + renderToPipeableStream().pipe(writable); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + {/* The First 10 high priority images were just the first 10 rendered images */} + + + + + + + + + + + {/* The "a" image was rendered a few times but since at least one of those was with + fetchPriorty="high" it ends up in the high priority queue */} + + {/* Stylesheets come in between high priority images and regular preloads */} + + {/* The remainig images that preloaded at regular priority */} + + + + + + + + + + + + + + + + + + + + + + + + , + ); + }); + it('preloads from rendered images properly use srcSet and sizes', async () => { function App() { ReactDOM.preload('1', {as: 'image', imageSrcSet: 'ss1'}); @@ -4119,6 +4200,501 @@ body { ); }); + it('should warn if you preload a stylesheet and then render a style tag with the same href', async () => { + const style = 'body { color: red; }'; + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( + + + hello + + + + ); + } + + await expect(async () => { + await act(() => { + renderToPipeableStream().pipe(writable); + }); + }).toErrorDev([ + 'React encountered a hoistable style tag for the same href as a preload: "foo". When using a style tag to inline styles you should not also preload it as a stylsheet.', + ]); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + hello + , + ); + }); + + it('should preload only once even if you discover a stylesheet, script, or moduleScript late', async () => { + function App() { + // We start with preinitializing some resources first + ReactDOM.preinit('shell preinit/shell', {as: 'style'}); + ReactDOM.preinit('shell preinit/shell', {as: 'script'}); + ReactDOM.preinitModule('shell preinit/shell', {as: 'script'}); + + // We initiate all the shell preloads + ReactDOM.preload('shell preinit/shell', {as: 'style'}); + ReactDOM.preload('shell preinit/shell', {as: 'script'}); + ReactDOM.preloadModule('shell preinit/shell', {as: 'script'}); + + ReactDOM.preload('shell/shell preinit', {as: 'style'}); + ReactDOM.preload('shell/shell preinit', {as: 'script'}); + ReactDOM.preloadModule('shell/shell preinit', {as: 'script'}); + + ReactDOM.preload('shell/shell render', {as: 'style'}); + ReactDOM.preload('shell/shell render', {as: 'script'}); + ReactDOM.preloadModule('shell/shell render'); + + ReactDOM.preload('shell/late preinit', {as: 'style'}); + ReactDOM.preload('shell/late preinit', {as: 'script'}); + ReactDOM.preloadModule('shell/late preinit'); + + ReactDOM.preload('shell/late render', {as: 'style'}); + ReactDOM.preload('shell/late render', {as: 'script'}); + ReactDOM.preloadModule('shell/late render'); + + // we preinit later ones that should be created by + ReactDOM.preinit('shell/shell preinit', {as: 'style'}); + ReactDOM.preinit('shell/shell preinit', {as: 'script'}); + ReactDOM.preinitModule('shell/shell preinit'); + + ReactDOM.preinit('late/shell preinit', {as: 'style'}); + ReactDOM.preinit('late/shell preinit', {as: 'script'}); + ReactDOM.preinitModule('late/shell preinit'); + return ( + + + + "`, + `"
hello world
"`, ); });