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:
parent
1ad8d81292
commit
52c393b5d2
|
@ -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.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue