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 <SVG> or <mATH>. + 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 <SVG> or <mATH>. - 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 = '<script><' + '/script>'; // eslint-disable-line - // This is guaranteed to yield a script element. - const firstChild = ((div.firstChild: any): HTMLScriptElement); - domElement = div.removeChild(firstChild); - } else if (typeof props.is === 'string') { - domElement = ownerDocument.createElement(type, {is: props.is}); - } else { - // Separate else branch instead of using `props.is || undefined` above because of a Firefox bug. - // See discussion in https://github.com/facebook/react/pull/6896 - // and discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1276240 - domElement = ownerDocument.createElement(type); - // Normally attributes are assigned in `setInitialDOMProperties`, however the `multiple` and `size` - // attributes on `select`s needs to be added before `option`s are inserted. - // This prevents: - // - a bug where the `select` does not scroll to the correct option because singular - // `select` elements automatically pick the first item #13222 - // - a bug where the `select` set the first item as selected despite the `size` attribute #14239 - // See https://github.com/facebook/react/issues/13222 - // and https://github.com/facebook/react/issues/14239 - if (type === 'select') { - const node = ((domElement: any): HTMLSelectElement); - if (props.multiple) { - node.multiple = true; - } else if (props.size) { - // Setting a size greater than 1 causes a select to behave like `multiple=true`, where - // it is possible that no option is selected. - // - // This is only necessary when a select in "single selection mode". - node.size = props.size; - } + div.innerHTML = '<script><' + '/script>'; // eslint-disable-line + // This is guaranteed to yield a script element. + const firstChild = ((div.firstChild: any): HTMLScriptElement); + domElement = div.removeChild(firstChild); + } else if (typeof props.is === 'string') { + domElement = ownerDocument.createElement(type, {is: props.is}); + } else { + // Separate else branch instead of using `props.is || undefined` above because of a Firefox bug. + // See discussion in https://github.com/facebook/react/pull/6896 + // and discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1276240 + domElement = ownerDocument.createElement(type); + // Normally attributes are assigned in `setInitialDOMProperties`, however the `multiple` and `size` + // attributes on `select`s needs to be added before `option`s are inserted. + // This prevents: + // - a bug where the `select` does not scroll to the correct option because singular + // `select` elements automatically pick the first item #13222 + // - a bug where the `select` set the first item as selected despite the `size` attribute #14239 + // See https://github.com/facebook/react/issues/13222 + // and https://github.com/facebook/react/issues/14239 + if (type === 'select') { + const node = ((domElement: any): HTMLSelectElement); + if (props.multiple) { + node.multiple = true; + } else if (props.size) { + // Setting a size greater than 1 causes a select to behave like `multiple=true`, where + // it is possible that no option is selected. + // + // This is only necessary when a select in "single selection mode". + node.size = props.size; } } - } else { - domElement = ownerDocument.createElementNS(namespaceURI, type); } if (__DEV__) { - if (namespaceURI === HTML_NAMESPACE) { - if ( - !isCustomComponentTag && - // $FlowFixMe[method-unbinding] - Object.prototype.toString.call(domElement) === - '[object HTMLUnknownElement]' && - !hasOwnProperty.call(warnedUnknownTags, type) - ) { - warnedUnknownTags[type] = true; - console.error( - 'The tag <%s> is unrecognized in this browser. ' + - 'If you meant to render a React component, start its name with ' + - 'an uppercase letter.', - type, - ); - } + if ( + !isCustomComponentTag && + // $FlowFixMe[method-unbinding] + Object.prototype.toString.call(domElement) === + '[object HTMLUnknownElement]' && + !hasOwnProperty.call(warnedUnknownTags, type) + ) { + warnedUnknownTags[type] = true; + console.error( + 'The tag <%s> is unrecognized in this browser. ' + + 'If you meant to render a React component, start its name with ' + + 'an uppercase letter.', + type, + ); } } return domElement; } +export function createSVGElement( + type: string, + ownerDocument: Document, +): Element { + return ownerDocument.createElementNS(SVG_NAMESPACE, type); +} + +export function createMathElement( + type: string, + ownerDocument: Document, +): Element { + return ownerDocument.createElementNS(MATH_NAMESPACE, type); +} + export function createTextNode( text: string, rootContainerElement: Element | Document | DocumentFragment, @@ -864,9 +869,9 @@ export function diffHydratedProperties( domElement: Element, tag: string, rawProps: Object, - parentNamespace: string, isConcurrentMode: boolean, shouldWarnDev: boolean, + parentNamespaceDev: string, ): null | Array<mixed> { let isCustomComponentTag; let extraAttributeNames: Set<string>; @@ -1109,11 +1114,11 @@ export function diffHydratedProperties( propertyInfo, ); } else { - let ownNamespace = parentNamespace; - if (ownNamespace === HTML_NAMESPACE) { - ownNamespace = getIntrinsicNamespace(tag); + let ownNamespaceDev = parentNamespaceDev; + if (ownNamespaceDev === HTML_NAMESPACE) { + ownNamespaceDev = getIntrinsicNamespace(tag); } - if (ownNamespace === HTML_NAMESPACE) { + if (ownNamespaceDev === HTML_NAMESPACE) { // $FlowFixMe - Should be inferred as not undefined. extraAttributeNames.delete(propKey.toLowerCase()); } else { diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 89e7f6bc0ac29..8a6eac6987667 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -9,30 +9,32 @@ import type {Instance, Container} from './ReactDOMHostConfig'; -import {isAttributeNameSafe} from '../shared/DOMProperty'; -import {precacheFiberNode} from './ReactDOMComponentTree'; +import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext'; import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js'; const {Dispatcher} = ReactDOMSharedInternals; +import { + checkAttributeStringCoercion, + checkPropStringCoercion, +} from 'shared/CheckStringCoercion'; + import {DOCUMENT_NODE} from '../shared/HTMLNodeType'; +import {isAttributeNameSafe} from '../shared/DOMProperty'; +import {SVG_NAMESPACE} from '../shared/DOMNamespaces'; import { validatePreloadArguments, validatePreinitArguments, getValueDescriptorExpectingObjectForWarning, getValueDescriptorExpectingEnumForWarning, } from '../shared/ReactDOMResourceValidation'; -import {createElement, setInitialProperties} from './ReactDOMComponent'; -import { - checkAttributeStringCoercion, - checkPropStringCoercion, -} from 'shared/CheckStringCoercion'; + +import {precacheFiberNode} from './ReactDOMComponentTree'; +import {createHTMLElement, setInitialProperties} from './ReactDOMComponent'; import { getResourcesFromRoot, isMarkedResource, markNodeAsResource, } from './ReactDOMComponentTree'; -import {HTML_NAMESPACE, SVG_NAMESPACE} from '../shared/DOMNamespaces'; -import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext'; // The resource types we support. currently they match the form for the as argument. // In the future this may need to change, especially when modules / scripts are supported @@ -174,11 +176,10 @@ function preconnectAs( const preconnectProps = {rel, crossOrigin, href}; if (null === ownerDocument.querySelector(key)) { - const preloadInstance = createElement( + const preloadInstance = createHTMLElement( 'link', preconnectProps, ownerDocument, - HTML_NAMESPACE, ); setInitialProperties(preloadInstance, 'link', preconnectProps); markNodeAsResource(preloadInstance); @@ -289,11 +290,10 @@ function preload(href: string, options: PreloadOptions) { preloadPropsMap.set(key, preloadProps); if (null === ownerDocument.querySelector(preloadKey)) { - const preloadInstance = createElement( + const preloadInstance = createHTMLElement( 'link', preloadProps, ownerDocument, - HTML_NAMESPACE, ); setInitialProperties(preloadInstance, 'link', preloadProps); markNodeAsResource(preloadInstance); @@ -371,11 +371,10 @@ function preinit(href: string, options: PreinitOptions) { preloadPropsMap.set(key, preloadProps); if (null === preloadDocument.querySelector(preloadKey)) { - const preloadInstance = createElement( + const preloadInstance = createHTMLElement( 'link', preloadProps, preloadDocument, - HTML_NAMESPACE, ); setInitialProperties(preloadInstance, 'link', preloadProps); markNodeAsResource(preloadInstance); @@ -417,12 +416,8 @@ function preinit(href: string, options: PreinitOptions) { if (preloadProps) { adoptPreloadPropsForStylesheet(stylesheetProps, preloadProps); } - instance = createElement( - 'link', - stylesheetProps, - resourceRoot, - HTML_NAMESPACE, - ); + const ownerDocument = getDocumentFromRoot(resourceRoot); + instance = createHTMLElement('link', stylesheetProps, ownerDocument); markNodeAsResource(instance); setInitialProperties(instance, 'link', stylesheetProps); insertStylesheet(instance, precedence, resourceRoot); @@ -463,12 +458,8 @@ function preinit(href: string, options: PreinitOptions) { if (preloadProps) { adoptPreloadPropsForScript(scriptProps, preloadProps); } - instance = createElement( - 'script', - scriptProps, - resourceRoot, - HTML_NAMESPACE, - ); + const ownerDocument = getDocumentFromRoot(resourceRoot); + instance = createHTMLElement('script', scriptProps, ownerDocument); markNodeAsResource(instance); setInitialProperties(instance, 'link', scriptProps); (getDocumentFromRoot(resourceRoot).head: any).appendChild(instance); @@ -703,11 +694,10 @@ function preloadStylesheet( null === ownerDocument.querySelector(getPreloadStylesheetSelectorFromKey(key)) ) { - const preloadInstance = createElement( + const preloadInstance = createHTMLElement( 'link', preloadProps, ownerDocument, - HTML_NAMESPACE, ); setInitialProperties(preloadInstance, 'link', preloadProps); markNodeAsResource(preloadInstance); @@ -762,16 +752,13 @@ export function acquireResource( ); if (instance) { resource.instance = instance; + markNodeAsResource(instance); return instance; } const styleProps = styleTagPropsFromRawProps(props); - instance = createElement( - 'style', - styleProps, - hoistableRoot, - HTML_NAMESPACE, - ); + const ownerDocument = getDocumentFromRoot(hoistableRoot); + instance = createHTMLElement('style', styleProps, ownerDocument); markNodeAsResource(instance); setInitialProperties(instance, 'style', styleProps); @@ -793,6 +780,7 @@ export function acquireResource( ); if (instance) { resource.instance = instance; + markNodeAsResource(instance); return instance; } @@ -803,12 +791,8 @@ export function acquireResource( } // Construct and insert a new instance - instance = createElement( - 'link', - stylesheetProps, - hoistableRoot, - HTML_NAMESPACE, - ); + const ownerDocument = getDocumentFromRoot(hoistableRoot); + instance = createHTMLElement('link', stylesheetProps, ownerDocument); markNodeAsResource(instance); const linkInstance: HTMLLinkElement = (instance: any); (linkInstance: any)._p = new Promise((resolve, reject) => { @@ -837,6 +821,7 @@ export function acquireResource( ); if (instance) { resource.instance = instance; + markNodeAsResource(instance); return instance; } @@ -848,12 +833,8 @@ export function acquireResource( } // Construct and insert a new instance - instance = createElement( - 'script', - scriptProps, - hoistableRoot, - HTML_NAMESPACE, - ); + const ownerDocument = getDocumentFromRoot(hoistableRoot); + instance = createHTMLElement('script', scriptProps, ownerDocument); markNodeAsResource(instance); setInitialProperties(instance, 'link', scriptProps); (getDocumentFromRoot(hoistableRoot).head: any).appendChild(instance); @@ -1092,7 +1073,7 @@ export function hydrateHoistable( } // There is no matching instance to hydrate, we create it now - const instance = createElement(type, props, ownerDocument, HTML_NAMESPACE); + const instance = createHTMLElement(type, props, ownerDocument); setInitialProperties(instance, type, props); precacheFiberNode(internalInstanceHandle, instance); markNodeAsResource(instance); diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index b774d570f5dab..89182928857ef 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -32,7 +32,9 @@ import { export {detachDeletedInstance}; import {hasRole} from './DOMAccessibilityRoles'; import { - createElement, + createHTMLElement, + createSVGElement, + createMathElement, createTextNode, setInitialProperties, diffProperties, @@ -58,6 +60,7 @@ import { import { getChildNamespace, SVG_NAMESPACE, + MATH_NAMESPACE, HTML_NAMESPACE, } from '../shared/DOMNamespaces'; import { @@ -144,7 +147,7 @@ export interface SuspenseInstance extends Comment { export type HydratableInstance = Instance | TextInstance | SuspenseInstance; export type PublicInstance = Element | Text; type HostContextDev = { - namespace: string, + namespace: HostContextProd, ancestorInfo: AncestorInfoDev, }; type HostContextProd = string; @@ -178,7 +181,7 @@ export function getRootHostContext( rootContainerInstance: Container, ): HostContext { let type; - let namespace; + let namespace: HostContextProd; const nodeType = rootContainerInstance.nodeType; switch (nodeType) { case DOCUMENT_NODE: @@ -274,12 +277,10 @@ export function createHoistableInstance( rootContainerInstance: Container, internalInstanceHandle: Object, ): Instance { - const domElement: Instance = createElement( - type, - props, + const ownerDocument = getOwnerDocumentFromRootContainer( rootContainerInstance, - HTML_NAMESPACE, ); + const domElement: Instance = createHTMLElement(type, props, ownerDocument); precacheFiberNode(internalInstanceHandle, domElement); updateFiberProps(domElement, props); setInitialProperties(domElement, type, props); @@ -294,10 +295,10 @@ export function createInstance( hostContext: HostContext, internalInstanceHandle: Object, ): Instance { - let parentNamespace: string; + let namespace; 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 +311,37 @@ export function createInstance( ); validateDOMNesting(null, string, ownAncestorInfo); } - parentNamespace = hostContextDev.namespace; + namespace = hostContextDev.namespace; } else { - parentNamespace = ((hostContext: any): HostContextProd); + const hostContextProd: HostContextProd = (hostContext: any); + namespace = hostContextProd; } - const domElement: Instance = createElement( - type, - props, + + const ownerDocument = getOwnerDocumentFromRootContainer( rootContainerInstance, - parentNamespace, ); + + let domElement: Instance; + create: switch (namespace) { + case SVG_NAMESPACE: + domElement = createSVGElement(type, ownerDocument); + break; + case MATH_NAMESPACE: + domElement = createMathElement(type, ownerDocument); + break; + case HTML_NAMESPACE: + switch (type) { + case 'svg': + domElement = createSVGElement(type, ownerDocument); + break create; + case 'math': + domElement = createMathElement(type, ownerDocument); + break create; + } + // eslint-disable-next-line no-fallthrough + default: + domElement = createHTMLElement(type, props, ownerDocument); + } precacheFiberNode(internalInstanceHandle, domElement); updateFiberProps(domElement, props); return domElement; @@ -825,17 +847,9 @@ export const supportsHydration = true; // With Resources, some HostComponent types will never be server rendered and need to be // inserted without breaking hydration -export function isHydratable(type: string, props: Props): boolean { +export function isHydratableType(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)); } @@ -844,6 +858,129 @@ export function isHydratable(type: string, props: Props): boolean { return true; } } +export function isHydratableText(text: string): boolean { + return text !== ''; +} + +export function shouldSkipHydratableForInstance( + instance: HydratableInstance, + type: string, + props: Props, +): boolean { + if (instance.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 <body> + // 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 <div> in the <body> 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 <svg> 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 <head> / <body>. 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 <head> or <body> 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 <link> inside <svg> 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 <head> or <body> 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', () => { <meta property="foo" content="bar" /> <title>foo</title> <link rel="foo" href="bar" /> - <link rel="foo" href="bar" /> <noscript><link rel="icon" href="icon"/></noscript> <base target="foo" href="bar" /> <script async="" src="foo" /> @@ -2304,6 +2303,342 @@ body { ); }); + it('does not hoist anything with an itemprop prop', async () => { + function App() { + return ( + <html> + <head> + <meta itemProp="outside" content="unscoped" /> + <link itemProp="link" rel="foo" href="foo" /> + <title itemProp="outside-title">title</title> + <link + itemProp="outside-stylesheet" + rel="stylesheet" + href="bar" + precedence="default" + /> + <style itemProp="outside-style" href="baz" precedence="default"> + outside style + </style> + <script itemProp="outside-script" async={true} src="qux" /> + </head> + <body> + <div itemScope={true}> + <div> + <meta itemProp="inside-meta" content="scoped" /> + <link itemProp="inside-link" rel="foo" href="foo" /> + <title itemProp="inside-title">title</title> + <link + itemProp="inside-stylesheet" + rel="stylesheet" + href="bar" + precedence="default" + /> + <style itemProp="inside-style" href="baz" precedence="default"> + inside style + </style> + <script itemProp="inside-script" async={true} src="qux" /> + </div> + </div> + </body> + </html> + ); + } + await actIntoEmptyDocument(() => { + renderToPipeableStream(<App />).pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + <html> + <head> + <meta itemprop="outside" content="unscoped" /> + <link itemprop="link" rel="foo" href="foo" /> + <title itemprop="outside-title">title</title> + <link + itemprop="outside-stylesheet" + rel="stylesheet" + href="bar" + precedence="default" + /> + <style itemprop="outside-style" href="baz" precedence="default"> + outside style + </style> + <script itemprop="outside-script" async="" src="qux" /> + </head> + <body> + <div itemscope=""> + <div> + <meta itemprop="inside-meta" content="scoped" /> + <link itemprop="inside-link" rel="foo" href="foo" /> + <title itemprop="inside-title">title</title> + <link + itemprop="inside-stylesheet" + rel="stylesheet" + href="bar" + precedence="default" + /> + <style itemprop="inside-style" href="baz" precedence="default"> + inside style + </style> + <script itemprop="inside-script" async="" src="qux" /> + </div> + </div> + </body> + </html>, + ); + + ReactDOMClient.hydrateRoot(document, <App />); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getMeaningfulChildren(document)).toEqual( + <html> + <head> + <meta itemprop="outside" content="unscoped" /> + <link itemprop="link" rel="foo" href="foo" /> + <title itemprop="outside-title">title</title> + <link + itemprop="outside-stylesheet" + rel="stylesheet" + href="bar" + precedence="default" + /> + <style itemprop="outside-style" href="baz" precedence="default"> + outside style + </style> + <script itemprop="outside-script" async="" src="qux" /> + </head> + <body> + <div itemscope=""> + <div> + <meta itemprop="inside-meta" content="scoped" /> + <link itemprop="inside-link" rel="foo" href="foo" /> + <title itemprop="inside-title">title</title> + <link + itemprop="inside-stylesheet" + rel="stylesheet" + href="bar" + precedence="default" + /> + <style itemprop="inside-style" href="baz" precedence="default"> + inside style + </style> + <script itemprop="inside-script" async="" src="qux" /> + </div> + </div> + </body> + </html>, + ); + }); + + it('warns if you render a tag with itemProp outside <body> or <head>', async () => { + const root = ReactDOMClient.createRoot(document); + root.render( + <html> + <meta itemProp="foo" /> + <title itemProp="foo">title</title> + <style itemProp="foo">style</style> + <link itemProp="foo" /> + <script itemProp="foo" /> + </html>, + ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev([ + 'Cannot render a <meta> 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 <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.', + 'Cannot render a <title> 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 <title> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.', + 'Cannot render a <style> 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 <style> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.', + 'Cannot render a <link> 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 <link> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.', + 'Cannot render a <script> 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 <script> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.', + 'validateDOMNesting(...): <meta> cannot appear as a child of <html>', + 'validateDOMNesting(...): <title> cannot appear as a child of <html>', + 'validateDOMNesting(...): <style> cannot appear as a child of <html>', + 'validateDOMNesting(...): <link> cannot appear as a child of <html>', + 'validateDOMNesting(...): <script> cannot appear as a child of <html>', + ]); + }); + + // @gate enableFloat + it('can hydrate resources and components in the head and body even if a browser or 3rd party script injects extra html nodes', async () => { + // This is a stress test case for hydrating a complex combination of hoistable elements, hoistable resources and host components + // in an environment that has been manipulated by 3rd party scripts/extensions to modify the <head> and <body> + function App() { + return ( + <> + <link rel="foo" href="foo" /> + <script async={true} src="rendered" /> + <link rel="stylesheet" href="stylesheet" precedence="default" /> + <html itemScope={true}> + <head> + {/* Component */} + <link rel="stylesheet" href="stylesheet" /> + <script src="sync rendered" data-meaningful="" /> + <style>{'body { background-color: red; }'}</style> + <script src="async rendered" async={true} onLoad={() => {}} /> + <noscript> + <meta name="noscript" content="noscript" /> + </noscript> + <link rel="foo" href="foo" onLoad={() => {}} /> + </head> + <body> + {/* Component because it has itemProp */} + <meta name="foo" content="foo" itemProp="a prop" /> + {/* regular Hoistable */} + <meta name="foo" content="foo" /> + {/* regular Hoistable */} + <title>title</title> + <div itemScope={true}> + <div> + <div>deep hello</div> + {/* Component because it has itemProp */} + <meta name="foo" content="foo" itemProp="a prop" /> + </div> + </div> + </body> + </html> + <link rel="foo" href="foo" /> + </> + ); + } + + await actIntoEmptyDocument(() => { + renderToPipeableStream(<App />).pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + <html itemscope=""> + <head> + {/* Hoisted Resources and elements */} + <link rel="stylesheet" href="stylesheet" data-precedence="default" /> + <script async="" src="rendered" /> + <link rel="preload" as="script" href="sync rendered" /> + <link rel="preload" as="script" href="async rendered" /> + <link rel="foo" href="foo" /> + <meta name="foo" content="foo" /> + <title>title</title> + <link rel="foo" href="foo" /> + {/* rendered host components */} + <link rel="stylesheet" href="stylesheet" /> + <script src="sync rendered" data-meaningful="" /> + <style>{'body { background-color: red; }'}</style> + <noscript><meta name="noscript" content="noscript"/></noscript> + <link rel="foo" href="foo" /> + </head> + <body> + <meta name="foo" content="foo" itemprop="a prop" /> + <div itemscope=""> + <div> + <div>deep hello</div> + <meta name="foo" content="foo" itemprop="a prop" /> + </div> + </div> + </body> + </html>, + ); + + // 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 <div> in body because this will conflict with hyration + // We still mostly hydrate by matchign tag and <div> does not have any attributes to + // differentiate between likely-inject and likely-rendered cases. If a <div> is prepended + // in the <body> and you render a <div> as the first child of <body> 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, <App />); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + <html itemscope=""> + <head> + <script async="" src="injected" /> + <div /> + <style>{'body { background-color: blue; }'}</style> + <link rel="stylesheet" href="stylesheet" data-precedence="default" /> + <script async="" src="rendered" /> + <link rel="preload" as="script" href="sync rendered" /> + <link rel="preload" as="script" href="async rendered" /> + <link rel="foo" href="foo" /> + <meta name="foo" content="foo" /> + <title>title</title> + <link rel="foo" href="foo" /> + <link rel="stylesheet" href="stylesheet" /> + <script src="sync rendered" data-meaningful="" /> + <style>{'body { background-color: red; }'}</style> + <script src="async rendered" async="" /> + <noscript><meta name="noscript" content="noscript"/></noscript> + <link rel="foo" href="foo" /> + <style>{'body { background-color: blue; }'}</style> + <div /> + <script async="" src="injected" /> + </head> + <body> + <script async="" src="injected" /> + <style>{'body { background-color: blue; }'}</style> + <meta name="foo" content="foo" itemprop="a prop" /> + <div itemscope=""> + <div> + <div>deep hello</div> + <meta name="foo" content="foo" itemprop="a prop" /> + </div> + </div> + <style>{'body { background-color: blue; }'}</style> + <div /> + <script async="" src="injected" /> + </body> + </html>, + ); + + // We unmount. The nodes that remain are + // 1. Hoisted resources (we don't clean these up on unmount to address races with streaming suspense and navigation) + // 2. preloads that are injected to hint the browser to load a resource but are not associated to Fibers directly + // 3. Nodes that React skipped over during hydration + root.unmount(); + expect(getMeaningfulChildren(document)).toEqual( + <html> + <head> + <script async="" src="injected" /> + <div /> + <style>{'body { background-color: blue; }'}</style> + <link rel="stylesheet" href="stylesheet" data-precedence="default" /> + <script async="" src="rendered" /> + <link rel="preload" as="script" href="sync rendered" /> + <link rel="preload" as="script" href="async rendered" /> + <style>{'body { background-color: blue; }'}</style> + <div /> + <script async="" src="injected" /> + </head> + <body> + <script async="" src="injected" /> + <style>{'body { background-color: blue; }'}</style> + <style>{'body { background-color: blue; }'}</style> + <div /> + <script async="" src="injected" /> + </body> + </html>, + ); + }); + describe('ReactDOM.prefetchDNS(href)', () => { it('creates a dns-prefetch resource when called', async () => { function App({url}) { diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index e4d9e5806396a..24ce917a18d5a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -3380,7 +3380,6 @@ describe('ReactDOMServerPartialHydration', () => { itHydratesWithoutMismatch('an empty string in class component', TestAppClass); it('fallback to client render on hydration mismatch at root', async () => { - let isClient = false; let suspend = true; let resolve; const promise = new Promise((res, rej) => { @@ -3389,36 +3388,35 @@ describe('ReactDOMServerPartialHydration', () => { res(); }; }); - function App() { + function App({isClient}) { return ( <> <Suspense fallback={<div>Loading</div>}> - <ChildThatSuspends id={1} /> + <ChildThatSuspends id={1} isClient={isClient} /> </Suspense> {isClient ? <span>client</span> : <div>server</div>} <Suspense fallback={<div>Loading</div>}> - <ChildThatSuspends id={2} /> + <ChildThatSuspends id={2} isClient={isClient} /> </Suspense> </> ); } - function ChildThatSuspends({id}) { + function ChildThatSuspends({id, isClient}) { if (isClient && suspend) { throw promise; } return <div>{id}</div>; } - const finalHTML = ReactDOMServer.renderToString(<App />); + const finalHTML = ReactDOMServer.renderToString(<App isClient={false} />); const container = document.createElement('div'); document.body.appendChild(container); container.innerHTML = finalHTML; - isClient = true; expect(() => { act(() => { - ReactDOMClient.hydrateRoot(container, <App />, { + ReactDOMClient.hydrateRoot(container, <App isClient={true} />, { onRecoverableError(error) { Scheduler.log('Log recoverable error: ' + error.message); }, @@ -3435,9 +3433,6 @@ describe('ReactDOMServerPartialHydration', () => { {withoutStack: 1}, ); assertLog([ - 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.', - // TODO: There were multiple mismatches in a single container. Should - // we attempt to de-dupe them? 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.', 'Log recoverable error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', ]); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index e3b08fb1dcb01..4126a7a324c2f 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -227,6 +227,8 @@ import { resetHydrationState, claimHydratableSingleton, tryToClaimNextHydratableInstance, + tryToClaimNextHydratableTextInstance, + tryToClaimNextHydratableSuspenseInstance, warnIfHydrating, queueHydrationError, } from './ReactFiberHydrationContext'; @@ -1694,7 +1696,7 @@ function updateHostSingleton( function updateHostText(current: null | Fiber, workInProgress: Fiber) { if (current === null) { - tryToClaimNextHydratableInstance(workInProgress); + tryToClaimNextHydratableTextInstance(workInProgress); } // Nothing to do here. This is terminal. We'll do the completion step // immediately after. @@ -2250,7 +2252,7 @@ function updateSuspenseComponent( } else { pushFallbackTreeSuspenseHandler(workInProgress); } - tryToClaimNextHydratableInstance(workInProgress); + tryToClaimNextHydratableSuspenseInstance(workInProgress); // This could've been a dehydrated suspense component. const suspenseState: null | SuspenseState = workInProgress.memoizedState; if (suspenseState !== null) { diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js index 26737c8d24fe9..93f82b1965dc9 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js @@ -21,10 +21,8 @@ function shim(...args: any): empty { // Hydration (when unsupported) export type SuspenseInstance = mixed; export const supportsHydration = false; -export const isHydratable = shim; -export const canHydrateInstance = shim; -export const canHydrateTextInstance = shim; -export const canHydrateSuspenseInstance = shim; +export const isHydratableType = shim; +export const isHydratableText = shim; export const isSuspenseInstancePending = shim; export const isSuspenseInstanceFallback = shim; export const getSuspenseInstanceFallbackErrorDetails = shim; @@ -33,6 +31,12 @@ export const getNextHydratableSibling = shim; export const getFirstHydratableChild = shim; export const getFirstHydratableChildWithinContainer = shim; export const getFirstHydratableChildWithinSuspenseInstance = shim; +export const shouldSkipHydratableForInstance = shim; +export const shouldSkipHydratableForTextInstance = shim; +export const shouldSkipHydratableForSuspenseInstance = shim; +export const canHydrateInstance = shim; +export const canHydrateTextInstance = shim; +export const canHydrateSuspenseInstance = shim; export const hydrateInstance = shim; export const hydrateTextInstance = shim; export const hydrateSuspenseInstance = shim; diff --git a/packages/react-reconciler/src/ReactFiberHostContext.js b/packages/react-reconciler/src/ReactFiberHostContext.js index ed1cfa92cddb0..539dedbbd1c8b 100644 --- a/packages/react-reconciler/src/ReactFiberHostContext.js +++ b/packages/react-reconciler/src/ReactFiberHostContext.js @@ -40,7 +40,7 @@ function getRootHostContainer(): Container { return rootInstance; } -function pushHostContainer(fiber: Fiber, nextRootInstance: Container) { +function pushHostContainer(fiber: Fiber, nextRootInstance: Container): void { // Push current root instance onto the stack; // This allows us to reset root when portals are popped. push(rootInstanceStackCursor, nextRootInstance, fiber); diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index fa6bb47298851..fbf74de14396b 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -45,10 +45,6 @@ import { shouldSetTextContent, supportsHydration, supportsSingletons, - isHydratable, - canHydrateInstance, - canHydrateTextInstance, - canHydrateSuspenseInstance, getNextHydratableSibling, getFirstHydratableChild, getFirstHydratableChildWithinContainer, @@ -73,6 +69,14 @@ import { didNotFindHydratableTextInstance, didNotFindHydratableSuspenseInstance, resolveSingletonInstance, + shouldSkipHydratableForInstance, + shouldSkipHydratableForTextInstance, + shouldSkipHydratableForSuspenseInstance, + canHydrateInstance, + canHydrateTextInstance, + canHydrateSuspenseInstance, + isHydratableType, + isHydratableText, } from './ReactFiberHostConfig'; import {OffscreenLane} from './ReactFiberLane'; import { @@ -95,6 +99,8 @@ let didSuspendOrErrorDEV: boolean = false; // Hydration errors that were thrown inside this boundary let hydrationErrors: Array<CapturedValue<mixed>> | null = null; +let rootOrSingletonContext = false; + function warnIfHydrating() { if (__DEV__) { if (isHydrating) { @@ -130,6 +136,7 @@ function enterHydrationState(fiber: Fiber): boolean { isHydrating = true; hydrationErrors = null; didSuspendOrErrorDEV = false; + rootOrSingletonContext = true; return true; } @@ -147,6 +154,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance( isHydrating = true; hydrationErrors = null; didSuspendOrErrorDEV = false; + rootOrSingletonContext = false; if (treeContext !== null) { restoreSuspendedTreeContext(fiber, treeContext); } @@ -336,63 +344,62 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) { warnNonhydratedInstance(returnFiber, fiber); } -function tryHydrate(fiber: Fiber, nextInstance: any) { - switch (fiber.tag) { - // HostSingleton is intentionally omitted. the hydration pathway for singletons is non-fallible - // you can find it inlined in claimHydratableSingleton - case HostComponent: { - const type = fiber.type; - const props = fiber.pendingProps; - const instance = canHydrateInstance(nextInstance, type, props); - if (instance !== null) { - fiber.stateNode = (instance: Instance); - hydrationParentFiber = fiber; - nextHydratableInstance = getFirstHydratableChild(instance); - return true; - } - return false; - } - case HostText: { - const text = fiber.pendingProps; - const textInstance = canHydrateTextInstance(nextInstance, text); - if (textInstance !== null) { - fiber.stateNode = (textInstance: TextInstance); - hydrationParentFiber = fiber; - // Text Instances don't have children so there's nothing to hydrate. - nextHydratableInstance = null; - return true; - } - return false; - } - case SuspenseComponent: { - const suspenseInstance: null | SuspenseInstance = - canHydrateSuspenseInstance(nextInstance); - if (suspenseInstance !== null) { - const suspenseState: SuspenseState = { - dehydrated: suspenseInstance, - treeContext: getSuspendedTreeContext(), - retryLane: OffscreenLane, - }; - fiber.memoizedState = suspenseState; - // Store the dehydrated fragment as a child fiber. - // This simplifies the code for getHostSibling and deleting nodes, - // since it doesn't have to consider all Suspense boundaries and - // check if they're dehydrated ones or not. - const dehydratedFragment = - createFiberFromDehydratedFragment(suspenseInstance); - dehydratedFragment.return = fiber; - fiber.child = dehydratedFragment; - hydrationParentFiber = fiber; - // While a Suspense Instance does have children, we won't step into - // it during the first pass. Instead, we'll reenter it later. - nextHydratableInstance = null; - return true; - } - return false; - } - default: - return false; +function tryHydrateInstance(fiber: Fiber, nextInstance: any) { + // fiber is a HostComponent Fiber + const instance = canHydrateInstance( + nextInstance, + fiber.type, + fiber.pendingProps, + ); + if (instance !== null) { + fiber.stateNode = (instance: Instance); + hydrationParentFiber = fiber; + nextHydratableInstance = getFirstHydratableChild(instance); + rootOrSingletonContext = false; + return true; } + return false; +} + +function tryHydrateText(fiber: Fiber, nextInstance: any) { + // fiber is a HostText Fiber + const text = fiber.pendingProps; + const textInstance = canHydrateTextInstance(nextInstance, text); + if (textInstance !== null) { + fiber.stateNode = (textInstance: TextInstance); + hydrationParentFiber = fiber; + // Text Instances don't have children so there's nothing to hydrate. + nextHydratableInstance = null; + return true; + } + return false; +} + +function tryHydrateSuspense(fiber: Fiber, nextInstance: any) { + // fiber is a SuspenseComponent Fiber + const suspenseInstance = canHydrateSuspenseInstance(nextInstance); + if (suspenseInstance !== null) { + const suspenseState: SuspenseState = { + dehydrated: suspenseInstance, + treeContext: getSuspendedTreeContext(), + retryLane: OffscreenLane, + }; + fiber.memoizedState = suspenseState; + // Store the dehydrated fragment as a child fiber. + // This simplifies the code for getHostSibling and deleting nodes, + // since it doesn't have to consider all Suspense boundaries and + // check if they're dehydrated ones or not. + const dehydratedFragment = + createFiberFromDehydratedFragment(suspenseInstance); + dehydratedFragment.return = fiber; + fiber.child = dehydratedFragment; + hydrationParentFiber = fiber; + // While a Suspense Instance does have children, we won't step into + // it during the first pass. Instead, we'll reenter it later. + nextHydratableInstance = null; + return true; + } + return false; } function shouldClientRenderOnMismatch(fiber: Fiber) { @@ -424,22 +431,189 @@ function claimHydratableSingleton(fiber: Fiber): void { false, )); hydrationParentFiber = fiber; + rootOrSingletonContext = true; nextHydratableInstance = getFirstHydratableChild(instance); } } +function advanceToFirstAttempableInstance(fiber: Fiber) { + // fiber is HostComponent Fiber + while ( + nextHydratableInstance && + shouldSkipHydratableForInstance( + nextHydratableInstance, + fiber.type, + fiber.pendingProps, + ) + ) { + // Flow doesn't understand that inside this block nextHydratableInstance is not null + const instance: HydratableInstance = (nextHydratableInstance: any); + nextHydratableInstance = getNextHydratableSibling(instance); + } +} + +function advanceToFirstAttempableTextInstance() { + while ( + nextHydratableInstance && + shouldSkipHydratableForTextInstance(nextHydratableInstance) + ) { + // Flow doesn't understand that inside this block nextHydratableInstance is not null + const instance: HydratableInstance = (nextHydratableInstance: any); + nextHydratableInstance = getNextHydratableSibling(instance); + } +} + +function advanceToFirstAttempableSuspenseInstance() { + while ( + nextHydratableInstance && + shouldSkipHydratableForSuspenseInstance(nextHydratableInstance) + ) { + // Flow doesn't understand that inside this block nextHydratableInstance is not null + const instance: HydratableInstance = (nextHydratableInstance: any); + nextHydratableInstance = getNextHydratableSibling(instance); + } +} + function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } - if (enableFloat && !isHydratable(fiber.type, fiber.pendingProps)) { - // This fiber never hydrates from the DOM and always does an insert - fiber.flags = (fiber.flags & ~Hydrating) | Placement; + if (enableFloat) { + if (!isHydratableType(fiber.type, fiber.pendingProps)) { + // This fiber never hydrates from the DOM and always does an insert + fiber.flags = (fiber.flags & ~Hydrating) | Placement; + isHydrating = false; + hydrationParentFiber = fiber; + return; + } + } + const initialInstance = nextHydratableInstance; + if (rootOrSingletonContext) { + // We may need to skip past certain nodes in these contexts + advanceToFirstAttempableInstance(fiber); + } + const nextInstance = nextHydratableInstance; + if (!nextInstance) { + if (shouldClientRenderOnMismatch(fiber)) { + warnNonhydratedInstance((hydrationParentFiber: any), fiber); + throwOnHydrationMismatch(fiber); + } + // Nothing to hydrate. Make it an insertion. + insertNonHydratedInstance((hydrationParentFiber: any), fiber); isHydrating = false; hydrationParentFiber = fiber; + nextHydratableInstance = initialInstance; return; } - let nextInstance = nextHydratableInstance; + const firstAttemptedInstance = nextInstance; + if (!tryHydrateInstance(fiber, nextInstance)) { + if (shouldClientRenderOnMismatch(fiber)) { + warnNonhydratedInstance((hydrationParentFiber: any), fiber); + throwOnHydrationMismatch(fiber); + } + // If we can't hydrate this instance let's try the next one. + // We use this as a heuristic. It's based on intuition and not data so it + // might be flawed or unnecessary. + nextHydratableInstance = getNextHydratableSibling(nextInstance); + const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any); + if (rootOrSingletonContext) { + // We may need to skip past certain nodes in these contexts + advanceToFirstAttempableInstance(fiber); + } + if ( + !nextHydratableInstance || + !tryHydrateInstance(fiber, nextHydratableInstance) + ) { + // Nothing to hydrate. Make it an insertion. + insertNonHydratedInstance((hydrationParentFiber: any), fiber); + isHydrating = false; + hydrationParentFiber = fiber; + nextHydratableInstance = initialInstance; + return; + } + // We matched the next one, we'll now assume that the first one was + // superfluous and we'll delete it. Since we can't eagerly delete it + // we'll have to schedule a deletion. To do that, this node needs a dummy + // fiber associated with it. + deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance); + } +} + +function tryToClaimNextHydratableTextInstance(fiber: Fiber): void { + if (!isHydrating) { + return; + } + const text = fiber.pendingProps; + const isHydratable = isHydratableText(text); + + const initialInstance = nextHydratableInstance; + if (rootOrSingletonContext && isHydratable) { + // We may need to skip past certain nodes in these contexts. + // We don't skip if the text is not hydratable because we know no hydratables + // exist which could match this Fiber + advanceToFirstAttempableTextInstance(); + } + const nextInstance = nextHydratableInstance; + if (!nextInstance || !isHydratable) { + // We exclude non hydrabable text because we know there are no matching hydratables. + // We either throw or insert depending on the render mode. + if (shouldClientRenderOnMismatch(fiber)) { + warnNonhydratedInstance((hydrationParentFiber: any), fiber); + throwOnHydrationMismatch(fiber); + } + // Nothing to hydrate. Make it an insertion. + insertNonHydratedInstance((hydrationParentFiber: any), fiber); + isHydrating = false; + hydrationParentFiber = fiber; + nextHydratableInstance = initialInstance; + return; + } + const firstAttemptedInstance = nextInstance; + if (!tryHydrateText(fiber, nextInstance)) { + if (shouldClientRenderOnMismatch(fiber)) { + warnNonhydratedInstance((hydrationParentFiber: any), fiber); + throwOnHydrationMismatch(fiber); + } + // If we can't hydrate this instance let's try the next one. + // We use this as a heuristic. It's based on intuition and not data so it + // might be flawed or unnecessary. + nextHydratableInstance = getNextHydratableSibling(nextInstance); + const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any); + + if (rootOrSingletonContext && isHydratable) { + // We may need to skip past certain nodes in these contexts + advanceToFirstAttempableTextInstance(); + } + + if ( + !nextHydratableInstance || + !tryHydrateText(fiber, nextHydratableInstance) + ) { + // Nothing to hydrate. Make it an insertion. + insertNonHydratedInstance((hydrationParentFiber: any), fiber); + isHydrating = false; + hydrationParentFiber = fiber; + nextHydratableInstance = initialInstance; + return; + } + // We matched the next one, we'll now assume that the first one was + // superfluous and we'll delete it. Since we can't eagerly delete it + // we'll have to schedule a deletion. To do that, this node needs a dummy + // fiber associated with it. + deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance); + } +} + +function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void { + if (!isHydrating) { + return; + } + const initialInstance = nextHydratableInstance; + if (rootOrSingletonContext) { + // We may need to skip past certain nodes in these contexts + advanceToFirstAttempableSuspenseInstance(); + } + const nextInstance = nextHydratableInstance; if (!nextInstance) { if (shouldClientRenderOnMismatch(fiber)) { warnNonhydratedInstance((hydrationParentFiber: any), fiber); @@ -449,10 +623,11 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { insertNonHydratedInstance((hydrationParentFiber: any), fiber); isHydrating = false; hydrationParentFiber = fiber; + nextHydratableInstance = initialInstance; return; } const firstAttemptedInstance = nextInstance; - if (!tryHydrate(fiber, nextInstance)) { + if (!tryHydrateSuspense(fiber, nextInstance)) { if (shouldClientRenderOnMismatch(fiber)) { warnNonhydratedInstance((hydrationParentFiber: any), fiber); throwOnHydrationMismatch(fiber); @@ -460,13 +635,23 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { // If we can't hydrate this instance let's try the next one. // We use this as a heuristic. It's based on intuition and not data so it // might be flawed or unnecessary. - nextInstance = getNextHydratableSibling(firstAttemptedInstance); + nextHydratableInstance = getNextHydratableSibling(nextInstance); const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any); - if (!nextInstance || !tryHydrate(fiber, nextInstance)) { + + if (rootOrSingletonContext) { + // We may need to skip past certain nodes in these contexts + advanceToFirstAttempableSuspenseInstance(); + } + + if ( + !nextHydratableInstance || + !tryHydrateSuspense(fiber, nextHydratableInstance) + ) { // Nothing to hydrate. Make it an insertion. insertNonHydratedInstance((hydrationParentFiber: any), fiber); isHydrating = false; hydrationParentFiber = fiber; + nextHydratableInstance = initialInstance; return; } // We matched the next one, we'll now assume that the first one was @@ -616,19 +801,21 @@ function skipPastDehydratedSuspenseInstance( } function popToNextHostParent(fiber: Fiber): void { - let parent = fiber.return; - while ( - parent !== null && - parent.tag !== HostComponent && - parent.tag !== HostRoot && - parent.tag !== SuspenseComponent && - (!(enableHostSingletons && supportsSingletons) - ? true - : parent.tag !== HostSingleton) - ) { - parent = parent.return; + hydrationParentFiber = fiber.return; + while (hydrationParentFiber) { + switch (hydrationParentFiber.tag) { + case HostRoot: + case HostSingleton: + rootOrSingletonContext = true; + return; + case HostComponent: + case SuspenseComponent: + rootOrSingletonContext = false; + return; + default: + hydrationParentFiber = hydrationParentFiber.return; + } } - hydrationParentFiber = parent; } function popHydrationState(fiber: Fiber): boolean { @@ -755,6 +942,8 @@ export { resetHydrationState, claimHydratableSingleton, tryToClaimNextHydratableInstance, + tryToClaimNextHydratableTextInstance, + tryToClaimNextHydratableSuspenseInstance, prepareToHydrateHostInstance, prepareToHydrateHostTextInstance, prepareToHydrateHostSuspenseInstance, diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index e42a8bb2841f8..96ba80f601d47 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -130,11 +130,8 @@ export const cloneHiddenTextInstance = $$$hostConfig.cloneHiddenTextInstance; // Hydration // (optional) // ------------------- -export const isHydratable = $$$hostConfig.isHydratable; -export const canHydrateInstance = $$$hostConfig.canHydrateInstance; -export const canHydrateTextInstance = $$$hostConfig.canHydrateTextInstance; -export const canHydrateSuspenseInstance = - $$$hostConfig.canHydrateSuspenseInstance; +export const isHydratableType = $$$hostConfig.isHydratableType; +export const isHydratableText = $$$hostConfig.isHydratableText; export const isSuspenseInstancePending = $$$hostConfig.isSuspenseInstancePending; export const isSuspenseInstanceFallback = @@ -149,6 +146,16 @@ export const getFirstHydratableChildWithinContainer = $$$hostConfig.getFirstHydratableChildWithinContainer; export const getFirstHydratableChildWithinSuspenseInstance = $$$hostConfig.getFirstHydratableChildWithinSuspenseInstance; +export const shouldSkipHydratableForInstance = + $$$hostConfig.shouldSkipHydratableForInstance; +export const shouldSkipHydratableForTextInstance = + $$$hostConfig.shouldSkipHydratableForTextInstance; +export const shouldSkipHydratableForSuspenseInstance = + $$$hostConfig.shouldSkipHydratableForSuspenseInstance; +export const canHydrateInstance = $$$hostConfig.canHydrateInstance; +export const canHydrateTextInstance = $$$hostConfig.canHydrateTextInstance; +export const canHydrateSuspenseInstance = + $$$hostConfig.canHydrateSuspenseInstance; export const hydrateInstance = $$$hostConfig.hydrateInstance; export const hydrateTextInstance = $$$hostConfig.hydrateTextInstance; export const hydrateSuspenseInstance = $$$hostConfig.hydrateSuspenseInstance;