Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revert to client render on text mismatch #23354

Merged
merged 2 commits into from
Feb 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is not prod behavior it’s a bit more perf sensitive. We can first check if the original string is equal first, which it in most cases will be and if so bail early. Only if that is not true should we normalize.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nvm. We only get here if they’re not already equal.

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