@samitouri / QOS-React / commits / 52c393b5d2

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.

Andrew Clark committed Feb 24, 2022 at 00:23 UTC 52c393b5d261550609dda955f059a26d1db63538
7 files changed +240 -120
packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
+73
@@ -3361,4 +3361,77 @@ describe('ReactDOMServerPartialHydration', () => {
3361 '<div>1</div><span>client</span><div>2</div>',
3362 );
3363 });
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 + });
3437 });
packages/react-dom/src/client/ReactDOMComponent.js
+66 -52
@@ -72,6 +72,7 @@ import {validateProperties as validateUnknownProperties} from '../shared/ReactDO
72 import {
73 enableTrustedTypesIntegration,
74 enableCustomElementPropertySupport,
75 + enableClientRenderFallbackOnHydrationMismatch,
76 } from 'shared/ReactFeatureFlags';
77 import {
78 mediaEventTypes,
@@ -93,13 +94,11 @@ let warnedUnknownTags;
94 let suppressHydrationWarning;
95
96 let validatePropertiesInDevelopment;
96 -let warnForTextDifference;
97 let warnForPropDifference;
98 let warnForExtraAttributes;
99 let warnForInvalidEventListener;
100 let canDiffStyleForHydrationWarning;
101
102 -let normalizeMarkupForTextOrAttribute;
102 let normalizeHTML;
103
104 if (__DEV__) {
@@ -133,45 +132,6 @@ if (__DEV__) {
132 // See https://github.com/facebook/react/issues/11807
133 canDiffStyleForHydrationWarning = canUseDOM && !document.documentMode;
134
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 -
135 warnForPropDifference = function(
136 propName: string,
137 serverValue: mixed,
@@ -248,6 +208,53 @@ if (__DEV__) {
208 };
209 }
210
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 +
258 function getOwnerDocumentFromRootContainer(
259 rootContainerElement: Element | Document,
260 ): Document {
@@ -858,6 +865,7 @@ export function diffHydratedProperties(
865 rawProps: Object,
866 parentNamespace: string,
867 rootContainerElement: Element | Document,
868 + isConcurrentMode: boolean,
869 ): null | Array<mixed> {
870 let isCustomComponentTag;
871 let extraAttributeNames: Set<string>;
@@ -972,15 +980,23 @@ export function diffHydratedProperties(
980 // TODO: Should we use domElement.firstChild.nodeValue to compare?
981 if (typeof nextProp === 'string') {
982 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 + );
989 }
990 updatePayload = [CHILDREN, nextProp];
991 }
992 } else if (typeof nextProp === 'number') {
993 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 + );
1000 }
1001 updatePayload = [CHILDREN, '' + nextProp];
1002 }
@@ -1165,17 +1181,15 @@ export function diffHydratedProperties(
1181 return updatePayload;
1182 }
1183
1168 -export function diffHydratedText(textNode: Text, text: string): boolean {
1184 +export function diffHydratedText(
1185 + textNode: Text,
1186 + text: string,
1187 + isConcurrentMode: boolean,
1188 +): boolean {
1189 const isDifferent = textNode.nodeValue !== text;
1190 return isDifferent;
1191 }
1192
1173 -export function warnForUnmatchedText(textNode: Text, text: string) {
1174 - if (__DEV__) {
1175 - warnForTextDifference(textNode.nodeValue, text);
1176 - }
1177 -}
1178 -
1193 export function warnForDeletedHydratableElement(
1194 parentNode: Element | Document,
1195 child: Element,
packages/react-dom/src/client/ReactDOMHostConfig.js
+23 -7
@@ -35,7 +35,7 @@ import {
35 diffHydratedProperties,
36 diffHydratedText,
37 trapClickOnNonInteractiveElement,
38 - warnForUnmatchedText,
38 + checkForUnmatchedText,
39 warnForDeletedHydratableElement,
40 warnForDeletedHydratableText,
41 warnForInsertedHydratedElement,
@@ -71,6 +71,9 @@ import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem';
71
72 import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities';
73
74 +// TODO: Remove this deep import when we delete the legacy root API
75 +import {ConcurrentMode, NoMode} from 'react-reconciler/src/ReactTypeOfMode';
76 +
77 export type Type = string;
78 export type Props = {
79 autoFocus?: boolean,
@@ -795,12 +798,19 @@ export function hydrateInstance(
798 } else {
799 parentNamespace = ((hostContext: any): HostContextProd);
800 }
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 +
807 return diffHydratedProperties(
808 instance,
809 type,
810 props,
811 parentNamespace,
812 rootContainerInstance,
813 + isConcurrentMode,
814 );
815 }
816
@@ -810,7 +820,13 @@ export function hydrateTextInstance(
820 internalInstanceHandle: Object,
821 ): boolean {
822 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);
830 }
831
832 export function hydrateSuspenseInstance(
@@ -906,10 +922,9 @@ export function didNotMatchHydratedContainerTextInstance(
922 parentContainer: Container,
923 textInstance: TextInstance,
924 text: string,
925 + isConcurrentMode: boolean,
926 ) {
910 - if (__DEV__) {
911 - warnForUnmatchedText(textInstance, text);
912 - }
927 + checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode);
928 }
929
930 export function didNotMatchHydratedTextInstance(
@@ -918,9 +933,10 @@ export function didNotMatchHydratedTextInstance(
933 parentInstance: Instance,
934 textInstance: TextInstance,
935 text: string,
936 + isConcurrentMode: boolean,
937 ) {
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);
940 }
941 }
942
packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+32 -29
@@ -447,35 +447,38 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
447 const textInstance: TextInstance = fiber.stateNode;
448 const textContent: string = fiber.memoizedProps;
449 const shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber);
450 - if (__DEV__) {
451 - if (shouldUpdate) {
452 - // We assume that prepareToHydrateHostTextInstance is called in a context where the
453 - // hydration parent is the parent host component of this host text.
454 - const returnFiber = hydrationParentFiber;
455 - if (returnFiber !== null) {
456 - switch (returnFiber.tag) {
457 - case HostRoot: {
458 - const parentContainer = returnFiber.stateNode.containerInfo;
459 - didNotMatchHydratedContainerTextInstance(
460 - parentContainer,
461 - textInstance,
462 - textContent,
463 - );
464 - break;
465 - }
466 - case HostComponent: {
467 - const parentType = returnFiber.type;
468 - const parentProps = returnFiber.memoizedProps;
469 - const parentInstance = returnFiber.stateNode;
470 - didNotMatchHydratedTextInstance(
471 - parentType,
472 - parentProps,
473 - parentInstance,
474 - textInstance,
475 - textContent,
476 - );
477 - break;
478 - }
450 + if (shouldUpdate) {
451 + // We assume that prepareToHydrateHostTextInstance is called in a context where the
452 + // hydration parent is the parent host component of this host text.
453 + const returnFiber = hydrationParentFiber;
454 + if (returnFiber !== null) {
455 + const isConcurrentMode = (returnFiber.mode & ConcurrentMode) !== NoMode;
456 + switch (returnFiber.tag) {
457 + case HostRoot: {
458 + const parentContainer = returnFiber.stateNode.containerInfo;
459 + didNotMatchHydratedContainerTextInstance(
460 + parentContainer,
461 + textInstance,
462 + textContent,
463 + // TODO: Delete this argument when we remove the legacy root API.
464 + isConcurrentMode,
465 + );
466 + break;
467 + }
468 + case HostComponent: {
469 + const parentType = returnFiber.type;
470 + const parentProps = returnFiber.memoizedProps;
471 + const parentInstance = returnFiber.stateNode;
472 + didNotMatchHydratedTextInstance(
473 + parentType,
474 + parentProps,
475 + parentInstance,
476 + textInstance,
477 + textContent,
478 + // TODO: Delete this argument when we remove the legacy root API.
479 + isConcurrentMode,
480 + );
481 + break;
482 }
483 }
484 }
packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+32 -29
@@ -447,35 +447,38 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
447 const textInstance: TextInstance = fiber.stateNode;
448 const textContent: string = fiber.memoizedProps;
449 const shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber);
450 - if (__DEV__) {
451 - if (shouldUpdate) {
452 - // We assume that prepareToHydrateHostTextInstance is called in a context where the
453 - // hydration parent is the parent host component of this host text.
454 - const returnFiber = hydrationParentFiber;
455 - if (returnFiber !== null) {
456 - switch (returnFiber.tag) {
457 - case HostRoot: {
458 - const parentContainer = returnFiber.stateNode.containerInfo;
459 - didNotMatchHydratedContainerTextInstance(
460 - parentContainer,
461 - textInstance,
462 - textContent,
463 - );
464 - break;
465 - }
466 - case HostComponent: {
467 - const parentType = returnFiber.type;
468 - const parentProps = returnFiber.memoizedProps;
469 - const parentInstance = returnFiber.stateNode;
470 - didNotMatchHydratedTextInstance(
471 - parentType,
472 - parentProps,
473 - parentInstance,
474 - textInstance,
475 - textContent,
476 - );
477 - break;
478 - }
450 + if (shouldUpdate) {
451 + // We assume that prepareToHydrateHostTextInstance is called in a context where the
452 + // hydration parent is the parent host component of this host text.
453 + const returnFiber = hydrationParentFiber;
454 + if (returnFiber !== null) {
455 + const isConcurrentMode = (returnFiber.mode & ConcurrentMode) !== NoMode;
456 + switch (returnFiber.tag) {
457 + case HostRoot: {
458 + const parentContainer = returnFiber.stateNode.containerInfo;
459 + didNotMatchHydratedContainerTextInstance(
460 + parentContainer,
461 + textInstance,
462 + textContent,
463 + // TODO: Delete this argument when we remove the legacy root API.
464 + isConcurrentMode,
465 + );
466 + break;
467 + }
468 + case HostComponent: {
469 + const parentType = returnFiber.type;
470 + const parentProps = returnFiber.memoizedProps;
471 + const parentInstance = returnFiber.stateNode;
472 + didNotMatchHydratedTextInstance(
473 + parentType,
474 + parentProps,
475 + parentInstance,
476 + textInstance,
477 + textContent,
478 + // TODO: Delete this argument when we remove the legacy root API.
479 + isConcurrentMode,
480 + );
481 + break;
482 }
483 }
484 }
packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js
+12 -2
@@ -169,6 +169,7 @@ describe('useMutableSourceHydration', () => {
169 });
170
171 // @gate enableUseMutableSource
172 + // @gate enableClientRenderFallbackOnHydrationMismatch
173 it('should detect a tear before hydrating a component', () => {
174 const source = createSource('one');
175 const mutableSource = createMutableSource(source, param => param.version);
@@ -204,9 +205,18 @@ describe('useMutableSourceHydration', () => {
205 source.value = 'two';
206 });
207 }).toErrorDev(
207 - 'Warning: Text content did not match. Server: "only:one" Client: "only:two"',
208 + [
209 + 'Warning: Text content did not match. Server: "only:one" Client: "only:two"',
210 + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.',
211 + ],
212 + {withoutStack: 1},
213 );
209 - expect(Scheduler).toHaveYielded(['only:two']);
214 + expect(Scheduler).toHaveYielded([
215 + 'only:two',
216 + 'only:two',
217 + 'Log error: Text content does not match server-rendered HTML.',
218 + '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.',
219 + ]);
220 expect(source.listenerCount).toBe(1);
221 });
222
scripts/error-codes/codes.json
+2 -1
@@ -408,5 +408,6 @@
408 "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.",
409 "421": "There was an error while hydrating this Suspense boundary. Switched to client rendering.",
410 "422": "There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.",
411 - "423": "This root received an early update, before anything was able hydrate. Switched the entire root to client rendering."
411 + "423": "This root received an early update, before anything was able hydrate. Switched the entire root to client rendering.",
412 + "424": "Text content does not match server-rendered HTML."
413 }