diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 622b8e044951d..6067ba010d4f8 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -68,6 +68,7 @@ import sanitizeURL from '../shared/sanitizeURL'; import { enableBigIntSupport, enableCustomElementPropertySupport, + enableClientRenderFallbackOnTextMismatch, disableIEWorkarounds, enableTrustedTypesIntegration, enableFilterEmptyStringAttributesDOM, @@ -80,6 +81,7 @@ import { let didWarnControlledToUncontrolled = false; let didWarnUncontrolledToControlled = false; +let didWarnInvalidHydration = false; let didWarnFormActionType = false; let didWarnFormActionName = false; let didWarnFormActionTarget = false; @@ -226,9 +228,11 @@ function warnForPropDifference( propName: string, serverValue: mixed, clientValue: mixed, - serverDifferences: {[propName: string]: mixed}, -): void { +) { if (__DEV__) { + if (didWarnInvalidHydration) { + return; + } if (serverValue === clientValue) { return; } @@ -239,23 +243,27 @@ function warnForPropDifference( if (normalizedServerValue === normalizedClientValue) { return; } - - serverDifferences[propName] = serverValue; + didWarnInvalidHydration = true; + console.error( + 'Prop `%s` did not match. Server: %s Client: %s', + propName, + JSON.stringify(normalizedServerValue), + JSON.stringify(normalizedClientValue), + ); } } -function warnForExtraAttributes( - domElement: Element, - attributeNames: Set, - serverDifferences: {[propName: string]: mixed}, -) { +function warnForExtraAttributes(attributeNames: Set) { if (__DEV__) { - attributeNames.forEach(function (attributeName) { - serverDifferences[attributeName] = - attributeName === 'style' - ? getStylesObjectFromElement(domElement) - : domElement.getAttribute(attributeName); + if (didWarnInvalidHydration) { + return; + } + didWarnInvalidHydration = true; + const names = []; + attributeNames.forEach(function (name) { + names.push(name); }); + console.error('Extra attributes from the server: %s', names); } } @@ -319,16 +327,35 @@ function normalizeMarkupForTextOrAttribute(markup: mixed): string { .replace(NORMALIZE_NULL_AND_REPLACEMENT_REGEX, ''); } -function checkForUnmatchedText( +export function checkForUnmatchedText( serverText: string, clientText: string | number | bigint, + shouldWarnDev: boolean, ) { const normalizedClientText = normalizeMarkupForTextOrAttribute(clientText); const normalizedServerText = normalizeMarkupForTextOrAttribute(serverText); if (normalizedServerText === normalizedClientText) { - return true; + return; + } + + if (shouldWarnDev) { + if (__DEV__) { + if (!didWarnInvalidHydration) { + didWarnInvalidHydration = true; + console.error( + 'Text content did not match. Server: "%s" Client: "%s"', + normalizedServerText, + normalizedClientText, + ); + } + } + } + + if (enableClientRenderFallbackOnTextMismatch) { + // In concurrent roots, we throw when there's a text mismatch and revert to + // client rendering, up to the nearest Suspense boundary. + throw new Error('Text content does not match server-rendered HTML.'); } - return false; } function noop() {} @@ -1829,69 +1856,18 @@ function getPossibleStandardName(propName: string): string | null { return null; } -export function getPropsFromElement(domElement: Element): Object { - const serverDifferences: {[propName: string]: mixed} = {}; - const attributes = domElement.attributes; - for (let i = 0; i < attributes.length; i++) { - const attr = attributes[i]; - serverDifferences[attr.name] = - attr.name.toLowerCase() === 'style' - ? getStylesObjectFromElement(domElement) - : attr.value; - } - return serverDifferences; -} - -function getStylesObjectFromElement(domElement: Element): { - [styleName: string]: string, -} { - const serverValueInObjectForm: {[prop: string]: string} = {}; - const style = ((domElement: any): HTMLElement).style; - for (let i = 0; i < style.length; i++) { - const styleName: string = style[i]; - // TODO: We should use the original prop value here if it is equivalent. - // TODO: We could use the original client capitalization if the equivalent - // other capitalization exists in the DOM. - serverValueInObjectForm[styleName] = style.getPropertyValue(styleName); - } - return serverValueInObjectForm; -} - -function diffHydratedStyles( - domElement: Element, - value: mixed, - serverDifferences: {[propName: string]: mixed}, -): void { +function diffHydratedStyles(domElement: Element, value: mixed) { if (value != null && typeof value !== 'object') { - if (__DEV__) { - console.error( - 'The `style` prop expects a mapping from style properties to values, ' + - "not a string. For example, style={{marginRight: spacing + 'em'}} when " + - 'using JSX.', - ); - } - return; + throw new Error( + 'The `style` prop expects a mapping from style properties to values, ' + + "not a string. For example, style={{marginRight: spacing + 'em'}} when " + + 'using JSX.', + ); } if (canDiffStyleForHydrationWarning) { - // First we compare the string form and see if it's equivalent. - // This lets us bail out on anything that used to pass in this form. - // It also lets us compare anything that's not parsed by this browser. - const clientValue = createDangerousStringForStyles(value); + const expectedStyle = createDangerousStringForStyles(value); const serverValue = domElement.getAttribute('style'); - - if (serverValue === clientValue) { - return; - } - const normalizedClientValue = - normalizeMarkupForTextOrAttribute(clientValue); - const normalizedServerValue = - normalizeMarkupForTextOrAttribute(serverValue); - if (normalizedServerValue === normalizedClientValue) { - return; - } - - // Otherwise, we create the object from the DOM for the diff view. - serverDifferences.style = getStylesObjectFromElement(domElement); + warnForPropDifference('style', serverValue, expectedStyle); } } @@ -1901,7 +1877,6 @@ function hydrateAttribute( attributeName: string, value: any, extraAttributes: Set, - serverDifferences: {[propName: string]: mixed}, ): void { extraAttributes.delete(attributeName); const serverValue = domElement.getAttribute(attributeName); @@ -1934,7 +1909,7 @@ function hydrateAttribute( } } } - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); } function hydrateBooleanAttribute( @@ -1943,7 +1918,6 @@ function hydrateBooleanAttribute( attributeName: string, value: any, extraAttributes: Set, - serverDifferences: {[propName: string]: mixed}, ): void { extraAttributes.delete(attributeName); const serverValue = domElement.getAttribute(attributeName); @@ -1971,7 +1945,7 @@ function hydrateBooleanAttribute( } } } - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); } function hydrateOverloadedBooleanAttribute( @@ -1980,7 +1954,6 @@ function hydrateOverloadedBooleanAttribute( attributeName: string, value: any, extraAttributes: Set, - serverDifferences: {[propName: string]: mixed}, ): void { extraAttributes.delete(attributeName); const serverValue = domElement.getAttribute(attributeName); @@ -2020,7 +1993,7 @@ function hydrateOverloadedBooleanAttribute( } } } - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); } function hydrateBooleanishAttribute( @@ -2029,7 +2002,6 @@ function hydrateBooleanishAttribute( attributeName: string, value: any, extraAttributes: Set, - serverDifferences: {[propName: string]: mixed}, ): void { extraAttributes.delete(attributeName); const serverValue = domElement.getAttribute(attributeName); @@ -2060,7 +2032,7 @@ function hydrateBooleanishAttribute( } } } - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); } function hydrateNumericAttribute( @@ -2069,7 +2041,6 @@ function hydrateNumericAttribute( attributeName: string, value: any, extraAttributes: Set, - serverDifferences: {[propName: string]: mixed}, ): void { extraAttributes.delete(attributeName); const serverValue = domElement.getAttribute(attributeName); @@ -2111,7 +2082,7 @@ function hydrateNumericAttribute( } } } - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); } function hydratePositiveNumericAttribute( @@ -2120,7 +2091,6 @@ function hydratePositiveNumericAttribute( attributeName: string, value: any, extraAttributes: Set, - serverDifferences: {[propName: string]: mixed}, ): void { extraAttributes.delete(attributeName); const serverValue = domElement.getAttribute(attributeName); @@ -2162,7 +2132,7 @@ function hydratePositiveNumericAttribute( } } } - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); } function hydrateSanitizedAttribute( @@ -2171,7 +2141,6 @@ function hydrateSanitizedAttribute( attributeName: string, value: any, extraAttributes: Set, - serverDifferences: {[propName: string]: mixed}, ): void { extraAttributes.delete(attributeName); const serverValue = domElement.getAttribute(attributeName); @@ -2205,7 +2174,7 @@ function hydrateSanitizedAttribute( } } } - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); } function diffHydratedCustomComponent( @@ -2214,7 +2183,6 @@ function diffHydratedCustomComponent( props: Object, hostContext: HostContext, extraAttributes: Set, - serverDifferences: {[propName: string]: mixed}, ) { for (const propKey in props) { if (!props.hasOwnProperty(propKey)) { @@ -2236,18 +2204,7 @@ function diffHydratedCustomComponent( } // Validate that the properties correspond to their expected values. switch (propKey) { - case 'children': { - if (typeof value === 'string' || typeof value === 'number') { - warnForPropDifference( - 'children', - domElement.textContent, - value, - serverDifferences, - ); - } - continue; - } - // Checked above already + case 'children': // Checked above already case 'suppressContentEditableWarning': case 'suppressHydrationWarning': case 'defaultValue': @@ -2261,17 +2218,12 @@ function diffHydratedCustomComponent( const nextHtml = value ? value.__html : undefined; if (nextHtml != null) { const expectedHTML = normalizeHTML(domElement, nextHtml); - warnForPropDifference( - propKey, - serverHTML, - expectedHTML, - serverDifferences, - ); + warnForPropDifference(propKey, serverHTML, expectedHTML); } continue; case 'style': extraAttributes.delete(propKey); - diffHydratedStyles(domElement, value, serverDifferences); + diffHydratedStyles(domElement, value); continue; case 'offsetParent': case 'offsetTop': @@ -2301,12 +2253,7 @@ function diffHydratedCustomComponent( 'class', value, ); - warnForPropDifference( - 'className', - serverValue, - value, - serverDifferences, - ); + warnForPropDifference('className', serverValue, value); continue; } // Fall through @@ -2328,7 +2275,7 @@ function diffHydratedCustomComponent( propKey, value, ); - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); } } } @@ -2347,7 +2294,6 @@ function diffHydratedGenericElement( props: Object, hostContext: HostContext, extraAttributes: Set, - serverDifferences: {[propName: string]: mixed}, ) { for (const propKey in props) { if (!props.hasOwnProperty(propKey)) { @@ -2369,18 +2315,7 @@ function diffHydratedGenericElement( } // Validate that the properties correspond to their expected values. switch (propKey) { - case 'children': { - if (typeof value === 'string' || typeof value === 'number') { - warnForPropDifference( - 'children', - domElement.textContent, - value, - serverDifferences, - ); - } - continue; - } - // Checked above already + case 'children': // Checked above already case 'suppressContentEditableWarning': case 'suppressHydrationWarning': case 'value': // Controlled attributes are not validated @@ -2397,22 +2332,11 @@ function diffHydratedGenericElement( const nextHtml = value ? value.__html : undefined; if (nextHtml != null) { const expectedHTML = normalizeHTML(domElement, nextHtml); - if (serverHTML !== expectedHTML) { - serverDifferences[propKey] = { - __html: serverHTML, - }; - } + warnForPropDifference(propKey, serverHTML, expectedHTML); } continue; case 'className': - hydrateAttribute( - domElement, - propKey, - 'class', - value, - extraAttributes, - serverDifferences, - ); + hydrateAttribute(domElement, propKey, 'class', value, extraAttributes); continue; case 'tabIndex': hydrateAttribute( @@ -2421,29 +2345,28 @@ function diffHydratedGenericElement( 'tabindex', value, extraAttributes, - serverDifferences, ); continue; case 'style': extraAttributes.delete(propKey); - diffHydratedStyles(domElement, value, serverDifferences); + diffHydratedStyles(domElement, value); continue; case 'multiple': { extraAttributes.delete(propKey); const serverValue = (domElement: any).multiple; - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); continue; } case 'muted': { extraAttributes.delete(propKey); const serverValue = (domElement: any).muted; - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); continue; } case 'autoFocus': { extraAttributes.delete('autofocus'); const serverValue = (domElement: any).autofocus; - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); continue; } case 'src': @@ -2480,7 +2403,6 @@ function diffHydratedGenericElement( propKey, null, extraAttributes, - serverDifferences, ); continue; } @@ -2491,7 +2413,6 @@ function diffHydratedGenericElement( propKey, value, extraAttributes, - serverDifferences, ); continue; case 'action': @@ -2520,7 +2441,7 @@ function diffHydratedGenericElement( continue; } else if (serverValue === EXPECTED_FORM_ACTION_URL) { extraAttributes.delete(propKey.toLowerCase()); - warnForPropDifference(propKey, 'function', value, serverDifferences); + warnForPropDifference(propKey, 'function', value); continue; } hydrateSanitizedAttribute( @@ -2529,7 +2450,6 @@ function diffHydratedGenericElement( propKey.toLowerCase(), value, extraAttributes, - serverDifferences, ); continue; } @@ -2540,7 +2460,6 @@ function diffHydratedGenericElement( 'xlink:href', value, extraAttributes, - serverDifferences, ); continue; case 'contentEditable': { @@ -2551,7 +2470,6 @@ function diffHydratedGenericElement( 'contenteditable', value, extraAttributes, - serverDifferences, ); continue; } @@ -2563,7 +2481,6 @@ function diffHydratedGenericElement( 'spellcheck', value, extraAttributes, - serverDifferences, ); continue; } @@ -2579,7 +2496,6 @@ function diffHydratedGenericElement( propKey, value, extraAttributes, - serverDifferences, ); continue; } @@ -2612,7 +2528,6 @@ function diffHydratedGenericElement( propKey.toLowerCase(), value, extraAttributes, - serverDifferences, ); continue; } @@ -2624,7 +2539,6 @@ function diffHydratedGenericElement( propKey, value, extraAttributes, - serverDifferences, ); continue; } @@ -2638,7 +2552,6 @@ function diffHydratedGenericElement( propKey, value, extraAttributes, - serverDifferences, ); continue; } @@ -2649,7 +2562,6 @@ function diffHydratedGenericElement( 'rowspan', value, extraAttributes, - serverDifferences, ); continue; } @@ -2660,7 +2572,6 @@ function diffHydratedGenericElement( propKey, value, extraAttributes, - serverDifferences, ); continue; } @@ -2671,7 +2582,6 @@ function diffHydratedGenericElement( 'x-height', value, extraAttributes, - serverDifferences, ); continue; case 'xlinkActuate': @@ -2681,7 +2591,6 @@ function diffHydratedGenericElement( 'xlink:actuate', value, extraAttributes, - serverDifferences, ); continue; case 'xlinkArcrole': @@ -2691,7 +2600,6 @@ function diffHydratedGenericElement( 'xlink:arcrole', value, extraAttributes, - serverDifferences, ); continue; case 'xlinkRole': @@ -2701,7 +2609,6 @@ function diffHydratedGenericElement( 'xlink:role', value, extraAttributes, - serverDifferences, ); continue; case 'xlinkShow': @@ -2711,7 +2618,6 @@ function diffHydratedGenericElement( 'xlink:show', value, extraAttributes, - serverDifferences, ); continue; case 'xlinkTitle': @@ -2721,7 +2627,6 @@ function diffHydratedGenericElement( 'xlink:title', value, extraAttributes, - serverDifferences, ); continue; case 'xlinkType': @@ -2731,7 +2636,6 @@ function diffHydratedGenericElement( 'xlink:type', value, extraAttributes, - serverDifferences, ); continue; case 'xmlBase': @@ -2741,7 +2645,6 @@ function diffHydratedGenericElement( 'xml:base', value, extraAttributes, - serverDifferences, ); continue; case 'xmlLang': @@ -2751,7 +2654,6 @@ function diffHydratedGenericElement( 'xml:lang', value, extraAttributes, - serverDifferences, ); continue; case 'xmlSpace': @@ -2761,7 +2663,6 @@ function diffHydratedGenericElement( 'xml:space', value, extraAttributes, - serverDifferences, ); continue; case 'inert': @@ -2787,7 +2688,6 @@ function diffHydratedGenericElement( propKey, value, extraAttributes, - serverDifferences, ); continue; } @@ -2834,19 +2734,20 @@ function diffHydratedGenericElement( value, ); if (!isMismatchDueToBadCasing) { - warnForPropDifference(propKey, serverValue, value, serverDifferences); + warnForPropDifference(propKey, serverValue, value); } } } } } -export function hydrateProperties( +export function diffHydratedProperties( domElement: Element, tag: string, props: Object, + shouldWarnDev: boolean, hostContext: HostContext, -): boolean { +): void { if (__DEV__) { validatePropertiesInDevelopment(tag, props); } @@ -2959,13 +2860,21 @@ export function hydrateProperties( typeof children === 'number' || (enableBigIntSupport && typeof children === 'bigint') ) { - if ( - // $FlowFixMe[unsafe-addition] Flow doesn't want us to use `+` operator with string and bigint - domElement.textContent !== '' + children && - props.suppressHydrationWarning !== true && - !checkForUnmatchedText(domElement.textContent, children) - ) { - return false; + // $FlowFixMe[unsafe-addition] Flow doesn't want us to use `+` operator with string and bigint + if (domElement.textContent !== '' + children) { + if (props.suppressHydrationWarning !== true) { + checkForUnmatchedText(domElement.textContent, children, shouldWarnDev); + } + if (!enableClientRenderFallbackOnTextMismatch) { + // We really should be patching this in the commit phase but since + // this only affects legacy mode hydration which is deprecated anyway + // we can get away with it. + // Host singletons get their children appended and don't use the text + // content mechanism. + if (tag !== 'body') { + domElement.textContent = (children: any); + } + } } } @@ -2982,17 +2891,7 @@ export function hydrateProperties( trapClickOnNonInteractiveElement(((domElement: any): HTMLElement)); } - return true; -} - -export function diffHydratedProperties( - domElement: Element, - tag: string, - props: Object, - hostContext: HostContext, -): null | Object { - const serverDifferences: {[propName: string]: mixed} = {}; - if (__DEV__) { + if (__DEV__ && shouldWarnDev) { const extraAttributes: Set = new Set(); const attributes = domElement.attributes; for (let i = 0; i < attributes.length; i++) { @@ -3019,7 +2918,6 @@ export function diffHydratedProperties( props, hostContext, extraAttributes, - serverDifferences, ); } else { diffHydratedGenericElement( @@ -3028,47 +2926,86 @@ export function diffHydratedProperties( props, hostContext, extraAttributes, - serverDifferences, ); } if (extraAttributes.size > 0 && props.suppressHydrationWarning !== true) { - warnForExtraAttributes(domElement, extraAttributes, serverDifferences); + warnForExtraAttributes(extraAttributes); } } - if (Object.keys(serverDifferences).length === 0) { - return null; - } - return serverDifferences; } -export function hydrateText( - textNode: Text, - text: string, - parentProps: null | Object, -): boolean { +export function diffHydratedText(textNode: Text, text: string): boolean { const isDifferent = textNode.nodeValue !== text; - if ( - isDifferent && - (parentProps === null || parentProps.suppressHydrationWarning !== true) && - !checkForUnmatchedText(textNode.nodeValue, text) - ) { - return false; + return isDifferent; +} + +export function warnForDeletedHydratableElement( + parentNode: Element | Document | DocumentFragment, + child: Element, +) { + if (__DEV__) { + if (didWarnInvalidHydration) { + return; + } + didWarnInvalidHydration = true; + console.error( + 'Did not expect server HTML to contain a <%s> in <%s>.', + child.nodeName.toLowerCase(), + parentNode.nodeName.toLowerCase(), + ); } - return true; } -export function diffHydratedText(textNode: Text, text: string): null | string { - if (textNode.nodeValue === text) { - return null; +export function warnForDeletedHydratableText( + parentNode: Element | Document | DocumentFragment, + child: Text, +) { + if (__DEV__) { + if (didWarnInvalidHydration) { + return; + } + didWarnInvalidHydration = true; + console.error( + 'Did not expect server HTML to contain the text node "%s" in <%s>.', + child.nodeValue, + parentNode.nodeName.toLowerCase(), + ); } - const normalizedClientText = normalizeMarkupForTextOrAttribute(text); - const normalizedServerText = normalizeMarkupForTextOrAttribute( - textNode.nodeValue, - ); - if (normalizedServerText === normalizedClientText) { - return null; +} + +export function warnForInsertedHydratedElement( + parentNode: Element | Document | DocumentFragment, + tag: string, + props: Object, +) { + if (__DEV__) { + if (didWarnInvalidHydration) { + return; + } + didWarnInvalidHydration = true; + console.error( + 'Expected server HTML to contain a matching <%s> in <%s>.', + tag, + parentNode.nodeName.toLowerCase(), + ); + } +} + +export function warnForInsertedHydratedText( + parentNode: Element | Document | DocumentFragment, + text: string, +) { + if (__DEV__) { + if (didWarnInvalidHydration) { + return; + } + didWarnInvalidHydration = true; + console.error( + 'Expected server HTML to contain a matching text node for "%s" in <%s>.', + text, + parentNode.nodeName.toLowerCase(), + ); } - return textNode.nodeValue; } export function restoreControlledState( diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 395392650904a..a06f9c00b610f 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -52,12 +52,14 @@ import {hasRole} from './DOMAccessibilityRoles'; import { setInitialProperties, updateProperties, - hydrateProperties, - hydrateText, diffHydratedProperties, - getPropsFromElement, diffHydratedText, trapClickOnNonInteractiveElement, + checkForUnmatchedText, + warnForDeletedHydratableElement, + warnForDeletedHydratableText, + warnForInsertedHydratedElement, + warnForInsertedHydratedText, } from './ReactDOMComponent'; import {getSelectionInformation, restoreSelection} from './ReactInputSelection'; import setTextContent from './setTextContent'; @@ -1340,26 +1342,6 @@ export function getFirstHydratableChildWithinSuspenseInstance( return getNextHydratable(parentInstance.nextSibling); } -export function describeHydratableInstanceForDevWarnings( - instance: HydratableInstance, -): string | {type: string, props: $ReadOnly} { - // Reverse engineer a pseudo react-element from hydratable instnace - if (instance.nodeType === ELEMENT_NODE) { - // Reverse engineer a set of props that can print for dev warnings - return { - type: instance.nodeName.toLowerCase(), - props: getPropsFromElement((instance: any)), - }; - } else if (instance.nodeType === COMMENT_NODE) { - return { - type: 'Suspense', - props: {}, - }; - } else { - return instance.nodeValue; - } -} - export function validateHydratableInstance( type: string, props: Props, @@ -1379,23 +1361,14 @@ export function hydrateInstance( props: Props, hostContext: HostContext, internalInstanceHandle: Object, -): boolean { + shouldWarnDev: boolean, +): void { precacheFiberNode(internalInstanceHandle, instance); // TODO: Possibly defer this until the commit phase where all the events // get attached. updateFiberProps(instance, props); - return hydrateProperties(instance, type, props, hostContext); -} - -// Returns a Map of properties that were different on the server. -export function diffHydratedPropsForDevWarnings( - instance: Instance, - type: string, - props: Props, - hostContext: HostContext, -): null | $ReadOnly { - return diffHydratedProperties(instance, type, props, hostContext); + diffHydratedProperties(instance, type, props, shouldWarnDev, hostContext); } export function validateHydratableTextInstance( @@ -1416,26 +1389,11 @@ export function hydrateTextInstance( textInstance: TextInstance, text: string, internalInstanceHandle: Object, - parentInstanceProps: null | Props, + shouldWarnDev: boolean, ): boolean { precacheFiberNode(internalInstanceHandle, textInstance); - return hydrateText(textInstance, text, parentInstanceProps); -} - -// Returns the server text if it differs from the client. -export function diffHydratedTextForDevWarnings( - textInstance: TextInstance, - text: string, - parentProps: null | Props, -): null | string { - if ( - parentProps === null || - parentProps[SUPPRESS_HYDRATION_WARNING] !== true - ) { - return diffHydratedText(textInstance, text); - } - return null; + return diffHydratedText(textInstance, text); } export function hydrateSuspenseInstance( @@ -1527,6 +1485,183 @@ export function shouldDeleteUnhydratedTailInstances( return parentType !== 'form' && parentType !== 'button'; } +export function didNotMatchHydratedContainerTextInstance( + parentContainer: Container, + textInstance: TextInstance, + text: string, + shouldWarnDev: boolean, +) { + checkForUnmatchedText(textInstance.nodeValue, text, shouldWarnDev); +} + +export function didNotMatchHydratedTextInstance( + parentType: string, + parentProps: Props, + parentInstance: Instance, + textInstance: TextInstance, + text: string, + shouldWarnDev: boolean, +) { + if (parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { + checkForUnmatchedText(textInstance.nodeValue, text, shouldWarnDev); + } +} + +export function didNotHydrateInstanceWithinContainer( + parentContainer: Container, + instance: HydratableInstance, +) { + if (__DEV__) { + if (instance.nodeType === ELEMENT_NODE) { + warnForDeletedHydratableElement(parentContainer, (instance: any)); + } else if (instance.nodeType === COMMENT_NODE) { + // TODO: warnForDeletedHydratableSuspenseBoundary + } else { + warnForDeletedHydratableText(parentContainer, (instance: any)); + } + } +} + +export function didNotHydrateInstanceWithinSuspenseInstance( + parentInstance: SuspenseInstance, + instance: HydratableInstance, +) { + if (__DEV__) { + // $FlowFixMe[incompatible-type]: Only Element or Document can be parent nodes. + const parentNode: Element | Document | null = parentInstance.parentNode; + if (parentNode !== null) { + if (instance.nodeType === ELEMENT_NODE) { + warnForDeletedHydratableElement(parentNode, (instance: any)); + } else if (instance.nodeType === COMMENT_NODE) { + // TODO: warnForDeletedHydratableSuspenseBoundary + } else { + warnForDeletedHydratableText(parentNode, (instance: any)); + } + } + } +} + +export function didNotHydrateInstance( + parentType: string, + parentProps: Props, + parentInstance: Instance, + instance: HydratableInstance, +) { + if (__DEV__) { + if (instance.nodeType === ELEMENT_NODE) { + warnForDeletedHydratableElement(parentInstance, (instance: any)); + } else if (instance.nodeType === COMMENT_NODE) { + // TODO: warnForDeletedHydratableSuspenseBoundary + } else { + warnForDeletedHydratableText(parentInstance, (instance: any)); + } + } +} + +export function didNotFindHydratableInstanceWithinContainer( + parentContainer: Container, + type: string, + props: Props, +) { + if (__DEV__) { + warnForInsertedHydratedElement(parentContainer, type, props); + } +} + +export function didNotFindHydratableTextInstanceWithinContainer( + parentContainer: Container, + text: string, +) { + if (__DEV__) { + warnForInsertedHydratedText(parentContainer, text); + } +} + +export function didNotFindHydratableSuspenseInstanceWithinContainer( + parentContainer: Container, +) { + if (__DEV__) { + // TODO: warnForInsertedHydratedSuspense(parentContainer); + } +} + +export function didNotFindHydratableInstanceWithinSuspenseInstance( + parentInstance: SuspenseInstance, + type: string, + props: Props, +) { + if (__DEV__) { + // $FlowFixMe[incompatible-type]: Only Element or Document can be parent nodes. + const parentNode: Element | Document | null = parentInstance.parentNode; + if (parentNode !== null) + warnForInsertedHydratedElement(parentNode, type, props); + } +} + +export function didNotFindHydratableTextInstanceWithinSuspenseInstance( + parentInstance: SuspenseInstance, + text: string, +) { + if (__DEV__) { + // $FlowFixMe[incompatible-type]: Only Element or Document can be parent nodes. + const parentNode: Element | Document | null = parentInstance.parentNode; + if (parentNode !== null) warnForInsertedHydratedText(parentNode, text); + } +} + +export function didNotFindHydratableSuspenseInstanceWithinSuspenseInstance( + parentInstance: SuspenseInstance, +) { + if (__DEV__) { + // const parentNode: Element | Document | null = parentInstance.parentNode; + // TODO: warnForInsertedHydratedSuspense(parentNode); + } +} + +export function didNotFindHydratableInstance( + parentType: string, + parentProps: Props, + parentInstance: Instance, + type: string, + props: Props, +) { + if (__DEV__) { + warnForInsertedHydratedElement(parentInstance, type, props); + } +} + +export function didNotFindHydratableTextInstance( + parentType: string, + parentProps: Props, + parentInstance: Instance, + text: string, +) { + if (__DEV__) { + warnForInsertedHydratedText(parentInstance, text); + } +} + +export function didNotFindHydratableSuspenseInstance( + parentType: string, + parentProps: Props, + parentInstance: Instance, +) { + if (__DEV__) { + // TODO: warnForInsertedHydratedSuspense(parentInstance); + } +} + +export function errorHydratingContainer(parentContainer: Container): void { + if (__DEV__) { + // TODO: This gets logged by onRecoverableError, too, so we should be + // able to remove it. + console.error( + 'An error occurred during hydration. The server HTML was replaced with client content in <%s>.', + parentContainer.nodeName.toLowerCase(), + ); + } +} + // ------------------- // Test Selectors // ------------------- diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 33b2b322de873..06d0c1b93a307 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -2407,8 +2407,8 @@ describe('ReactDOMFizzServer', () => { ]); }).toErrorDev( [ - 'Warning: An error occurred during hydration. The server HTML was replaced with client content.', - 'Warning: Expected server HTML to contain a matching
in the root.\n' + + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', + 'Warning: Expected server HTML to contain a matching
in
.\n' + ' in div (at **)\n' + ' in App (at **)', ], @@ -2492,7 +2492,7 @@ describe('ReactDOMFizzServer', () => { }).toErrorDev( [ 'Warning: An error occurred during hydration. The server HTML was replaced with client content', - 'Warning: Expected server HTML to contain a matching
in the root.\n' + + 'Warning: Expected server HTML to contain a matching
in
.\n' + ' in div (at **)\n' + ' in App (at **)', ], @@ -4419,6 +4419,7 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate enableClientRenderFallbackOnTextMismatch it('#24384: Suspending should halt hydration warnings but still emit hydration warnings after unsuspending if mismatches are genuine', async () => { const makeApp = () => { let resolve, resolved; @@ -4504,6 +4505,7 @@ describe('ReactDOMFizzServer', () => { await waitForAll([]); }); + // @gate enableClientRenderFallbackOnTextMismatch it('only warns once on hydration mismatch while within a suspense boundary', async () => { const originalConsoleError = console.error; const mockError = jest.fn(); @@ -6343,7 +6345,7 @@ describe('ReactDOMFizzServer', () => { await waitForAll([]); }).toErrorDev( [ - 'Expected server HTML to contain a matching in the root', + 'Expected server HTML to contain a matching in
', 'An error occurred during hydration', ], {withoutStack: 1}, diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js index 5fa719e3d36cc..3300297b77ee0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js @@ -130,6 +130,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => { : children; } + // @gate enableClientRenderFallbackOnTextMismatch it('suppresses but does not fix text mismatches with suppressHydrationWarning', async () => { function App({isClient}) { return ( @@ -169,6 +170,47 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); }); + // @gate !enableClientRenderFallbackOnTextMismatch + it('suppresses and fixes text mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + {isClient ? 'Client Text' : 'Server Text'} + + {isClient ? 2 : 1} +
+ ); + } + await act(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ Server Text + 1 +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + // Don't miss a hydration error. There should be none. + Scheduler.log(error.message); + }, + }); + await waitForAll([]); + // The text mismatch should be *silently* fixed. Even in production. + expect(getVisibleChildren(container)).toEqual( +
+ Client Text + 2 +
, + ); + }); + + // @gate enableClientRenderFallbackOnTextMismatch it('suppresses but does not fix multiple text node mismatches with suppressHydrationWarning', async () => { function App({isClient}) { return ( @@ -210,6 +252,48 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); }); + // @gate !enableClientRenderFallbackOnTextMismatch + it('suppresses and fixes multiple text node mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + {isClient ? 'Client1' : 'Server1'} + {isClient ? 'Client2' : 'Server2'} + +
+ ); + } + await act(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ + {'Server1'} + {'Server2'} + +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.log(error.message); + }, + }); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual( +
+ + {'Client1'} + {'Client2'} + +
, + ); + }); + it('errors on text-to-element mismatches with suppressHydrationWarning', async () => { function App({isClient}) { return ( @@ -248,7 +332,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => { }).toErrorDev( [ 'Expected server HTML to contain a matching in ', - 'An error occurred during hydration. The server HTML was replaced with client content.', + 'An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); @@ -261,6 +345,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); }); + // @gate enableClientRenderFallbackOnTextMismatch it('suppresses but does not fix client-only single text node mismatches with suppressHydrationWarning', async () => { function App({text}) { return ( @@ -301,6 +386,41 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); }); + // @gate !enableClientRenderFallbackOnTextMismatch + it('suppresses and fixes client-only single text node mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + {isClient ? 'Client' : null} + +
+ ); + } + await act(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.log(error.message); + }, + }); + await waitForAll([]); + expect(getVisibleChildren(container)).toEqual( +
+ {'Client'} +
, + ); + }); + // TODO: This behavior is not consistent with client-only single text node. it('errors on server-only single text node mismatches with suppressHydrationWarning', async () => { @@ -337,7 +457,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => { }).toErrorDev( [ 'Did not expect server HTML to contain the text node "Server" in ', - 'An error occurred during hydration. The server HTML was replaced with client content.', + 'An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); @@ -385,7 +505,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => { }).toErrorDev( [ 'Expected server HTML to contain a matching text node for "Client" in .', - 'An error occurred during hydration. The server HTML was replaced with client content.', + 'An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); @@ -436,7 +556,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => { }).toErrorDev( [ 'Did not expect server HTML to contain the text node "Server" in .', - 'An error occurred during hydration. The server HTML was replaced with client content.', + 'An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); @@ -485,7 +605,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => { }).toErrorDev( [ 'Expected server HTML to contain a matching text node for "Client" in .', - 'An error occurred during hydration. The server HTML was replaced with client content.', + 'An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); @@ -608,7 +728,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => { }).toErrorDev( [ 'Expected server HTML to contain a matching

