diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 4d71e6e9f0d67..abd1327c0de01 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -223,7 +223,7 @@ export function getInternalReactConstants(version: string): { HostComponent: 5, HostPortal: 4, HostRoot: 3, - HostResource: 26, // In reality, 18.2+. But doesn't hurt to include it here + HostHoistable: 26, // In reality, 18.2+. But doesn't hurt to include it here HostSingleton: 27, // Same as above HostText: 6, IncompleteClassComponent: 17, @@ -257,7 +257,7 @@ export function getInternalReactConstants(version: string): { HostComponent: 5, HostPortal: 4, HostRoot: 3, - HostResource: -1, // Doesn't exist yet + HostHoistable: -1, // Doesn't exist yet HostSingleton: -1, // Doesn't exist yet HostText: 6, IncompleteClassComponent: 17, @@ -290,7 +290,7 @@ export function getInternalReactConstants(version: string): { HostComponent: 5, HostPortal: 4, HostRoot: 3, - HostResource: -1, // Doesn't exist yet + HostHoistable: -1, // Doesn't exist yet HostSingleton: -1, // Doesn't exist yet HostText: 6, IncompleteClassComponent: 17, @@ -323,7 +323,7 @@ export function getInternalReactConstants(version: string): { HostComponent: 7, HostPortal: 6, HostRoot: 5, - HostResource: -1, // Doesn't exist yet + HostHoistable: -1, // Doesn't exist yet HostSingleton: -1, // Doesn't exist yet HostText: 8, IncompleteClassComponent: -1, // Doesn't exist yet @@ -356,7 +356,7 @@ export function getInternalReactConstants(version: string): { HostComponent: 5, HostPortal: 4, HostRoot: 3, - HostResource: -1, // Doesn't exist yet + HostHoistable: -1, // Doesn't exist yet HostSingleton: -1, // Doesn't exist yet HostText: 6, IncompleteClassComponent: -1, // Doesn't exist yet @@ -397,7 +397,7 @@ export function getInternalReactConstants(version: string): { IndeterminateComponent, ForwardRef, HostRoot, - HostResource, + HostHoistable, HostSingleton, HostComponent, HostPortal, @@ -465,7 +465,7 @@ export function getInternalReactConstants(version: string): { return null; case HostComponent: case HostSingleton: - case HostResource: + case HostHoistable: return type; case HostPortal: case HostText: @@ -591,7 +591,7 @@ export function attach( Fragment, FunctionComponent, HostRoot, - HostResource, + HostHoistable, HostSingleton, HostPortal, HostComponent, @@ -1032,7 +1032,7 @@ export function attach( case HostRoot: return ElementTypeRoot; case HostComponent: - case HostResource: + case HostHoistable: case HostSingleton: return ElementTypeHostComponent; case HostPortal: diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index ef03c5717a505..3bf60b8cb5416 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -40,7 +40,7 @@ export type WorkTagMap = { HostComponent: WorkTag, HostPortal: WorkTag, HostRoot: WorkTag, - HostResource: WorkTag, + HostHoistable: WorkTag, HostSingleton: WorkTag, HostText: WorkTag, IncompleteClassComponent: WorkTag, diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js index f3a83da5ea628..a1767f7f6e07c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js @@ -36,7 +36,7 @@ export default function InspectedElementStateTree({ }: Props): React.Node { const {state, type} = inspectedElement; - // HostSingleton and HostResource may have state that we don't want to expose to users + // HostSingleton and HostHoistable may have state that we don't want to expose to users const isHostComponent = type === ElementTypeHostComponent; const entries = state != null ? Object.entries(state) : null; diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js index aa28e7e13c6a6..58244b6ef47a0 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js @@ -7,7 +7,7 @@ * @flow */ -import type {FloatRoot, RootResources} from './ReactDOMFloatClient'; +import type {HoistableRoot, RootResources} from './ReactDOMFloatClient'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type {ReactScopeInstance} from 'shared/ReactTypes'; import type { @@ -24,7 +24,7 @@ import type { import { HostComponent, - HostResource, + HostHoistable, HostSingleton, HostText, HostRoot, @@ -178,7 +178,7 @@ export function getInstanceFromNode(node: Node): Fiber | null { tag === HostComponent || tag === HostText || tag === SuspenseComponent || - (enableFloat ? tag === HostResource : false) || + (enableFloat ? tag === HostHoistable : false) || (enableHostSingletons ? tag === HostSingleton : false) || tag === HostRoot ) { @@ -198,7 +198,7 @@ export function getNodeFromInstance(inst: Fiber): Instance | TextInstance { const tag = inst.tag; if ( tag === HostComponent || - (enableFloat ? tag === HostResource : false) || + (enableFloat ? tag === HostHoistable : false) || (enableHostSingletons ? tag === HostSingleton : false) || tag === HostText ) { @@ -277,14 +277,12 @@ export function doesTargetHaveEventHandle( return eventHandles.has(eventHandle); } -export function getResourcesFromRoot(root: FloatRoot): RootResources { +export function getResourcesFromRoot(root: HoistableRoot): RootResources { let resources = (root: any)[internalRootNodeResourcesKey]; if (!resources) { resources = (root: any)[internalRootNodeResourcesKey] = { - styles: new Map(), - scripts: new Map(), - head: new Map(), - lastStructuredMeta: new Map(), + hoistableStyles: new Map(), + hoistableScripts: new Map(), }; } return resources; diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 3ea7dfe16d115..33d2800ec37e4 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -9,23 +9,24 @@ import type {Instance, Container} from './ReactDOMHostConfig'; +import {isAttributeNameSafe} from '../shared/DOMProperty'; +import {precacheFiberNode} from './ReactDOMComponentTree'; + import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js'; const {Dispatcher} = ReactDOMSharedInternals; import {DOCUMENT_NODE} from '../shared/HTMLNodeType'; import { - warnOnMissingHrefAndRel, - validatePreloadResourceDifference, - validateURLKeyedUpdatedProps, - validateStyleResourceDifference, - validateScriptResourceDifference, - validateLinkPropsForStyleResource, - validateLinkPropsForPreloadResource, validatePreloadArguments, validatePreinitArguments, } from '../shared/ReactDOMResourceValidation'; import {createElement, setInitialProperties} from './ReactDOMComponent'; +import { + checkAttributeStringCoercion, + checkPropStringCoercion, +} from 'shared/CheckStringCoercion'; import { getResourcesFromRoot, + isMarkedResource, markNodeAsResource, } from './ReactDOMComponentTree'; import {HTML_NAMESPACE, SVG_NAMESPACE} from '../shared/DOMNamespaces'; @@ -35,128 +36,48 @@ import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostCo // In the future this may need to change, especially when modules / scripts are supported type ResourceType = 'style' | 'font' | 'script'; -type PreloadProps = { - rel: 'preload', - href: string, - [string]: mixed, +type HoistableTagType = 'link' | 'meta' | 'title' | 'script' | 'style'; +type TResource = { + type: T, + instance: null | Instance, + count: number, }; -type PreloadResource = { - type: 'preload', - href: string, - ownerDocument: Document, - props: PreloadProps, - instance: Element, +type StylesheetResource = TResource<'stylesheet'>; +type StyleTagResource = TResource<'style'>; +type StyleResource = StyleTagResource | StylesheetResource; +type ScriptResource = TResource<'script'>; +type VoidResource = TResource<'void'>; +type Resource = StyleResource | ScriptResource | VoidResource; + +type StyleTagProps = { + 'data-href': string, + 'data-precedence': string, + [string]: mixed, }; - -type StyleProps = { +type StylesheetProps = { rel: 'stylesheet', href: string, 'data-precedence': string, [string]: mixed, }; -type StyleResource = { - type: 'style', - - // Ref count for resource - count: number, - // Resource Descriptors - href: string, - precedence: string, - props: StyleProps, - - // Related Resources - hint: ?PreloadResource, - - // Insertion - preloaded: boolean, - loaded: boolean, - error: mixed, - instance: ?Element, - root: FloatRoot, -}; type ScriptProps = { src: string, + async: true, [string]: mixed, }; -type ScriptResource = { - type: 'script', - src: string, - props: ScriptProps, - - instance: ?Element, - root: FloatRoot, -}; - -type TitleProps = { - [string]: mixed, -}; -type TitleResource = { - type: 'title', - props: TitleProps, - - count: number, - instance: ?Element, - root: Document, -}; - -type MetaProps = { - [string]: mixed, -}; -type MetaResource = { - type: 'meta', - matcher: string, - property: ?string, - parentResource: ?MetaResource, - props: MetaProps, - - count: number, - instance: ?Element, - root: Document, -}; -type LinkProps = { +type PreloadProps = { + rel: 'preload', href: string, - rel: string, [string]: mixed, }; -type LinkResource = { - type: 'link', - props: LinkProps, - - count: number, - instance: ?Element, - root: Document, -}; - -type BaseResource = { - type: 'base', - matcher: string, - props: Props, - - count: number, - instance: ?Element, - root: Document, -}; - -type Props = {[string]: mixed}; - -type HeadResource = TitleResource | MetaResource | LinkResource | BaseResource; -type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource; export type RootResources = { - styles: Map, - scripts: Map, - head: Map, - lastStructuredMeta: Map, + hoistableStyles: Map, + hoistableScripts: Map, }; -// Brief on purpose due to insertion by script when streaming late boundaries -// s = Status -// l = loaded -// e = errored -type StyleResourceLoadingState = Promise & {s?: 'l' | 'e'}; - // It is valid to preload even when we aren't actively rendering. For cases where Float functions are // called when there is no rendering we track the last used document. It is not safe to insert // arbitrary resources into the lastCurrentDocument b/c it may not actually be the document @@ -184,13 +105,13 @@ export function cleanupAfterRenderResources() { // from Internals -> ReactDOM -> FloatClient -> Internals so this doesn't introduce a new one. export const ReactDOMClientDispatcher = {preload, preinit}; -export type FloatRoot = Document | ShadowRoot; +export type HoistableRoot = Document | ShadowRoot; // global maps of Resources -const preloadResources: Map = new Map(); +const preloadPropsMap: Map = new Map(); // getRootNode is missing from IE and old jsdom versions -function getRootNode(container: Container): FloatRoot { +function getRootNode(container: Container): HoistableRoot { // $FlowFixMe[method-unbinding] return typeof container.getRootNode === 'function' ? /* $FlowFixMe[incompatible-return] Flow types this as returning a `Node`, @@ -199,33 +120,11 @@ function getRootNode(container: Container): FloatRoot { : container.ownerDocument; } -function getCurrentResourceRoot(): null | FloatRoot { +function getCurrentResourceRoot(): null | HoistableRoot { const currentContainer = getCurrentRootHostContainer(); return currentContainer ? getRootNode(currentContainer) : null; } -// This resource type constraint can be loosened. It really is everything except PreloadResource -// because that is the only one that does not have an optional instance type. Expand as needed. -function resetInstance(resource: ScriptResource | HeadResource) { - resource.instance = undefined; -} - -export function clearRootResources(rootContainer: Container): void { - const rootNode = getRootNode(rootContainer); - const resources = getResourcesFromRoot(rootNode); - - // We can't actually delete the resource cache because this function is called - // during commit after we have rendered. Instead we detatch any instances from - // the Resource object if they are going to be cleared - - // Styles stay put - // Scripts get reset - resources.scripts.forEach(resetInstance); - // Head Resources get reset - resources.head.forEach(resetInstance); - // lastStructuredMeta stays put -} - // Preloads are somewhat special. Even if we don't have the Document // used by the root that is rendering a component trying to insert a preload // we can still seed the file cache by doing the preload on any document we have @@ -245,10 +144,15 @@ function getDocumentForPreloads(): ?Document { } } -function getDocumentFromRoot(root: FloatRoot): Document { +function getDocumentFromRoot(root: HoistableRoot): Document { return root.ownerDocument || root; } +export function getHoistableRoot(container: Container): HoistableRoot { + // Flow thinks getRootNode returns Node but we know it is actualy either a Document or ShadowRoot + return ((container.getRootNode(): any): Document | ShadowRoot); +} + // -------------------------------------- // ReactDOM.Preload // -------------------------------------- @@ -267,22 +171,33 @@ function preload(href: string, options: PreloadOptions) { ownerDocument ) { const as = options.as; - const resource = preloadResources.get(href); - if (resource) { - if (__DEV__) { - const originallyImplicit = - (resource: any)._dev_implicit_construction === true; - const latestProps = preloadPropsFromPreloadOptions(href, as, options); - validatePreloadResourceDifference( - resource.props, - originallyImplicit, - latestProps, - false, + const limitedEscapedHref = + escapeSelectorAttributeValueInsideDoubleQuotes(href); + const preloadKey = `link[rel="preload"][as="${as}"][href="${limitedEscapedHref}"]`; + let key = preloadKey; + switch (as) { + case 'style': + key = getStyleKey(href); + break; + case 'script': + key = getScriptKey(href); + break; + } + if (!preloadPropsMap.has(key)) { + const preloadProps = preloadPropsFromPreloadOptions(href, as, options); + preloadPropsMap.set(key, preloadProps); + + if (null === ownerDocument.querySelector(preloadKey)) { + const preloadInstance = createElement( + 'link', + preloadProps, + ownerDocument, + HTML_NAMESPACE, ); + setInitialProperties(preloadInstance, 'link', preloadProps); + markNodeAsResource(preloadInstance); + (ownerDocument.head: any).appendChild(preloadInstance); } - } else { - const resourceProps = preloadPropsFromPreloadOptions(href, as, options); - createPreloadResource(ownerDocument, href, resourceProps); } } } @@ -326,20 +241,45 @@ function preinit(href: string, options: PreinitOptions) { const resourceRoot = getCurrentResourceRoot(); const as = options.as; if (!resourceRoot) { - // We are going to emit a preload as a best effort fallback since this preinit - // was called outside of a render. Given the passive nature of this fallback - // we do not warn in dev when props disagree if there happens to already be a - // matching preload with this href - const preloadDocument = getDocumentForPreloads(); - if (preloadDocument) { - const preloadResource = preloadResources.get(href); - if (!preloadResource) { - const preloadProps = preloadPropsFromPreinitOptions( - href, - as, - options, - ); - createPreloadResource(preloadDocument, href, preloadProps); + if (as === 'style' || as === 'script') { + // We are going to emit a preload as a best effort fallback since this preinit + // was called outside of a render. Given the passive nature of this fallback + // we do not warn in dev when props disagree if there happens to already be a + // matching preload with this href + const preloadDocument = getDocumentForPreloads(); + if (preloadDocument) { + const limitedEscapedHref = + escapeSelectorAttributeValueInsideDoubleQuotes(href); + const preloadKey = `link[rel="preload"][as="${as}"][href="${limitedEscapedHref}"]`; + let key = preloadKey; + switch (as) { + case 'style': + key = getStyleKey(href); + break; + case 'script': + key = getScriptKey(href); + break; + } + if (!preloadPropsMap.has(key)) { + const preloadProps = preloadPropsFromPreinitOptions( + href, + as, + options, + ); + preloadPropsMap.set(key, preloadProps); + + if (null === preloadDocument.querySelector(preloadKey)) { + const preloadInstance = createElement( + 'link', + preloadProps, + preloadDocument, + HTML_NAMESPACE, + ); + setInitialProperties(preloadInstance, 'link', preloadProps); + markNodeAsResource(preloadInstance); + (preloadDocument.head: any).appendChild(preloadInstance); + } + } } } return; @@ -347,54 +287,98 @@ function preinit(href: string, options: PreinitOptions) { switch (as) { case 'style': { - const styleResources = getResourcesFromRoot(resourceRoot).styles; + const styles = getResourcesFromRoot(resourceRoot).hoistableStyles; + + const key = getStyleKey(href); const precedence = options.precedence || 'default'; - let resource = styleResources.get(href); + + // Check if this resource already exists + let resource = styles.get(key); if (resource) { - if (__DEV__) { - const latestProps = stylePropsFromPreinitOptions( - href, - precedence, - options, - ); - validateStyleResourceDifference(resource.props, latestProps); - } - } else { - const resourceProps = stylePropsFromPreinitOptions( + // We can early return. The resource exists and there is nothing + // more to do + return; + } + + // Attempt to hydrate instance from DOM + let instance: null | Instance = resourceRoot.querySelector( + getStylesheetSelectorFromKey(key), + ); + if (!instance) { + // Construct a new instance and insert it + const stylesheetProps = stylesheetPropsFromPreinitOptions( href, precedence, options, ); - resource = createStyleResource( - styleResources, + const preloadProps = preloadPropsMap.get(key); + if (preloadProps) { + adoptPreloadPropsForStylesheet(stylesheetProps, preloadProps); + } + instance = createElement( + 'link', + stylesheetProps, resourceRoot, - href, - precedence, - resourceProps, + HTML_NAMESPACE, ); + markNodeAsResource(instance); + setInitialProperties(instance, 'link', stylesheetProps); + insertStylesheet(instance, precedence, resourceRoot); } - acquireResource(resource); + + // Construct a Resource and cache it + resource = { + type: 'stylesheet', + instance, + count: 1, + }; + styles.set(key, resource); return; } case 'script': { const src = href; - const scriptResources = getResourcesFromRoot(resourceRoot).scripts; - let resource = scriptResources.get(src); + const scripts = getResourcesFromRoot(resourceRoot).hoistableScripts; + + const key = getScriptKey(src); + + // Check if this resource already exists + let resource = scripts.get(key); if (resource) { - if (__DEV__) { - const latestProps = scriptPropsFromPreinitOptions(src, options); - validateScriptResourceDifference(resource.props, latestProps); + // We can early return. The resource exists and there is nothing + // more to do + return; + } + + // Attempt to hydrate instance from DOM + let instance: null | Instance = resourceRoot.querySelector( + getScriptSelectorFromKey(key), + ); + if (!instance) { + // Construct a new instance and insert it + const scriptProps = scriptPropsFromPreinitOptions(src, options); + // Adopt certain preload props + const preloadProps = preloadPropsMap.get(key); + if (preloadProps) { + adoptPreloadPropsForScript(scriptProps, preloadProps); } - } else { - const resourceProps = scriptPropsFromPreinitOptions(src, options); - resource = createScriptResource( - scriptResources, + instance = createElement( + 'script', + scriptProps, resourceRoot, - src, - resourceProps, + HTML_NAMESPACE, ); + markNodeAsResource(instance); + setInitialProperties(instance, 'link', scriptProps); + (getDocumentFromRoot(resourceRoot).head: any).appendChild(instance); } - acquireResource(resource); + + // Construct a Resource and cache it + resource = { + type: 'script', + instance, + count: 1, + }; + scripts.set(key, resource); return; } } @@ -415,11 +399,11 @@ function preloadPropsFromPreinitOptions( }; } -function stylePropsFromPreinitOptions( +function stylesheetPropsFromPreinitOptions( href: string, precedence: string, options: PreinitOptions, -): StyleProps { +): StylesheetProps { return { rel: 'stylesheet', href, @@ -444,32 +428,24 @@ function scriptPropsFromPreinitOptions( // Resources from render // -------------------------------------- -type StyleQualifyingProps = { - rel: 'stylesheet', +type StyleTagQualifyingProps = { href: string, precedence: string, [string]: mixed, }; -type PreloadQualifyingProps = { - rel: 'preload', + +type StylesheetQualifyingProps = { + rel: 'stylesheet', href: string, - [string]: mixed, -}; -type ScriptQualifyingProps = { - src: string, - async: true, + precedence: string, [string]: mixed, }; -function getTitleKey(child: string | number): string { - return 'title:' + child; -} - // This function is called in begin work and we should always have a currentDocument set export function getResource( type: string, - pendingProps: Props, - currentProps: null | Props, + currentProps: any, + pendingProps: any, ): null | Resource { const resourceRoot = getCurrentResourceRoot(); if (!resourceRoot) { @@ -478,515 +454,169 @@ export function getResource( ); } switch (type) { - case 'base': { - const headRoot: Document = getDocumentFromRoot(resourceRoot); - const headResources = getResourcesFromRoot(headRoot).head; - const {target, href} = pendingProps; - let matcher = 'base'; - matcher += - typeof href === 'string' - ? `[href="${escapeSelectorAttributeValueInsideDoubleQuotes(href)}"]` - : ':not([href])'; - matcher += - typeof target === 'string' - ? `[target="${escapeSelectorAttributeValueInsideDoubleQuotes( - target, - )}"]` - : ':not([target])'; - let resource = headResources.get(matcher); - if (!resource) { - resource = { - type: 'base', - matcher, - props: Object.assign({}, pendingProps), - count: 0, - instance: null, - root: headRoot, - }; - headResources.set(matcher, resource); - } - return resource; + case 'meta': + case 'title': { + return null; } - case 'meta': { - let matcher, propertyString, parentResource; - const {charSet, content, httpEquiv, name, itemProp, property} = - pendingProps; - const headRoot: Document = getDocumentFromRoot(resourceRoot); - const {head: headResources, lastStructuredMeta} = - getResourcesFromRoot(headRoot); - if (typeof charSet === 'string') { - matcher = 'meta[charset]'; - } else if (typeof content === 'string') { - if (typeof httpEquiv === 'string') { - matcher = `meta[http-equiv="${escapeSelectorAttributeValueInsideDoubleQuotes( - httpEquiv, - )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes( - content, - )}"]`; - } else if (typeof property === 'string') { - propertyString = property; - matcher = `meta[property="${escapeSelectorAttributeValueInsideDoubleQuotes( - property, - )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes( - content, - )}"]`; - - const parentPropertyPath = property.split(':').slice(0, -1).join(':'); - parentResource = lastStructuredMeta.get(parentPropertyPath); - if (parentResource) { - // When using parentResource the matcher is not functional for locating - // the instance in the DOM but it still serves as a unique key. - matcher = parentResource.matcher + matcher; - } - } else if (typeof name === 'string') { - matcher = `meta[name="${escapeSelectorAttributeValueInsideDoubleQuotes( - name, - )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes( - content, - )}"]`; - } else if (typeof itemProp === 'string') { - matcher = `meta[itemprop="${escapeSelectorAttributeValueInsideDoubleQuotes( - itemProp, - )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes( - content, - )}"]`; - } - } - if (matcher) { - let resource = headResources.get(matcher); + case 'style': { + if ( + typeof pendingProps.precedence === 'string' && + typeof pendingProps.href === 'string' + ) { + const key = getStyleKey(pendingProps.href); + const styles = getResourcesFromRoot(resourceRoot).hoistableStyles; + let resource = styles.get(key); if (!resource) { resource = { - type: 'meta', - matcher, - property: propertyString, - parentResource, - props: Object.assign({}, pendingProps), - count: 0, + type: 'style', instance: null, - root: headRoot, + count: 0, }; - headResources.set(matcher, resource); - } - if (typeof resource.property === 'string') { - // We cast because flow doesn't know that this resource must be a Meta resource - lastStructuredMeta.set(resource.property, (resource: any)); + styles.set(key, resource); } return resource; } - return null; + return { + type: 'void', + instance: null, + count: 0, + }; } - case 'title': { - const children = pendingProps.children; - let child; - if (Array.isArray(children)) { - child = children.length === 1 ? children[0] : null; - } else { - child = children; - } + case 'link': { if ( - typeof child !== 'function' && - typeof child !== 'symbol' && - child !== null && - child !== undefined + pendingProps.rel === 'stylesheet' && + typeof pendingProps.href === 'string' && + typeof pendingProps.precedence === 'string' ) { - // eslint-disable-next-line react-internal/safe-string-coercion - const childString = '' + (child: any); - const headRoot: Document = getDocumentFromRoot(resourceRoot); - const headResources = getResourcesFromRoot(headRoot).head; - const key = getTitleKey(childString); - let resource = headResources.get(key); + const qualifiedProps: StylesheetQualifyingProps = pendingProps; + const key = getStyleKey(qualifiedProps.href); + + const styles = getResourcesFromRoot(resourceRoot).hoistableStyles; + + let resource = styles.get(key); if (!resource) { - const titleProps = titlePropsFromRawProps(childString, pendingProps); + // We asserted this above but Flow can't figure out that the type satisfies + const ownerDocument = getDocumentFromRoot(resourceRoot); resource = { - type: 'title', - props: titleProps, - count: 0, + type: 'stylesheet', instance: null, - root: headRoot, + count: 0, }; - headResources.set(key, resource); + styles.set(key, resource); + if (!preloadPropsMap.has(key)) { + preloadStylesheet( + ownerDocument, + key, + preloadPropsFromStylesheet(qualifiedProps), + ); + } } return resource; } return null; } - case 'link': { - const {rel} = pendingProps; - switch (rel) { - case 'stylesheet': { - const styleResources = getResourcesFromRoot(resourceRoot).styles; - let didWarn; - if (__DEV__) { - if (currentProps) { - didWarn = validateURLKeyedUpdatedProps( - pendingProps, - currentProps, - 'style', - 'href', - ); - } - if (!didWarn) { - didWarn = validateLinkPropsForStyleResource(pendingProps); - } - } - const {precedence, href} = pendingProps; - if (typeof href === 'string' && typeof precedence === 'string') { - // We've asserted all the specific types for StyleQualifyingProps - const styleRawProps: StyleQualifyingProps = (pendingProps: any); - - // We construct or get an existing resource for the style itself and return it - let resource = styleResources.get(href); - if (resource) { - if (__DEV__) { - if (!didWarn) { - const latestProps = stylePropsFromRawProps(styleRawProps); - if ((resource: any)._dev_preload_props) { - adoptPreloadPropsForStyle( - latestProps, - (resource: any)._dev_preload_props, - ); - } - validateStyleResourceDifference(resource.props, latestProps); - } - } - } else { - const resourceProps = stylePropsFromRawProps(styleRawProps); - resource = createStyleResource( - styleResources, - resourceRoot, - href, - precedence, - resourceProps, - ); - immediatelyPreloadStyleResource(resource); - } - return resource; - } - return null; - } - case 'preload': { - if (__DEV__) { - validateLinkPropsForPreloadResource(pendingProps); - } - 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); - if (resource) { - if (__DEV__) { - const originallyImplicit = - (resource: any)._dev_implicit_construction === true; - const latestProps = preloadPropsFromRawProps(preloadRawProps); - validatePreloadResourceDifference( - resource.props, - originallyImplicit, - latestProps, - false, - ); - } - } else { - const resourceProps = preloadPropsFromRawProps(preloadRawProps); - resource = createPreloadResource( - getDocumentFromRoot(resourceRoot), - href, - resourceProps, - ); - } - return resource; - } - return null; - } - default: { - const {href, sizes, media} = pendingProps; - if (typeof rel === 'string' && typeof href === 'string') { - const sizeKey = - '::sizes:' + (typeof sizes === 'string' ? sizes : ''); - const mediaKey = - '::media:' + (typeof media === 'string' ? media : ''); - const key = 'rel:' + rel + '::href:' + href + sizeKey + mediaKey; - const headRoot = getDocumentFromRoot(resourceRoot); - const headResources = getResourcesFromRoot(headRoot).head; - let resource = headResources.get(key); - if (!resource) { - resource = { - type: 'link', - props: Object.assign({}, pendingProps), - count: 0, - instance: null, - root: headRoot, - }; - headResources.set(key, resource); - } - return resource; - } - if (__DEV__) { - warnOnMissingHrefAndRel(pendingProps, currentProps); - } - return null; - } - } - } 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, - ); + if (typeof pendingProps.src === 'string' && pendingProps.async === true) { + const scriptProps: ScriptProps = pendingProps; + const key = getScriptKey(scriptProps.src); + const scripts = getResourcesFromRoot(resourceRoot).hoistableScripts; + + let resource = scripts.get(key); + if (!resource) { + resource = { + type: 'script', + instance: null, + count: 0, + }; + scripts.set(key, resource); } return resource; } - return null; + return { + type: 'void', + instance: null, + count: 0, + }; } default: { throw new Error( - `getResource encountered a resource type it did not expect: "${type}". this is a bug in React.`, + `getResource encountered a type it did not expect: "${type}". this is a bug in React.`, ); } } } -function preloadPropsFromRawProps( - rawBorrowedProps: PreloadQualifyingProps, -): PreloadProps { - // $FlowFixMe[prop-missing] - recommended fix is to use object spread operator - return Object.assign({}, rawBorrowedProps); -} - -function titlePropsFromRawProps( - child: string | number, - rawProps: Props, -): TitleProps { - const props: TitleProps = Object.assign({}, rawProps); - props.children = child; - return props; +function styleTagPropsFromRawProps( + rawProps: StyleTagQualifyingProps, +): StyleTagProps { + return { + ...rawProps, + 'data-href': rawProps.href, + 'data-precedence': rawProps.precedence, + href: null, + precedence: null, + }; } -function stylePropsFromRawProps(rawProps: StyleQualifyingProps): StyleProps { - // $FlowFixMe[prop-missing] - recommended fix is to use object spread operator - const props: StyleProps = Object.assign({}, rawProps); - props['data-precedence'] = rawProps.precedence; - props.precedence = null; - - return props; +function getStyleKey(href: string) { + const limitedEscapedHref = + escapeSelectorAttributeValueInsideDoubleQuotes(href); + return `href="${limitedEscapedHref}"`; } -function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps { - // $FlowFixMe[prop-missing] - recommended fix is to use object spread operator - const props: ScriptProps = Object.assign({}, rawProps); - return props; +function getStyleTagSelectorFromKey(key: string) { + return `style[data-${key}]`; } -// -------------------------------------- -// Resource Reconciliation -// -------------------------------------- - -export function acquireResource(resource: Resource): Instance { - switch (resource.type) { - case 'base': - case 'title': - case 'link': - case 'meta': { - return acquireHeadResource(resource); - } - case 'style': { - return acquireStyleResource(resource); - } - case 'script': { - return acquireScriptResource(resource); - } - case 'preload': { - return resource.instance; - } - default: { - throw new Error( - `acquireResource encountered a resource type it did not expect: "${resource.type}". this is a bug in React.`, - ); - } - } +function getStylesheetSelectorFromKey(key: string) { + return `link[rel="stylesheet"][${key}]`; } -export function releaseResource(resource: Resource): void { - switch (resource.type) { - case 'link': - case 'title': - case 'meta': { - return releaseHeadResource(resource); - } - case 'style': { - resource.count--; - return; - } - } +function getPreloadStylesheetSelectorFromKey(key: string) { + return `link[rel="preload"][as="style"][${key}]`; } -function releaseHeadResource(resource: HeadResource): void { - if (--resource.count === 0) { - // the instance will have existed since we acquired it - const instance: Instance = (resource.instance: any); - const parent = instance.parentNode; - if (parent) { - parent.removeChild(instance); - } - resource.instance = null; - } +function stylesheetPropsFromRawProps( + rawProps: StylesheetQualifyingProps, +): StylesheetProps { + return { + ...rawProps, + 'data-precedence': rawProps.precedence, + precedence: null, + }; } -function createResourceInstance( - type: string, - props: Object, +function preloadStylesheet( ownerDocument: Document, -): Instance { - const element = createElement(type, props, ownerDocument, HTML_NAMESPACE); - setInitialProperties(element, type, props); - markNodeAsResource(element); - return element; -} - -function createStyleResource( - styleResources: Map, - root: FloatRoot, - href: string, - precedence: string, - props: StyleProps, -): StyleResource { - if (__DEV__) { - if (styleResources.has(href)) { - console.error( - 'createStyleResource was called when a style Resource matching the same href already exists. This is a bug in React.', + key: string, + preloadProps: PreloadProps, +) { + preloadPropsMap.set(key, preloadProps); + + if (!ownerDocument.querySelector(getStylesheetSelectorFromKey(key))) { + // There is no matching stylesheet instance in the Document. + // We will insert a preload now to kick off loading because + // we expect this stylesheet to commit + if ( + null === + ownerDocument.querySelector(getPreloadStylesheetSelectorFromKey(key)) + ) { + const preloadInstance = createElement( + 'link', + preloadProps, + ownerDocument, + HTML_NAMESPACE, ); + setInitialProperties(preloadInstance, 'link', preloadProps); + markNodeAsResource(preloadInstance); + (ownerDocument.head: any).appendChild(preloadInstance); } } - - const limitedEscapedHref = - escapeSelectorAttributeValueInsideDoubleQuotes(href); - const existingEl = root.querySelector( - `link[rel="stylesheet"][href="${limitedEscapedHref}"]`, - ); - const resource = { - type: 'style', - count: 0, - href, - precedence, - props, - hint: null, - preloaded: false, - loaded: false, - error: false, - root, - instance: null, - }; - styleResources.set(href, resource); - - if (existingEl) { - // If we have an existing element in the DOM we don't need to preload this resource nor can we - // adopt props from any preload that might exist already for this resource. We do need to try - // to reify the Resource loading state the best we can. - const loadingState: ?StyleResourceLoadingState = (existingEl: any)._p; - if (loadingState) { - switch (loadingState.s) { - case 'l': { - resource.loaded = true; - break; - } - case 'e': { - resource.error = true; - break; - } - default: { - attachLoadListeners(existingEl, resource); - } - } - } else { - // This is unfortunately just an assumption. The rationale here is that stylesheets without - // a loading state must have been flushed in the shell and would have blocked until loading - // or error. we can't know afterwards which happened for all types of stylesheets (cross origin) - // for instance) and the techniques for determining if a sheet has loaded that we do have still - // fail if the sheet loaded zero rules. At the moment we are going to just opt to assume the - // sheet is loaded if it was flushed in the shell - resource.loaded = true; - } - } else { - const hint = preloadResources.get(href); - if (hint) { - // $FlowFixMe[incompatible-type]: found when upgrading Flow - resource.hint = 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; - adoptPreloadPropsForStyle(resource.props, hint.props); - if (__DEV__) { - (resource: any)._dev_preload_props = preloadProps; - } - } - } - - return resource; } -function adoptPreloadPropsForStyle( - styleProps: StyleProps, - preloadProps: PreloadProps, -): void { - if (styleProps.crossOrigin == null) - styleProps.crossOrigin = preloadProps.crossOrigin; - if (styleProps.referrerPolicy == null) - styleProps.referrerPolicy = preloadProps.referrerPolicy; - if (styleProps.title == null) styleProps.title = preloadProps.title; -} - -function immediatelyPreloadStyleResource(resource: StyleResource) { - // This function must be called synchronously after creating a styleResource otherwise it may - // violate assumptions around the existence of a preload. The reason it is extracted out is we - // don't always want to preload a style, in particular when we are going to synchronously insert - // that style. We confirm the style resource has no preload already and then construct it. If - // we wait and call this later it is possible a preload will already exist for this href - if (resource.loaded === false && resource.hint === null) { - const {href, props} = resource; - const preloadProps = preloadPropsFromStyleProps(props); - resource.hint = createPreloadResource( - getDocumentFromRoot(resource.root), - href, - preloadProps, - ); - } -} - -function preloadPropsFromStyleProps(props: StyleProps): PreloadProps { +function preloadPropsFromStylesheet( + props: StylesheetQualifyingProps, +): PreloadProps { return { rel: 'preload', as: 'style', @@ -999,378 +629,160 @@ 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.', - ); - } - } - +function getScriptKey(src: string): string { 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; - } - } - } else { - markNodeAsResource(existingEl); - } - - return resource; + return `[src="${limitedEscapedSrc}"]`; } -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 getScriptSelectorFromKey(key: string): string { + return 'script[async]' + key; } -function createPreloadResource( - ownerDocument: Document, - href: string, - props: PreloadProps, -): PreloadResource { - const limitedEscapedHref = - escapeSelectorAttributeValueInsideDoubleQuotes(href); - let element: null | Instance | HTMLElement = ownerDocument.querySelector( - `link[rel="preload"][href="${limitedEscapedHref}"]`, - ); - if (!element) { - element = createResourceInstance('link', props, ownerDocument); - insertResourceInstanceBefore(ownerDocument, element, null); - } else { - markNodeAsResource(element); - } - return { - type: 'preload', - href: href, - ownerDocument, - props, - instance: element, - }; -} +// -------------------------------------- +// Hoistable Resource Reconciliation +// -------------------------------------- -function acquireHeadResource(resource: HeadResource): Instance { +export function acquireResource( + hoistableRoot: HoistableRoot, + resource: Resource, + props: any, +): null | Instance { resource.count++; - let instance = resource.instance; - if (!instance) { - const {props, root, type} = resource; - switch (type) { - case 'title': { - const titles = root.querySelectorAll('title'); - for (let i = 0; i < titles.length; i++) { - if (titles[i].textContent === props.children) { - instance = resource.instance = titles[i]; - markNodeAsResource(instance); - return instance; - } - } - instance = resource.instance = createResourceInstance( - type, - props, - root, - ); - const firstTitle = titles[0]; - insertResourceInstanceBefore( - root, - instance, - firstTitle && firstTitle.namespaceURI !== SVG_NAMESPACE - ? firstTitle - : null, + if (resource.instance === null) { + switch (resource.type) { + case 'style': { + const qualifiedProps: StyleTagQualifyingProps = props; + const key = getStyleKey(qualifiedProps.href); + + // Attempt to hydrate instance from DOM + let instance: null | Instance = hoistableRoot.querySelector( + getStyleTagSelectorFromKey(key), ); - break; - } - case 'meta': { - let insertBefore = null; - - const metaResource: MetaResource = (resource: any); - const {matcher, property, parentResource} = metaResource; - - if (parentResource && typeof property === 'string') { - // This resoruce is a structured meta type with a parent. - // Instead of using the matcher we just traverse forward - // siblings of the parent instance until we find a match - // or exhaust. - const parent = parentResource.instance; - if (parent) { - let node = null; - let nextNode = (insertBefore = parent.nextSibling); - while ((node = nextNode)) { - nextNode = node.nextSibling; - if (node.nodeName === 'META') { - const meta: Element = (node: any); - const propertyAttr = meta.getAttribute('property'); - if (typeof propertyAttr !== 'string') { - continue; - } else if ( - propertyAttr === property && - meta.getAttribute('content') === props.content - ) { - resource.instance = meta; - markNodeAsResource(meta); - return meta; - } else if (property.startsWith(propertyAttr + ':')) { - // This meta starts a new instance of a parent structure for this meta type - // We need to halt our search here because even if we find a later match it - // is for a different parent element - break; - } - } - } - } - } else if ((instance = root.querySelector(matcher))) { + if (instance) { resource.instance = instance; - markNodeAsResource(instance); return instance; } - instance = resource.instance = createResourceInstance( - type, - props, - root, + + const styleProps = styleTagPropsFromRawProps(props); + instance = createElement( + 'style', + styleProps, + hoistableRoot, + HTML_NAMESPACE, ); - insertResourceInstanceBefore(root, instance, insertBefore); - break; + + markNodeAsResource(instance); + setInitialProperties(instance, 'style', styleProps); + insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot); + resource.instance = instance; + + return instance; } - case 'link': { - const linkProps: LinkProps = (props: any); - const limitedEscapedRel = - escapeSelectorAttributeValueInsideDoubleQuotes(linkProps.rel); - const limitedEscapedHref = - escapeSelectorAttributeValueInsideDoubleQuotes(linkProps.href); - let selector = `link[rel="${limitedEscapedRel}"][href="${limitedEscapedHref}"]`; - if (typeof linkProps.sizes === 'string') { - const limitedEscapedSizes = - escapeSelectorAttributeValueInsideDoubleQuotes(linkProps.sizes); - selector += `[sizes="${limitedEscapedSizes}"]`; - } - if (typeof linkProps.media === 'string') { - const limitedEscapedMedia = - escapeSelectorAttributeValueInsideDoubleQuotes(linkProps.media); - selector += `[media="${limitedEscapedMedia}"]`; - } - const existingEl = root.querySelector(selector); - if (existingEl) { - instance = resource.instance = existingEl; - markNodeAsResource(instance); + case 'stylesheet': { + // This typing is enforce by `getResource`. If we change the logic + // there for what qualifies as a stylesheet resource we need to ensure + // this cast still makes sense; + const qualifiedProps: StylesheetQualifyingProps = props; + const key = getStyleKey(qualifiedProps.href); + + // Attempt to hydrate instance from DOM + let instance: null | Instance = hoistableRoot.querySelector( + getStylesheetSelectorFromKey(key), + ); + if (instance) { + resource.instance = instance; return instance; } - instance = resource.instance = createResourceInstance( - type, - props, - root, + + const stylesheetProps = stylesheetPropsFromRawProps(props); + const preloadProps = preloadPropsMap.get(key); + if (preloadProps) { + adoptPreloadPropsForStylesheet(stylesheetProps, preloadProps); + } + + // Construct and insert a new instance + instance = createElement( + 'link', + stylesheetProps, + hoistableRoot, + HTML_NAMESPACE, + ); + markNodeAsResource(instance); + const linkInstance: HTMLLinkElement = (instance: any); + (linkInstance: any)._p = new Promise((resolve, reject) => { + linkInstance.onload = resolve; + linkInstance.onerror = reject; + }).then( + () => ((linkInstance: any)._p.s = 'l'), + () => ((linkInstance: any)._p.s = 'e'), ); - insertResourceInstanceBefore(root, instance, null); + setInitialProperties(instance, 'link', stylesheetProps); + insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot); + resource.instance = instance; + return instance; } - case 'base': { - const baseResource: BaseResource = (resource: any); - const {matcher} = baseResource; - const base = root.querySelector(matcher); - if (base) { - instance = resource.instance = base; - markNodeAsResource(instance); - } else { - instance = resource.instance = createResourceInstance( - type, - props, - root, - ); - insertResourceInstanceBefore( - root, - instance, - root.querySelector('base'), - ); + case 'script': { + // This typing is enforce by `getResource`. If we change the logic + // there for what qualifies as a stylesheet resource we need to ensure + // this cast still makes sense; + const borrowedScriptProps: ScriptProps = props; + const key = getScriptKey(borrowedScriptProps.src); + + // Attempt to hydrate instance from DOM + let instance: null | Instance = hoistableRoot.querySelector( + getScriptSelectorFromKey(key), + ); + if (instance) { + resource.instance = instance; + return instance; } + + let scriptProps = borrowedScriptProps; + const preloadProps = preloadPropsMap.get(key); + if (preloadProps) { + scriptProps = {...borrowedScriptProps}; + adoptPreloadPropsForScript(scriptProps, preloadProps); + } + + // Construct and insert a new instance + instance = createElement( + 'script', + scriptProps, + hoistableRoot, + HTML_NAMESPACE, + ); + markNodeAsResource(instance); + setInitialProperties(instance, 'link', scriptProps); + (getDocumentFromRoot(hoistableRoot).head: any).appendChild(instance); + resource.instance = instance; + return instance; } + case 'void': { + return null; + } default: { throw new Error( - `acquireHeadResource encountered a resource type it did not expect: "${type}". This is a bug in React.`, + `acquireResource encountered a resource type it did not expect: "${resource.type}". this is a bug in React.`, ); } } } - return instance; -} - -function acquireStyleResource(resource: StyleResource): Instance { - let instance = resource.instance; - if (!instance) { - const {props, root, precedence} = resource; - const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( - props.href, - ); - const existingEl = root.querySelector( - `link[rel="stylesheet"][data-precedence][href="${limitedEscapedHref}"]`, - ); - if (existingEl) { - instance = resource.instance = existingEl; - markNodeAsResource(instance); - resource.preloaded = true; - const loadingState: ?StyleResourceLoadingState = (existingEl: any)._p; - if (loadingState) { - // if an existingEl is found there should always be a loadingState because if - // the resource was flushed in the head it should have already been found when - // the resource was first created. Still defensively we gate this - switch (loadingState.s) { - case 'l': { - resource.loaded = true; - resource.error = false; - break; - } - case 'e': { - resource.error = true; - break; - } - default: { - attachLoadListeners(existingEl, resource); - } - } - } else { - resource.loaded = true; - } - } else { - instance = resource.instance = createResourceInstance( - 'link', - resource.props, - getDocumentFromRoot(root), - ); - - attachLoadListeners(instance, resource); - insertStyleInstance(instance, precedence, root); - } - } - resource.count++; - return instance; -} - -function acquireScriptResource(resource: ScriptResource): Instance { - let instance = resource.instance; - if (!instance) { - const {props, root} = resource; - const limitedEscapedSrc = escapeSelectorAttributeValueInsideDoubleQuotes( - props.src, - ); - const existingEl = root.querySelector( - `script[async][src="${limitedEscapedSrc}"]`, - ); - if (existingEl) { - instance = resource.instance = existingEl; - markNodeAsResource(instance); - } else { - instance = resource.instance = createResourceInstance( - 'script', - resource.props, - getDocumentFromRoot(root), - ); - - insertResourceInstanceBefore(getDocumentFromRoot(root), instance, null); - } - } - return instance; -} - -function attachLoadListeners(instance: Instance, resource: StyleResource) { - const listeners: { - [string]: () => mixed, - } = {}; - listeners.load = onResourceLoad.bind( - null, - instance, - resource, - listeners, - loadAndErrorEventListenerOptions, - ); - listeners.error = onResourceError.bind( - null, - instance, - resource, - listeners, - loadAndErrorEventListenerOptions, - ); - - instance.addEventListener( - 'load', - listeners.load, - loadAndErrorEventListenerOptions, - ); - instance.addEventListener( - 'error', - listeners.error, - loadAndErrorEventListenerOptions, - ); + return resource.instance; } -const loadAndErrorEventListenerOptions = { - passive: true, -}; - -function onResourceLoad( - instance: Instance, - resource: StyleResource, - listeners: {[string]: () => mixed}, - listenerOptions: typeof loadAndErrorEventListenerOptions, -) { - resource.loaded = true; - resource.error = false; - for (const event in listeners) { - instance.removeEventListener(event, listeners[event], listenerOptions); - } -} - -function onResourceError( - instance: Instance, - resource: StyleResource, - listeners: {[string]: () => mixed}, - listenerOptions: typeof loadAndErrorEventListenerOptions, -) { - resource.loaded = false; - resource.error = true; - for (const event in listeners) { - instance.removeEventListener(event, listeners[event], listenerOptions); - } +export function releaseResource(resource: Resource): void { + resource.count--; } -function insertStyleInstance( +function insertStylesheet( instance: Instance, precedence: string, - root: FloatRoot, + root: HoistableRoot, ): void { const nodes = root.querySelectorAll( - 'link[rel="stylesheet"][data-precedence]', + 'link[rel="stylesheet"][data-precedence],style[data-precedence]', ); const last = nodes.length ? nodes[nodes.length - 1] : null; let prior = last; @@ -1390,40 +802,220 @@ function insertStyleInstance( ((prior.parentNode: any): Node).insertBefore(instance, prior.nextSibling); } else { const parent = - root.nodeType === DOCUMENT_NODE ? ((root: any): Document).head : root; - if (parent) { - parent.insertBefore(instance, parent.firstChild); - } else { - throw new Error( - 'While attempting to insert a Resource, React expected the Document to contain' + - ' a head element but it was not found.', - ); - } + root.nodeType === DOCUMENT_NODE + ? ((((root: any): Document).head: any): Element) + : ((root: any): ShadowRoot); + parent.insertBefore(instance, parent.firstChild); } } -function insertResourceInstanceBefore( - ownerDocument: Document, - instance: Instance, - before: ?Node, +function adoptPreloadPropsForStylesheet( + stylesheetProps: StylesheetProps, + preloadProps: PreloadProps, ): void { - if (__DEV__) { - if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') { - console.error( - 'insertResourceInstanceBefore was called with a stylesheet. Stylesheets must be' + - ' inserted with insertStyleInstance instead. This is a bug in React.', - ); - } + if (stylesheetProps.crossOrigin == null) + stylesheetProps.crossOrigin = preloadProps.crossOrigin; + if (stylesheetProps.referrerPolicy == null) + stylesheetProps.referrerPolicy = preloadProps.referrerPolicy; + if (stylesheetProps.title == null) stylesheetProps.title = preloadProps.title; +} + +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; +} + +// -------------------------------------- +// Hoistable Element Reconciliation +// -------------------------------------- + +export function hydrateHoistable( + hoistableRoot: HoistableRoot, + type: HoistableTagType, + props: any, + internalInstanceHandle: Object, +): Instance { + const ownerDocument = getDocumentFromRoot(hoistableRoot); + const nodes = ownerDocument.getElementsByTagName(type); + + const children = props.children; + let child, childString; + if (Array.isArray(children)) { + child = children.length === 1 ? children[0] : null; + } else { + child = children; } - const parent = (before && before.parentNode) || ownerDocument.head; - if (parent) { - parent.insertBefore(instance, before); + if ( + typeof child !== 'function' && + typeof child !== 'symbol' && + child !== null && + child !== undefined + ) { + if (__DEV__) { + checkPropStringCoercion(child, 'children'); + } + childString = '' + (child: any); } else { - throw new Error( - 'While attempting to insert a Resource, React expected the Document to contain' + - ' a head element but it was not found.', - ); + childString = ''; + } + nodeLoop: for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ( + isMarkedResource(node) || + node.namespaceURI === SVG_NAMESPACE || + node.textContent !== childString + ) { + continue; + } + let checkedAttributes = 0; + for (const propName in props) { + const propValue = props[propName]; + if (!props.hasOwnProperty(propName)) { + continue; + } + switch (propName) { + // Reserved props will never have an attribute partner + case 'children': + case 'defaultValue': + case 'dangerouslySetInnerHTML': + case 'defaultChecked': + case 'innerHTML': + case 'suppressContentEditableWarning': + case 'suppressHydrationWarning': + case 'style': + // we advance to the next prop + continue; + + // Name remapped props used by hoistable tag types + case 'className': { + if (__DEV__) { + checkAttributeStringCoercion(propValue, propName); + } + if (node.getAttribute('class') !== '' + propValue) continue nodeLoop; + break; + } + case 'httpEquiv': { + if (__DEV__) { + checkAttributeStringCoercion(propValue, propName); + } + if (node.getAttribute('http-equiv') !== '' + propValue) + continue nodeLoop; + break; + } + + // Booleanish props used by hoistable tag types + case 'contentEditable': + case 'draggable': + case 'spellCheck': { + if (__DEV__) { + checkAttributeStringCoercion(propValue, propName); + } + if (node.getAttribute(propName) !== '' + propValue) continue nodeLoop; + break; + } + + // Boolean props used by hoistable tag types + case 'async': + case 'defer': + case 'disabled': + case 'hidden': + case 'noModule': + case 'scoped': + case 'itemScope': + if (propValue !== node.hasAttribute(propName)) continue nodeLoop; + break; + + // The following properties are left out because they do not apply to + // the current set of hoistable types. They may have special handling + // requirements if they end up applying to a hoistable type in the future + // case 'acceptCharset': + // case 'value': + // case 'allowFullScreen': + // case 'autoFocus': + // case 'autoPlay': + // case 'controls': + // case 'default': + // case 'disablePictureInPicture': + // case 'disableRemotePlayback': + // case 'formNoValidate': + // case 'loop': + // case 'noValidate': + // case 'open': + // case 'playsInline': + // case 'readOnly': + // case 'required': + // case 'reversed': + // case 'seamless': + // case 'multiple': + // case 'selected': + // case 'capture': + // case 'download': + // case 'cols': + // case 'rows': + // case 'size': + // case 'span': + // case 'rowSpan': + // case 'start': + + default: + if (isAttributeNameSafe(propName)) { + const attributeName = propName; + if (propValue == null && node.hasAttribute(attributeName)) + continue nodeLoop; + if (__DEV__) { + checkAttributeStringCoercion(propValue, attributeName); + } + if (node.getAttribute(attributeName) !== '' + (propValue: any)) + continue nodeLoop; + } + } + checkedAttributes++; + } + + if (node.attributes.length !== checkedAttributes) { + // We didn't match ever attribute so we abandon this node + continue nodeLoop; + } + + // We found a matching instance. We can return early after marking it + markNodeAsResource(node); + return node; } + + // There is no matching instance to hydrate, we create it now + const instance = createElement(type, props, ownerDocument, HTML_NAMESPACE); + setInitialProperties(instance, type, props); + precacheFiberNode(internalInstanceHandle, instance); + markNodeAsResource(instance); + + (ownerDocument.head: any).insertBefore( + instance, + type === 'title' ? ownerDocument.querySelector('head > title') : null, + ); + return instance; +} + +export function mountHoistable( + hoistableRoot: HoistableRoot, + type: HoistableTagType, + instance: Instance, +): void { + const ownerDocument = getDocumentFromRoot(hoistableRoot); + (ownerDocument.head: any).insertBefore( + instance, + type === 'title' ? ownerDocument.querySelector('head > title') : null, + ); +} + +export function unmountHoistable(instance: Instance): void { + (instance.parentNode: any).removeChild(instance); } // When passing user input into querySelector(All) the embedded string must not alter diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index 92e5efd880c54..b774d570f5dab 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -27,6 +27,7 @@ import { isContainerMarkedAsRoot, detachDeletedInstance, isMarkedResource, + markNodeAsResource, } from './ReactDOMComponentTree'; export {detachDeletedInstance}; import {hasRole} from './DOMAccessibilityRoles'; @@ -54,7 +55,11 @@ import { setEnabled as ReactBrowserEventEmitterSetEnabled, getEventPriority, } from '../events/ReactDOMEventListener'; -import {getChildNamespace, SVG_NAMESPACE} from '../shared/DOMNamespaces'; +import { + getChildNamespace, + SVG_NAMESPACE, + HTML_NAMESPACE, +} from '../shared/DOMNamespaces'; import { ELEMENT_NODE, TEXT_NODE, @@ -75,7 +80,7 @@ import { } from 'shared/ReactFeatureFlags'; import { HostComponent, - HostResource, + HostHoistable, HostText, HostSingleton, } from 'react-reconciler/src/ReactWorkTags'; @@ -89,7 +94,6 @@ import {ConcurrentMode, NoMode} from 'react-reconciler/src/ReactTypeOfMode'; import { prepareToRenderResources, cleanupAfterRenderResources, - clearRootResources, } from './ReactDOMFloatClient'; import {validateLinkPropsForStyleResource} from '../shared/ReactDOMResourceValidation'; @@ -133,6 +137,7 @@ export type Container = | interface extends DocumentFragment {_reactRootContainer?: FiberRoot}; export type Instance = Element; export type TextInstance = Text; +export type {HoistableRoot} from './ReactDOMFloatClient'; export interface SuspenseInstance extends Comment { _reactRetry?: () => void; } @@ -263,6 +268,25 @@ export function resetAfterCommit(containerInfo: Container): void { selectionInformation = null; } +export function createHoistableInstance( + type: string, + props: Props, + rootContainerInstance: Container, + internalInstanceHandle: Object, +): Instance { + const domElement: Instance = createElement( + type, + props, + rootContainerInstance, + HTML_NAMESPACE, + ); + precacheFiberNode(internalInstanceHandle, domElement); + updateFiberProps(domElement, props); + setInitialProperties(domElement, type, props); + markNodeAsResource(domElement); + return domElement; +} + export function createInstance( type: string, props: Props, @@ -716,17 +740,10 @@ export function clearContainer(container: Container): void { if (enableHostSingletons) { const nodeType = container.nodeType; if (nodeType === DOCUMENT_NODE) { - clearRootResources(container); clearContainerSparingly(container); } else if (nodeType === ELEMENT_NODE) { switch (container.nodeName) { - case 'HEAD': { - // If we are clearing document.head as a container we are essentially clearing everything - // that was hoisted to the head and should forget the instances that will no longer be in the DOM - clearRootResources(container); - // fall through to clear child contents - } - // eslint-disable-next-line-no-fallthrough + case 'HEAD': case 'HTML': case 'BODY': clearContainerSparingly(container); @@ -933,7 +950,6 @@ function getNextHydratable(node: ?Node) { // developer on how to fix. case 'TITLE': case 'META': - case 'BASE': case 'HTML': case 'HEAD': case 'BODY': { @@ -975,8 +991,7 @@ function getNextHydratable(node: ?Node) { const element: Element = (node: any); switch (element.tagName) { case 'TITLE': - case 'META': - case 'BASE': { + case 'META': { continue; } case 'LINK': { @@ -1445,7 +1460,7 @@ export function matchAccessibilityRole(node: Instance, role: string): boolean { export function getTextContent(fiber: Fiber): string | null { switch (fiber.tag) { - case HostResource: + case HostHoistable: case HostSingleton: case HostComponent: let textContent = ''; @@ -1563,7 +1578,7 @@ export function requestPostPaintCallback(callback: (time: number) => void) { export const supportsResources = true; -export function isHostResourceType( +export function isHostHoistableType( type: string, props: RawProps, hostContext: HostContext, @@ -1581,100 +1596,127 @@ export function isHostResourceType( namespace = hostContextProd; } switch (type) { - case 'base': - case 'meta': { - return true; - } + case 'meta': case 'title': { return namespace !== SVG_NAMESPACE; } - case 'link': { - const {onLoad, onError} = props; - if (onLoad || onError) { + case 'style': { + if ( + typeof props.precedence !== 'string' || + typeof props.href !== 'string' || + props.href === '' || + namespace === SVG_NAMESPACE + ) { if (__DEV__) { if (outsideHostContainerContext) { console.error( - 'Cannot render a with onLoad or onError listeners outside the main document.' + - ' Try removing onLoad={...} and onError={...} or moving it into the root tag or' + - ' somewhere in the .', - ); - } else if (namespace === SVG_NAMESPACE) { - console.error( - 'Cannot render a with onLoad or onError listeners as a descendent of .' + - ' Try removing onLoad={...} and onError={...} or moving it above the ancestor.', + 'Cannot render a or consider adding a `precedence="default"` and `href="some unique resource identifier"`, or move the '); -export function writeInitialResources( +function flushResourceInPreamble(this: Destination, resource: T) { + if ((resource.state & (Flushed | Blocked)) === NoState) { + const chunks = resource.chunks; + for (let i = 0; i < chunks.length; i++) { + writeChunk(this, chunks[i]); + } + resource.state |= FlushedInPreamble; + } +} + +function flushResourceLate(this: Destination, resource: T) { + if ((resource.state & Flushed) === NoState) { + const chunks = resource.chunks; + for (let i = 0; i < chunks.length; i++) { + writeChunk(this, chunks[i]); + } + resource.state |= FlushedLate; + } +} + +let didFlush = false; + +function flushUnblockedStyle( + this: Destination, + resource: StyleResource, + key: mixed, + set: Set, +) { + const chunks = resource.chunks; + if (resource.state & Flushed) { + // In theory this should never happen because we clear from the + // Set on flush but to ensure correct semantics we don't emit + // anything if we are in this state. + set.delete(resource); + } else if (resource.state & Blocked) { + // We can't flush but we can preload. We will do this in a second pass + } else { + didFlush = true; + // We can emit this style or stylesheet as is. + + if (resource.type === 'stylesheet') { + // We still need to encode stylesheet chunks + // because unlike most Hoistables and Resources we do not eagerly encode + // them during render. This is because if we flush late we have to send a + // different encoding and we don't want to encode multiple times + pushLinkImpl(chunks, resource.props); + } + for (let i = 0; i < chunks.length; i++) { + writeChunk(this, chunks[i]); + } + resource.state |= FlushedInPreamble; + set.delete(resource); + } +} + +function flushUnblockedStyles( + this: Destination, + set: Set, + precedence: string, +) { + didFlush = false; + set.forEach(flushUnblockedStyle, this); + if (!didFlush) { + // if we did not flush anything for this precedence slot we emit + // an empty , ).toErrorDev([ - 'Cannot render + + + + + + + + + + + + + + + + + + + + + + + + + + + + + , - ); + ).pipe(writable); }); - // @gate enableFloat - it('inserts a preload resource when called in a passive effect', async () => { - function App() { - React.useEffect(() => { - ReactDOM.preload('foo', {as: 'style'}); - }, []); - return 'foobar'; - } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + , + ); - expect(getMeaningfulChildren(document)).toEqual( - - - - - -
foobar
- - , - ); + await act(() => { + resolveText('block'); }); - // @gate enableFloat - it('inserts a preload resource when called in module scope if a root has already been created', async () => { - // The requirement that a root be created has to do with bootstrapping the dispatcher. - // We are intentionally avoiding setting it to the default via import due to cycles and - // we are trying to avoid doing a mutable initailation in module scope. - ReactDOM.preload('foo', {as: 'style'}); - ReactDOMClient.createRoot(container); - ReactDOM.preload('bar', {as: 'style'}); - // We need to use global.document because preload falls back - // 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(getMeaningfulChildren(global.document)).toEqual( - - - - - - , - ); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + , + ); + + await act(() => { + resolveText('block2'); }); - // @gate enableFloat - it('supports script preloads', async () => { - function ServerApp() { - ReactDOM.preload('foo', {as: 'script', integrity: 'foo hash'}); - ReactDOM.preload('bar', { - as: 'script', - crossOrigin: 'use-credentials', - integrity: 'bar hash', - }); - return ( - - - - hi - - foo - - ); - } - function ClientApp() { - ReactDOM.preload('foo', {as: 'script', integrity: 'foo hash'}); - ReactDOM.preload('qux', {as: 'script'}); - return ( - - - hi - - foo - - - ); - } + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + , + ); - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - hi - - foo - , - ); + await act(() => { + resolveText('block again'); + }); - ReactDOMClient.hydrateRoot(document, ); - expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + , + ); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - hi - - - - foo - , - ); - }); - }); + ReactDOMClient.hydrateRoot( + document, + + + + + + + + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); - describe('ReactDOM.preinit as style', () => { - // @gate enableFloat - it('creates a style Resource when called during server rendering before first flush', async () => { - function Component() { - ReactDOM.preinit('foo', {as: 'style'}); - return 'foo'; - } - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - - - - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - foo - , - ); - }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + , + ); + }); - // @gate enableFloat - it('creates a preload Resource when called during server rendering after first flush', async () => { - function BlockedOn({text, children}) { - readText(text); - return children; - } - function Component() { - ReactDOM.preinit('foo', {as: 'style', precedence: 'foo'}); - return 'foo'; - } - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - - - - - - - - , - ); - pipe(writable); - }); - await act(() => { - resolveText('unblock'); - }); - expect(getMeaningfulChildren(document)).toEqual( + // @gate enableFloat + it('client renders a boundary if a style Resource dependency fails to load', async () => { + function App() { + return ( - foo - + + + + + Hello + + - , + ); + } + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); - // @gate enableFloat - it('inserts a style Resource into the document during render when called during client rendering', async () => { - function Component() { - ReactDOM.preinit('foo', {as: 'style', precedence: 'foo'}); - return 'foo'; - } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - -
foo
- - , + await act(() => { + resolveText('unblock'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + loading... + + + + , + ); + + await act(() => { + const barLink = document.querySelector( + 'link[rel="stylesheet"][href="bar"]', ); + const event = document.createEvent('Events'); + event.initEvent('error', true, true); + barLink.dispatchEvent(event); }); - // @gate enableFloat - it('inserts a preload resource into the document when called in an insertion effect, layout effect, or passive effect', async () => { - function App() { - React.useEffect(() => { - ReactDOM.preinit('passive', {as: 'style', precedence: 'default'}); - }, []); - React.useLayoutEffect(() => { - ReactDOM.preinit('layout', {as: 'style', precedence: 'default'}); - }); - React.useInsertionEffect(() => { - ReactDOM.preinit('insertion', {as: 'style', precedence: 'default'}); - }); - return 'foobar'; - } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); + const boundaryTemplateInstance = document.getElementById('B:0'); + const suspenseInstance = boundaryTemplateInstance.previousSibling; - expect(getMeaningfulChildren(document)).toEqual( + expect(suspenseInstance.data).toEqual('$!'); + expect(boundaryTemplateInstance.dataset.dgst).toBe( + 'Resource failed to load', + ); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + loading... + + + + , + ); + + const errors = []; + ReactDOMClient.hydrateRoot(document, , { + onRecoverableError(err, errInfo) { + errors.push(err.message); + errors.push(err.digest); + }, + }); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + Hello + + , + ); + expect(errors).toEqual([ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + 'Resource failed to load', + ]); + }); + + // @gate enableFloat + it('treats stylesheet links with a precedence as a resource', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( - - - - - + -
foobar
+ + Hello , ); + pipe(writable); }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + Hello + , + ); - // @gate enableFloat - it('inserts a preload resource when called in module scope', async () => { - // The requirement that a root be created has to do with bootstrapping the dispatcher. - // We are intentionally avoiding setting it to the default via import due to cycles and - // we are trying to avoid doing a mutable initailation in module scope. - ReactDOM.preinit('foo', {as: 'style'}); - ReactDOMClient.hydrateRoot(container, null); - ReactDOM.preinit('bar', {as: 'style'}); - // We need to use global.document because preload falls back - // 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(getMeaningfulChildren(global.document)).toEqual( - - - - - - , - ); - }); + ReactDOMClient.hydrateRoot( + document, + + + Hello + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + Hello + , + ); }); - 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} = renderToPipeableStream( - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - -