Skip to content

Commit 52c393b

Browse files
authored
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.
1 parent 1ad8d81 commit 52c393b

7 files changed

+240
-120
lines changed

packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js

+73
Original file line numberDiff line numberDiff line change
@@ -3361,4 +3361,77 @@ describe('ReactDOMServerPartialHydration', () => {
33613361
'<div>1</div><span>client</span><div>2</div>',
33623362
);
33633363
});
3364+
3365+
// @gate enableClientRenderFallbackOnHydrationMismatch
3366+
it("falls back to client rendering when there's a text mismatch (direct text child)", async () => {
3367+
function DirectTextChild({text}) {
3368+
return <div>{text}</div>;
3369+
}
3370+
const container = document.createElement('div');
3371+
container.innerHTML = ReactDOMServer.renderToString(
3372+
<DirectTextChild text="good" />,
3373+
);
3374+
expect(() => {
3375+
act(() => {
3376+
ReactDOM.hydrateRoot(container, <DirectTextChild text="bad" />, {
3377+
onRecoverableError(error) {
3378+
Scheduler.unstable_yieldValue(error.message);
3379+
},
3380+
});
3381+
});
3382+
}).toErrorDev(
3383+
[
3384+
'Text content did not match. Server: "good" Client: "bad"',
3385+
'An error occurred during hydration. The server HTML was replaced with ' +
3386+
'client content in <div>.',
3387+
],
3388+
{withoutStack: 1},
3389+
);
3390+
expect(Scheduler).toHaveYielded([
3391+
'Text content does not match server-rendered HTML.',
3392+
'There was an error while hydrating. Because the error happened outside ' +
3393+
'of a Suspense boundary, the entire root will switch to client rendering.',
3394+
]);
3395+
});
3396+
3397+
// @gate enableClientRenderFallbackOnHydrationMismatch
3398+
it("falls back to client rendering when there's a text mismatch (text child with siblings)", async () => {
3399+
function Sibling() {
3400+
return 'Sibling';
3401+
}
3402+
3403+
function TextChildWithSibling({text}) {
3404+
return (
3405+
<div>
3406+
<Sibling />
3407+
{text}
3408+
</div>
3409+
);
3410+
}
3411+
const container2 = document.createElement('div');
3412+
container2.innerHTML = ReactDOMServer.renderToString(
3413+
<TextChildWithSibling text="good" />,
3414+
);
3415+
expect(() => {
3416+
act(() => {
3417+
ReactDOM.hydrateRoot(container2, <TextChildWithSibling text="bad" />, {
3418+
onRecoverableError(error) {
3419+
Scheduler.unstable_yieldValue(error.message);
3420+
},
3421+
});
3422+
});
3423+
}).toErrorDev(
3424+
[
3425+
'Text content did not match. Server: "good" Client: "bad"',
3426+
'An error occurred during hydration. The server HTML was replaced with ' +
3427+
'client content in <div>.',
3428+
],
3429+
{withoutStack: 1},
3430+
);
3431+
expect(Scheduler).toHaveYielded([
3432+
'Text content does not match server-rendered HTML.',
3433+
'There was an error while hydrating. Because the error happened outside ' +
3434+
'of a Suspense boundary, the entire root will switch to client rendering.',
3435+
]);
3436+
});
33643437
});

packages/react-dom/src/client/ReactDOMComponent.js

