Revert to client render on text mismatch (#23354)

* 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.
This commit is contained in:
Andrew Clark 2022-02-24 00:23:56 -05:00 committed by GitHub
parent 1ad8d81292
commit 52c393b5d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 240 additions and 120 deletions

View File

@ -3361,4 +3361,77 @@ describe('ReactDOMServerPartialHydration', () => {
'<div>1</div><span>client</span><div>2</div>',
);
});
// @gate enableClientRenderFallbackOnHydrationMismatch
it("falls back to client rendering when there's a text mismatch (direct text child)", async () => {
function DirectTextChild({text}) {
return <div>{text}</div>;
}
const container = document.createElement('div');
container.innerHTML = ReactDOMServer.renderToString(
<DirectTextChild text="good" />,
);
expect(() => {
act(() => {
ReactDOM.hydrateRoot(container, <DirectTextChild text="bad" />, {
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 <div>.',
],
{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 (
<div>
<Sibling />
{text}
</div>
);
}
const container2 = document.createElement('div');
container2.innerHTML = ReactDOMServer.renderToString(
<TextChildWithSibling text="good" />,
);
expect(() => {
act(() => {
ReactDOM.hydrateRoot(container2, <TextChildWithSibling text="bad" />, {
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 <div>.',
],
{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.',
]);
});
});

View File

@ -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<mixed> {
let isCustomComponentTag;
let extraAttributeNames: Set<string>;
@ -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,

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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 <div>.',
],
{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);
});

View File

@ -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."
}