diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 4d71e6e9f0..abd1327c0d 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 ef03c5717a..3bf60b8cb5 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 f3a83da5ea..a1767f7f6e 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 aa28e7e13c..58244b6ef4 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 3ea7dfe16d..33d2800ec3 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, +type HoistableTagType = 'link' | 'meta' | 'title' | 'script' | 'style'; +type TResource = { + type: T, + instance: null | Instance, + count: number, +}; +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 PreloadResource = { - type: 'preload', - href: string, - ownerDocument: Document, - props: PreloadProps, - instance: Element, -}; - -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); - } - } else { - const resourceProps = scriptPropsFromPreinitOptions(src, options); - resource = createScriptResource( - scriptResources, - resourceRoot, - src, - resourceProps, - ); + // We can early return. The resource exists and there is nothing + // more to do + return; } - acquireResource(resource); + + // 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); + } + instance = createElement( + 'script', + scriptProps, + resourceRoot, + HTML_NAMESPACE, + ); + markNodeAsResource(instance); + setInitialProperties(instance, 'link', scriptProps); + (getDocumentFromRoot(resourceRoot).head: any).appendChild(instance); + } + + // 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 = { +type StyleTagQualifyingProps = { + href: string, + precedence: string, + [string]: mixed, +}; + +type StylesheetQualifyingProps = { rel: 'stylesheet', href: string, precedence: string, [string]: mixed, }; -type PreloadQualifyingProps = { - rel: 'preload', - href: string, - [string]: mixed, -}; -type ScriptQualifyingProps = { - src: string, - async: true, - [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': { - 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); - if (!resource) { - resource = { - type: 'meta', - matcher, - property: propertyString, - parentResource, - props: Object.assign({}, pendingProps), - count: 0, - instance: null, - root: headRoot, - }; - 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)); - } - return resource; - } - return null; - } + case 'meta': case 'title': { - const children = pendingProps.children; - let child; - if (Array.isArray(children)) { - child = children.length === 1 ? children[0] : null; - } else { - child = children; - } + return null; + } + case 'style': { if ( - typeof child !== 'function' && - typeof child !== 'symbol' && - child !== null && - child !== undefined + typeof pendingProps.precedence === 'string' && + typeof pendingProps.href === '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 key = getStyleKey(pendingProps.href); + const styles = getResourcesFromRoot(resourceRoot).hoistableStyles; + let resource = styles.get(key); if (!resource) { - const titleProps = titlePropsFromRawProps(childString, pendingProps); resource = { - type: 'title', - props: titleProps, - count: 0, + type: 'style', instance: null, - root: headRoot, + count: 0, }; - headResources.set(key, resource); + styles.set(key, resource); } return resource; } - return null; + return { + type: 'void', + instance: null, + count: 0, + }; } 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); + if ( + pendingProps.rel === 'stylesheet' && + typeof pendingProps.href === 'string' && + typeof pendingProps.precedence === 'string' + ) { + const qualifiedProps: StylesheetQualifyingProps = pendingProps; + const key = getStyleKey(qualifiedProps.href); - // 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; + const styles = getResourcesFromRoot(resourceRoot).hoistableStyles; + + let resource = styles.get(key); + if (!resource) { + // We asserted this above but Flow can't figure out that the type satisfies + const ownerDocument = getDocumentFromRoot(resourceRoot); + resource = { + type: 'stylesheet', + instance: null, + count: 0, + }; + styles.set(key, resource); + if (!preloadPropsMap.has(key)) { + preloadStylesheet( + ownerDocument, + key, + preloadPropsFromStylesheet(qualifiedProps), + ); } - 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, - ); } return resource; } return null; } - default: { - throw new Error( - `getResource encountered a resource 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 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 scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps { - // $FlowFixMe[prop-missing] - recommended fix is to use object spread operator - const props: ScriptProps = Object.assign({}, rawProps); - return props; -} - -// -------------------------------------- -// 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; + 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 { + type: 'void', + instance: null, + count: 0, + }; } default: { throw new Error( - `acquireResource encountered a resource type it did not expect: "${resource.type}". this is a bug in React.`, + `getResource encountered a type it did not expect: "${type}". this is a bug in React.`, ); } } } -export function releaseResource(resource: Resource): void { - switch (resource.type) { - case 'link': - case 'title': - case 'meta': { - return releaseHeadResource(resource); - } - case 'style': { - resource.count--; - return; - } - } +function styleTagPropsFromRawProps( + rawProps: StyleTagQualifyingProps, +): StyleTagProps { + return { + ...rawProps, + 'data-href': rawProps.href, + 'data-precedence': rawProps.precedence, + href: null, + precedence: null, + }; } -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 createResourceInstance( - type: string, - props: Object, - 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.', - ); - } - } - +function getStyleKey(href: string) { 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, + return `href="${limitedEscapedHref}"`; +} + +function getStyleTagSelectorFromKey(key: string) { + return `style[data-${key}]`; +} + +function getStylesheetSelectorFromKey(key: string) { + return `link[rel="stylesheet"][${key}]`; +} + +function getPreloadStylesheetSelectorFromKey(key: string) { + return `link[rel="preload"][as="style"][${key}]`; +} + +function stylesheetPropsFromRawProps( + rawProps: StylesheetQualifyingProps, +): StylesheetProps { + return { + ...rawProps, + 'data-precedence': rawProps.precedence, + precedence: 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, +function preloadStylesheet( + ownerDocument: Document, + key: string, 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; -} +) { + preloadPropsMap.set(key, preloadProps); -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, - ); + 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); + } } } -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, - ); - break; - } - case 'meta': { - let insertBefore = null; + if (resource.instance === null) { + switch (resource.type) { + case 'style': { + const qualifiedProps: StyleTagQualifyingProps = props; + const key = getStyleKey(qualifiedProps.href); - 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))) { + // Attempt to hydrate instance from DOM + let instance: null | Instance = hoistableRoot.querySelector( + getStyleTagSelectorFromKey(key), + ); + 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, ); - insertResourceInstanceBefore(root, instance, null); + 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'), + ); + 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; + return resource.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; +export function releaseResource(resource: Resource): void { + resource.count--; } -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, - ); -} - -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); - } -} - -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.', - ); - } - } - const parent = (before && before.parentNode) || ownerDocument.head; - if (parent) { - parent.insertBefore(instance, before); + 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 { - throw new Error( - 'While attempting to insert a Resource, React expected the Document to contain' + - ' a head element but it was not found.', - ); + child = children; } + if ( + typeof child !== 'function' && + typeof child !== 'symbol' && + child !== null && + child !== undefined + ) { + if (__DEV__) { + checkPropStringCoercion(child, 'children'); + } + childString = '' + (child: any); + } else { + 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 92e5efd880..b774d570f5 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); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + , + ); + + await act(() => { + resolveText('block'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + , + ); + + await act(() => { + resolveText('block2'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + , + ); + + await act(() => { + resolveText('block again'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + , + ); + + ReactDOMClient.hydrateRoot( + document, + + + + + + + + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + , + ); + }); + + // @gate enableFloat + it('client renders a boundary if a style Resource dependency fails to load', async () => { + function App() { + return ( + + + + + + + + Hello + + + + + ); + } + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + + 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); + }); + + const boundaryTemplateInstance = document.getElementById('B:0'); + const suspenseInstance = boundaryTemplateInstance.previousSibling; + + 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( + + + + + Hello + + , + ); + pipe(writable); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + Hello + , + ); + + ReactDOMClient.hydrateRoot( + document, + + + Hello + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + Hello + , + ); + }); + + // @gate enableFloat + it('inserts text separators following text when followed by an element that is converted to a resource and thus removed from the html inline', async () => { + // If you render many of these as siblings the values get emitted as a single text with no separator sometimes + // because the link gets elided as a resource + function AsyncTextWithResource({text, href, precedence}) { + const value = readText(text); + return ( + <> + {value} + + + ); + } + + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + + + + + + , + ); + pipe(writable); + resolveText('foo'); + resolveText('bar'); + resolveText('baz'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + {'foo'} + {'bar'} + {'baz'} + + , + ); + }); + + // @gate enableFloat + it('hoists late stylesheets the correct precedence', async () => { + function PresetPrecedence() { + ReactDOM.preinit('preset', {as: 'style', precedence: 'preset'}); + } + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + + + +
+ +
foo
+ + +
bar
+ +
+
+
+
+ + +
bar
+ +
+ +
baz
+ +
+ +
qux
+ +
+
+
+
+ + + +
bar
+ +
+ +
baz
+ +
+ +
qux
+ +
+
+
, ); + pipe(writable); }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + +
loading foo bar...
+
loading bar baz qux...
+
loading bar baz qux...
+ + , + ); + + await act(() => { + resolveText('foo'); + resolveText('bar'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + +
loading foo bar...
+
loading bar baz qux...
+
loading bar baz qux...
+ + + , + ); + + await act(() => { + const link = document.querySelector('link[rel="stylesheet"][href="foo"]'); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + link.dispatchEvent(event); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + +
loading foo bar...
+
loading bar baz qux...
+
loading bar baz qux...
+ + + , + ); + + await act(() => { + const link = document.querySelector('link[rel="stylesheet"][href="bar"]'); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + link.dispatchEvent(event); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + +
+
foo
+
bar
+
+
loading bar baz qux...
+
loading bar baz qux...
+ + + , + ); + + await act(() => { + resolveText('baz'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + +
+
foo
+
bar
+
+
loading bar baz qux...
+
loading bar baz qux...
+ + + + , + ); + + await act(() => { + resolveText('qux'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + +
+
foo
+
bar
+
+
loading bar baz qux...
+
loading bar baz qux...
+ + + + + , + ); + + await act(() => { + const bazlink = document.querySelector( + 'link[rel="stylesheet"][href="baz"]', + ); + const quxlink = document.querySelector( + 'link[rel="stylesheet"][href="qux"]', + ); + const presetLink = document.querySelector( + 'link[rel="stylesheet"][href="preset"]', + ); + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + bazlink.dispatchEvent(event); + quxlink.dispatchEvent(event); + presetLink.dispatchEvent(event); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + +
+
foo
+
bar
+
+
+
bar
+
baz
+
qux
+
+
loading bar baz qux...
+ + + + + , + ); + + await act(() => { + resolveText('unblock'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + +
+
foo
+
bar
+
+
+
bar
+
baz
+
qux
+
+
+
bar
+
baz
+
qux
+
+ + + + + , + ); }); - describe('ReactDOM.preload', () => { - // @gate enableFloat - it('inserts a preload resource into the stream when called during server rendering', async () => { - function Component() { - ReactDOM.preload('foo', {as: 'style'}); - return 'foo'; + // @gate enableFloat + it('normalizes stylesheet resource precedence for all boundaries inlined as part of the shell flush', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + +
+ outer + + + + + +
+ middle + + + + + +
+ inner + + + + + +
+
+
+
+ +
middle
+ + + + +
+
+ + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + + + + + + + + + + + + +
+ outer +
+ middle
inner
+
+
middle
+
+ + , + ); + }); + + // @gate enableFloat + it('stylesheet resources are inserted according to precedence order on the client', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + +
+ + + Hello +
+ + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
Hello
+ + , + ); + + const root = ReactDOMClient.hydrateRoot( + document, + + + +
+ + + Hello +
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
Hello
+ + , + ); + + root.render( + + + +
Hello
+ + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
Hello
+ + , + ); + }); + + // @gate enableFloat + it('inserts preloads in render phase eagerly', async () => { + function Throw() { + throw new Error('Uh oh!'); + } + class ErrorBoundary extends React.Component { + state = {hasError: false, error: null}; + static getDerivedStateFromError(error) { + return { + hasError: true, + error, + }; } + render() { + if (this.state.hasError) { + return this.state.error.message; + } + return this.props.children; + } + } + + const root = ReactDOMClient.createRoot(container); + root.render( + + +
foo
+ +
, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
Uh oh!
+ + , + ); + }); + + // @gate enableFloat + it('will include child boundary stylesheet resources in the boundary reveal instruction', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + +
+ + +
foo
+ + + +
bar
+ + + +
baz
+ +
+
+
+
+
+
+
+ + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading foo...
+ + , + ); + + await act(() => { + resolveText('bar'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading foo...
+ + , + ); + + await act(() => { + resolveText('baz'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading foo...
+ + , + ); + + await act(() => { + resolveText('foo'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + +
loading foo...
+ + + + + , + ); + + await act(() => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).forEach( + el => { + el.dispatchEvent(event); + }, + ); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + +
+
foo
+
bar
+
baz
+
+ + + + + , + ); + }); + + // @gate enableFloat + it('will hoist resources of child boundaries emitted as part of a partial boundary to the parent boundary', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( + + + +
+ +
+ +
foo
+ + + +
bar
+ + +
+ +
baz
+ +
+
+
+
+
+
+ +
qux
+ +
+
+
+
+ + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading...
+ + , + ); + + // This will enqueue a stylesheet resource in a deep blocked boundary (loading baz...). + await act(() => { + resolveText('baz'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading...
+ + , + ); + + // This will enqueue a stylesheet resource in the intermediate blocked boundary (loading bar...). + await act(() => { + resolveText('bar'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading...
+ + , + ); + + // This will complete a segment in the top level boundary that is still blocked on another segment. + // It will flush the completed segment however the inner boundaries should not emit their style dependencies + // because they are not going to be revealed yet. instead their dependencies are hoisted to the blocked + // boundary (top level). + await act(() => { + resolveText('foo'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading...
+ + + + + , + ); + + // This resolves the last blocked segment on the top level boundary so we see all dependencies of the + // nested boundaries emitted at this level + await act(() => { + resolveText('qux'); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
loading...
+ + + + + + , + ); + + // We load all stylesheets and confirm the content is revealed + await act(() => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).forEach( + el => { + el.dispatchEvent(event); + }, + ); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
+
+
foo
+
bar
+
+
baz
+
+
qux
+
+
+ + + + + + , + ); + }); + + // @gate enableFloat + it('encodes attributes consistently whether resources are flushed in shell or in late boundaries', async () => { + function App() { + return ( + + + +
+ {}} + norsymbols={Symbol('foo')} + /> + + + {}} + norsymbols={Symbol('foo')} + /> + + +
+ + + ); + } + await expect(async () => { await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - - - - , - ); + const {pipe} = renderToPipeableStream(); pipe(writable); }); expect(getMeaningfulChildren(document)).toEqual( - - - foo - , - ); - }); - - // @gate enableFloat - it('inserts a preload resource into the document during render when called during client rendering', async () => { - function Component() { - ReactDOM.preload('foo', {as: 'style'}); - return 'foo'; - } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - + -
foo
+
loading...
, ); + }).toErrorDev([ + 'React does not recognize the `nonStandardAttr` prop on a DOM element.' + + ' If you intentionally want it to appear in the DOM as a custom attribute,' + + ' spell it as lowercase `nonstandardattr` instead. If you accidentally passed it from a' + + ' parent component, remove it from the DOM element.', + 'Invalid values for props `shouldnotincludefunctions`, `norsymbols` on tag. Either remove them from' + + ' the element, or pass a string or number value to keep them in the DOM. For' + + ' details, see https://reactjs.org/link/attribute-behavior', + ]); + + // Now we flush the stylesheet with the boundary + await act(() => { + resolveText('unblock'); }); - // @gate enableFloat - it('inserts a preload resource when called in a layout effect', async () => { - function App() { - React.useLayoutEffect(() => { - ReactDOM.preload('foo', {as: 'style'}); - }, []); - return 'foobar'; - } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
loading...
+ + + , + ); + }); - expect(getMeaningfulChildren(document)).toEqual( + // @gate enableFloat + it('boundary stylesheet resource dependencies hoist to a parent boundary when flushed inline', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream( - - - + -
foobar
+
+ + + + + + + + + + + + + + + + + + +
, ); + pipe(writable); }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading A...
+ + , + ); - // @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( - - - - - -
foobar
- - , - ); + await act(() => { + resolveText('unblock'); + resolveText('AAAA'); + resolveText('AA'); }); + expect(getMeaningfulChildren(document)).toEqual( + + + +
loading A...
+ + + + + + , + ); - // @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( + await act(() => { + resolveText('A'); + }); + await act(() => { + document.querySelectorAll('link[rel="stylesheet"]').forEach(l => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + l.dispatchEvent(event); + }); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
+ {'A'} + {'AA'} + {'loading AAA...'} +
+ + + + + + , + ); + + await act(() => { + resolveText('AAA'); + }); + await act(() => { + document.querySelectorAll('link[rel="stylesheet"]').forEach(l => { + const event = document.createEvent('Events'); + event.initEvent('load', true, true); + l.dispatchEvent(event); + }); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + +
+ {'A'} + {'AA'} + {'AAA'} + {'AAAA'} +
+ + + + + + , + ); + }); + + // @gate enableFloat + it('always enforces crossOrigin "anonymous" for font preloads', async () => { + function App() { + ReactDOM.preload('foo', {as: 'font'}); + ReactDOM.preload('bar', {as: 'font', crossOrigin: 'foo'}); + ReactDOM.preload('baz', {as: 'font', crossOrigin: 'use-credentials'}); + ReactDOM.preload('qux', {as: 'font', crossOrigin: 'anonymous'}); + return ( - - - + - , - ); - }); - - // @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 - - - ); - } - - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream(); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - hi - - foo - , - ); - - ReactDOMClient.hydrateRoot(document, ); - expect(Scheduler).toFlushWithoutYielding(); - - expect(getMeaningfulChildren(document)).toEqual( - - - - - - hi - - - - foo - , + ); + } + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + , + ); }); - describe('ReactDOM.preinit as style', () => { + describe('ReactDOM.preload(href, { as: ... })', () => { // @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( + it('creates a preload resource when called', async () => { + function App() { + ReactDOM.preload('foo', {as: 'style'}); + return ( - - - - - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - foo - , - ); - }); - - // @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( - - - + - , + ); + } + function Component() { + ReactDOM.preload('bar', {as: 'script'}); + return
hello
; + } + + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); pipe(writable); }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + loading... + , + ); + await act(() => { - resolveText('unblock'); + resolveText('blocked'); }); expect(getMeaningfulChildren(document)).toEqual( - - - foo + + + +
hello
+ , ); - }); - // @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'; + function ClientApp() { + ReactDOM.preload('foo', {as: 'style'}); + React.useInsertionEffect(() => ReactDOM.preload('bar', {as: 'script'})); + React.useLayoutEffect(() => ReactDOM.preload('baz', {as: 'font'})); + React.useEffect(() => ReactDOM.preload('qux', {as: 'style'})); + return ( + + + +
hello
+
+ + + ); } - const root = ReactDOMClient.createRoot(container); - root.render(); + ReactDOMClient.hydrateRoot(document, ); expect(Scheduler).toFlushWithoutYielding(); expect(getMeaningfulChildren(document)).toEqual( - + + + -
foo
+
hello
+ , ); }); // @gate enableFloat - it('inserts a preload resource into the document when called in an insertion effect, layout effect, or passive effect', async () => { + it('can seed connection props for stylesheet and script resources', async () => { function App() { - React.useEffect(() => { - ReactDOM.preinit('passive', {as: 'style', precedence: 'default'}); - }, []); - React.useLayoutEffect(() => { - ReactDOM.preinit('layout', {as: 'style', precedence: 'default'}); + ReactDOM.preload('foo', { + as: 'style', + crossOrigin: 'use-credentials', + integrity: 'some hash', }); - React.useInsertionEffect(() => { - ReactDOM.preinit('insertion', {as: 'style', precedence: 'default'}); - }); - return 'foobar'; + return ( + + +
hello
+ + + + ); } - const root = ReactDOMClient.createRoot(container); - root.render(); - expect(Scheduler).toFlushWithoutYielding(); + + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); expect(getMeaningfulChildren(document)).toEqual( - - - + -
foobar
+
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( - - - - - - , - ); + it('warns if you do not pass in a valid href argument or options argument', async () => { + function App() { + ReactDOM.preload(); + ReactDOM.preload(''); + ReactDOM.preload('foo', null); + ReactDOM.preload('foo', {}); + ReactDOM.preload('foo', {as: 'foo'}); + return
foo
; + } + + await expect(async () => { + await act(() => { + renderToPipeableStream().pipe(writable); + }); + }).toErrorDev([ + 'ReactDOM.preload(): Expected the `href` argument (first) to be a non-empty string but encountered `undefined` instead.', + 'ReactDOM.preload(): Expected the `href` argument (first) to be a non-empty string but encountered an empty string instead.', + 'ReactDOM.preload(): Expected the `options` argument (second) to be an object with an `as` property describing the type of resource to be preloaded but encountered `null` instead.', + 'ReactDOM.preload(): Expected the `as` property in the `options` argument (second) to contain a string value describing the type of resource to be preloaded but encountered `undefined` instead. Values that are valid in for the `as` attribute of a `` tag are valid here.', + ]); + }); + + // @gate enableFloat + it('warns if you pass incompatible options to two `ReactDOM.preload(...)` with the same href', async () => { + function Component() { + ReactDOM.preload('foo', { + as: 'font', + crossOrigin: 'use-credentials', + }); + ReactDOM.preload('foo', { + as: 'font', + integrity: 'some hash', + crossOrigin: 'anonymous', + }); + ReactDOM.preload('foo', { + as: 'font', + extra: 'ignored', + }); + } + + await expect(async () => { + await actIntoEmptyDocument(() => { + renderToPipeableStream( + + + + + , + ); + }); + }).toErrorDev([ + 'Warning: ReactDOM.preload(): The options provided conflict with another call to `ReactDOM.preload("foo", { as: "font", ...})`. React will always use the options it first encounters when preloading a resource for a given `href` and `as` type, and any later options will be ignored if different. Try updating all calls to `ReactDOM.preload()` with the same `href` and `as` type to use the same options, or eliminate one of the calls.\n "integrity" option value: "some hash", missing from original options\n "crossOrigin" option value: "anonymous", original option value: "use-credentials"', + 'Warning: ReactDOM.preload(): The options provided conflict with another call to `ReactDOM.preload("foo", { as: "font", ...})`. React will always use the options it first encounters when preloading a resource for a given `href` and `as` type, and any later options will be ignored if different. Try updating all calls to `ReactDOM.preload()` with the same `href` and `as` type to use the same options, or eliminate one of the calls.\n "crossOrigin" missing from options, original option value: "use-credentials"', + ]); + }); + + // @gate enableFloat + it('warns if you pass incompatible options to two `ReactDOM.preload(...)` when an implicit preload already exists with the same href', async () => { + function Component() { + ReactDOM.preload('foo', { + as: 'style', + crossOrigin: 'use-credentials', + }); + } + + await expect(async () => { + await actIntoEmptyDocument(() => { + renderToPipeableStream( + + + + + + , + ); + }); + }).toErrorDev([ + 'ReactDOM.preload(): For `href` "foo", The options provided conflict with props on a matching element. When the preload options disagree with the underlying resource it usually means the browser will not be able to use the preload when the resource is fetched, negating any benefit the preload would provide. React will preload the resource using props derived from the resource instead and ignore the options provided to the `ReactDOM.preload()` call. In general, preloading is useful when you expect to render a resource soon but have not yet done so. In this case since the underlying resource was already rendered the preload call may be extraneous. Try removing the call, otherwise try adjusting both the props on the and the options passed to `ReactDOM.preload()` to agree.\n "integrity" missing from options, underlying prop value: "some hash"\n "media" missing from options, underlying prop value: "print"\n "crossOrigin" option value: "use-credentials", missing from underlying props', + ]); }); }); - describe('ReactDOM.preinit as script', () => { + describe('ReactDOM.preinit(href, { as: ... })', () => { // @gate enableFloat - it('can preinit a script', async () => { - function App({srcs}) { - srcs.forEach(src => ReactDOM.preinit(src, {as: 'script'})); + it('creates a stylesheet resource when ReactDOM.preinit(..., {as: "style" }) is called', async () => { + function App() { + ReactDOM.preinit('foo', {as: 'style'}); + return ( + + + + + + + + + + ); + } + + function Component() { + ReactDOM.preinit('bar', {as: 'style'}); + return
hello
; + } + + await actIntoEmptyDocument(() => { + const {pipe} = renderToPipeableStream(); + pipe(writable); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + loading... + , + ); + + await act(() => { + resolveText('bar'); + }); + // The reason we do not see the "bar" stylesheet here is that ReactDOM.preinit is not about + // encoding a resource dependency but is a hint that a resource will be used in the near future. + // If we call preinit on the server after the shell has flushed the best we can do is emit a preload + // because any flushing suspense boundaries are not actually dependent on that resource and we don't + // want to delay reveal based on when that resource loads. + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
hello
+ + + , + ); + + function ClientApp() { + ReactDOM.preinit('bar', {as: 'style'}); + return ( + + + +
hello
+
+ + + ); + } + + ReactDOMClient.hydrateRoot(document, ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
hello
+ + + , + ); + }); + + // @gate enableFloat + it('creates a preload resource when ReactDOM.preinit(..., {as: "style" }) is called outside of render on the client', async () => { + function App() { + React.useEffect(() => { + ReactDOM.preinit('foo', {as: 'style'}); + }, []); return ( - - title - foo ); } - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - -