From f2d94fdb33399aa89cffeff57332b20693068339 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 17 Oct 2022 14:00:20 -0700 Subject: [PATCH] [Float] add support for scripts and other enhancements (#25480) * float enhance!!! Support preinit as script Support resources from async scripts Support saving the precedence place when rendering the shell There was a significant change to the flushing order of resources which follows the general principal of... 1. stuff that blocks display 2. stuff that we know will be used 3. stuff that was explicitly preloaded As a consequence if you preinit a style now it won't automatically flush in the shell unless you actually depend on it in your tree. To avoid races with precedence order we now emit a tag that saves the place amongst the precedence hierarchy so late insertions still end up where they were intended There is also a novel hydration pathway for certain tags. If you render an async script with an onLoad or onError it will always treat it like an insertion rather than a hydration. * restore preinit style flushing behavior and nits --- .../src/client/ReactDOMComponentTree.js | 23 +- .../src/client/ReactDOMFloatClient.js | 281 +++++-- .../src/client/ReactDOMHostConfig.js | 63 +- .../src/server/ReactDOMFloatServer.js | 381 ++++++--- .../src/server/ReactDOMServerFormatConfig.js | 184 +++-- .../ReactDOMFizzInstructionSet.js | 8 +- ...tDOMFizzInstructionSetInlineCodeStrings.js | 2 +- .../src/shared/ReactDOMResourceValidation.js | 184 ++++- .../src/__tests__/ReactDOMFloat-test.js | 740 ++++++++++++------ .../react-dom-server-rendering-stub-test.js | 2 +- .../ReactFiberHostConfigWithNoHydration.js | 1 + .../src/ReactFiberHydrationContext.new.js | 10 +- .../src/ReactFiberHydrationContext.old.js | 10 +- .../src/forks/ReactFiberHostConfig.custom.js | 1 + .../rollup/generate-inline-fizz-runtime.js | 2 +- 15 files changed, 1397 insertions(+), 495 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js index f3f9e122c2306..37ec47f6fea1e 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js @@ -7,7 +7,11 @@ * @flow */ -import type {FloatRoot, StyleResource} from './ReactDOMFloatClient'; +import type { + FloatRoot, + StyleResource, + ScriptResource, +} from './ReactDOMFloatClient'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type {ReactScopeInstance} from 'shared/ReactTypes'; import type { @@ -48,7 +52,7 @@ const internalContainerInstanceKey = '__reactContainer$' + randomKey; const internalEventHandlersKey = '__reactEvents$' + randomKey; const internalEventHandlerListenersKey = '__reactListeners$' + randomKey; const internalEventHandlesSetKey = '__reactHandles$' + randomKey; -const internalRootNodeStylesSetKey = '__reactStyles$' + randomKey; +const internalRootNodeResourcesKey = '__reactResources$' + randomKey; export function detachDeletedInstance(node: Instance): void { // TODO: This function is only called on host components. I don't think all of @@ -278,10 +282,15 @@ export function doesTargetHaveEventHandle( return eventHandles.has(eventHandle); } -export function getStylesFromRoot(root: FloatRoot): Map { - let styles = (root: any)[internalRootNodeStylesSetKey]; - if (!styles) { - styles = (root: any)[internalRootNodeStylesSetKey] = new Map(); +export function getResourcesFromRoot( + root: FloatRoot, +): {styles: Map, scripts: Map} { + let resources = (root: any)[internalRootNodeResourcesKey]; + if (!resources) { + resources = (root: any)[internalRootNodeResourcesKey] = { + styles: new Map(), + scripts: new Map(), + }; } - return styles; + return resources; } diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 5bcdfcd8d189d..ca60aba1b2b8a 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -15,15 +15,16 @@ import {DOCUMENT_NODE} from '../shared/HTMLNodeType'; import { validateUnmatchedLinkResourceProps, validatePreloadResourceDifference, - validateHrefKeyedUpdatedProps, + validateURLKeyedUpdatedProps, validateStyleResourceDifference, + validateScriptResourceDifference, validateLinkPropsForStyleResource, validateLinkPropsForPreloadResource, validatePreloadArguments, validatePreinitArguments, } from '../shared/ReactDOMResourceValidation'; import {createElement, setInitialProperties} from './ReactDOMComponent'; -import {getStylesFromRoot} from './ReactDOMComponentTree'; +import {getResourcesFromRoot} from './ReactDOMComponentTree'; import {HTML_NAMESPACE} from '../shared/DOMNamespaces'; import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext'; @@ -33,7 +34,6 @@ type ResourceType = 'style' | 'font' | 'script'; type PreloadProps = { rel: 'preload', - as: ResourceType, href: string, [string]: mixed, }; @@ -48,7 +48,7 @@ type PreloadResource = { type StyleProps = { rel: 'stylesheet', href: string, - 'data-rprec': string, + 'data-precedence': string, [string]: mixed, }; export type StyleResource = { @@ -72,10 +72,22 @@ export type StyleResource = { instance: ?Element, root: FloatRoot, }; +type ScriptProps = { + src: string, + [string]: mixed, +}; +export type ScriptResource = { + type: 'script', + src: string, + props: ScriptProps, + + instance: ?Element, + root: FloatRoot, +}; type Props = {[string]: mixed}; -type Resource = StyleResource | PreloadResource; +type Resource = StyleResource | ScriptResource | PreloadResource; // Brief on purpose due to insertion by script when streaming late boundaries // s = Status @@ -202,11 +214,12 @@ function preloadPropsFromPreloadOptions( // ReactDOM.preinit // -------------------------------------- -type PreinitAs = 'style'; +type PreinitAs = 'style' | 'script'; type PreinitOptions = { as: PreinitAs, - crossOrigin?: string, precedence?: string, + crossOrigin?: string, + integrity?: string, }; function preinit(href: string, options: PreinitOptions) { if (__DEV__) { @@ -243,7 +256,7 @@ function preinit(href: string, options: PreinitOptions) { switch (as) { case 'style': { - const styleResources = getStylesFromRoot(resourceRoot); + const styleResources = getResourcesFromRoot(resourceRoot).styles; const precedence = options.precedence || 'default'; let resource = styleResources.get(href); if (resource) { @@ -270,6 +283,28 @@ function preinit(href: string, options: PreinitOptions) { ); } acquireResource(resource); + return; + } + case 'script': { + const src = href; + const scriptResources = getResourcesFromRoot(resourceRoot).scripts; + let resource = scriptResources.get(src); + if (resource) { + if (__DEV__) { + const latestProps = scriptPropsFromPreinitOptions(src, options); + validateScriptResourceDifference(resource.props, latestProps); + } + } else { + const resourceProps = scriptPropsFromPreinitOptions(src, options); + resource = createScriptResource( + scriptResources, + resourceRoot, + src, + resourceProps, + ); + } + acquireResource(resource); + return; } } } @@ -285,6 +320,7 @@ function preloadPropsFromPreinitOptions( rel: 'preload', as, crossOrigin: as === 'font' ? '' : options.crossOrigin, + integrity: options.integrity, }; } @@ -296,8 +332,20 @@ function stylePropsFromPreinitOptions( return { rel: 'stylesheet', href, - 'data-rprec': precedence, + 'data-precedence': precedence, + crossOrigin: options.crossOrigin, + }; +} + +function scriptPropsFromPreinitOptions( + src: string, + options: PreinitOptions, +): ScriptProps { + return { + src, + async: true, crossOrigin: options.crossOrigin, + integrity: options.integrity, }; } @@ -314,7 +362,11 @@ type StyleQualifyingProps = { type PreloadQualifyingProps = { rel: 'preload', href: string, - as: ResourceType, + [string]: mixed, +}; +type ScriptQualifyingProps = { + src: string, + async: true, [string]: mixed, }; @@ -335,13 +387,15 @@ export function getResource( const {rel} = pendingProps; switch (rel) { case 'stylesheet': { - const styleResources = getStylesFromRoot(resourceRoot); + const styleResources = getResourcesFromRoot(resourceRoot).styles; let didWarn; if (__DEV__) { if (currentProps) { - didWarn = validateHrefKeyedUpdatedProps( + didWarn = validateURLKeyedUpdatedProps( pendingProps, currentProps, + 'style', + 'href', ); } if (!didWarn) { @@ -360,7 +414,7 @@ export function getResource( if (!didWarn) { const latestProps = stylePropsFromRawProps(styleRawProps); if ((resource: any)._dev_preload_props) { - adoptPreloadProps( + adoptPreloadPropsForStyle( latestProps, (resource: any)._dev_preload_props, ); @@ -387,8 +441,8 @@ export function getResource( if (__DEV__) { validateLinkPropsForPreloadResource(pendingProps); } - const {href, as} = pendingProps; - if (typeof href === 'string' && isResourceAsType(as)) { + const {href} = pendingProps; + if (typeof href === 'string') { // We've asserted all the specific types for PreloadQualifyingProps const preloadRawProps: PreloadQualifyingProps = (pendingProps: any); let resource = preloadResources.get(href); @@ -424,6 +478,49 @@ export function getResource( } } } + case 'script': { + const scriptResources = getResourcesFromRoot(resourceRoot).scripts; + let didWarn; + if (__DEV__) { + if (currentProps) { + didWarn = validateURLKeyedUpdatedProps( + pendingProps, + currentProps, + 'script', + 'src', + ); + } + } + const {src, async} = pendingProps; + if (async && typeof src === 'string') { + const scriptRawProps: ScriptQualifyingProps = (pendingProps: any); + let resource = scriptResources.get(src); + if (resource) { + if (__DEV__) { + if (!didWarn) { + const latestProps = scriptPropsFromRawProps(scriptRawProps); + if ((resource: any)._dev_preload_props) { + adoptPreloadPropsForScript( + latestProps, + (resource: any)._dev_preload_props, + ); + } + validateScriptResourceDifference(resource.props, latestProps); + } + } + } else { + const resourceProps = scriptPropsFromRawProps(scriptRawProps); + resource = createScriptResource( + scriptResources, + resourceRoot, + src, + resourceProps, + ); + } + return resource; + } + return null; + } default: { throw new Error( `getResource encountered a resource type it did not expect: "${type}". this is a bug in React.`, @@ -440,12 +537,17 @@ function preloadPropsFromRawProps( function stylePropsFromRawProps(rawProps: StyleQualifyingProps): StyleProps { const props: StyleProps = Object.assign({}, rawProps); - props['data-rprec'] = rawProps.precedence; + props['data-precedence'] = rawProps.precedence; props.precedence = null; return props; } +function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps { + const props: ScriptProps = Object.assign({}, rawProps); + return props; +} + // -------------------------------------- // Resource Reconciliation // -------------------------------------- @@ -455,6 +557,9 @@ export function acquireResource(resource: Resource): Instance { case 'style': { return acquireStyleResource(resource); } + case 'script': { + return acquireScriptResource(resource); + } case 'preload': { return resource.instance; } @@ -558,7 +663,7 @@ function createStyleResource( // the preload pathways. For instance if you have diffreent crossOrigin attributes for a preload // and a stylesheet the stylesheet will make a new request even if the preload had already loaded const preloadProps = hint.props; - adoptPreloadProps(resource.props, hint.props); + adoptPreloadPropsForStyle(resource.props, hint.props); if (__DEV__) { (resource: any)._dev_preload_props = preloadProps; } @@ -568,7 +673,7 @@ function createStyleResource( return resource; } -function adoptPreloadProps( +function adoptPreloadPropsForStyle( styleProps: StyleProps, preloadProps: PreloadProps, ): void { @@ -576,7 +681,6 @@ function adoptPreloadProps( styleProps.crossOrigin = preloadProps.crossOrigin; if (styleProps.referrerPolicy == null) styleProps.referrerPolicy = preloadProps.referrerPolicy; - if (styleProps.media == null) styleProps.media = preloadProps.media; if (styleProps.title == null) styleProps.title = preloadProps.title; } @@ -610,6 +714,63 @@ function preloadPropsFromStyleProps(props: StyleProps): PreloadProps { }; } +function createScriptResource( + scriptResources: Map, + root: FloatRoot, + src: string, + props: ScriptProps, +): ScriptResource { + if (__DEV__) { + if (scriptResources.has(src)) { + console.error( + 'createScriptResource was called when a script Resource matching the same src already exists. This is a bug in React.', + ); + } + } + + const limitedEscapedSrc = escapeSelectorAttributeValueInsideDoubleQuotes(src); + const existingEl = root.querySelector( + `script[async][src="${limitedEscapedSrc}"]`, + ); + const resource = { + type: 'script', + src, + props, + root, + instance: existingEl || null, + }; + scriptResources.set(src, resource); + + if (!existingEl) { + const hint = preloadResources.get(src); + if (hint) { + // If a preload for this style Resource already exists there are certain props we want to adopt + // on the style Resource, primarily focussed on making sure the style network pathways utilize + // the preload pathways. For instance if you have diffreent crossOrigin attributes for a preload + // and a stylesheet the stylesheet will make a new request even if the preload had already loaded + const preloadProps = hint.props; + adoptPreloadPropsForScript(props, hint.props); + if (__DEV__) { + (resource: any)._dev_preload_props = preloadProps; + } + } + } + + return resource; +} + +function adoptPreloadPropsForScript( + scriptProps: ScriptProps, + preloadProps: PreloadProps, +): void { + if (scriptProps.crossOrigin == null) + scriptProps.crossOrigin = preloadProps.crossOrigin; + if (scriptProps.referrerPolicy == null) + scriptProps.referrerPolicy = preloadProps.referrerPolicy; + if (scriptProps.integrity == null) + scriptProps.referrerPolicy = preloadProps.integrity; +} + function createPreloadResource( ownerDocument: Document, href: string, @@ -623,7 +784,7 @@ function createPreloadResource( ); if (!element) { element = createResourceInstance('link', props, ownerDocument); - insertPreloadInstance(element, ownerDocument); + insertResourceInstance(element, ownerDocument); } return { type: 'preload', @@ -641,7 +802,7 @@ function acquireStyleResource(resource: StyleResource): Instance { props.href, ); const existingEl = root.querySelector( - `link[rel="stylesheet"][data-rprec][href="${limitedEscapedHref}"]`, + `link[rel="stylesheet"][data-precedence][href="${limitedEscapedHref}"]`, ); if (existingEl) { resource.instance = existingEl; @@ -685,6 +846,31 @@ function acquireStyleResource(resource: StyleResource): Instance { return resource.instance; } +function acquireScriptResource(resource: ScriptResource): Instance { + if (!resource.instance) { + const {props, root} = resource; + const limitedEscapedSrc = escapeSelectorAttributeValueInsideDoubleQuotes( + props.src, + ); + const existingEl = root.querySelector( + `script[async][src="${limitedEscapedSrc}"]`, + ); + if (existingEl) { + resource.instance = existingEl; + } else { + const instance = createResourceInstance( + 'script', + resource.props, + getDocumentFromRoot(root), + ); + + insertResourceInstance(instance, getDocumentFromRoot(root)); + resource.instance = instance; + } + } + return resource.instance; +} + function attachLoadListeners(instance: Instance, resource: StyleResource) { const listeners = {}; listeners.load = onResourceLoad.bind( @@ -749,12 +935,14 @@ function insertStyleInstance( precedence: string, root: FloatRoot, ): void { - const nodes = root.querySelectorAll('link[rel="stylesheet"][data-rprec]'); + const nodes = root.querySelectorAll( + 'link[rel="stylesheet"][data-precedence]', + ); const last = nodes.length ? nodes[nodes.length - 1] : null; let prior = last; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; - const nodePrecedence = node.dataset.rprec; + const nodePrecedence = node.dataset.precedence; if (nodePrecedence === precedence) { prior = node; } else if (prior !== last) { @@ -780,21 +968,27 @@ function insertStyleInstance( } } -function insertPreloadInstance( +function insertResourceInstance( instance: Instance, ownerDocument: Document, ): void { - if (!ownerDocument.contains(instance)) { - const parent = ownerDocument.head; - if (parent) { - parent.appendChild(instance); - } else { - throw new Error( - 'While attempting to insert a Resource, React expected the Document to contain' + - ' a head element but it was not found.', + if (__DEV__) { + if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') { + console.error( + 'insertResourceInstance was called with a stylesheet. Stylesheets must be' + + ' inserted with insertStyleInstance instead. This is a bug in React.', ); } } + const parent = ownerDocument.head; + if (parent) { + parent.appendChild(instance); + } else { + throw new Error( + 'While attempting to insert a Resource, React expected the Document to contain' + + ' a head element but it was not found.', + ); + } } export function isHostResourceType(type: string, props: Props): boolean { @@ -815,27 +1009,22 @@ export function isHostResourceType(type: string, props: Props): boolean { ); } case 'preload': { - if (__DEV__) { - validateLinkPropsForStyleResource(props); - } - const {href, as, onLoad, onError} = props; - return ( - !onLoad && - !onError && - typeof href === 'string' && - isResourceAsType(as) - ); + const {href, onLoad, onError} = props; + return !onLoad && !onError && typeof href === 'string'; } } + return false; + } + case 'script': { + // We don't validate because it is valid to use async with onLoad/onError unlike combining + // precedence with these for style resources + const {src, async, onLoad, onError} = props; + return (async: any) && typeof src === 'string' && !onLoad && !onError; } } return false; } -function isResourceAsType(as: mixed): boolean { - return as === 'style' || as === 'font' || as === 'script'; -} - // When passing user input into querySelector(All) the embedded string must not alter // the semantics of the query. This escape function is safe to use when we know the // provided value is going to be wrapped in double quotes as part of an attribute selector diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index 3f57d064439b0..a4ebed9a510a2 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -794,6 +794,20 @@ export function bindInstance( export const supportsHydration = true; +// With Resources, some HostComponent types will never be server rendered and need to be +// inserted without breaking hydration +export function isHydratable(type: string, props: Props): boolean { + if (enableFloat) { + if (type === 'script') { + const {async, onLoad, onError} = (props: any); + return !(async && (onLoad || onError)); + } + return true; + } else { + return true; + } +} + export function canHydrateInstance( instance: HydratableInstance, type: string, @@ -889,12 +903,26 @@ function getNextHydratable(node) { const rel = linkEl.rel; if ( rel === 'preload' || - (rel === 'stylesheet' && linkEl.hasAttribute('data-rprec')) + (rel === 'stylesheet' && linkEl.hasAttribute('data-precedence')) ) { continue; } break; } + case 'STYLE': { + const styleEl: HTMLStyleElement = (element: any); + if (styleEl.hasAttribute('data-precedence')) { + continue; + } + break; + } + case 'SCRIPT': { + const scriptEl: HTMLScriptElement = (element: any); + if (scriptEl.hasAttribute('async')) { + continue; + } + break; + } case 'HTML': case 'HEAD': case 'BODY': { @@ -908,14 +936,31 @@ function getNextHydratable(node) { } else if (enableFloat) { if (nodeType === ELEMENT_NODE) { const element: Element = (node: any); - if (element.tagName === 'LINK') { - const linkEl: HTMLLinkElement = (element: any); - const rel = linkEl.rel; - if ( - rel === 'preload' || - (rel === 'stylesheet' && linkEl.hasAttribute('data-rprec')) - ) { - continue; + switch (element.tagName) { + case 'LINK': { + const linkEl: HTMLLinkElement = (element: any); + const rel = linkEl.rel; + if ( + rel === 'preload' || + (rel === 'stylesheet' && linkEl.hasAttribute('data-precedence')) + ) { + continue; + } + break; + } + case 'STYLE': { + const styleEl: HTMLStyleElement = (element: any); + if (styleEl.hasAttribute('data-precedence')) { + continue; + } + break; + } + case 'SCRIPT': { + const scriptEl: HTMLScriptElement = (element: any); + if (scriptEl.hasAttribute('async')) { + continue; + } + break; } } break; diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index b8f541b5250b5..3198d193ed100 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -11,6 +11,8 @@ import { validatePreloadResourceDifference, validateStyleResourceDifference, validateStyleAndHintProps, + validateScriptResourceDifference, + validateScriptAndHintProps, validateLinkPropsForStyleResource, validateLinkPropsForPreloadResource, validatePreloadArguments, @@ -38,7 +40,7 @@ type PreloadResource = { type StyleProps = { rel: 'stylesheet', href: string, - 'data-rprec': string, + 'data-precedence': string, [string]: mixed, }; type StyleResource = { @@ -50,22 +52,44 @@ type StyleResource = { flushed: boolean, inShell: boolean, // flushedInShell hint: PreloadResource, + set: Set, // the precedence set this resource should be flushed in }; -export type Resource = PreloadResource | StyleResource; +type ScriptProps = { + src: string, + [string]: mixed, +}; +type ScriptResource = { + type: 'script', + src: string, + props: ScriptProps, + + flushed: boolean, + hint: PreloadResource, +}; + +export type Resource = PreloadResource | StyleResource | ScriptResource; export type Resources = { // Request local cache preloadsMap: Map, stylesMap: Map, + scriptsMap: Map, // Flushing queues for Resource dependencies - explicitPreloads: Set, - implicitPreloads: Set, + fontPreloads: Set, + // usedImagePreloads: Set, precedences: Map>, + usedStylePreloads: Set, + scripts: Set, + usedScriptPreloads: Set, + explicitStylePreloads: Set, + // explicitImagePreloads: Set, + explicitScriptPreloads: Set, // Module-global-like reference for current boundary resources boundaryResources: ?BoundaryResources, + ... }; // @TODO add bootstrap script to implicit preloads @@ -74,11 +98,18 @@ export function createResources(): Resources { // persistent preloadsMap: new Map(), stylesMap: new Map(), + scriptsMap: new Map(), // cleared on flush - explicitPreloads: new Set(), - implicitPreloads: new Set(), + fontPreloads: new Set(), + // usedImagePreloads: new Set(), precedences: new Map(), + usedStylePreloads: new Set(), + scripts: new Set(), + usedScriptPreloads: new Set(), + explicitStylePreloads: new Set(), + // explicitImagePreloads: new Set(), + explicitScriptPreloads: new Set(), // like a module global for currently rendering boundary boundaryResources: null, @@ -91,13 +122,6 @@ export function createBoundaryResources(): BoundaryResources { return new Set(); } -export function mergeBoundaryResources( - target: BoundaryResources, - source: BoundaryResources, -) { - source.forEach(resource => target.add(resource)); -} - let currentResources: null | Resources = null; const currentResourcesStack = []; @@ -134,6 +158,7 @@ function preload(href: string, options: PreloadOptions) { // simply return and do not warn. return; } + const resources = currentResources; if (__DEV__) { validatePreloadArguments(href, options); } @@ -144,8 +169,7 @@ function preload(href: string, options: PreloadOptions) { options !== null ) { const as = options.as; - // $FlowFixMe[incompatible-use] found when upgrading Flow - let resource = currentResources.preloadsMap.get(href); + let resource = resources.preloadsMap.get(href); if (resource) { if (__DEV__) { const originallyImplicit = @@ -160,23 +184,35 @@ function preload(href: string, options: PreloadOptions) { } } else { resource = createPreloadResource( - // $FlowFixMe[incompatible-call] found when upgrading Flow - currentResources, + resources, href, as, preloadPropsFromPreloadOptions(href, as, options), ); } - // $FlowFixMe[incompatible-call] found when upgrading Flow - captureExplicitPreloadResourceDependency(currentResources, resource); + switch (as) { + case 'font': { + resources.fontPreloads.add(resource); + break; + } + case 'style': { + resources.explicitStylePreloads.add(resource); + break; + } + case 'script': { + resources.explicitScriptPreloads.add(resource); + break; + } + } } } -type PreinitAs = 'style'; +type PreinitAs = 'style' | 'script'; type PreinitOptions = { as: PreinitAs, precedence?: string, crossOrigin?: string, + integrity?: string, }; function preinit(href: string, options: PreinitOptions) { if (!currentResources) { @@ -188,6 +224,7 @@ function preinit(href: string, options: PreinitOptions) { // simply return and do not warn. return; } + const resources = currentResources; if (__DEV__) { validatePreinitArguments(href, options); } @@ -200,38 +237,48 @@ function preinit(href: string, options: PreinitOptions) { const as = options.as; switch (as) { case 'style': { - const precedence = options.precedence || 'default'; - - // $FlowFixMe[incompatible-use] found when upgrading Flow - let resource = currentResources.stylesMap.get(href); + let resource = resources.stylesMap.get(href); if (resource) { if (__DEV__) { const latestProps = stylePropsFromPreinitOptions( href, - precedence, + resource.precedence, options, ); validateStyleResourceDifference(resource.props, latestProps); } } else { + const precedence = options.precedence || 'default'; const resourceProps = stylePropsFromPreinitOptions( href, precedence, options, ); resource = createStyleResource( - // $FlowFixMe[incompatible-call] found when upgrading Flow - currentResources, + resources, href, precedence, resourceProps, ); } + resource.set.add(resource); + resources.explicitStylePreloads.add(resource.hint); - // Do not associate preinit style resources with any specific boundary regardless of where it is called - // $FlowFixMe[incompatible-call] found when upgrading Flow - captureStyleResourceDependency(currentResources, null, resource); - + return; + } + case 'script': { + const src = href; + let resource = resources.scriptsMap.get(src); + if (resource) { + if (__DEV__) { + const latestProps = scriptPropsFromPreinitOptions(src, options); + validateScriptResourceDifference(resource.props, latestProps); + } + } else { + const scriptProps = scriptPropsFromPreinitOptions(src, options); + resource = createScriptResource(resources, src, scriptProps); + resources.scripts.add(resource); + } return; } } @@ -286,6 +333,20 @@ function preloadAsStylePropsFromProps( }; } +function preloadAsScriptPropsFromProps( + href: string, + props: Props | ScriptProps, +): PreloadProps { + return { + rel: 'preload', + as: 'script', + href, + crossOrigin: props.crossOrigin, + integrity: props.integrity, + referrerPolicy: props.referrerPolicy, + }; +} + function createPreloadResource( resources: Resources, href: string, @@ -320,7 +381,7 @@ function stylePropsFromRawProps( const props: StyleProps = Object.assign({}, rawProps); props.href = href; props.rel = 'stylesheet'; - props['data-rprec'] = precedence; + props['data-precedence'] = precedence; delete props.precedence; return props; @@ -334,7 +395,7 @@ function stylePropsFromPreinitOptions( return { rel: 'stylesheet', href, - 'data-rprec': precedence, + 'data-precedence': precedence, crossOrigin: options.crossOrigin, }; } @@ -352,7 +413,15 @@ function createStyleResource( ); } } - const {stylesMap, preloadsMap} = resources; + const {stylesMap, preloadsMap, precedences} = resources; + + // If this is the first time we've seen this precedence we encode it's position in our set even though + // we don't add the resource to this set yet + let precedenceSet = precedences.get(precedence); + if (!precedenceSet) { + precedenceSet = new Set(); + precedences.set(precedence, precedenceSet); + } let hint = preloadsMap.get(href); if (hint) { @@ -360,16 +429,11 @@ function createStyleResource( // on the style Resource, primarily focussed on making sure the style network pathways utilize // the preload pathways. For instance if you have diffreent crossOrigin attributes for a preload // and a stylesheet the stylesheet will make a new request even if the preload had already loaded - const preloadProps = hint.props; - if (props.crossOrigin == null) props.crossOrigin = preloadProps.crossOrigin; - if (props.referrerPolicy == null) - props.referrerPolicy = preloadProps.referrerPolicy; - if (props.media == null) props.media = preloadProps.media; - if (props.title == null) props.title = preloadProps.title; + adoptPreloadPropsForStyleProps(props, hint.props); if (__DEV__) { validateStyleAndHintProps( - preloadProps, + hint.props, props, (hint: any)._dev_implicit_construction, ); @@ -385,7 +449,7 @@ function createStyleResource( if (__DEV__) { (hint: any)._dev_implicit_construction = true; } - captureImplicitPreloadResourceDependency(resources, hint); + resources.explicitStylePreloads.add(hint); } const resource = { @@ -396,47 +460,107 @@ function createStyleResource( inShell: false, props, hint, + set: precedenceSet, }; stylesMap.set(href, resource); return resource; } -function captureStyleResourceDependency( - resources: Resources, - boundaryResources: ?BoundaryResources, - styleResource: StyleResource, +function adoptPreloadPropsForStyleProps( + resourceProps: StyleProps, + preloadProps: PreloadProps, ): void { - const {precedences} = resources; - const {precedence} = styleResource; + if (resourceProps.crossOrigin == null) + resourceProps.crossOrigin = preloadProps.crossOrigin; + if (resourceProps.referrerPolicy == null) + resourceProps.referrerPolicy = preloadProps.referrerPolicy; + if (resourceProps.title == null) resourceProps.title = preloadProps.title; +} + +function scriptPropsFromPreinitOptions( + src: string, + options: PreinitOptions, +): ScriptProps { + return { + src, + async: true, + crossOrigin: options.crossOrigin, + integrity: options.integrity, + }; +} + +function scriptPropsFromRawProps(src: string, rawProps: Props): ScriptProps { + const props = Object.assign({}, rawProps); + props.src = src; + return props; +} - if (boundaryResources) { - boundaryResources.add(styleResource); - if (!precedences.has(precedence)) { - precedences.set(precedence, new Set()); +function createScriptResource( + resources: Resources, + src: string, + props: ScriptProps, +): ScriptResource { + if (__DEV__) { + if (resources.scriptsMap.has(src)) { + console.error( + 'createScriptResource was called when a script Resource matching the same src already exists. This is a bug in React.', + ); + } + } + const {scriptsMap, preloadsMap} = resources; + + let hint = preloadsMap.get(src); + if (hint) { + // If a preload for this style Resource already exists there are certain props we want to adopt + // on the style Resource, primarily focussed on making sure the style network pathways utilize + // the preload pathways. For instance if you have diffreent crossOrigin attributes for a preload + // and a stylesheet the stylesheet will make a new request even if the preload had already loaded + adoptPreloadPropsForScriptProps(props, hint.props); + + if (__DEV__) { + validateScriptAndHintProps( + hint.props, + props, + (hint: any)._dev_implicit_construction, + ); } } else { - let set = precedences.get(precedence); - if (!set) { - set = new Set(); - precedences.set(precedence, set); + const preloadResourceProps = preloadAsScriptPropsFromProps(src, props); + hint = createPreloadResource( + resources, + src, + 'script', + preloadResourceProps, + ); + if (__DEV__) { + (hint: any)._dev_implicit_construction = true; } - set.add(styleResource); + resources.explicitScriptPreloads.add(hint); } -} -function captureExplicitPreloadResourceDependency( - resources: Resources, - preloadResource: PreloadResource, -): void { - resources.explicitPreloads.add(preloadResource); + const resource = { + type: 'script', + src, + flushed: false, + props, + hint, + }; + scriptsMap.set(src, resource); + + return resource; } -function captureImplicitPreloadResourceDependency( - resources: Resources, - preloadResource: PreloadResource, +function adoptPreloadPropsForScriptProps( + resourceProps: ScriptProps, + preloadProps: PreloadProps, ): void { - resources.implicitPreloads.add(preloadResource); + if (resourceProps.crossOrigin == null) + resourceProps.crossOrigin = preloadProps.crossOrigin; + if (resourceProps.referrerPolicy == null) + resourceProps.referrerPolicy = preloadProps.referrerPolicy; + if (resourceProps.integrity == null) + resourceProps.integrity = preloadProps.integrity; } // Construct a resource from link props. @@ -446,6 +570,8 @@ export function resourcesFromLink(props: Props): boolean { '"currentResources" was expected to exist. This is a bug in React.', ); } + const resources = currentResources; + const {rel, href} = props; if (!href || typeof href !== 'string') { return false; @@ -467,11 +593,11 @@ export function resourcesFromLink(props: Props): boolean { validateLinkPropsForStyleResource(props); } // $FlowFixMe[incompatible-use] found when upgrading Flow - let preloadResource = currentResources.preloadsMap.get(href); + let preloadResource = resources.preloadsMap.get(href); if (!preloadResource) { preloadResource = createPreloadResource( // $FlowFixMe[incompatible-call] found when upgrading Flow - currentResources, + resources, href, 'style', preloadAsStylePropsFromProps(href, props), @@ -479,17 +605,14 @@ export function resourcesFromLink(props: Props): boolean { if (__DEV__) { (preloadResource: any)._dev_implicit_construction = true; } + resources.usedStylePreloads.add(preloadResource); } - captureImplicitPreloadResourceDependency( - // $FlowFixMe[incompatible-call] found when upgrading Flow - currentResources, - preloadResource, - ); return false; } else { // We are able to convert this link element to a resource exclusively. We construct the relevant Resource // and return true indicating that this link was fully consumed. - let resource = currentResources.stylesMap.get(href); + let resource = resources.stylesMap.get(href); + if (resource) { if (__DEV__) { const resourceProps = stylePropsFromRawProps( @@ -497,6 +620,7 @@ export function resourcesFromLink(props: Props): boolean { precedence, props, ); + adoptPreloadPropsForStyleProps(resourceProps, resource.hint.props); validateStyleResourceDifference(resource.props, resourceProps); } } else { @@ -508,24 +632,18 @@ export function resourcesFromLink(props: Props): boolean { precedence, resourceProps, ); + resources.usedStylePreloads.add(resource.hint); + } + if (resources.boundaryResources) { + resources.boundaryResources.add(resource); + } else { + resource.set.add(resource); } - captureStyleResourceDependency( - // $FlowFixMe[incompatible-call] found when upgrading Flow - currentResources, - // $FlowFixMe[incompatible-use] found when upgrading Flow - currentResources.boundaryResources, - resource, - ); return true; } } case 'preload': { - const {as, onLoad, onError} = props; - if (onLoad || onError) { - // these props signal an opt-out of Resource semantics. We don't warn because there is no - // conflicting opt-in like there is with Style Resources - return false; - } + const {as} = props; switch (as) { case 'script': case 'style': @@ -533,8 +651,7 @@ export function resourcesFromLink(props: Props): boolean { if (__DEV__) { validateLinkPropsForPreloadResource(props); } - // $FlowFixMe[incompatible-use] found when upgrading Flow - let resource = currentResources.preloadsMap.get(href); + let resource = resources.preloadsMap.get(href); if (resource) { if (__DEV__) { const originallyImplicit = @@ -549,15 +666,26 @@ export function resourcesFromLink(props: Props): boolean { } } else { resource = createPreloadResource( - // $FlowFixMe[incompatible-call] found when upgrading Flow - currentResources, + resources, href, as, preloadPropsFromRawProps(href, as, props), ); + switch (as) { + case 'script': { + resources.explicitScriptPreloads.add(resource); + break; + } + case 'style': { + resources.explicitStylePreloads.add(resource); + break; + } + case 'font': { + resources.fontPreloads.add(resource); + break; + } + } } - // $FlowFixMe[incompatible-call] found when upgrading Flow - captureExplicitPreloadResourceDependency(currentResources, resource); return true; } } @@ -567,12 +695,65 @@ export function resourcesFromLink(props: Props): boolean { return false; } +// Construct a resource from link props. +export function resourcesFromScript(props: Props): boolean { + if (!currentResources) { + throw new Error( + '"currentResources" was expected to exist. This is a bug in React.', + ); + } + const resources = currentResources; + const {src, async, onLoad, onError} = props; + if (!src || typeof src !== 'string') { + return false; + } + + if (async) { + if (onLoad || onError) { + if (__DEV__) { + // validate + } + let preloadResource = resources.preloadsMap.get(src); + if (!preloadResource) { + preloadResource = createPreloadResource( + // $FlowFixMe[incompatible-call] found when upgrading Flow + resources, + src, + 'script', + preloadAsScriptPropsFromProps(src, props), + ); + if (__DEV__) { + (preloadResource: any)._dev_implicit_construction = true; + } + resources.usedScriptPreloads.add(preloadResource); + } + } else { + let resource = resources.scriptsMap.get(src); + if (resource) { + if (__DEV__) { + const latestProps = scriptPropsFromRawProps(src, props); + adoptPreloadPropsForScriptProps(latestProps, resource.hint.props); + validateScriptResourceDifference(resource.props, latestProps); + } + } else { + const resourceProps = scriptPropsFromRawProps(src, props); + resource = createScriptResource(resources, src, resourceProps); + resources.scripts.add(resource); + } + } + return true; + } + + return false; +} + export function hoistResources( resources: Resources, source: BoundaryResources, ): void { - if (resources.boundaryResources) { - mergeBoundaryResources(resources.boundaryResources, source); + const currentBoundaryResources = resources.boundaryResources; + if (currentBoundaryResources) { + source.forEach(resource => currentBoundaryResources.add(resource)); source.clear(); } } @@ -581,12 +762,6 @@ export function hoistResourcesToRoot( resources: Resources, boundaryResources: BoundaryResources, ): void { - boundaryResources.forEach(resource => { - // all precedences are set upon discovery. so we know we will have a set here - const set: Set = (resources.precedences.get( - resource.precedence, - ): any); - set.add(resource); - }); + boundaryResources.forEach(resource => resource.set.add(resource)); boundaryResources.clear(); } diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index f5c95eaa37c69..1a2f466263b1d 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -64,6 +64,7 @@ import { prepareToRenderResources, finishRenderingResources, resourcesFromLink, + resourcesFromScript, ReactDOMServerDispatcher, } from './ReactDOMFloatServer'; export { @@ -1349,6 +1350,26 @@ function pushStartHtml( return pushStartGenericElement(target, props, tag, responseState); } +function pushStartScript( + target: Array, + props: Object, + responseState: ResponseState, + textEmbedded: boolean, +): ReactNodeList { + if (enableFloat && resourcesFromScript(props)) { + if (textEmbedded) { + // This link follows text but we aren't writing a tag. while not as efficient as possible we need + // to be safe and assume text will follow by inserting a textSeparator + target.push(textSeparator); + } + // We have converted this link exclusively to a resource and no longer + // need to emit it + return null; + } + + return pushStartGenericElement(target, props, 'script', responseState); +} + function pushStartGenericElement( target: Array, props: Object, @@ -1625,6 +1646,8 @@ export function pushStartInstance( return pushStartTitle(target, props, responseState); case 'link': return pushLink(target, props, responseState, textEmbedded); + case 'script': + return pushStartScript(target, props, responseState, textEmbedded); // Newline eating tags case 'listing': case 'pre': { @@ -2235,57 +2258,90 @@ function escapeJSObjectForInstructionScripts(input: Object): string { }); } +const precedencePlaceholderStart = stringToPrecomputedChunk( + ''); + export function writeInitialResources( destination: Destination, resources: Resources, responseState: ResponseState, ): boolean { - const explicitPreloadsTarget = []; - const remainingTarget = []; + function flushLinkResource(resource) { + if (!resource.flushed) { + pushLinkImpl(target, resource.props, responseState); + resource.flushed = true; + } + } - const {precedences, explicitPreloads, implicitPreloads} = resources; + const target = []; - // Flush stylesheets first by earliest precedence - precedences.forEach(precedenceResources => { - precedenceResources.forEach(resource => { - // resources should not already be flushed so we elide this check - pushLinkImpl(remainingTarget, resource.props, responseState); - resource.flushed = true; - resource.inShell = true; - resource.hint.flushed = true; - }); + const { + fontPreloads, + precedences, + usedStylePreloads, + scripts, + usedScriptPreloads, + explicitStylePreloads, + explicitScriptPreloads, + } = resources; + + fontPreloads.forEach(r => { + // font preload Resources should not already be flushed so we elide this check + pushLinkImpl(target, r.props, responseState); + r.flushed = true; }); + fontPreloads.clear(); - explicitPreloads.forEach(resource => { - if (!resource.flushed) { - pushLinkImpl(explicitPreloadsTarget, resource.props, responseState); - resource.flushed = true; + // Flush stylesheets first by earliest precedence + precedences.forEach((p, precedence) => { + if (p.size) { + p.forEach(r => { + // resources should not already be flushed so we elide this check + pushLinkImpl(target, r.props, responseState); + r.flushed = true; + r.inShell = true; + r.hint.flushed = true; + }); + p.clear(); + } else { + target.push( + precedencePlaceholderStart, + escapeTextForBrowser(stringToChunk(precedence)), + precedencePlaceholderEnd, + ); } }); - explicitPreloads.clear(); - implicitPreloads.forEach(resource => { - if (!resource.flushed) { - pushLinkImpl(remainingTarget, resource.props, responseState); - resource.flushed = true; - } + usedStylePreloads.forEach(flushLinkResource); + usedStylePreloads.clear(); + + scripts.forEach(r => { + // should never be flushed already + pushStartGenericElement(target, r.props, 'script', responseState); + pushEndInstance(target, target, 'script', r.props); + r.flushed = true; + r.hint.flushed = true; }); - implicitPreloads.clear(); + scripts.clear(); + + usedScriptPreloads.forEach(flushLinkResource); + usedScriptPreloads.clear(); + + explicitStylePreloads.forEach(flushLinkResource); + explicitStylePreloads.clear(); + + explicitScriptPreloads.forEach(flushLinkResource); + explicitScriptPreloads.clear(); let i; let r = true; - for (i = 0; i < explicitPreloadsTarget.length - 1; i++) { - writeChunk(destination, explicitPreloadsTarget[i]); - } - if (i < explicitPreloadsTarget.length) { - r = writeChunkAndReturn(destination, explicitPreloadsTarget[i]); - } - - for (i = 0; i < remainingTarget.length - 1; i++) { - writeChunk(destination, remainingTarget[i]); + for (i = 0; i < target.length - 1; i++) { + writeChunk(destination, target[i]); } - if (i < remainingTarget.length) { - r = writeChunkAndReturn(destination, remainingTarget[i]); + if (i < target.length) { + r = writeChunkAndReturn(destination, target[i]); } return r; } @@ -2295,33 +2351,61 @@ export function writeImmediateResources( resources: Resources, responseState: ResponseState, ): boolean { - const {explicitPreloads, implicitPreloads} = resources; - const target = []; - - explicitPreloads.forEach(resource => { + function flushLinkResource(resource) { if (!resource.flushed) { pushLinkImpl(target, resource.props, responseState); resource.flushed = true; } + } + + const target = []; + + const { + fontPreloads, + usedStylePreloads, + scripts, + usedScriptPreloads, + explicitStylePreloads, + explicitScriptPreloads, + } = resources; + + fontPreloads.forEach(r => { + // font preload Resources should not already be flushed so we elide this check + pushLinkImpl(target, r.props, responseState); + r.flushed = true; }); - explicitPreloads.clear(); + fontPreloads.clear(); - implicitPreloads.forEach(resource => { - if (!resource.flushed) { - pushLinkImpl(target, resource.props, responseState); - resource.flushed = true; - } + usedStylePreloads.forEach(flushLinkResource); + usedStylePreloads.clear(); + + scripts.forEach(r => { + // should never be flushed already + pushStartGenericElement(target, r.props, 'script', responseState); + pushEndInstance(target, target, 'script', r.props); + r.flushed = true; + r.hint.flushed = true; }); - implicitPreloads.clear(); + scripts.clear(); - let i = 0; - for (; i < target.length - 1; i++) { + usedScriptPreloads.forEach(flushLinkResource); + usedScriptPreloads.clear(); + + explicitStylePreloads.forEach(flushLinkResource); + explicitStylePreloads.clear(); + + explicitScriptPreloads.forEach(flushLinkResource); + explicitScriptPreloads.clear(); + + let i; + let r = true; + for (i = 0; i < target.length - 1; i++) { writeChunk(destination, target[i]); } if (i < target.length) { - return writeChunkAndReturn(destination, target[i]); + r = writeChunkAndReturn(destination, target[i]); } - return false; + return r; } function hasStyleResourceDependencies( @@ -2434,7 +2518,7 @@ function writeStyleResourceDependency( case 'href': case 'rel': case 'precedence': - case 'data-rprec': { + case 'data-precedence': { break; } case 'children': diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSet.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSet.js index 9f7ad8bd034cf..4abe722309f74 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSet.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSet.js @@ -57,9 +57,11 @@ export function completeBoundaryWithStyles( let lastResource, node; // Seed the precedence list with existing resources - const nodes = thisDocument.querySelectorAll('link[data-rprec]'); + const nodes = thisDocument.querySelectorAll( + 'link[data-precedence],style[data-precedence]', + ); for (let i = 0; (node = nodes[i++]); ) { - precedences.set(node.dataset['rprec'], (lastResource = node)); + precedences.set(node.dataset['precedence'], (lastResource = node)); } let i = 0; @@ -89,7 +91,7 @@ export function completeBoundaryWithStyles( resourceEl = thisDocument.createElement('link'); resourceEl.href = href; resourceEl.rel = 'stylesheet'; - resourceEl.dataset['rprec'] = precedence = style[j++]; + resourceEl.dataset['precedence'] = precedence = style[j++]; while ((attr = style[j++])) { resourceEl.setAttribute(attr, style[j++]); } diff --git a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js index 6862f77651a04..17ec195e07c71 100644 --- a/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js +++ b/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetInlineCodeStrings.js @@ -6,6 +6,6 @@ export const clientRenderBoundary = export const completeBoundary = '$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};'; export const completeBoundaryWithStyles = - '$RM=new Map;\n$RR=function(p,q,v){function r(l){this.s=l}for(var t=$RC,u=$RM,m=new Map,n=document,g,e,f=n.querySelectorAll("link[data-rprec]"),d=0;e=f[d++];)m.set(e.dataset.rprec,g=e);e=0;f=[];for(var c,h,b,a;c=v[e++];){var k=0;h=c[k++];if(b=u.get(h))"l"!==b.s&&f.push(b);else{a=n.createElement("link");a.href=h;a.rel="stylesheet";for(a.dataset.rprec=d=c[k++];b=c[k++];)a.setAttribute(b,c[k++]);b=a._p=new Promise(function(l,w){a.onload=l;a.onerror=w});b.then(r.bind(b,"l"),r.bind(b,"e"));u.set(h,\nb);f.push(b);c=m.get(d)||g;c===g&&(g=a);m.set(d,a);c?c.parentNode.insertBefore(a,c.nextSibling):(d=n.head,d.insertBefore(a,d.firstChild))}}Promise.all(f).then(t.bind(null,p,q,""),t.bind(null,p,q,"Resource failed to load"))};'; + '$RM=new Map;\n$RR=function(p,q,v){function r(l){this.s=l}for(var t=$RC,u=$RM,m=new Map,n=document,g,e,f=n.querySelectorAll("link[data-precedence],style[data-precedence]"),d=0;e=f[d++];)m.set(e.dataset.precedence,g=e);e=0;f=[];for(var c,h,b,a;c=v[e++];){var k=0;h=c[k++];if(b=u.get(h))"l"!==b.s&&f.push(b);else{a=n.createElement("link");a.href=h;a.rel="stylesheet";for(a.dataset.precedence=d=c[k++];b=c[k++];)a.setAttribute(b,c[k++]);b=a._p=new Promise(function(l,w){a.onload=l;a.onerror=w});b.then(r.bind(b,\n"l"),r.bind(b,"e"));u.set(h,b);f.push(b);c=m.get(d)||g;c===g&&(g=a);m.set(d,a);c?c.parentNode.insertBefore(a,c.nextSibling):(d=n.head,d.insertBefore(a,d.firstChild))}}Promise.all(f).then(t.bind(null,p,q,""),t.bind(null,p,q,"Resource failed to load"))};'; export const completeSegment = '$RS=function(a,b){a=document.getElementById(a);b=document.getElementById(b);for(a.parentNode.removeChild(a);a.firstChild;)b.parentNode.insertBefore(a.firstChild,b);b.parentNode.removeChild(b)};'; diff --git a/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js b/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js index aaad9e9fbbc62..f451eb6623658 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js @@ -115,6 +115,7 @@ export function validatePreloadResourceDifference( if (missingProps || extraProps || differentProps) { warnDifferentProps( href, + 'href', originalWarningName, latestWarningName, extraProps, @@ -156,7 +157,7 @@ export function validateStyleResourceDifference( const originalValue = originalProps[propName]; if (propValue != null && propValue !== originalValue) { - propName = propName === 'data-rprec' ? 'precedence' : propName; + propName = propName === 'data-precedence' ? 'precedence' : propName; if (originalValue == null) { extraProps = extraProps || {}; extraProps[propName] = propValue; @@ -173,6 +174,7 @@ export function validateStyleResourceDifference( if (missingProps || extraProps || differentProps) { warnDifferentProps( href, + 'href', originalWarningName, latestWarningName, extraProps, @@ -183,6 +185,58 @@ export function validateStyleResourceDifference( } } +export function validateScriptResourceDifference( + originalProps: any, + latestProps: any, +) { + if (__DEV__) { + const {src} = originalProps; + // eslint-disable-next-line no-labels + const originalWarningName = getResourceNameForWarning( + 'script', + originalProps, + false, + ); + const latestWarningName = getResourceNameForWarning( + 'script', + latestProps, + false, + ); + let extraProps = null; + let differentProps = null; + + for (const propName in latestProps) { + const propValue = latestProps[propName]; + const originalValue = originalProps[propName]; + + if (propValue != null && propValue !== originalValue) { + if (originalValue == null) { + extraProps = extraProps || {}; + extraProps[propName] = propValue; + } else { + differentProps = differentProps || {}; + differentProps[propName] = { + original: originalValue, + latest: propValue, + }; + } + } + } + + if (extraProps || differentProps) { + warnDifferentProps( + src, + 'src', + originalWarningName, + latestWarningName, + extraProps, + null, + differentProps, + ); + } + } +} + export function validateStyleAndHintProps( preloadProps: any, styleProps: any, @@ -205,7 +259,7 @@ export function validateStyleAndHintProps( if (preloadProps.as !== 'style') { console.error( 'While creating a %s for href "%s" a %s for this same href was found. When preloading a stylesheet the' + - ' "as" prop must be of type "style". This most likely ocurred by rending a preload link with an incorrect' + + ' "as" prop must be of type "style". This most likely ocurred by rendering a preload link with an incorrect' + ' "as" prop or by calling ReactDOM.preload with an incorrect "as" option.', latestWarningName, href, @@ -252,6 +306,86 @@ export function validateStyleAndHintProps( if (missingProps || extraProps || differentProps) { warnDifferentProps( href, + 'href', + originalWarningName, + latestWarningName, + extraProps, + missingProps, + differentProps, + ); + } + } +} + +export function validateScriptAndHintProps( + preloadProps: any, + scriptProps: any, + implicitPreload: boolean, +) { + if (__DEV__) { + const {href} = preloadProps; + + const originalWarningName = getResourceNameForWarning( + 'preload', + preloadProps, + implicitPreload, + ); + const latestWarningName = getResourceNameForWarning( + 'script', + scriptProps, + false, + ); + + if (preloadProps.as !== 'script') { + console.error( + 'While creating a %s for href "%s" a %s for this same url was found. When preloading a script the' + + ' "as" prop must be of type "script". This most likely ocurred by rendering a preload link with an incorrect' + + ' "as" prop or by calling ReactDOM.preload with an incorrect "as" option.', + latestWarningName, + href, + originalWarningName, + ); + } + + let missingProps = null; + let extraProps = null; + let differentProps = null; + + for (const propName in scriptProps) { + const scriptValue = scriptProps[propName]; + const preloadValue = preloadProps[propName]; + switch (propName) { + // Check for difference on specific props that cross over or influence + // the relationship between the preload and stylesheet + case 'crossOrigin': + case 'referrerPolicy': + case 'integrity': { + if ( + preloadValue !== scriptValue && + !(preloadValue == null && scriptValue == null) + ) { + if (scriptValue == null) { + missingProps = missingProps || {}; + missingProps[propName] = preloadValue; + } else if (preloadValue == null) { + extraProps = extraProps || {}; + extraProps[propName] = scriptValue; + } else { + differentProps = differentProps || {}; + differentProps[propName] = { + original: preloadValue, + latest: scriptValue, + }; + } + } + } + } + } + + if (missingProps || extraProps || differentProps) { + warnDifferentProps( + href, + 'href', originalWarningName, latestWarningName, extraProps, @@ -263,7 +397,8 @@ export function validateStyleAndHintProps( } function warnDifferentProps( - href: string, + url: string, + urlPropKey: string, originalName: string, latestName: string, extraProps: ?{[string]: any}, @@ -274,7 +409,7 @@ function warnDifferentProps( const juxtaposedNameStatement = latestName === originalName ? 'an earlier instance of this Resource' - : `a ${originalName} with the same href`; + : `a ${originalName} with the same ${urlPropKey}`; let comparisonStatement = ''; if (missingProps !== null && typeof missingProps === 'object') { @@ -294,12 +429,14 @@ function warnDifferentProps( } console.error( - 'A %s with href "%s" has props that disagree with those found on %s. Resources always use the props' + + 'A %s with %s "%s" has props that disagree with those found on %s. Resources always use the props' + ' that were provided the first time they are encountered so any differences will be ignored. Please' + - ' update Resources that share an href to have props that agree. The differences are described below.%s', + ' update Resources that share an %s to have props that agree. The differences are described below.%s', latestName, - href, + urlPropKey, + url, juxtaposedNameStatement, + urlPropKey, comparisonStatement, ); } @@ -315,6 +452,9 @@ function getResourceNameForWarning( case 'style': { return 'style Resource'; } + case 'script': { + return 'script Resource'; + } case 'preload': { if (implicit) { return `preload for a ${props.as} Resource`; @@ -326,15 +466,17 @@ function getResourceNameForWarning( return 'Resource'; } -export function validateHrefKeyedUpdatedProps( +export function validateURLKeyedUpdatedProps( pendingProps: Props, currentProps: Props, + resourceType: 'style' | 'script' | 'href', + urlPropKey: 'href' | 'src', ): boolean { if (__DEV__) { - // This function should never be called if we don't have hrefs so we don't bother considering + // This function should never be called if we don't have /srcs so we don't bother considering // Whether they are null or undefined - if (pendingProps.href === currentProps.href) { - // If we have the same href we need all other props to be the same + if (pendingProps[urlPropKey] === currentProps[urlPropKey]) { + // If we have the same href/src we need all other props to be the same let missingProps; let extraProps; let differentProps; @@ -366,7 +508,7 @@ export function validateHrefKeyedUpdatedProps( } if (missingProps || extraProps || differentProps) { const latestWarningName = getResourceNameForWarning( - 'style', + resourceType, currentProps, false, ); @@ -388,14 +530,17 @@ export function validateHrefKeyedUpdatedProps( } } console.error( - 'A %s with href "%s" recieved new props with different values from the props used' + + 'A %s with %s "%s" recieved new props with different values from the props used' + ' when this Resource was first rendered. React will only use the props provided when' + - ' this resource was first rendered until a new href is provided. Unlike conventional' + + ' this resource was first rendered until a new %s is provided. Unlike conventional' + ' DOM elements, Resources instances do not have a one to one correspondence with Elements' + ' in the DOM and as such, every instance of a Resource for a single Resource identifier' + - ' (href) must have props that agree with each other. The differences are described below.%s', + ' (%s) must have props that agree with each other. The differences are described below.%s', latestWarningName, - currentProps.href, + urlPropKey, + currentProps[urlPropKey], + urlPropKey, + urlPropKey, comparisonStatement, ); return true; @@ -556,7 +701,8 @@ export function validatePreinitArguments(href: mixed, options: mixed) { } else { const as = options.as; switch (as) { - case 'style': { + case 'style': + case 'script': { break; } @@ -565,8 +711,8 @@ export function validatePreinitArguments(href: mixed, options: mixed) { const typeOfAs = getValueDescriptorExpectingEnumForWarning(as); console.error( 'ReactDOM.preinit() expected the second argument to be an options argument containing at least an "as" property' + - ' specifying the Resource type. It found %s instead. Currently, the only valid resource type for preinit is "style".' + - ' The href for the preinit call where this warning originated is "%s".', + ' specifying the Resource type. It found %s instead. Currently, valid resource types for for preinit are "style"' + + ' and "script". The href for the preinit call where this warning originated is "%s".', typeOfAs, href, ); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 5118381920f48..99f107cced027 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -137,17 +137,23 @@ describe('ReactDOMFloat', () => { buffer = ''; } - function getVisibleChildren(element) { + function getMeaningfulChildren(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') + // some tags are ambiguous and might be hidden because they look like non-meaningful children + // so we have a global override where if this data attribute is included we also include the node + node.hasAttribute('data-meaningful') || + (node.tagName === 'SCRIPT' && + node.hasAttribute('src') && + node.hasAttribute('async')) || + (node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden')) ) { const props = {}; const attributes = node.attributes; @@ -161,7 +167,7 @@ describe('ReactDOMFloat', () => { } props[attributes[i].name] = attributes[i].value; } - props.children = getVisibleChildren(node); + props.children = getMeaningfulChildren(node); children.push(React.createElement(node.tagName.toLowerCase(), props)); } } else if (node.nodeType === 3) { @@ -264,7 +270,7 @@ describe('ReactDOMFloat', () => { , )}foo`; }); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -291,7 +297,7 @@ describe('ReactDOMFloat', () => { , )}foo`; }); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -327,10 +333,10 @@ describe('ReactDOMFloat', () => { ' a Resource at all. valid rel types for Resources are "stylesheet" and "preload". The previous' + ' rel for this instance was "stylesheet". The updated rel is "author" and the updated href is "bar".', ); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( - + @@ -361,7 +367,7 @@ describe('ReactDOMFloat', () => { ); pipe(writable); }); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -380,7 +386,7 @@ describe('ReactDOMFloat', () => { const root = ReactDOMClient.createRoot(container); root.render(); expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -404,7 +410,7 @@ describe('ReactDOMFloat', () => { root.render(); expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -428,7 +434,7 @@ describe('ReactDOMFloat', () => { root.render(); expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -452,7 +458,7 @@ describe('ReactDOMFloat', () => { // to the window.document global when no other documents have been used // The way the JSDOM runtim is created for these tests the local document // global does not point to the global.document - expect(getVisibleChildren(global.document)).toEqual( + expect(getMeaningfulChildren(global.document)).toEqual( @@ -505,7 +511,7 @@ describe('ReactDOMFloat', () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); pipe(writable); }); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -526,7 +532,7 @@ describe('ReactDOMFloat', () => { ReactDOMClient.hydrateRoot(document, ); expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -558,7 +564,7 @@ describe('ReactDOMFloat', () => { // @gate enableFloat it('creates a style Resource when called during server rendering before first flush', async () => { function Component() { - ReactDOM.preinit('foo', {as: 'style', precedence: 'foo'}); + ReactDOM.preinit('foo', {as: 'style'}); return 'foo'; } await actIntoEmptyDocument(() => { @@ -572,10 +578,10 @@ describe('ReactDOMFloat', () => { ); pipe(writable); }); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( - + foo , @@ -610,7 +616,7 @@ describe('ReactDOMFloat', () => { await act(() => { resolveText('unblock'); }); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -630,10 +636,10 @@ describe('ReactDOMFloat', () => { const root = ReactDOMClient.createRoot(container); root.render(); expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( - +
foo
@@ -660,7 +666,7 @@ describe('ReactDOMFloat', () => { root.render(); expect(Scheduler).toFlushWithoutYielding(); - expect(getVisibleChildren(document)).toEqual( + expect(getMeaningfulChildren(document)).toEqual( @@ -686,7 +692,7 @@ describe('ReactDOMFloat', () => { // to the window.document global when no other documents have been used // The way the JSDOM runtim is created for these tests the local document // global does not point to the global.document - expect(getVisibleChildren(global.document)).toEqual( + expect(getMeaningfulChildren(global.document)).toEqual( @@ -697,6 +703,53 @@ describe('ReactDOMFloat', () => { }); }); + describe('ReactDOM.preinit as script', () => { + // @gate enableFloat + it('can preinit a script', async () => { + function App({srcs}) { + srcs.forEach(src => ReactDOM.preinit(src, {as: 'script'})); + return ( + + + title + + foo + + ); + } + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getMeaningfulChildren(document)).toEqual( + + +