+66-52
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {validateProperties as validateUnknownProperties} from '../shared/ReactDO
7272
import {
7373
enableTrustedTypesIntegration,
7474
enableCustomElementPropertySupport,
75+
enableClientRenderFallbackOnHydrationMismatch,
7576
} from 'shared/ReactFeatureFlags';
7677
import {
7778
mediaEventTypes,
@@ -93,13 +94,11 @@ let warnedUnknownTags;
9394
let suppressHydrationWarning;
9495

9596
let validatePropertiesInDevelopment;
96-
let warnForTextDifference;
9797
let warnForPropDifference;
9898
let warnForExtraAttributes;
9999
let warnForInvalidEventListener;
100100
let canDiffStyleForHydrationWarning;
101101

102-
let normalizeMarkupForTextOrAttribute;
103102
let normalizeHTML;
104103

105104
if (__DEV__) {
@@ -133,45 +132,6 @@ if (__DEV__) {
133132
// See https://github.com/facebook/react/issues/11807
134133
canDiffStyleForHydrationWarning = canUseDOM && !document.documentMode;
135134

136-
// HTML parsing normalizes CR and CRLF to LF.
137-
// It also can turn \u0000 into \uFFFD inside attributes.
138-
// https://www.w3.org/TR/html5/single-page.html#preprocessing-the-input-stream
139-
// If we have a mismatch, it might be caused by that.
140-
// We will still patch up in this case but not fire the warning.
141-
const NORMALIZE_NEWLINES_REGEX = /\r\n?/g;
142-
const NORMALIZE_NULL_AND_REPLACEMENT_REGEX = /\u0000|\uFFFD/g;
143-
144-
normalizeMarkupForTextOrAttribute = function(markup: mixed): string {
145-
if (__DEV__) {
146-
checkHtmlStringCoercion(markup);
147-
}
148-
const markupString =
149-
typeof markup === 'string' ? markup : '' + (markup: any);
150-
return markupString
151-
.replace(NORMALIZE_NEWLINES_REGEX, '\n')
152-
.replace(NORMALIZE_NULL_AND_REPLACEMENT_REGEX, '');
153-
};
154-
155-
warnForTextDifference = function(
156-
serverText: string,
157-
clientText: string | number,
158-
) {
159-
if (didWarnInvalidHydration) {
160-
return;
161-
}
162-
const normalizedClientText = normalizeMarkupForTextOrAttribute(clientText);
163-
const normalizedServerText = normalizeMarkupForTextOrAttribute(serverText);
164-
if (normalizedServerText === normalizedClientText) {
165-
return;
166-
}
167-
didWarnInvalidHydration = true;
168-
console.error(
169-
'Text content did not match. Server: "%s" Client: "%s"',
170-
normalizedServerText,
171-
normalizedClientText,
172-
);
173-
};
174-
175135
warnForPropDifference = function(
176136
propName: string,
177137
serverValue: mixed,
@@ -248,6 +208,53 @@ if (__DEV__) {
248208
};
249209
}
250210

211+
// HTML parsing normalizes CR and CRLF to LF.
212+
// It also can turn \u0000 into \uFFFD inside attributes.
213+
// https://www.w3.org/TR/html5/single-page.html#preprocessing-the-input-stream
214+
// If we have a mismatch, it might be caused by that.
215+
// We will still patch up in this case but not fire the warning.
216+
const NORMALIZE_NEWLINES_REGEX = /\r\n?/g;
217+
const NORMALIZE_NULL_AND_REPLACEMENT_REGEX = /\u0000|\uFFFD/g;
218+
219+
function normalizeMarkupForTextOrAttribute(markup: mixed): string {
220+
if (__DEV__) {
221+
checkHtmlStringCoercion(markup);
222+
}
223+
const markupString = typeof markup === 'string' ? markup : '' + (markup: any);
224+
return markupString
225+
.replace(NORMALIZE_NEWLINES_REGEX, '\n')
226+
.replace(NORMALIZE_NULL_AND_REPLACEMENT_REGEX, '');
227+
}
228+
229+
export function checkForUnmatchedText(
230+
serverText: string,
231+
clientText: string | number,
232+
isConcurrentMode: boolean,
233+
) {
234+
const normalizedClientText = normalizeMarkupForTextOrAttribute(clientText);
235+
const normalizedServerText = normalizeMarkupForTextOrAttribute(serverText);
236+
if (normalizedServerText === normalizedClientText) {
237+
return;
238+
}
239+
240+
if (__DEV__) {
241+
if (!didWarnInvalidHydration) {
242+
didWarnInvalidHydration = true;
243+
console.error(
244+
'Text content did not match. Server: "%s" Client: "%s"',
245+
normalizedServerText,
246+
normalizedClientText,
247+
);
248+
}
249+
}
250+
251+
if (isConcurrentMode && enableClientRenderFallbackOnHydrationMismatch) {
252+
// In concurrent roots, we throw when there's a text mismatch and revert to
253+
// client rendering, up to the nearest Suspense boundary.
254+
throw new Error('Text content does not match server-rendered HTML.');
255+
}
256+
}
257+
251258
function getOwnerDocumentFromRootContainer(
252259
rootContainerElement: Element | Document,
253260
): Document {
@@ -858,6 +865,7 @@ export function diffHydratedProperties(
858865
rawProps: Object,
859866
parentNamespace: string,
860867
rootContainerElement: Element | Document,
868+
isConcurrentMode: boolean,
861869
): null | Array<mixed> {
862870
let isCustomComponentTag;
863871
let extraAttributeNames: Set<string>;
@@ -972,15 +980,23 @@ export function diffHydratedProperties(
972980
// TODO: Should we use domElement.firstChild.nodeValue to compare?
973981
if (typeof nextProp === 'string') {
974982
if (domElement.textContent !== nextProp) {
975-
if (__DEV__ && !suppressHydrationWarning) {
976-
warnForTextDifference(domElement.textContent, nextProp);
983+
if (!suppressHydrationWarning) {
984+
checkForUnmatchedText(
985+
domElement.textContent,
986+
nextProp,
987+
isConcurrentMode,
988+
);
977989
}
978990
updatePayload = [CHILDREN, nextProp];
979991
}
980992
} else if (typeof nextProp === 'number') {
981993
if (domElement.textContent !== '' + nextProp) {
982-
if (__DEV__ && !suppressHydrationWarning) {
983-
warnForTextDifference(domElement.textContent, nextProp);
994+
if (!suppressHydrationWarning) {
995+
checkForUnmatchedText(
996+
domElement.textContent,
997+
nextProp,
998+
isConcurrentMode,
999+
);
9841000
}
9851001
updatePayload = [CHILDREN, '' + nextProp];
9861002
}
@@ -1165,17 +1181,15 @@ export function diffHydratedProperties(
11651181
return updatePayload;
11661182
}
11671183

1168-
export function diffHydratedText(textNode: Text, text: string): boolean {
1184+
export function diffHydratedText(
1185+
textNode: Text,
1186+
text: string,
1187+
isConcurrentMode: boolean,
1188+
): boolean {
11691189
const isDifferent = textNode.nodeValue !== text;
11701190
return isDifferent;
11711191
}
11721192

1173-
export function warnForUnmatchedText(textNode: Text, text: string) {
1174-
if (__DEV__) {
1175-
warnForTextDifference(textNode.nodeValue, text);
1176-
}
1177-
}
1178-
11791193
export function warnForDeletedHydratableElement(
11801194
parentNode: Element | Document,
11811195
child: Element,

packages/react-dom/src/client/ReactDOMHostConfig.js

+23-7
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
diffHydratedProperties,
3636
diffHydratedText,
3737
trapClickOnNonInteractiveElement,
38-
warnForUnmatchedText,
38+
checkForUnmatchedText,
3939
warnForDeletedHydratableElement,
4040
warnForDeletedHydratableText,
4141
warnForInsertedHydratedElement,
@@ -71,6 +71,9 @@ import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem';
7171

7272
import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities';
7373

74+
// TODO: Remove this deep import when we delete the legacy root API
75+
import {ConcurrentMode, NoMode} from 'react-reconciler/src/ReactTypeOfMode';
76+
7477
export type Type = string;
7578
export type Props = {
7679
autoFocus?: boolean,
@@ -795,12 +798,19 @@ export function hydrateInstance(
795798
} else {
796799
parentNamespace = ((hostContext: any): HostContextProd);
797800
}
801+
802+
// TODO: Temporary hack to check if we're in a concurrent root. We can delete
803+
// when the legacy root API is removed.
804+
const isConcurrentMode =
805+
((internalInstanceHandle: Fiber).mode & ConcurrentMode) !== NoMode;
806+
798807
return diffHydratedProperties(
799808
instance,
800809
type,
801810
props,
802811
parentNamespace,
803812
rootContainerInstance,
813+
isConcurrentMode,
804814
);
805815
}
806816

@@ -810,7 +820,13 @@ export function hydrateTextInstance(
810820
internalInstanceHandle: Object,
811821
): boolean {
812822
precacheFiberNode(internalInstanceHandle, textInstance);
813-
return diffHydratedText(textInstance, text);
823+
824+
// TODO: Temporary hack to check if we're in a concurrent root. We can delete
825+
// when the legacy root API is removed.
826+
const isConcurrentMode =
827+
((internalInstanceHandle: Fiber).mode & ConcurrentMode) !== NoMode;
828+
829+
return diffHydratedText(textInstance, text, isConcurrentMode);
814830
}
815831

816832
export function hydrateSuspenseInstance(
@@ -906,10 +922,9 @@ export function didNotMatchHydratedContainerTextInstance(
906922
parentContainer: Container,
907923
textInstance: TextInstance,
908924
text: string,
925+
isConcurrentMode: boolean,
909926
) {
910-
if (__DEV__) {
911-
warnForUnmatchedText(textInstance, text);
912-
}
927+
checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode);
913928
}
914929

915930
export function didNotMatchHydratedTextInstance(
@@ -918,9 +933,10 @@ export function didNotMatchHydratedTextInstance(
918933
parentInstance: Instance,
919934
textInstance: TextInstance,
920935
text: string,
936+
isConcurrentMode: boolean,
921937
) {
922-
if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
923-
warnForUnmatchedText(textInstance, text);
938+
if (parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
939+
checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode);
924940
}
925941
}
926942

0 commit comments

Comments
 (0)