Skip to content

Commit 5910eb3

Browse files
authored
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.
1 parent 2ec2aae commit 5910eb3

15 files changed

+216
-82
lines changed

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4423,6 +4423,7 @@ describe('ReactDOMFizzServer', () => {
44234423
);
44244424
});
44254425

4426+
// @gate favorSafetyOverHydrationPerf
44264427
it('#24384: Suspending should halt hydration warnings but still emit hydration warnings after unsuspending if mismatches are genuine', async () => {
44274428
const makeApp = () => {
44284429
let resolve, resolved;
@@ -4506,6 +4507,7 @@ describe('ReactDOMFizzServer', () => {
45064507
await waitForAll([]);
45074508
});
45084509

4510+
// @gate favorSafetyOverHydrationPerf
45094511
it('only warns once on hydration mismatch while within a suspense boundary', async () => {
45104512
const originalConsoleError = console.error;
45114513
const mockError = jest.fn();

packages/react-dom/src/__tests__/ReactDOMFloat-test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6446,6 +6446,7 @@ body {
64466446
);
64476447
});
64486448

6449+
// @gate favorSafetyOverHydrationPerf
64496450
it('retains styles even when a new html, head, and/body mount', async () => {
64506451
await act(() => {
64516452
const {pipe} = renderToPipeableStream(
@@ -8230,6 +8231,7 @@ background-color: green;
82308231
]);
82318232
});
82328233

8234+
// @gate favorSafetyOverHydrationPerf
82338235
it('can render a title before a singleton even if that singleton clears its contents', async () => {
82348236
await act(() => {
82358237
const {pipe} = renderToPipeableStream(

packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js

Lines changed: 131 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -80,30 +80,55 @@ describe('ReactDOMServerHydration', () => {
8080
</div>
8181
);
8282
}
83-
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
84-
[
85-
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
86-
"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:
83+
if (gate(flags => flags.favorSafetyOverHydrationPerf)) {
84+
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
85+
[
86+
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
87+
"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:
88+
89+
- A server/client branch \`if (typeof window !== 'undefined')\`.
90+
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
91+
- Date formatting in a user's locale which doesn't match the server.
92+
- External changing data without sending a snapshot of it along with the HTML.
93+
- Invalid HTML tag nesting.
94+
95+
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
96+
97+
https://react.dev/link/hydration-mismatch
98+
99+
<Mismatch isClient={true}>
100+
<div className="parent">
101+
<main className="child">
102+
+ client
103+
- server
104+
]",
105+
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
106+
]
107+
`);
108+
} else {
109+
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
110+
[
111+
"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:
87112
88-
- A server/client branch \`if (typeof window !== 'undefined')\`.
89-
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
90-
- Date formatting in a user's locale which doesn't match the server.
91-
- External changing data without sending a snapshot of it along with the HTML.
92-
- Invalid HTML tag nesting.
113+
- A server/client branch \`if (typeof window !== 'undefined')\`.
114+
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
115+
- Date formatting in a user's locale which doesn't match the server.
116+
- External changing data without sending a snapshot of it along with the HTML.
117+
- Invalid HTML tag nesting.
93118
94-
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
119+
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
95120
96-
https://react.dev/link/hydration-mismatch
121+
https://react.dev/link/hydration-mismatch
97122
98-
<Mismatch isClient={true}>
99-
<div className="parent">
100-
<main className="child">
101-
+ client
102-
- server
103-
]",
104-
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
105-
]
106-
`);
123+
<Mismatch isClient={true}>
124+
<div className="parent">
125+
<main className="child">
126+
+ client
127+
- server
128+
",
129+
]
130+
`);
131+
}
107132
});
108133

109134
// @gate __DEV__
@@ -120,29 +145,53 @@ describe('ReactDOMServerHydration', () => {
120145
}
121146

122147
/* eslint-disable no-irregular-whitespace */
123-
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
124-
[
125-
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
126-
"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:
148+
if (gate(flags => flags.favorSafetyOverHydrationPerf)) {
149+
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
150+
[
151+
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
152+
"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:
127153
128-
- A server/client branch \`if (typeof window !== 'undefined')\`.
129-
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
130-
- Date formatting in a user's locale which doesn't match the server.
131-
- External changing data without sending a snapshot of it along with the HTML.
132-
- Invalid HTML tag nesting.
154+
- A server/client branch \`if (typeof window !== 'undefined')\`.
155+
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
156+
- Date formatting in a user's locale which doesn't match the server.
157+
- External changing data without sending a snapshot of it along with the HTML.
158+
- Invalid HTML tag nesting.
133159
134-
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
160+
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
135161
136-
https://react.dev/link/hydration-mismatch
162+
https://react.dev/link/hydration-mismatch
137163
138-
<Mismatch isClient={true}>
139-
<div>
140-
+ This markup contains an nbsp entity:   client text
141-
- This markup contains an nbsp entity:   server text
142-
]",
143-
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
144-
]
145-
`);
164+
<Mismatch isClient={true}>
165+
<div>
166+
+ This markup contains an nbsp entity:   client text
167+
- This markup contains an nbsp entity:   server text
168+
]",
169+
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
170+
]
171+
`);
172+
} else {
173+
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
174+
[
175+
"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:
176+
177+
- A server/client branch \`if (typeof window !== 'undefined')\`.
178+
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
179+
- Date formatting in a user's locale which doesn't match the server.
180+
- External changing data without sending a snapshot of it along with the HTML.
181+
- Invalid HTML tag nesting.
182+
183+
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
184+
185+
https://react.dev/link/hydration-mismatch
186+
187+
<Mismatch isClient={true}>
188+
<div>
189+
+ This markup contains an nbsp entity:   client text
190+
- This markup contains an nbsp entity:   server text
191+
",
192+
]
193+
`);
194+
}
146195
/* eslint-enable no-irregular-whitespace */
147196
});
148197

@@ -549,29 +598,53 @@ describe('ReactDOMServerHydration', () => {
549598
function Mismatch({isClient}) {
550599
return <div className="parent">{isClient && 'only'}</div>;
551600
}
552-
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
553-
[
554-
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
555-
"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:
601+
if (gate(flags => flags.favorSafetyOverHydrationPerf)) {
602+
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
603+
[
604+
"Warning: An error occurred during hydration. The server HTML was replaced with client content.",
605+
"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:
606+
607+
- A server/client branch \`if (typeof window !== 'undefined')\`.
608+
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
609+
- Date formatting in a user's locale which doesn't match the server.
610+
- External changing data without sending a snapshot of it along with the HTML.
611+
- Invalid HTML tag nesting.
612+
613+
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
614+
615+
https://react.dev/link/hydration-mismatch
616+
617+
<Mismatch isClient={true}>
618+
<div className="parent">
619+
+ only
620+
-
621+
]",
622+
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
623+
]
624+
`);
625+
} else {
626+
expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
627+
[
628+
"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:
556629
557-
- A server/client branch \`if (typeof window !== 'undefined')\`.
558-
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
559-
- Date formatting in a user's locale which doesn't match the server.
560-
- External changing data without sending a snapshot of it along with the HTML.
561-
- Invalid HTML tag nesting.
630+
- A server/client branch \`if (typeof window !== 'undefined')\`.
631+
- Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called.
632+
- Date formatting in a user's locale which doesn't match the server.
633+
- External changing data without sending a snapshot of it along with the HTML.
634+
- Invalid HTML tag nesting.
562635
563-
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
636+
It can also happen if the client has a browser extension installed which messes with the HTML before React loaded.
564637
565-
https://react.dev/link/hydration-mismatch
638+
https://react.dev/link/hydration-mismatch
566639
567-
<Mismatch isClient={true}>
568-
<div className="parent">
569-
+ only
570-
-
571-
]",
572-
"Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
573-
]
574-
`);
640+
<Mismatch isClient={true}>
641+
<div className="parent">
642+
+ only
643+
-
644+
",
645+
]
646+
`);
647+
}
575648
});
576649

577650
// @gate __DEV__

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3816,6 +3816,7 @@ describe('ReactDOMServerPartialHydration', () => {
38163816
);
38173817
});
38183818

3819+
// @gate favorSafetyOverHydrationPerf
38193820
it("falls back to client rendering when there's a text mismatch (direct text child)", async () => {
38203821
function DirectTextChild({text}) {
38213822
return <div>{text}</div>;
@@ -3845,6 +3846,7 @@ describe('ReactDOMServerPartialHydration', () => {
38453846
]);
38463847
});
38473848

3849+
// @gate favorSafetyOverHydrationPerf
38483850
it("falls back to client rendering when there's a text mismatch (text child with siblings)", async () => {
38493851
function Sibling() {
38503852
return 'Sibling';

packages/react-dom/src/__tests__/ReactRenderDocument-test.js

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@ describe('rendering React components at document', () => {
276276
);
277277
const testDocument = getTestDocument(markup);
278278

279+
const favorSafetyOverHydrationPerf = gate(
280+
flags => flags.favorSafetyOverHydrationPerf,
281+
);
279282
expect(() => {
280283
ReactDOM.flushSync(() => {
281284
ReactDOMClient.hydrateRoot(
@@ -291,19 +294,29 @@ describe('rendering React components at document', () => {
291294
);
292295
});
293296
}).toErrorDev(
294-
[
295-
'Warning: An error occurred during hydration. The server HTML was replaced with client content.',
296-
],
297+
favorSafetyOverHydrationPerf
298+
? [
299+
'Warning: An error occurred during hydration. The server HTML was replaced with client content.',
300+
]
301+
: [
302+
"Warning: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.",
303+
],
297304
{
298305
withoutStack: 1,
299306
},
300307
);
301308

302-
assertLog([
303-
"Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
304-
'Log recoverable error: There was an error while hydrating.',
305-
]);
306-
expect(testDocument.body.innerHTML).toBe('Hello world');
309+
assertLog(
310+
favorSafetyOverHydrationPerf
311+
? [
312+
"Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.",
313+
'Log recoverable error: There was an error while hydrating.',
314+
]
315+
: [],
316+
);
317+
expect(testDocument.body.innerHTML).toBe(
318+
favorSafetyOverHydrationPerf ? 'Hello world' : 'Goodbye world',
319+
);
307320
});
308321

309322
it('should render w/ no markup to full document', async () => {

0 commit comments

Comments
 (0)