in

.', - 'An error occurred during hydration. The server HTML was replaced with client content.', + 'An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); @@ -654,7 +774,7 @@ describe('ReactDOMFizzServerHydrationWarning', () => { }).toErrorDev( [ 'Did not expect server HTML to contain a

in

.', - 'An error occurred during hydration. The server HTML was replaced with client content.', + 'An error occurred during hydration. The server HTML was replaced with client content in
.', ], {withoutStack: 1}, ); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index fae926c622564..bd17e32742451 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -6446,6 +6446,7 @@ body { ); }); + // @gate enableClientRenderFallbackOnTextMismatch it('retains styles even when a new html, head, and/body mount', async () => { await act(() => { const {pipe} = renderToPipeableStream( @@ -6481,7 +6482,7 @@ body { }).toErrorDev( [ 'Warning: Text content did not match. Server: "server" Client: "client"', - 'Warning: An error occurred during hydration. The server HTML was replaced with client content.', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', ], {withoutStack: 1}, ); @@ -8231,6 +8232,7 @@ background-color: green; ]); }); + // @gate enableClientRenderFallbackOnTextMismatch || !__DEV__ it('can render a title before a singleton even if that singleton clears its contents', async () => { await act(() => { const {pipe} = renderToPipeableStream( @@ -8271,7 +8273,7 @@ background-color: green; }).toErrorDev( [ 'Warning: Text content did not match. Server: "server" Client: "client"', - 'Warning: An error occurred during hydration. The server HTML was replaced with client content.', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', ], {withoutStack: 1}, ); diff --git a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js index ef5507744b4ed..0eb9cc3fe47d7 100644 --- a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js @@ -80,17 +80,28 @@ describe('ReactDOMServerHydration', () => {
); } - expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` - [ - "Warning: Text content did not match. Server: "server" Client: "client" - in main (at **) - in div (at **) - in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", - "Caught [Text content does not match server-rendered HTML.]", - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", - ] - `); + if (gate(flags => flags.enableClientRenderFallbackOnTextMismatch)) { + expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` + [ + "Warning: Text content did not match. Server: "server" Client: "client" + in main (at **) + in div (at **) + in Mismatch (at **)", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", + "Caught [Text content does not match server-rendered HTML.]", + "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", + ] + `); + } else { + expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` + [ + "Warning: Text content did not match. Server: "server" Client: "client" + in main (at **) + in div (at **) + in Mismatch (at **)", + ] + `); + } }); // @gate __DEV__ @@ -107,16 +118,26 @@ describe('ReactDOMServerHydration', () => { } /* eslint-disable no-irregular-whitespace */ - expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` - [ - "Warning: Text content did not match. Server: "This markup contains an nbsp entity:   server text" Client: "This markup contains an nbsp entity:   client text" - in div (at **) - in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", - "Caught [Text content does not match server-rendered HTML.]", - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", - ] - `); + if (gate(flags => flags.enableClientRenderFallbackOnTextMismatch)) { + expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` + [ + "Warning: Text content did not match. Server: "This markup contains an nbsp entity:   server text" Client: "This markup contains an nbsp entity:   client text" + in div (at **) + in Mismatch (at **)", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", + "Caught [Text content does not match server-rendered HTML.]", + "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", + ] + `); + } else { + expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` + [ + "Warning: Text content did not match. Server: "This markup contains an nbsp entity:   server text" Client: "This markup contains an nbsp entity:   client text" + in div (at **) + in Mismatch (at **)", + ] + `); + } /* eslint-enable no-irregular-whitespace */ }); @@ -138,7 +159,7 @@ describe('ReactDOMServerHydration', () => { } expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` [ - "Warning: Prop \`dangerouslySetInnerHTML\` did not match. Server: {"__html":"server"} Client: {"__html":"client"} + "Warning: Prop \`dangerouslySetInnerHTML\` did not match. Server: "server" Client: "client" in main (at **) in div (at **) in Mismatch (at **)", @@ -185,7 +206,7 @@ describe('ReactDOMServerHydration', () => { } expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` [ - "Warning: Prop \`tabIndex\` did not match. Server: null Client: 1 + "Warning: Prop \`tabIndex\` did not match. Server: "null" Client: "1" in main (at **) in div (at **) in Mismatch (at **)", @@ -208,7 +229,7 @@ describe('ReactDOMServerHydration', () => { } expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` [ - "Warning: Extra attribute from the server: tabindex + "Warning: Extra attributes from the server: tabindex,dir in main (at **) in div (at **) in Mismatch (at **)", @@ -231,7 +252,7 @@ describe('ReactDOMServerHydration', () => { } expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` [ - "Warning: Prop \`tabIndex\` did not match. Server: null Client: 1 + "Warning: Prop \`tabIndex\` did not match. Server: "null" Client: "1" in main (at **) in div (at **) in Mismatch (at **)", @@ -255,7 +276,7 @@ describe('ReactDOMServerHydration', () => { } expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` [ - "Warning: Prop \`style\` did not match. Server: {"opacity":"0"} Client: {"opacity":1} + "Warning: Prop \`style\` did not match. Server: "opacity:0" Client: "opacity:1" in main (at **) in div (at **) in Mismatch (at **)", @@ -281,7 +302,7 @@ describe('ReactDOMServerHydration', () => { in main (at **) in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -305,7 +326,7 @@ describe('ReactDOMServerHydration', () => { in header (at **) in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -329,7 +350,7 @@ describe('ReactDOMServerHydration', () => { in main (at **) in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -353,7 +374,7 @@ describe('ReactDOMServerHydration', () => { in footer (at **) in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -367,16 +388,26 @@ describe('ReactDOMServerHydration', () => { function Mismatch({isClient}) { return
{isClient && 'only'}
; } - expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` - [ - "Warning: Text content did not match. Server: "" Client: "only" - in div (at **) - in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", - "Caught [Text content does not match server-rendered HTML.]", - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", - ] - `); + if (gate(flags => flags.enableClientRenderFallbackOnTextMismatch)) { + expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` + [ + "Warning: Text content did not match. Server: "" Client: "only" + in div (at **) + in Mismatch (at **)", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", + "Caught [Text content does not match server-rendered HTML.]", + "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", + ] + `); + } else { + expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` + [ + "Warning: Text content did not match. Server: "" Client: "only" + in div (at **) + in Mismatch (at **)", + ] + `); + } }); // @gate __DEV__ @@ -395,7 +426,7 @@ describe('ReactDOMServerHydration', () => { "Warning: Expected server HTML to contain a matching text node for "second" in
. in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -418,7 +449,7 @@ describe('ReactDOMServerHydration', () => { "Warning: Expected server HTML to contain a matching text node for "first" in
. in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -441,7 +472,7 @@ describe('ReactDOMServerHydration', () => { "Warning: Expected server HTML to contain a matching text node for "third" in
. in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -466,7 +497,7 @@ describe('ReactDOMServerHydration', () => { "Warning: Did not expect server HTML to contain a
in
. in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -490,7 +521,7 @@ describe('ReactDOMServerHydration', () => { in main (at **) in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -514,7 +545,7 @@ describe('ReactDOMServerHydration', () => { in footer (at **) in div (at **) in Mismatch (at **)", - "Warning: An error occurred during hydration. The server HTML was replaced with client content.", + "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.", "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]", "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] @@ -537,7 +568,7 @@ describe('ReactDOMServerHydration', () => { "Warning: Did not expect server HTML to contain a