From 52c393b5d261550609dda955f059a26d1db63538 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 24 Feb 2022 00:23:56 -0500 Subject: [PATCH] Revert to client render on text mismatch (#23354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor warnForTextDifference We're going to fork the behavior of this function between concurrent roots and legacy roots. The legacy behavior is to warn in dev when the text mismatches during hydration. In concurrent roots, we'll log a recoverable error and revert to client rendering. That means this is no longer a development-only function — it affects the prod behavior, too. I haven't changed any behavior in this commit. I only rearranged the code slightly so that the dev environment check is inside the body instead of around the function call. I also threaded through an isConcurrentMode argument. * Revert to client render on text content mismatch Expands the behavior of enableClientRenderFallbackOnHydrationMismatch to check text content, too. If the text is different from what was rendered on the server, we will recover the UI by falling back to client rendering, up to the nearest Suspense boundary. --- ...DOMServerPartialHydration-test.internal.js | 73 +++++++++++ .../react-dom/src/client/ReactDOMComponent.js | 118 ++++++++++-------- .../src/client/ReactDOMHostConfig.js | 30 +++-- .../src/ReactFiberHydrationContext.new.js | 61 ++++----- .../src/ReactFiberHydrationContext.old.js | 61 ++++----- .../useMutableSourceHydration-test.js | 14 ++- scripts/error-codes/codes.json | 3 +- 7 files changed, 240 insertions(+), 120 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 36c452b8cdf30..f19c38bc38cd5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -3361,4 +3361,77 @@ describe('ReactDOMServerPartialHydration', () => { '
1
client
2
', ); }); + + // @gate enableClientRenderFallbackOnHydrationMismatch + it("falls back to client rendering when there's a text mismatch (direct text child)", async () => { + function DirectTextChild({text}) { + return
{text}
; + } + const container = document.createElement('div'); + container.innerHTML = ReactDOMServer.renderToString( + , + ); + expect(() => { + act(() => { + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + }); + }).toErrorDev( + [ + 'Text content did not match. Server: "good" Client: "bad"', + 'An error occurred during hydration. The server HTML was replaced with ' + + 'client content in
.', + ], + {withoutStack: 1}, + ); + expect(Scheduler).toHaveYielded([ + 'Text content does not match server-rendered HTML.', + 'There was an error while hydrating. Because the error happened outside ' + + 'of a Suspense boundary, the entire root will switch to client rendering.', + ]); + }); + + // @gate enableClientRenderFallbackOnHydrationMismatch + it("falls back to client rendering when there's a text mismatch (text child with siblings)", async () => { + function Sibling() { + return 'Sibling'; + } + + function TextChildWithSibling({text}) { + return ( +
+ + {text} +
+ ); + } + const container2 = document.createElement('div'); + container2.innerHTML = ReactDOMServer.renderToString( + , + ); + expect(() => { + act(() => { + ReactDOM.hydrateRoot(container2, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + }); + }).toErrorDev( + [ + 'Text content did not match. Server: "good" Client: "bad"', + 'An error occurred during hydration. The server HTML was replaced with ' + + 'client content in
.', + ], + {withoutStack: 1}, + ); + expect(Scheduler).toHaveYielded([ + 'Text content does not match server-rendered HTML.', + '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-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index ffd90c128d462..767b200f29093 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -72,6 +72,7 @@ import {validateProperties as validateUnknownProperties} from '../shared/ReactDO import { enableTrustedTypesIntegration, enableCustomElementPropertySupport, + enableClientRenderFallbackOnHydrationMismatch, } from 'shared/ReactFeatureFlags'; import { mediaEventTypes, @@ -93,13 +94,11 @@ let warnedUnknownTags; let suppressHydrationWarning; let validatePropertiesInDevelopment; -let warnForTextDifference; let warnForPropDifference; let warnForExtraAttributes; let warnForInvalidEventListener; let canDiffStyleForHydrationWarning; -let normalizeMarkupForTextOrAttribute; let normalizeHTML; if (__DEV__) { @@ -133,45 +132,6 @@ if (__DEV__) { // See https://github.com/facebook/react/issues/11807 canDiffStyleForHydrationWarning = canUseDOM && !document.documentMode; - // HTML parsing normalizes CR and CRLF to LF. - // It also can turn \u0000 into \uFFFD inside attributes. - // https://www.w3.org/TR/html5/single-page.html#preprocessing-the-input-stream - // If we have a mismatch, it might be caused by that. - // We will still patch up in this case but not fire the warning. - const NORMALIZE_NEWLINES_REGEX = /\r\n?/g; - const NORMALIZE_NULL_AND_REPLACEMENT_REGEX = /\u0000|\uFFFD/g; - - normalizeMarkupForTextOrAttribute = function(markup: mixed): string { - if (__DEV__) { - checkHtmlStringCoercion(markup); - } - const markupString = - typeof markup === 'string' ? markup : '' + (markup: any); - return markupString - .replace(NORMALIZE_NEWLINES_REGEX, '\n') - .replace(NORMALIZE_NULL_AND_REPLACEMENT_REGEX, ''); - }; - - warnForTextDifference = function( - serverText: string, - clientText: string | number, - ) { - if (didWarnInvalidHydration) { - return; - } - const normalizedClientText = normalizeMarkupForTextOrAttribute(clientText); - const normalizedServerText = normalizeMarkupForTextOrAttribute(serverText); - if (normalizedServerText === normalizedClientText) { - return; - } - didWarnInvalidHydration = true; - console.error( - 'Text content did not match. Server: "%s" Client: "%s"', - normalizedServerText, - normalizedClientText, - ); - }; - warnForPropDifference = function( propName: string, serverValue: mixed, @@ -248,6 +208,53 @@ if (__DEV__) { }; } +// HTML parsing normalizes CR and CRLF to LF. +// It also can turn \u0000 into \uFFFD inside attributes. +// https://www.w3.org/TR/html5/single-page.html#preprocessing-the-input-stream +// If we have a mismatch, it might be caused by that. +// We will still patch up in this case but not fire the warning. +const NORMALIZE_NEWLINES_REGEX = /\r\n?/g; +const NORMALIZE_NULL_AND_REPLACEMENT_REGEX = /\u0000|\uFFFD/g; + +function normalizeMarkupForTextOrAttribute(markup: mixed): string { + if (__DEV__) { + checkHtmlStringCoercion(markup); + } + const markupString = typeof markup === 'string' ? markup : '' + (markup: any); + return markupString + .replace(NORMALIZE_NEWLINES_REGEX, '\n') + .replace(NORMALIZE_NULL_AND_REPLACEMENT_REGEX, ''); +} + +export function checkForUnmatchedText( + serverText: string, + clientText: string | number, + isConcurrentMode: boolean, +) { + const normalizedClientText = normalizeMarkupForTextOrAttribute(clientText); + const normalizedServerText = normalizeMarkupForTextOrAttribute(serverText); + if (normalizedServerText === normalizedClientText) { + return; + } + + if (__DEV__) { + if (!didWarnInvalidHydration) { + didWarnInvalidHydration = true; + console.error( + 'Text content did not match. Server: "%s" Client: "%s"', + normalizedServerText, + normalizedClientText, + ); + } + } + + if (isConcurrentMode && enableClientRenderFallbackOnHydrationMismatch) { + // 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.'); + } +} + function getOwnerDocumentFromRootContainer( rootContainerElement: Element | Document, ): Document { @@ -858,6 +865,7 @@ export function diffHydratedProperties( rawProps: Object, parentNamespace: string, rootContainerElement: Element | Document, + isConcurrentMode: boolean, ): null | Array { let isCustomComponentTag; let extraAttributeNames: Set; @@ -972,15 +980,23 @@ export function diffHydratedProperties( // TODO: Should we use domElement.firstChild.nodeValue to compare? if (typeof nextProp === 'string') { if (domElement.textContent !== nextProp) { - if (__DEV__ && !suppressHydrationWarning) { - warnForTextDifference(domElement.textContent, nextProp); + if (!suppressHydrationWarning) { + checkForUnmatchedText( + domElement.textContent, + nextProp, + isConcurrentMode, + ); } updatePayload = [CHILDREN, nextProp]; } } else if (typeof nextProp === 'number') { if (domElement.textContent !== '' + nextProp) { - if (__DEV__ && !suppressHydrationWarning) { - warnForTextDifference(domElement.textContent, nextProp); + if (!suppressHydrationWarning) { + checkForUnmatchedText( + domElement.textContent, + nextProp, + isConcurrentMode, + ); } updatePayload = [CHILDREN, '' + nextProp]; } @@ -1165,17 +1181,15 @@ export function diffHydratedProperties( return updatePayload; } -export function diffHydratedText(textNode: Text, text: string): boolean { +export function diffHydratedText( + textNode: Text, + text: string, + isConcurrentMode: boolean, +): boolean { const isDifferent = textNode.nodeValue !== text; return isDifferent; } -export function warnForUnmatchedText(textNode: Text, text: string) { - if (__DEV__) { - warnForTextDifference(textNode.nodeValue, text); - } -} - export function warnForDeletedHydratableElement( parentNode: Element | Document, child: Element, diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 08cd8cb81c787..4863a16eda68e 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -35,7 +35,7 @@ import { diffHydratedProperties, diffHydratedText, trapClickOnNonInteractiveElement, - warnForUnmatchedText, + checkForUnmatchedText, warnForDeletedHydratableElement, warnForDeletedHydratableText, warnForInsertedHydratedElement, @@ -71,6 +71,9 @@ import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem'; import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities'; +// TODO: Remove this deep import when we delete the legacy root API +import {ConcurrentMode, NoMode} from 'react-reconciler/src/ReactTypeOfMode'; + export type Type = string; export type Props = { autoFocus?: boolean, @@ -795,12 +798,19 @@ export function hydrateInstance( } else { parentNamespace = ((hostContext: any): HostContextProd); } + + // TODO: Temporary hack to check if we're in a concurrent root. We can delete + // when the legacy root API is removed. + const isConcurrentMode = + ((internalInstanceHandle: Fiber).mode & ConcurrentMode) !== NoMode; + return diffHydratedProperties( instance, type, props, parentNamespace, rootContainerInstance, + isConcurrentMode, ); } @@ -810,7 +820,13 @@ export function hydrateTextInstance( internalInstanceHandle: Object, ): boolean { precacheFiberNode(internalInstanceHandle, textInstance); - return diffHydratedText(textInstance, text); + + // TODO: Temporary hack to check if we're in a concurrent root. We can delete + // when the legacy root API is removed. + const isConcurrentMode = + ((internalInstanceHandle: Fiber).mode & ConcurrentMode) !== NoMode; + + return diffHydratedText(textInstance, text, isConcurrentMode); } export function hydrateSuspenseInstance( @@ -906,10 +922,9 @@ export function didNotMatchHydratedContainerTextInstance( parentContainer: Container, textInstance: TextInstance, text: string, + isConcurrentMode: boolean, ) { - if (__DEV__) { - warnForUnmatchedText(textInstance, text); - } + checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode); } export function didNotMatchHydratedTextInstance( @@ -918,9 +933,10 @@ export function didNotMatchHydratedTextInstance( parentInstance: Instance, textInstance: TextInstance, text: string, + isConcurrentMode: boolean, ) { - if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { - warnForUnmatchedText(textInstance, text); + if (parentProps[SUPPRESS_HYDRATION_WARNING] !== true) { + checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode); } } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 59ed33bb80e7d..6905f58e2ab1b 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -447,35 +447,38 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean { const textInstance: TextInstance = fiber.stateNode; const textContent: string = fiber.memoizedProps; const shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber); - if (__DEV__) { - if (shouldUpdate) { - // We assume that prepareToHydrateHostTextInstance is called in a context where the - // hydration parent is the parent host component of this host text. - const returnFiber = hydrationParentFiber; - if (returnFiber !== null) { - switch (returnFiber.tag) { - case HostRoot: { - const parentContainer = returnFiber.stateNode.containerInfo; - didNotMatchHydratedContainerTextInstance( - parentContainer, - textInstance, - textContent, - ); - break; - } - case HostComponent: { - const parentType = returnFiber.type; - const parentProps = returnFiber.memoizedProps; - const parentInstance = returnFiber.stateNode; - didNotMatchHydratedTextInstance( - parentType, - parentProps, - parentInstance, - textInstance, - textContent, - ); - break; - } + if (shouldUpdate) { + // We assume that prepareToHydrateHostTextInstance is called in a context where the + // hydration parent is the parent host component of this host text. + const returnFiber = hydrationParentFiber; + if (returnFiber !== null) { + const isConcurrentMode = (returnFiber.mode & ConcurrentMode) !== NoMode; + switch (returnFiber.tag) { + case HostRoot: { + const parentContainer = returnFiber.stateNode.containerInfo; + didNotMatchHydratedContainerTextInstance( + parentContainer, + textInstance, + textContent, + // TODO: Delete this argument when we remove the legacy root API. + isConcurrentMode, + ); + break; + } + case HostComponent: { + const parentType = returnFiber.type; + const parentProps = returnFiber.memoizedProps; + const parentInstance = returnFiber.stateNode; + didNotMatchHydratedTextInstance( + parentType, + parentProps, + parentInstance, + textInstance, + textContent, + // TODO: Delete this argument when we remove the legacy root API. + isConcurrentMode, + ); + break; } } } diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 4ff9011fddc80..b9e88d3a21af0 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -447,35 +447,38 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean { const textInstance: TextInstance = fiber.stateNode; const textContent: string = fiber.memoizedProps; const shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber); - if (__DEV__) { - if (shouldUpdate) { - // We assume that prepareToHydrateHostTextInstance is called in a context where the - // hydration parent is the parent host component of this host text. - const returnFiber = hydrationParentFiber; - if (returnFiber !== null) { - switch (returnFiber.tag) { - case HostRoot: { - const parentContainer = returnFiber.stateNode.containerInfo; - didNotMatchHydratedContainerTextInstance( - parentContainer, - textInstance, - textContent, - ); - break; - } - case HostComponent: { - const parentType = returnFiber.type; - const parentProps = returnFiber.memoizedProps; - const parentInstance = returnFiber.stateNode; - didNotMatchHydratedTextInstance( - parentType, - parentProps, - parentInstance, - textInstance, - textContent, - ); - break; - } + if (shouldUpdate) { + // We assume that prepareToHydrateHostTextInstance is called in a context where the + // hydration parent is the parent host component of this host text. + const returnFiber = hydrationParentFiber; + if (returnFiber !== null) { + const isConcurrentMode = (returnFiber.mode & ConcurrentMode) !== NoMode; + switch (returnFiber.tag) { + case HostRoot: { + const parentContainer = returnFiber.stateNode.containerInfo; + didNotMatchHydratedContainerTextInstance( + parentContainer, + textInstance, + textContent, + // TODO: Delete this argument when we remove the legacy root API. + isConcurrentMode, + ); + break; + } + case HostComponent: { + const parentType = returnFiber.type; + const parentProps = returnFiber.memoizedProps; + const parentInstance = returnFiber.stateNode; + didNotMatchHydratedTextInstance( + parentType, + parentProps, + parentInstance, + textInstance, + textContent, + // TODO: Delete this argument when we remove the legacy root API. + isConcurrentMode, + ); + break; } } } diff --git a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js index 5fe2f861dc5f0..4e9bd2f0c9973 100644 --- a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js +++ b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js @@ -169,6 +169,7 @@ describe('useMutableSourceHydration', () => { }); // @gate enableUseMutableSource + // @gate enableClientRenderFallbackOnHydrationMismatch it('should detect a tear before hydrating a component', () => { const source = createSource('one'); const mutableSource = createMutableSource(source, param => param.version); @@ -204,9 +205,18 @@ describe('useMutableSourceHydration', () => { source.value = 'two'; }); }).toErrorDev( - 'Warning: Text content did not match. Server: "only:one" Client: "only:two"', + [ + 'Warning: Text content did not match. Server: "only:one" Client: "only:two"', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', + ], + {withoutStack: 1}, ); - expect(Scheduler).toHaveYielded(['only:two']); + expect(Scheduler).toHaveYielded([ + 'only:two', + 'only:two', + 'Log error: Text content does not match server-rendered HTML.', + 'Log error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); expect(source.listenerCount).toBe(1); }); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 9c9de4605f2dd..dafa574903496 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -408,5 +408,6 @@ "420": "This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering. The usual way to fix this is to wrap the original update in startTransition.", "421": "There was an error while hydrating this Suspense boundary. Switched to client rendering.", "422": "There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.", - "423": "This root received an early update, before anything was able hydrate. Switched the entire root to client rendering." + "423": "This root received an early update, before anything was able hydrate. Switched the entire root to client rendering.", + "424": "Text content does not match server-rendered HTML." }