Skip to content

Commit

Permalink
Revert to client render on text mismatch (#23354)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
acdlite authored Feb 24, 2022
1 parent 1ad8d81 commit 52c393b
Show file tree
Hide file tree
Showing 7 changed files with 240 additions and 120 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
]);
});
});
118 changes: 66 additions & 52 deletions packages/react-dom/src/client/ReactDOMComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {validateProperties as validateUnknownProperties} from '../shared/ReactDO
import {
enableTrustedTypesIntegration,
enableCustomElementPropertySupport,
enableClientRenderFallbackOnHydrationMismatch,
} from 'shared/ReactFeatureFlags';
import {
mediaEventTypes,
Expand All @@ -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__) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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>;
Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 23 additions & 7 deletions packages/react-dom/src/client/ReactDOMHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
diffHydratedProperties,
diffHydratedText,
trapClickOnNonInteractiveElement,
warnForUnmatchedText,
checkForUnmatchedText,
warnForDeletedHydratableElement,
warnForDeletedHydratableText,
warnForInsertedHydratedElement,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
}

Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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);
}
}

Expand Down
Loading

0 comments on commit 52c393b

Please sign in to comment.