Add Flag to Favor Hydration Performance over User Safety (#28655)

If false, this ignores text comparison checks during hydration at the
risk of privacy safety.

Since React 18 we recreate the DOM starting from the nearest Suspense
boundary if any of the text content mismatches. This ensures that if we
have nodes that otherwise line up correctly such as if they're the same
type of Component but in a different order, then we don't accidentally
transfer state or attributes to the wrong one.

If we didn't do this e.g. attributes like image src might not line up
with the text. E.g. you might show the wrong profile picture with the
wrong name. However, the main reason we do this is because it's a
security/privacy concern if state from the original node can transfer to
the other one. For example if you start typing into a text field to
reply to a story but then it turns out that the hydration was in a
different order, you might submit that text into a different story than
you intended. Similarly, if you've already clicked an item and that gets
replayed using Action replaying or is synchronously force hydrated -
that click might end up applying to a different item in the list than
you intended. E.g. liking the wrong photo.

Unfortunately a common case where this happens is when Google Translate
is applied to a page. It'll always cause mismatches and recreate the
tree. Most of the time this wouldn't be visible to users because it'd
just recreate to the same thing and then translate again. It can affect
metrics that trace when this hydration happened though.

Meta can use this flag to decide if they favor this perf metric over the
risk to user privacy.

This is similar to the old enableClientRenderFallbackOnTextMismatch flag
except this flag doesn't patch up the text when there's a mismatch.
Because we don't have the patching anymore. The assumption is that it is
safe to ignore the safety concern because we assume it's a match and
therefore favoring not patching it will lead to better perf.
This commit is contained in:
Sebastian Markbåge
2024-03-26 19:52:46 -07:00
committed by GitHub
parent 2ec2aaea98
commit 5910eb3456
15 changed files with 216 additions and 82 deletions

View File

@@ -4423,6 +4423,7 @@ describe('ReactDOMFizzServer', () => {
);
});
// @gate favorSafetyOverHydrationPerf
it('#24384: Suspending should halt hydration warnings but still emit hydration warnings after unsuspending if mismatches are genuine', async () => {
const makeApp = () => {
let resolve, resolved;
@@ -4506,6 +4507,7 @@ describe('ReactDOMFizzServer', () => {
await waitForAll([]);
});
// @gate favorSafetyOverHydrationPerf
it('only warns once on hydration mismatch while within a suspense boundary', async () => {
const originalConsoleError = console.error;
const mockError = jest.fn();

View File

@@ -6446,6 +6446,7 @@ body {
);
});
// @gate favorSafetyOverHydrationPerf
it('retains styles even when a new html, head, and/body mount', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(
@@ -8230,6 +8231,7 @@ background-color: green;
]);
});
// @gate favorSafetyOverHydrationPerf
it('can render a title before a singleton even if that singleton clears its contents', async () => {
await act(() => {
const {pipe} = renderToPipeableStream(

View File

@@ -80,30 +80,55 @@ describe('ReactDOMServerHydration', () => {
</div>
);
}
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
"Caught [Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
if (gate(flags => flags.favorSafetyOverHydrationPerf)) {
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
"Caught [Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
https://react.dev/link/hydration-mismatch
<Mismatch isClient={true}>
<div className="parent">
<main className="child">
+ client
- server
]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
<Mismatch isClient={true}>
<div className="parent">
<main className="child">
+ client
- server
]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
} else {
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
<Mismatch isClient={true}>
<div className="parent">
<main className="child">
+ client
- server
",
]
`);
}
});
// @gate __DEV__
@@ -120,29 +145,53 @@ describe('ReactDOMServerHydration', () => {
}
/* eslint-disable no-irregular-whitespace */
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
"Caught [Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
if (gate(flags => flags.favorSafetyOverHydrationPerf)) {
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
"Caught [Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
https://react.dev/link/hydration-mismatch
<Mismatch isClient={true}>
<div>
+ This markup contains an nbsp entity:   client text
- This markup contains an nbsp entity:   server text
]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
<Mismatch isClient={true}>
<div>
+ This markup contains an nbsp entity:   client text
- This markup contains an nbsp entity:   server text
]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
} else {
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
<Mismatch isClient={true}>
<div>
+ This markup contains an nbsp entity:   client text
- This markup contains an nbsp entity:   server text
",
]
`);
}
/* eslint-enable no-irregular-whitespace */
});
@@ -549,29 +598,53 @@ describe('ReactDOMServerHydration', () => {
function Mismatch({isClient}) {
return <div className="parent">{isClient && 'only'}</div>;
}
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
"Caught [Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
if (gate(flags => flags.favorSafetyOverHydrationPerf)) {
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
"Caught [Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
https://react.dev/link/hydration-mismatch
<Mismatch isClient={true}>
<div className="parent">
+ only
-
]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
<Mismatch isClient={true}>
<div className="parent">
+ only
-
]",
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
]
`);
} else {
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
[
"Warning: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
- A server/client branch \`if (typeof window !== 'undefined')\`.
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
https://react.dev/link/hydration-mismatch
<Mismatch isClient={true}>
<div className="parent">
+ only
-
",
]
`);
}
});
// @gate __DEV__

