diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 620dc314485e1..e736f2ffb53a8 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -55,7 +55,12 @@ import { setValueForStyles, validateShorthandPropertyCollisionInDev, } from './CSSPropertyOperations'; -import {HTML_NAMESPACE, getIntrinsicNamespace} from '../shared/DOMNamespaces'; +import { + HTML_NAMESPACE, + MATH_NAMESPACE, + SVG_NAMESPACE, + getIntrinsicNamespace, +} from '../shared/DOMNamespaces'; import { getPropertyInfo, shouldIgnoreAttribute, @@ -375,112 +380,112 @@ function updateDOMProperties( } } -export function createElement( +// Creates elements in the HTML namesapce +export function createHTMLElement( type: string, props: Object, - rootContainerElement: Element | Document | DocumentFragment, - parentNamespace: string, + ownerDocument: Document, ): Element { let isCustomComponentTag; - // We create tags in the namespace of their parent container, except HTML - // tags get no namespace. - 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 = ' + // but it seems reasonable and conservative to reject this as a hydration error as well + return false; + } else if ( + instance.nodeName.toLowerCase() !== type.toLowerCase() || + isMarkedResource(instance) + ) { + // We are either about to + return true; + } else { + // We have an Element with the right type. + const element: Element = (instance: any); + const anyProps = (props: 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) { + // case 'title': + //We assume all titles are matchable. You should only have one in the Document, at least in a hoistable scope + // and if you are a HostComponent with type title we must either be in an context or this title must have an `itemProp` prop. + case 'meta': { + // The only way to opt out of hoisting meta tags is to give it an itemprop attribute. We assume there will be + // not 3rd party meta tags that are prepended, accepting the cases where this isn't true because meta tags + // are usually only functional for SSR so even in a rare case where we did bind to an injected tag the runtime + // implications are minimal + if (!element.hasAttribute('itemprop')) { + // This is a Hoistable + return true; + } + break; + } + case 'link': { + // Links come in many forms and we do expect 3rd parties to inject them into / . We exclude known resources + // and then use high-entroy attributes like href which are almost always used and almost always unique to filter out unlikely + // matches. + const rel = element.getAttribute('rel'); + if (rel === 'stylesheet' && element.hasAttribute('data-precedence')) { + // This is a stylesheet resource + return true; + } else if ( + rel !== anyProps.rel || + element.getAttribute('href') !== + (anyProps.href == null ? null : anyProps.href) || + element.getAttribute('crossorigin') !== + (anyProps.crossOrigin == null ? null : anyProps.crossOrigin) || + element.getAttribute('title') !== + (anyProps.title == null ? null : anyProps.title) + ) { + // rel + href should usually be enough to uniquely identify a link however crossOrigin can vary for rel preconnect + // and title could vary for rel alternate + return true; + } + break; + } + case 'style': { + // Styles are hard to match correctly. We can exclude known resources but otherwise we accept the fact that a non-hoisted style tags + // in or are likely never going to be unmounted given their position in the document and the fact they likely hold global styles + if (element.hasAttribute('data-precedence')) { + // This is a style resource + return true; + } + break; + } + case 'script': { + // Scripts are a little tricky, we exclude known resources and then similar to links try to use high-entropy attributes + // to reject poor matches. One challenge with scripts are inline scripts. We don't attempt to check text content which could + // in theory lead to a hydration error later if a 3rd party injected an inline script before the React rendered nodes. + // Falling back to client rendering if this happens should be seemless though so we will try this hueristic and revisit later + // if we learn it is problematic + const srcAttr = element.getAttribute('src'); + if ( + srcAttr && + element.hasAttribute('async') && + !element.hasAttribute('itemprop') + ) { + // This is an async script resource + return true; + } else if ( + srcAttr !== (anyProps.src == null ? null : anyProps.src) || + element.getAttribute('type') !== + (anyProps.type == null ? null : anyProps.type) || + element.getAttribute('crossorigin') !== + (anyProps.crossOrigin == null ? null : anyProps.crossOrigin) + ) { + // This script is for a different src + return true; + } + 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 false; + } +} + +export function shouldSkipHydratableForTextInstance( + instance: HydratableInstance, +): boolean { + return instance.nodeType === ELEMENT_NODE; +} + +export function shouldSkipHydratableForSuspenseInstance( + instance: HydratableInstance, +): boolean { + return instance.nodeType === ELEMENT_NODE; +} export function canHydrateInstance( instance: HydratableInstance, @@ -852,19 +989,21 @@ export function canHydrateInstance( ): null | Instance { if ( instance.nodeType !== ELEMENT_NODE || - type.toLowerCase() !== instance.nodeName.toLowerCase() + instance.nodeName.toLowerCase() !== type.toLowerCase() ) { return null; + } else { + return ((instance: any): Instance); } - // This has now been refined to an element node. - return ((instance: any): Instance); } export function canHydrateTextInstance( instance: HydratableInstance, text: string, ): null | TextInstance { - if (text === '' || instance.nodeType !== TEXT_NODE) { + if (text === '') return null; + + if (instance.nodeType !== TEXT_NODE) { // Empty strings are not parsed by HTML so there won't be a correct match here. return null; } @@ -876,7 +1015,6 @@ export function canHydrateSuspenseInstance( instance: HydratableInstance, ): null | SuspenseInstance { if (instance.nodeType !== COMMENT_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 suspense node. @@ -931,114 +1069,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,26 +1125,28 @@ 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; + let parentNamespace; + if (__DEV__) { + const hostContextDev = ((hostContext: any): HostContextDev); + parentNamespace = hostContextDev.namespace; + } else { + const hostContextProd = ((hostContext: any): HostContextProd); + parentNamespace = hostContextProd; + } + return diffHydratedProperties( instance, type, props, - parentNamespace, isConcurrentMode, shouldWarnDev, + parentNamespace, ); } @@ -1584,7 +1618,7 @@ export function isHostHoistableType( hostContext: HostContext, ): boolean { let outsideHostContainerContext: boolean; - let namespace: string; + let namespace: HostContextProd; if (__DEV__) { const hostContextDev: HostContextDev = (hostContext: any); // We can only render resources when we are not within the host container context @@ -1595,17 +1629,41 @@ export function isHostHoistableType( const hostContextProd: HostContextProd = (hostContext: any); namespace = hostContextProd; } + + // Global opt out of hoisting for anything in SVG Namespace or anything with an itemProp inside an itemScope + if (namespace === SVG_NAMESPACE || props.itemProp != null) { + if (__DEV__) { + if ( + outsideHostContainerContext && + props.itemProp != null && + (type === 'meta' || + type === 'title' || + type === 'style' || + type === 'link' || + type === 'script') + ) { + console.error( + 'Cannot render a <%s> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an' + + ' `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <%s> remove the `itemProp` prop.' + + ' Otherwise, try moving this tag into the or of the Document.', + type, + type, + ); + } + } + 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 +1687,7 @@ export function isHostHoistableType( typeof props.href !== 'string' || props.href === '' || props.onLoad || - props.onError || - namespace === SVG_NAMESPACE + props.onError ) { if (__DEV__) { if ( @@ -1686,8 +1743,7 @@ export function isHostHoistableType( props.onLoad || props.onError || typeof props.src !== 'string' || - !props.src || - namespace === SVG_NAMESPACE + !props.src ) { if (__DEV__) { if (outsideHostContainerContext) { @@ -1771,8 +1827,8 @@ 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); } } diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index f3a234305b4df..77a7067e5d413 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -1257,7 +1257,11 @@ function pushMeta( noscriptTagInScope: boolean, ): null { if (enableFloat) { - if (insertionMode === SVG_MODE || noscriptTagInScope) { + if ( + insertionMode === SVG_MODE || + noscriptTagInScope || + props.itemProp != null + ) { return pushSelfClosing(target, props, 'meta'); } else { if (textEmbedded) { @@ -1293,6 +1297,7 @@ function pushLink( if ( insertionMode === SVG_MODE || noscriptTagInScope || + props.itemProp != null || typeof rel !== 'string' || typeof href !== 'string' || href === '' @@ -1573,6 +1578,7 @@ function pushStyle( if ( insertionMode === SVG_MODE || noscriptTagInScope || + props.itemProp != null || typeof precedence !== 'string' || typeof href !== 'string' || href === '' @@ -1843,7 +1849,11 @@ function pushTitle( } if (enableFloat) { - if (insertionMode !== SVG_MODE && !noscriptTagInScope) { + if ( + insertionMode !== SVG_MODE && + !noscriptTagInScope && + props.itemProp == null + ) { pushTitleImpl(responseState.hoistableChunks, props); return null; } else { @@ -2034,6 +2044,7 @@ function pushScript( if ( insertionMode === SVG_MODE || noscriptTagInScope || + props.itemProp != null || typeof props.src !== 'string' || !props.src ) { diff --git a/packages/react-dom-bindings/src/shared/DOMNamespaces.js b/packages/react-dom-bindings/src/shared/DOMNamespaces.js index 43fdf00865155..6c6c887ffafa5 100644 --- a/packages/react-dom-bindings/src/shared/DOMNamespaces.js +++ b/packages/react-dom-bindings/src/shared/DOMNamespaces.js @@ -4,7 +4,7 @@ * 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 const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index ca5222a9b56b4..0d540b9541ffc 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 - + +
+
+ + + title + + + + +
+
+ + + title + + + + +
+
+ + + title + + + + + {/* Component because it has itemProp */} + + {/* regular Hoistable */} + + {/* regular Hoistable */} + title +
+
+
deep hello
+ {/* Component because it has itemProp */} + +
+
+ + + + + ); + } + + await actIntoEmptyDocument(() => { + renderToPipeableStream().pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + {/* Hoisted Resources and elements */} + + + + +
+
+
deep hello
+ +
+
+ + , + ); + + // We inject some styles, divs, scripts into the begginning, middle, and end + // of the head / body. + const injectedStyle = document.createElement('style'); + injectedStyle.textContent = 'body { background-color: blue; }'; + document.head.prepend(injectedStyle.cloneNode(true)); + document.head.appendChild(injectedStyle.cloneNode(true)); + document.body.prepend(injectedStyle.cloneNode(true)); + document.body.appendChild(injectedStyle.cloneNode(true)); + + const injectedDiv = document.createElement('div'); + document.head.prepend(injectedDiv); + document.head.appendChild(injectedDiv.cloneNode(true)); + // We do not prepend a
in body because this will conflict with hyration + // We still mostly hydrate by matchign tag and
does not have any attributes to + // differentiate between likely-inject and likely-rendered cases. If a
is prepended + // in the and you render a
as the first child of there will be a conflict. + // We consider this a rare edge case and even if it does happen the fallback to client rendering + // should patch up the DOM correctly + document.body.appendChild(injectedDiv.cloneNode(true)); + + const injectedScript = document.createElement('script'); + injectedScript.setAttribute('async', ''); + injectedScript.setAttribute('src', 'injected'); + document.head.prepend(injectedScript); + document.head.appendChild(injectedScript.cloneNode(true)); + document.body.prepend(injectedScript.cloneNode(true)); + document.body.appendChild(injectedScript.cloneNode(true)); + + // We hydrate the same App and confirm the output is identical except for the async + // script insertion that happens because we do not SSR async scripts with load handlers. + // All the extra inject nodes are preset + const root = ReactDOMClient.hydrateRoot(document, ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +