From 412905afa8d5cee5e7c8884b1f22fec62ab319d3 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 27 Feb 2023 13:42:25 -0800 Subject: [PATCH 1/4] Improve Hydration tolerance in and + support itemScope for hoisting One recent decision was to make elements using the `itemProp` prop not hoistable if they were inside and itemScope. This better fits with Microdata spec which allows for meta tags and other tag types usually reserved for the to be used in the when using itemScope. To implement this a number of small changes were necessary 1. HostContext in prod needed to expand beyond just tracking the element namespace for new element creation. It now tracks whether we are in an itemScope. To keep this efficient it is modeled as a bitmask. 2. To disambiguate what is and is not a potential instance in the DOM for hoistables the hydration algo was updated to skip past non-matching instances while attempting to claim the instance rather than ahead of time (getNextHydratable). 3. React will not consider an itemScope on , , or as a valid scope for the hoisting opt-out. This is important as an invariant so we can make assumptiosn about certain tags in these scopes. This should not be a functional breaking change because if any of these tags have an itemScope then it can just be moved into the first node inside the Since we were already updating the logic for hydration to better support itemScope opt-out I also changed the hydration behavior for suspected 3rd party nodes in and . Now if you are hydrating in either of those contexts hydration will skip past any non-matching nodes until it finds a match. This allows 3rd party scripts and extensions to inject nodes in either context that React does not expect and still avoid a hydation mismatch. This new algorithm isn't perfect and it is possible for a mismatch to occurr. The most glarying case may be if a 3rd party script prepends a
into and you render a
in in your app. there is nothing to signal to React that this div was 3rd party so it will claim is as the hydrated instance and hydration will almost certainly fail immediately afterwards. The expectation is that this is rare and that if falling back to client rendering is transparent to the user then there is not problem here. We will continue to evaluate this and may change the hydration matchign algorithm further to match user and developer expectations --- .../src/client/ReactDOMComponent.js | 171 +++--- .../src/client/ReactDOMFloatClient.js | 56 +- .../src/client/ReactDOMHostConfig.js | 566 ++++++++++++------ .../src/server/ReactDOMServerFormatConfig.js | 60 +- .../ReactDOMServerLegacyFormatConfig.js | 1 + .../src/shared/DOMNamespaces.js | 6 +- .../src/__tests__/ReactDOMFloat-test.js | 353 ++++++++++- ...DOMServerPartialHydration-test.internal.js | 17 +- .../src/ReactFabricHostConfig.js | 1 + .../src/ReactNativeHostConfig.js | 1 + .../src/ReactFiberBeginWork.js | 12 +- .../ReactFiberHostConfigWithNoHydration.js | 4 + .../src/ReactFiberHostContext.js | 10 +- .../src/ReactFiberHydrationContext.js | 67 ++- .../src/forks/ReactFiberHostConfig.custom.js | 8 + .../src/ReactTestHostConfig.js | 1 + 16 files changed, 997 insertions(+), 337 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 620dc314485e1..f09d8ad2e1299 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -7,6 +7,8 @@ * @flow */ +import type {ExoticNamespace} from '../shared/DOMNamespaces'; + import { registrationNameDependencies, possibleRegistrationNames, @@ -55,7 +57,7 @@ import { setValueForStyles, validateShorthandPropertyCollisionInDev, } from './CSSPropertyOperations'; -import {HTML_NAMESPACE, getIntrinsicNamespace} from '../shared/DOMNamespaces'; +import {HTML_NAMESPACE} from '../shared/DOMNamespaces'; import { getPropertyInfo, shouldIgnoreAttribute, @@ -375,11 +377,11 @@ function updateDOMProperties( } } -export function createElement( +// Creates elements in the HTML namesapce +export function createHTMLElement( type: string, props: Object, rootContainerElement: Element | Document | DocumentFragment, - parentNamespace: string, ): Element { let isCustomComponentTag; @@ -388,99 +390,102 @@ export function createElement( const ownerDocument: Document = getOwnerDocumentFromRootContainer(rootContainerElement); let domElement: Element; - let namespaceURI = parentNamespace; - if (namespaceURI === HTML_NAMESPACE) { - namespaceURI = getIntrinsicNamespace(type); + if (__DEV__) { + isCustomComponentTag = isCustomComponent(type, props); + // Should this check be gated by parent namespace? Not sure we want to + // allow or . + if (!isCustomComponentTag && type !== type.toLowerCase()) { + console.error( + '<%s /> is using incorrect casing. ' + + 'Use PascalCase for React components, ' + + 'or lowercase for HTML elements.', + type, + ); + } } - if (namespaceURI === HTML_NAMESPACE) { + + if (type === 'script') { + // Create the script via .innerHTML so its "parser-inserted" flag is + // set to true and it does not execute + const div = ownerDocument.createElement('div'); if (__DEV__) { - isCustomComponentTag = isCustomComponent(type, props); - // Should this check be gated by parent namespace? Not sure we want to - // allow or . - if (!isCustomComponentTag && type !== type.toLowerCase()) { + if (enableTrustedTypesIntegration && !didWarnScriptTags) { console.error( - '<%s /> is using incorrect casing. ' + - 'Use PascalCase for React components, ' + - 'or lowercase for HTML elements.', - type, + 'Encountered a script tag while rendering React component. ' + + 'Scripts inside React components are never executed when rendering ' + + 'on the client. Consider using template tag instead ' + + '(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).', ); + didWarnScriptTags = true; } } - - if (type === 'script') { - // Create the script via .innerHTML so its "parser-inserted" flag is - // set to true and it does not execute - const div = ownerDocument.createElement('div'); - if (__DEV__) { - if (enableTrustedTypesIntegration && !didWarnScriptTags) { - console.error( - 'Encountered a script tag while rendering React component. ' + - 'Scripts inside React components are never executed when rendering ' + - 'on the client. Consider using template tag instead ' + - '(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).', - ); - didWarnScriptTags = true; - } - } - div.innerHTML = '
we may still emit + // resources into that
context that we need to try to avoid when hydrating + context = HoistContext; + } else { + context = NoContext; + } const nodeType = rootContainerInstance.nodeType; switch (nodeType) { case DOCUMENT_NODE: case DOCUMENT_FRAGMENT_NODE: { type = nodeType === DOCUMENT_NODE ? '#document' : '#fragment'; const root = (rootContainerInstance: any).documentElement; - namespace = root ? root.namespaceURI : getChildNamespace(null, ''); + context |= root + ? getNamespaceContext(root.namespaceURI) + : getChildHostContextImpl(NoContext, '', null); break; } default: { @@ -195,33 +206,107 @@ export function getRootHostContext( : rootContainerInstance; const ownNamespace = container.namespaceURI || null; type = container.tagName; - namespace = getChildNamespace(ownNamespace, type); + context |= ownNamespace + ? getChildHostContextImpl(getNamespaceContext(ownNamespace), type, null) + : getChildHostContextImpl(NoContext, type, null); break; } } if (__DEV__) { const validatedTag = type.toLowerCase(); const ancestorInfo = updatedAncestorInfoDev(null, validatedTag); - return {namespace, ancestorInfo}; + return {context, ancestorInfo}; } - return namespace; + return context; } export function getChildHostContext( parentHostContext: HostContext, type: string, + props: Props, ): HostContext { if (__DEV__) { const parentHostContextDev = ((parentHostContext: any): HostContextDev); - const namespace = getChildNamespace(parentHostContextDev.namespace, type); + const parentContext = parentHostContextDev.context; + const context = getChildHostContextImpl(parentContext, type, props); + const ancestorInfo = updatedAncestorInfoDev( parentHostContextDev.ancestorInfo, type, ); - return {namespace, ancestorInfo}; + return {context, ancestorInfo}; + } else { + const parentHostContextProd = ((parentHostContext: any): HostContextProd); + return getChildHostContextImpl(parentHostContextProd, type, props); + } +} + +const NoContext /* */ = 0b000000; +// Element namespace contexts +const HTMLNamespaceContext /* */ = 0b000001; +const SVGNamespaceContext /* */ = 0b000010; +const MathNamespaceContext /* */ = 0b000100; +const AnyNamespaceContext /* */ = 0b000111; + +// Ancestor tag/attribute contexts +const HoistContext /* */ = 0b001000; // When we are directly inside the HostRoot or inside certain tags like , , and +const ItemInScope /* */ = 0b010000; // When we are inside an itemScope deeper than +const HeadOrBodyInScope /* */ = 0b100000; // When we are inside a or + +function getNamespaceContext(namespaceURI: string) { + switch (namespaceURI) { + case HTML_NAMESPACE: + return HTMLNamespaceContext; + case SVG_NAMESPACE: + return SVGNamespaceContext; + case MATH_NAMESPACE: + return MathNamespaceContext; + default: + return NoContext; + } +} + +function getChildHostContextImpl( + parentContext: number, + type: string, + props: null | Props, +) { + // We assume any child context is not a HoistContext. It will get re-added later if certain circumstances warrant it + let context = parentContext & ~HoistContext; + // We need to determine if we are now in an itemScope. + if (props && props.itemScope === true && parentContext & HeadOrBodyInScope) { + // We only allow itemscopes deeper than or . This is helpful to disambiguate + // resources that need to hoist vs those that do not + context |= ItemInScope; + } + + const namespaceContext = context & AnyNamespaceContext; + const otherContext = context & ~AnyNamespaceContext; + + if ( + namespaceContext === NoContext || + namespaceContext === HTMLNamespaceContext + ) { + switch (type) { + case 'svg': + return SVGNamespaceContext | otherContext; + case 'math': + return MathNamespaceContext | otherContext; + case 'html': + return HTMLNamespaceContext | otherContext | HoistContext; + case 'head': + case 'body': + return ( + HTMLNamespaceContext | otherContext | HoistContext | HeadOrBodyInScope + ); + default: + return context; + } } - const parentNamespace = ((parentHostContext: any): HostContextProd); - return getChildNamespace(parentNamespace, type); + if (namespaceContext === SVGNamespaceContext && type === 'foreignObject') { + return HTMLNamespaceContext | otherContext; + } + return context; } export function getPublicInstance(instance: Instance): Instance { @@ -274,11 +359,10 @@ export function createHoistableInstance( rootContainerInstance: Container, internalInstanceHandle: Object, ): Instance { - const domElement: Instance = createElement( + const domElement: Instance = createHTMLElement( type, props, rootContainerInstance, - HTML_NAMESPACE, ); precacheFiberNode(internalInstanceHandle, domElement); updateFiberProps(domElement, props); @@ -294,10 +378,10 @@ export function createInstance( hostContext: HostContext, internalInstanceHandle: Object, ): Instance { - let parentNamespace: string; + let namespaceContext; if (__DEV__) { // TODO: take namespace into account when validating. - const hostContextDev = ((hostContext: any): HostContextDev); + const hostContextDev: HostContextDev = (hostContext: any); validateDOMNesting(type, null, hostContextDev.ancestorInfo); if ( typeof props.children === 'string' || @@ -310,16 +394,26 @@ export function createInstance( ); validateDOMNesting(null, string, ownAncestorInfo); } - parentNamespace = hostContextDev.namespace; + namespaceContext = hostContextDev.context & AnyNamespaceContext; } else { - parentNamespace = ((hostContext: any): HostContextProd); + const hostContextProd: HostContextProd = (hostContext: any); + namespaceContext = hostContextProd & AnyNamespaceContext; + } + + let domElement: Instance; + if ( + namespaceContext === SVGNamespaceContext || + (type === 'svg' && namespaceContext === HTMLNamespaceContext) + ) { + domElement = createElementNS(SVG_NAMESPACE, type, rootContainerInstance); + } else if ( + namespaceContext === MathNamespaceContext || + (type === 'math' && namespaceContext === HTMLNamespaceContext) + ) { + domElement = createElementNS(MATH_NAMESPACE, type, rootContainerInstance); + } else { + domElement = createHTMLElement(type, props, rootContainerInstance); } - const domElement: Instance = createElement( - type, - props, - rootContainerInstance, - parentNamespace, - ); precacheFiberNode(internalInstanceHandle, domElement); updateFiberProps(domElement, props); return domElement; @@ -827,15 +921,7 @@ export const supportsHydration = true; // inserted without breaking hydration export function isHydratable(type: string, props: Props): boolean { if (enableFloat) { - if (type === 'link') { - if ( - (props: any).rel === 'stylesheet' && - typeof (props: any).precedence !== 'string' - ) { - return true; - } - return false; - } else if (type === 'script') { + if (type === 'script') { const {async, onLoad, onError} = (props: any); return !(async && (onLoad || onError)); } @@ -845,6 +931,225 @@ export function isHydratable(type: string, props: Props): boolean { } } +// In certain contexts, namely and , we want to skip past Nodes that are in theory +// hydratable but do not match the current Fiber being hydrated. We track the hydratable node we +// are currently attempting using this module global. If the hydration is unsuccessful Fiber will +// call getNextHydratableAfterFailedAttempt which uses this cursor to return the expected next +// hydratable. +let hydratableNode: null | HydratableInstance = null; + +export function getNextHydratableAfterFailedAttempt(): null | HydratableInstance { + return hydratableNode === null + ? null + : getNextHydratableSibling(hydratableNode); +} + +export function getNextMatchingHydratableInstance( + instance: HydratableInstance, + type: string, + props: Props, + hostContext: HostContext, +): null | Instance { + // We set this first because it must always be set on every invocation + hydratableNode = instance; + + let context: HostContextProd; + if (__DEV__) { + const hostContextDev: HostContextDev = (hostContext: any); + context = hostContextDev.context; + } else { + const hostContextProd: HostContextProd = (hostContext: any); + context = hostContextProd; + } + + if (context & HoistContext) { + // In the head and body we expect 3rd party scripts to + let node; + for (; hydratableNode; hydratableNode = getNextHydratableSibling(node)) { + node = hydratableNode; + if (node.nodeType !== ELEMENT_NODE) { + // This is a suspense boundary or Text node. + // Suspense Boundaries are never expected to be injected by 3rd parties. If we see one it should be matched + // and this is a hydration error. + // Text Nodes are also not expected to be injected by 3rd parties. This is less of a guarantee for + // but it seems reasonable and conservative to reject this as a hydration error as well + return null; + } else if ( + node.nodeName.toLowerCase() !== type.toLowerCase() || + isMarkedResource(node) + ) { + // This is either text or a tag type that differs from the tag we are trying to hydrate + // or a Node we already bound to a hoistable. We skip past it. + hydratableNode = getNextHydratableSibling(node); + continue; + } else { + // We have an Element with the right type. + const element: Element = (node: any); + + // We are going to try to exclude it if we can definitely identify it as a hoisted Node or if + // we can guess that the node is likely hoisted or was inserted by a 3rd party script or browser extension + // using high entropy attributes for certain types. This technique will fail for strange insertions like + // extension prepending
in the but that already breaks before and that is an edge case. + switch (type) { + // We make a leap and assume that no titles or metas will ever hydrate as components in the or + // This is because the only way they would opt out of hoisting semantics is to be in svg, which is not possible + // in these scopes, or to have an itemProp while being in an itemScope. We define , , and as not + // supporting itemScope to make this a strict guarantee. Because we can never be in this position we elide the check here. + // case 'title': + // case 'meta': { + // continue; + // } + case 'link': { + const rel = element.getAttribute('rel'); + if ( + rel === 'stylesheet' && + element.hasAttribute('data-precedence') + ) { + // This is a stylesheet resource and necessarily not a target for hydration of a component + continue; + } else if ( + rel !== (props: any).rel || + element.getAttribute('href') !== (props: any).href + ) { + // This link Node is definitely not a match for this rendered link + continue; + } + break; + } + case 'style': { + if (element.hasAttribute('data-precedence')) { + // This i a style resource necessarily not a target for hydration of a component + continue; + } else if ( + typeof props.children === 'string' && + element.textContent !== props.children + ) { + // The contents of this style Node do not match the contents of this rendered style + continue; + } + break; + } + case 'script': { + if (element.hasAttribute('async')) { + // This is an async script resource and necessarily not a target for hydration of a compoennt + continue; + } else if ( + typeof (props: any).src === 'string' && + element.getAttribute('src') !== (props: any).src + ) { + // This script is for a different src + continue; + } else if ( + typeof props.children === 'string' && + element.textContent !== props.children + ) { + // This is an inline script with different text content + continue; + } + break; + } + } + + // We have excluded the most likely cases of mismatch between hoistable tags, 3rd party script inserted tags, + // and browser extension inserted tags. While it is possible this is not the right match it is a decent hueristic + // that should work in the vast majority of cases. + return element; + } + } + return null; + } else { + if ( + instance.nodeType !== ELEMENT_NODE || + instance.nodeName.toLowerCase() !== type.toLowerCase() + ) { + return null; + } else { + return ((instance: any): Instance); + } + } +} + +export function getNextMatchingHydratableTextInstance( + instance: HydratableInstance, + text: string, + hostContext: HostContext, +): null | TextInstance { + // We set this first because it must always be set on every invocation + hydratableNode = instance; + + // Return early if there is nothing to hydrate (there will be no dom node if empty text) + if (text === '') return null; + + let context: HostContextProd; + if (__DEV__) { + const hostContextDev: HostContextDev = (hostContext: any); + context = hostContextDev.context; + } else { + const hostContextProd: HostContextProd = (hostContext: any); + context = hostContextProd; + } + + if (context & HoistContext) { + while (hydratableNode) { + const node = hydratableNode; + if (node.nodeType === COMMENT_NODE) { + // This is a suspense boundary we must halt here because we know this was not injected by 3rd party + return null; + } else if (node.nodeType !== TEXT_NODE) { + // Empty strings are not parsed by HTML so there won't be a correct match here. + hydratableNode = getNextHydratableSibling(node); + continue; + } + // This has now been refined to a text node. + return ((hydratableNode: any): TextInstance); + } + } else { + if (instance.nodeType !== TEXT_NODE) { + // Empty strings are not parsed by HTML so there won't be a correct match here. + return null; + } + // This has now been refined to a text node. + return ((instance: any): TextInstance); + } + + return null; +} + +export function getNextMatchingHydratableSuspenseInstance( + instance: HydratableInstance, + hostContext: HostContext, +): null | SuspenseInstance { + // We set this first because it must always be set on every invocation + hydratableNode = instance; + + let context: HostContextProd; + if (__DEV__) { + const hostContextDev: HostContextDev = (hostContext: any); + context = hostContextDev.context; + } else { + const hostContextProd: HostContextProd = (hostContext: any); + context = hostContextProd; + } + + if (context & HoistContext) { + while (hydratableNode) { + if (hydratableNode.nodeType !== COMMENT_NODE) { + hydratableNode = getNextHydratableSibling(hydratableNode); + continue; + } + // This has now been refined to a suspense node. + return ((hydratableNode: any): SuspenseInstance); + } + return null; + } else { + if (instance.nodeType !== COMMENT_NODE) { + return null; + } + // This has now been refined to a suspense node. + return ((instance: any): SuspenseInstance); + } +} + export function canHydrateInstance( instance: HydratableInstance, type: string, @@ -931,114 +1236,8 @@ function getNextHydratable(node: ?Node) { // Skip non-hydratable nodes. for (; node != null; node = ((node: any): Node).nextSibling) { const nodeType = node.nodeType; - if (enableFloat && enableHostSingletons) { - if (nodeType === ELEMENT_NODE) { - const element: Element = (node: any); - switch (element.tagName) { - // This is subtle. in SVG scope the title tag is case sensitive. we don't want to skip - // titles in svg but we do want to skip them outside of svg. there is an edge case where - // you could do `React.createElement('TITLE', ...)` inside an svg scope but the SSR serializer - // will still emit lowercase. Practically speaking the only time the DOM will have a non-uppercased - // title tagName is if it is inside an svg. - // Other Resource types like META, BASE, LINK, and SCRIPT should be treated as resources even inside - // svg scope because they are invalid otherwise. We still don't need to handle the lowercase variant - // because if they are present in the DOM already they would have been hoisted outside the SVG scope - // as Resources. So while it would be correct to skip a inside and this algorithm won't - // skip that link because the tagName will not be uppercased it functionally is irrelevant. If one - // tries to render incompatible types such as a non-resource stylesheet inside an svg the server will - // emit that invalid html and hydration will fail. In Dev this will present warnings guiding the - // developer on how to fix. - case 'TITLE': - case 'META': - case 'HTML': - case 'HEAD': - case 'BODY': { - continue; - } - case 'LINK': { - const linkEl: HTMLLinkElement = (element: any); - // All links that are server rendered are resources except - // stylesheets that do not have a precedence - if ( - linkEl.rel === 'stylesheet' && - !linkEl.hasAttribute('data-precedence') - ) { - break; - } - continue; - } - case 'STYLE': { - const styleEl: HTMLStyleElement = (element: any); - if (styleEl.hasAttribute('data-precedence')) { - continue; - } - break; - } - case 'SCRIPT': { - const scriptEl: HTMLScriptElement = (element: any); - if (scriptEl.hasAttribute('async')) { - continue; - } - break; - } - } - break; - } else if (nodeType === TEXT_NODE) { - break; - } - } else if (enableFloat) { - if (nodeType === ELEMENT_NODE) { - const element: Element = (node: any); - switch (element.tagName) { - case 'TITLE': - case 'META': { - continue; - } - case 'LINK': { - const linkEl: HTMLLinkElement = (element: any); - // All links that are server rendered are resources except - // stylesheets that do not have a precedence - if ( - linkEl.rel === 'stylesheet' && - !linkEl.hasAttribute('data-precedence') - ) { - break; - } - continue; - } - case 'STYLE': { - const styleEl: HTMLStyleElement = (element: any); - if (styleEl.hasAttribute('data-precedence')) { - continue; - } - break; - } - case 'SCRIPT': { - const scriptEl: HTMLScriptElement = (element: any); - if (scriptEl.hasAttribute('async')) { - continue; - } - break; - } - } - break; - } else if (nodeType === TEXT_NODE) { - break; - } - } else if (enableHostSingletons) { - if (nodeType === ELEMENT_NODE) { - const tag: string = (node: any).tagName; - if (tag === 'HTML' || tag === 'HEAD' || tag === 'BODY') { - continue; - } - break; - } else if (nodeType === TEXT_NODE) { - break; - } - } else { - if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) { - break; - } + if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) { + break; } if (nodeType === COMMENT_NODE) { const nodeData = (node: any).data; @@ -1093,27 +1292,49 @@ export function hydrateInstance( // TODO: Possibly defer this until the commit phase where all the events // get attached. updateFiberProps(instance, props); - let parentNamespace: string; - if (__DEV__) { - const hostContextDev = ((hostContext: any): HostContextDev); - parentNamespace = hostContextDev.namespace; - } else { - parentNamespace = ((hostContext: any): HostContextProd); - } // TODO: Temporary hack to check if we're in a concurrent root. We can delete // when the legacy root API is removed. const isConcurrentMode = ((internalInstanceHandle: Fiber).mode & ConcurrentMode) !== NoMode; - return diffHydratedProperties( - instance, - type, - props, - parentNamespace, - isConcurrentMode, - shouldWarnDev, - ); + if (__DEV__) { + const hostContextDev = ((hostContext: any): HostContextDev); + const namespaceContext = hostContextDev.context & AnyNamespaceContext; + let namespaceDev; + if ( + namespaceContext === SVGNamespaceContext || + (type === 'svg' && namespaceContext === HTMLNamespaceContext) + ) { + namespaceDev = SVG_NAMESPACE; + } else if ( + namespaceContext === MathNamespaceContext || + (type === 'math' && namespaceContext === HTMLNamespaceContext) + ) { + namespaceDev = MATH_NAMESPACE; + } else { + namespaceDev = HTML_NAMESPACE; + } + return diffHydratedProperties( + instance, + type, + props, + isConcurrentMode, + shouldWarnDev, + namespaceDev, + ); + } else { + // It is no longer free to derive the namespace from the HostContext in Prod. This value + // was originally required for diffHydratedProperties but since it is only used in development mode + // it is not optional and we omit the work to derive the namespace in prod + return diffHydratedProperties( + instance, + type, + props, + isConcurrentMode, + shouldWarnDev, + ); + } } export function hydrateTextInstance( @@ -1584,28 +1805,33 @@ export function isHostHoistableType( hostContext: HostContext, ): boolean { let outsideHostContainerContext: boolean; - let namespace: string; + let context: HostContextProd; if (__DEV__) { const hostContextDev: HostContextDev = (hostContext: any); // We can only render resources when we are not within the host container context outsideHostContainerContext = !hostContextDev.ancestorInfo.containerTagInScope; - namespace = hostContextDev.namespace; + context = hostContextDev.context; } else { const hostContextProd: HostContextProd = (hostContext: any); - namespace = hostContextProd; + context = hostContextProd; } + + // Global opt out of hoisting for anything in SVG Namespace or anything with an itemProp inside an itemScope + if (context & ItemInScope || context & SVGNamespaceContext) { + return false; + } + switch (type) { case 'meta': case 'title': { - return namespace !== SVG_NAMESPACE; + return true; } case 'style': { if ( typeof props.precedence !== 'string' || typeof props.href !== 'string' || - props.href === '' || - namespace === SVG_NAMESPACE + props.href === '' ) { if (__DEV__) { if (outsideHostContainerContext) { @@ -1629,8 +1855,7 @@ export function isHostHoistableType( typeof props.href !== 'string' || props.href === '' || props.onLoad || - props.onError || - namespace === SVG_NAMESPACE + props.onError ) { if (__DEV__) { if ( @@ -1686,8 +1911,7 @@ export function isHostHoistableType( props.onLoad || props.onError || typeof props.src !== 'string' || - !props.src || - namespace === SVG_NAMESPACE + !props.src ) { if (__DEV__) { if (outsideHostContainerContext) { @@ -1771,10 +1995,16 @@ export function resolveSingletonInstance( validateDOMNestingDev: boolean, ): Instance { if (__DEV__) { + const hostContextDev = ((hostContext: any): HostContextDev); if (validateDOMNestingDev) { - const hostContextDev = ((hostContext: any): HostContextDev); validateDOMNesting(type, null, hostContextDev.ancestorInfo); } + if (hostContextDev.context & ItemInScope && props.itemScope === true) { + console.error( + 'React expected the <%s> element to not have an `itemScope` prop but found this prop instead. React does not support itemScopes on , , or because it interferes with hoisting certain tags. Try moving the itemScope to an element just inside the instead.', + type, + ); + } } const ownerDocument = getOwnerDocumentFromRootContainer( rootContainerInstance, diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index f3a234305b4df..4e3cf36a31e2e 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -333,17 +333,20 @@ export type FormatContext = { insertionMode: InsertionMode, // root/svg/html/mathml/table selectedValue: null | string | Array, // the selected value(s) inside a noscriptTagInScope: boolean, + itemInScope: boolean, }; function createFormatContext( insertionMode: InsertionMode, - selectedValue: null | string, + selectedValue: null | string | Array, noscriptTagInScope: boolean, + itemInScope: boolean, ): FormatContext { return { insertionMode, selectedValue, noscriptTagInScope, + itemInScope, }; } @@ -354,7 +357,7 @@ export function createRootFormatContext(namespaceURI?: string): FormatContext { : namespaceURI === 'http://www.w3.org/1998/Math/MathML' ? MATHML_MODE : ROOT_HTML_MODE; - return createFormatContext(insertionMode, null, false); + return createFormatContext(insertionMode, null, false, false); } export function getChildFormatContext( @@ -362,32 +365,39 @@ export function getChildFormatContext( type: string, props: Object, ): FormatContext { + const itemScoped = + parentContext.itemInScope || + (props.itemScope === true && parentContext.insertionMode > HTML_HTML_MODE); switch (type) { case 'noscript': - return createFormatContext(HTML_MODE, null, true); + return createFormatContext(HTML_MODE, null, true, itemScoped); case 'select': return createFormatContext( HTML_MODE, props.value != null ? props.value : props.defaultValue, parentContext.noscriptTagInScope, + itemScoped, ); case 'svg': return createFormatContext( SVG_MODE, null, parentContext.noscriptTagInScope, + itemScoped, ); case 'math': return createFormatContext( MATHML_MODE, null, parentContext.noscriptTagInScope, + itemScoped, ); case 'foreignObject': return createFormatContext( HTML_MODE, null, parentContext.noscriptTagInScope, + itemScoped, ); // Table parents are special in that their children can only be created at all if they're // wrapped in a table parent. So we need to encode that we're entering this mode. @@ -396,6 +406,7 @@ export function getChildFormatContext( HTML_TABLE_MODE, null, parentContext.noscriptTagInScope, + itemScoped, ); case 'thead': case 'tbody': @@ -404,18 +415,21 @@ export function getChildFormatContext( HTML_TABLE_BODY_MODE, null, parentContext.noscriptTagInScope, + itemScoped, ); case 'colgroup': return createFormatContext( HTML_COLGROUP_MODE, null, parentContext.noscriptTagInScope, + itemScoped, ); case 'tr': return createFormatContext( HTML_TABLE_ROW_MODE, null, parentContext.noscriptTagInScope, + itemScoped, ); } if (parentContext.insertionMode >= HTML_TABLE_MODE) { @@ -425,19 +439,28 @@ export function getChildFormatContext( HTML_MODE, null, parentContext.noscriptTagInScope, + itemScoped, ); } if (parentContext.insertionMode === ROOT_HTML_MODE) { if (type === 'html') { // We've emitted the root and is now in mode. - return createFormatContext(HTML_HTML_MODE, null, false); + return createFormatContext(HTML_HTML_MODE, null, false, itemScoped); } else { // We've emitted the root and is now in plain HTML mode. - return createFormatContext(HTML_MODE, null, false); + return createFormatContext(HTML_MODE, null, false, itemScoped); } } else if (parentContext.insertionMode === HTML_HTML_MODE) { // We've emitted the document element and is now in plain HTML mode. - return createFormatContext(HTML_MODE, null, false); + return createFormatContext(HTML_MODE, null, false, itemScoped); + } + if (itemScoped !== parentContext.itemInScope) { + return createFormatContext( + parentContext.insertionMode, + parentContext.selectedValue, + parentContext.noscriptTagInScope, + itemScoped, + ); } return parentContext; } @@ -1255,9 +1278,14 @@ function pushMeta( textEmbedded: boolean, insertionMode: InsertionMode, noscriptTagInScope: boolean, + itemInScope: boolean, ): null { if (enableFloat) { - if (insertionMode === SVG_MODE || noscriptTagInScope) { + if ( + insertionMode === SVG_MODE || + noscriptTagInScope || + (itemInScope && props.itemProp != null) + ) { return pushSelfClosing(target, props, 'meta'); } else { if (textEmbedded) { @@ -1285,6 +1313,7 @@ function pushLink( textEmbedded: boolean, insertionMode: InsertionMode, noscriptTagInScope: boolean, + itemInScope: boolean, ): null { if (enableFloat) { const rel = props.rel; @@ -1293,6 +1322,7 @@ function pushLink( if ( insertionMode === SVG_MODE || noscriptTagInScope || + (itemInScope && props.itemProp != null) || typeof rel !== 'string' || typeof href !== 'string' || href === '' @@ -1536,6 +1566,7 @@ function pushStyle( textEmbedded: boolean, insertionMode: InsertionMode, noscriptTagInScope: boolean, + itemInScope: boolean, ): ReactNodeList { if (__DEV__) { if (hasOwnProperty.call(props, 'children')) { @@ -1573,6 +1604,7 @@ function pushStyle( if ( insertionMode === SVG_MODE || noscriptTagInScope || + (itemInScope && props.itemProp != null) || typeof precedence !== 'string' || typeof href !== 'string' || href === '' @@ -1793,6 +1825,7 @@ function pushTitle( responseState: ResponseState, insertionMode: InsertionMode, noscriptTagInScope: boolean, + itemInScope: boolean, ): ReactNodeList { if (__DEV__) { if (hasOwnProperty.call(props, 'children')) { @@ -1843,7 +1876,11 @@ function pushTitle( } if (enableFloat) { - if (insertionMode !== SVG_MODE && !noscriptTagInScope) { + if ( + insertionMode !== SVG_MODE && + !noscriptTagInScope && + (!itemInScope || props.itemProp == null) + ) { pushTitleImpl(responseState.hoistableChunks, props); return null; } else { @@ -2029,11 +2066,13 @@ function pushScript( textEmbedded: boolean, insertionMode: InsertionMode, noscriptTagInScope: boolean, + itemInScope: boolean, ): null { if (enableFloat) { if ( insertionMode === SVG_MODE || noscriptTagInScope || + (itemInScope && props.itemProp != null) || typeof props.src !== 'string' || !props.src ) { @@ -2492,6 +2531,7 @@ export function pushStartInstance( responseState, formatContext.insertionMode, formatContext.noscriptTagInScope, + formatContext.itemInScope, ) : pushStartTitle(target, props); case 'link': @@ -2503,6 +2543,7 @@ export function pushStartInstance( textEmbedded, formatContext.insertionMode, formatContext.noscriptTagInScope, + formatContext.itemInScope, ); case 'script': return enableFloat @@ -2513,6 +2554,7 @@ export function pushStartInstance( textEmbedded, formatContext.insertionMode, formatContext.noscriptTagInScope, + formatContext.itemInScope, ) : pushStartGenericElement(target, props, type); case 'style': @@ -2523,6 +2565,7 @@ export function pushStartInstance( textEmbedded, formatContext.insertionMode, formatContext.noscriptTagInScope, + formatContext.itemInScope, ); case 'meta': return pushMeta( @@ -2532,6 +2575,7 @@ export function pushStartInstance( textEmbedded, formatContext.insertionMode, formatContext.noscriptTagInScope, + formatContext.itemInScope, ); // Newline eating tags case 'listing': diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index 5ca51fbcbbac0..14b0d09e1c233 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -101,6 +101,7 @@ export function createRootFormatContext(): FormatContext { insertionMode: HTML_MODE, // We skip the root mode because we don't want to emit the DOCTYPE in legacy mode. selectedValue: null, noscriptTagInScope: false, + itemInScope: false, }; } diff --git a/packages/react-dom-bindings/src/shared/DOMNamespaces.js b/packages/react-dom-bindings/src/shared/DOMNamespaces.js index 43fdf00865155..ff39110dc769e 100644 --- a/packages/react-dom-bindings/src/shared/DOMNamespaces.js +++ b/packages/react-dom-bindings/src/shared/DOMNamespaces.js @@ -4,9 +4,13 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @noflow + * @flow */ +export type ExoticNamespace = + | 'http://www.w3.org/1998/Math/MathML' + | 'http://www.w3.org/2000/svg'; + export const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; export const MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index eebc2f078ead0..f88c733f06b73 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -369,7 +369,6 @@ describe('ReactDOMFloat', () => { foo -