View File

@@ -3816,6 +3816,7 @@ describe('ReactDOMServerPartialHydration', () => {
);
});
// @gate favorSafetyOverHydrationPerf
it("falls back to client rendering when there's a text mismatch (direct text child)", async () => {
function DirectTextChild({text}) {
return <div>{text}</div>;
@@ -3845,6 +3846,7 @@ describe('ReactDOMServerPartialHydration', () => {
]);
});
// @gate favorSafetyOverHydrationPerf
it("falls back to client rendering when there's a text mismatch (text child with siblings)", async () => {
function Sibling() {
return 'Sibling';

View File

@@ -276,6 +276,9 @@ describe('rendering React components at document', () => {
);
const testDocument = getTestDocument(markup);
const favorSafetyOverHydrationPerf = gate(
flags => flags.favorSafetyOverHydrationPerf,
);
expect(() => {
ReactDOM.flushSync(() => {
ReactDOMClient.hydrateRoot(
@@ -291,19 +294,29 @@ describe('rendering React components at document', () => {
);
});
}).toErrorDev(
[
'Warning: An error occurred during hydration. The server HTML was replaced with client content.',
],
favorSafetyOverHydrationPerf
? [
'Warning: An error occurred during hydration. The server HTML was replaced with client content.',
]
: [
"Warning: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.",
],
{
withoutStack: 1,
},
);
assertLog([
"Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
'Log recoverable error: There was an error while hydrating.',
]);
expect(testDocument.body.innerHTML).toBe('Hello world');
assertLog(
favorSafetyOverHydrationPerf
? [
"Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
'Log recoverable error: There was an error while hydrating.',
]
: [],
);
expect(testDocument.body.innerHTML).toBe(
favorSafetyOverHydrationPerf ? 'Hello world' : 'Goodbye world',
);
});
it('should render w/ no markup to full document', async () => {

View File

@@ -123,6 +123,9 @@ describe('ReactDOMServerHydration', () => {
// Now simulate a situation where the app is not idempotent. React should
// warn but do the right thing.
element.innerHTML = lastMarkup;
const favorSafetyOverHydrationPerf = gate(
flags => flags.favorSafetyOverHydrationPerf,
);
await expect(async () => {
root = await act(() => {
return ReactDOMClient.hydrateRoot(
@@ -139,14 +142,22 @@ describe('ReactDOMServerHydration', () => {
);
});
}).toErrorDev(
[
'An error occurred during hydration. The server HTML was replaced with client content.',
],
favorSafetyOverHydrationPerf
? [
'An error occurred during hydration. The server HTML was replaced with client content.',
]
: [
" A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.",
],
{withoutStack: 1},
);
expect(mountCount).toEqual(4);
expect(element.innerHTML.length > 0).toBe(true);
expect(element.innerHTML).not.toEqual(lastMarkup);
if (favorSafetyOverHydrationPerf) {
expect(element.innerHTML).not.toEqual(lastMarkup);
} else {
expect(element.innerHTML).toEqual(lastMarkup);
}
// Ensure the events system works after markup mismatch.
expect(numClicks).toEqual(1);
@@ -212,6 +223,9 @@ describe('ReactDOMServerHydration', () => {
const onFocusAfterHydration = jest.fn();
element.firstChild.focus = onFocusBeforeHydration;
const favorSafetyOverHydrationPerf = gate(
flags => flags.favorSafetyOverHydrationPerf,
);
await expect(async () => {
await act(() => {
ReactDOMClient.hydrateRoot(
@@ -223,9 +237,13 @@ describe('ReactDOMServerHydration', () => {
);
});
}).toErrorDev(
[
'An error occurred during hydration. The server HTML was replaced with client content.',
],
favorSafetyOverHydrationPerf
? [
'An error occurred during hydration. The server HTML was replaced with client content.',
]
: [
"A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.",
],
{withoutStack: 1},
);
@@ -514,6 +532,9 @@ describe('ReactDOMServerHydration', () => {
);
domElement.innerHTML = markup;
const favorSafetyOverHydrationPerf = gate(
flags => flags.favorSafetyOverHydrationPerf,
);
await expect(async () => {
await act(() => {
ReactDOMClient.hydrateRoot(
@@ -524,14 +545,22 @@ describe('ReactDOMServerHydration', () => {
{onRecoverableError: error => {}},
);
});
expect(domElement.innerHTML).not.toEqual(markup);
}).toErrorDev(
[
'An error occurred during hydration. The server HTML was replaced with client content.',
],
favorSafetyOverHydrationPerf
? [
'An error occurred during hydration. The server HTML was replaced with client content.',
]
: [
" A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.",
],
{withoutStack: 1},
);
if (favorSafetyOverHydrationPerf) {
expect(domElement.innerHTML).not.toEqual(markup);
} else {
expect(domElement.innerHTML).toEqual(markup);
}
});
it('should warn if innerHTML mismatches with dangerouslySetInnerHTML=undefined on the client', async () => {

View File

@@ -17,10 +17,12 @@ module.exports = function (initModules) {
let ReactDOMClient;
let ReactDOMServer;
let act;
let ReactFeatureFlags;
function resetModules() {
({ReactDOM, ReactDOMClient, ReactDOMServer} = initModules());
act = require('internal-test-utils').act;
ReactFeatureFlags = require('shared/ReactFeatureFlags');
}
function shouldUseDocument(reactElement) {
@@ -276,8 +278,10 @@ module.exports = function (initModules) {
const cleanTextContent =
(cleanContainer.lastChild && cleanContainer.lastChild.textContent) || '';
// The only guarantee is that text content has been patched up if needed.
expect(hydratedTextContent).toBe(cleanTextContent);
if (ReactFeatureFlags.favorSafetyOverHydrationPerf) {
// The only guarantee is that text content has been patched up if needed.
expect(hydratedTextContent).toBe(cleanTextContent);
}
// Abort any further expects. All bets are off at this point.
throw new BadMarkupExpected();

View File

@@ -27,6 +27,7 @@ import {
HostRoot,
SuspenseComponent,
} from './ReactWorkTags';
import {favorSafetyOverHydrationPerf} from 'shared/ReactFeatureFlags';
import {createFiberFromDehydratedFragment} from './ReactFiber';
import {
@@ -472,7 +473,7 @@ function prepareToHydrateHostInstance(
hostContext,
fiber,
);
if (!didHydrate) {
if (!didHydrate && favorSafetyOverHydrationPerf) {
throwOnHydrationMismatch(fiber);
}
}
@@ -538,7 +539,7 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): void {
fiber,
parentProps,
);
if (!didHydrate) {
if (!didHydrate && favorSafetyOverHydrationPerf) {
throwOnHydrationMismatch(fiber);
}
}

View File

@@ -30,6 +30,7 @@ export const enableComponentStackLocations = true;
// -----------------------------------------------------------------------------
// TODO: Finish rolling out in www
export const favorSafetyOverHydrationPerf = true;
export const enableAsyncActions = true;
// Need to remove didTimeout argument from Scheduler before landing

View File

@@ -65,6 +65,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false;
export const enableCPUSuspense = true;
export const enableUseMemoCacheHook = true;
export const enableUseEffectEventHook = false;
export const favorSafetyOverHydrationPerf = true;
export const enableLegacyFBSupport = false;
export const enableFilterEmptyStringAttributesDOM = true;
export const enableGetInspectorDataForInstanceInProduction = true;

View File

@@ -87,6 +87,7 @@ export const disableTextareaChildren = false;
export const enableSuspenseAvoidThisFallback = false;
export const enableSuspenseAvoidThisFallbackFizz = false;
export const enableUseEffectEventHook = false;
export const favorSafetyOverHydrationPerf = true;
export const enableLegacyFBSupport = false;
export const enableFilterEmptyStringAttributesDOM = true;
export const enableGetInspectorDataForInstanceInProduction = false;

View File

@@ -39,6 +39,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false;
export const enableCPUSuspense = false;
export const enableUseMemoCacheHook = true;
export const enableUseEffectEventHook = false;
export const favorSafetyOverHydrationPerf = true;
export const enableComponentStackLocations = true;
export const enableLegacyFBSupport = false;
export const enableFilterEmptyStringAttributesDOM = true;

View File

@@ -45,6 +45,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false;
export const enableCPUSuspense = false;
export const enableUseMemoCacheHook = true;
export const enableUseEffectEventHook = false;
export const favorSafetyOverHydrationPerf = true;
export const enableUseRefAccessWarning = false;
export const enableInfiniteRenderLoopDetection = false;
export const enableRenderableContext = false;

View File

@@ -41,6 +41,7 @@ export const enableSuspenseAvoidThisFallbackFizz = false;
export const enableCPUSuspense = false;
export const enableUseMemoCacheHook = true;
export const enableUseEffectEventHook = false;
export const favorSafetyOverHydrationPerf = true;
export const enableComponentStackLocations = true;
export const enableLegacyFBSupport = false;
export const enableFilterEmptyStringAttributesDOM = true;

View File

@@ -60,6 +60,8 @@ export const enableUseEffectEventHook = true;
export const enableFilterEmptyStringAttributesDOM = true;
export const enableAsyncActions = true;
export const favorSafetyOverHydrationPerf = false;
// Logs additional User Timing API marks for use with an experimental profiling tool.
export const enableSchedulingProfiler: boolean =
__PROFILE__ && dynamicFeatureFlags.enableSchedulingProfiler;