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( + + +