Skip to content

Commit f468816

Browse files
acdlitelaverdet
andauthored
Fix false positive hydration warnings (#23364)
* Failing test for react#23331 * Don't warn on hydration mismatch if suspended When something suspends during hydration, we continue rendering the siblings to warm up the cache and fire off any lazy network requests. However, if there are any mismatches while rendering the siblings, it's likely a false positive caused by the earlier suspended component. So we should suppress any hydration warnings until the tree no longer suspends. Fixes #23332 Co-authored-by: Marcel Laverdet <marcel@laverdet.com>
1 parent 5d08a24 commit f468816

7 files changed

+161
-15
lines changed

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

+66
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,72 @@ describe('ReactDOMFizzServer', () => {
301301
);
302302
});
303303

304+
// @gate experimental
305+
it('#23331: does not warn about hydration mismatches if something suspended in an earlier sibling', async () => {
306+
const makeApp = () => {
307+
let resolve;
308+
const imports = new Promise(r => {
309+
resolve = () => r({default: () => <span id="async">async</span>});
310+
});
311+
const Lazy = React.lazy(() => imports);
312+
313+
const App = () => (
314+
<div>
315+
<Suspense fallback={<span>Loading...</span>}>
316+
<Lazy />
317+
<span id="after">after</span>
318+
</Suspense>
319+
</div>
320+
);
321+
322+
return [App, resolve];
323+
};
324+
325+
// Server-side
326+
const [App, resolve] = makeApp();
327+
await act(async () => {
328+
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
329+
pipe(writable);
330+
});
331+
expect(getVisibleChildren(container)).toEqual(
332+
<div>
333+
<span>Loading...</span>
334+
</div>,
335+
);
336+
await act(async () => {
337+
resolve();
338+
});
339+
expect(getVisibleChildren(container)).toEqual(
340+
<div>
341+
<span id="async">async</span>
342+
<span id="after">after</span>
343+
</div>,
344+
);
345+
346+
// Client-side
347+
const [HydrateApp, hydrateResolve] = makeApp();
348+
await act(async () => {
349+
ReactDOM.hydrateRoot(container, <HydrateApp />);
350+
});
351+
352+
expect(getVisibleChildren(container)).toEqual(
353+
<div>
354+
<span id="async">async</span>
355+
<span id="after">after</span>
356+
</div>,
357+
);
358+
359+
await act(async () => {
360+
hydrateResolve();
361+
});
362+
expect(getVisibleChildren(container)).toEqual(
363+
<div>
364+
<span id="async">async</span>
365+
<span id="after">after</span>
366+
</div>,
367+
);
368+
});
369+
304370
// @gate experimental
305371
it('should support nonce scripts', async () => {
306372
CSPnonce = 'R4nd0m';

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

+20-11
Original file line numberDiff line numberDiff line change
@@ -230,21 +230,24 @@ export function checkForUnmatchedText(
230230
serverText: string,
231231
clientText: string | number,
232232
isConcurrentMode: boolean,
233+
shouldWarnDev: boolean,
233234
) {
234235
const normalizedClientText = normalizeMarkupForTextOrAttribute(clientText);
235236
const normalizedServerText = normalizeMarkupForTextOrAttribute(serverText);
236237
if (normalizedServerText === normalizedClientText) {
237238
return;
238239
}
239240

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-
);
241+
if (shouldWarnDev) {
242+
if (__DEV__) {
243+
if (!didWarnInvalidHydration) {
244+
didWarnInvalidHydration = true;
245+
console.error(
246+
'Text content did not match. Server: "%s" Client: "%s"',
247+
normalizedServerText,
248+
normalizedClientText,
249+
);
250+
}
248251
}
249252
}
250253

@@ -866,6 +869,7 @@ export function diffHydratedProperties(
866869
parentNamespace: string,
867870
rootContainerElement: Element | Document,
868871
isConcurrentMode: boolean,
872+
shouldWarnDev: boolean,
869873
): null | Array<mixed> {
870874
let isCustomComponentTag;
871875
let extraAttributeNames: Set<string>;
@@ -985,6 +989,7 @@ export function diffHydratedProperties(
985989
domElement.textContent,
986990
nextProp,
987991
isConcurrentMode,
992+
shouldWarnDev,
988993
);
989994
}
990995
updatePayload = [CHILDREN, nextProp];
@@ -996,6 +1001,7 @@ export function diffHydratedProperties(
9961001
domElement.textContent,
9971002
nextProp,
9981003
isConcurrentMode,
1004+
shouldWarnDev,
9991005
);
10001006
}
10011007
updatePayload = [CHILDREN, '' + nextProp];
@@ -1011,6 +1017,7 @@ export function diffHydratedProperties(
10111017
}
10121018
}
10131019
} else if (
1020+
shouldWarnDev &&
10141021
__DEV__ &&
10151022
// Convince Flow we've calculated it (it's DEV-only in this method.)
10161023
typeof isCustomComponentTag === 'boolean'
@@ -1142,10 +1149,12 @@ export function diffHydratedProperties(
11421149
}
11431150

11441151
if (__DEV__) {
1145-
// $FlowFixMe - Should be inferred as not undefined.
1146-
if (extraAttributeNames.size > 0 && !suppressHydrationWarning) {
1152+
if (shouldWarnDev) {
11471153
// $FlowFixMe - Should be inferred as not undefined.
1148-
warnForExtraAttributes(extraAttributeNames);
1154+
if (extraAttributeNames.size > 0 && !suppressHydrationWarning) {
1155+
// $FlowFixMe - Should be inferred as not undefined.
1156+
warnForExtraAttributes(extraAttributeNames);
1157+
}
11491158
}
11501159
}
11511160

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

+17-2
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,7 @@ export function hydrateInstance(
786786
rootContainerInstance: Container,
787787
hostContext: HostContext,
788788
internalInstanceHandle: Object,
789+
shouldWarnDev: boolean,
789790
): null | Array<mixed> {
790791
precacheFiberNode(internalInstanceHandle, instance);
791792
// TODO: Possibly defer this until the commit phase where all the events
@@ -811,13 +812,15 @@ export function hydrateInstance(
811812
parentNamespace,
812813
rootContainerInstance,
813814
isConcurrentMode,
815+
shouldWarnDev,
814816
);
815817
}
816818

817819
export function hydrateTextInstance(
818820
textInstance: TextInstance,
819821
text: string,
820822
internalInstanceHandle: Object,
823+
shouldWarnDev: boolean,
821824
): boolean {
822825
precacheFiberNode(internalInstanceHandle, textInstance);
823826

@@ -924,7 +927,13 @@ export function didNotMatchHydratedContainerTextInstance(
924927
text: string,
925928
isConcurrentMode: boolean,
926929
) {
927-
checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode);
930+
const shouldWarnDev = true;
931+
checkForUnmatchedText(
932+
textInstance.nodeValue,
933+
text,
934+
isConcurrentMode,
935+
shouldWarnDev,
936+
);
928937
}
929938

930939
export function didNotMatchHydratedTextInstance(
@@ -936,7 +945,13 @@ export function didNotMatchHydratedTextInstance(
936945
isConcurrentMode: boolean,
937946
) {
938947
if (parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
939-
checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode);
948+
const shouldWarnDev = true;
949+
checkForUnmatchedText(
950+
textInstance.nodeValue,
951+
text,
952+
isConcurrentMode,
953+
shouldWarnDev,
954+
);
940955
}
941956
}
942957

packages/react-reconciler/src/ReactFiberHydrationContext.new.js

+26-1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.new';
8484
let hydrationParentFiber: null | Fiber = null;
8585
let nextHydratableInstance: null | HydratableInstance = null;
8686
let isHydrating: boolean = false;
87+
let didSuspend: boolean = false;
8788

8889
// Hydration errors that were thrown inside this boundary
8990
let hydrationErrors: Array<mixed> | null = null;
@@ -98,6 +99,12 @@ function warnIfHydrating() {
9899
}
99100
}
100101

102+
export function markDidSuspendWhileHydratingDEV() {
103+
if (__DEV__) {
104+
didSuspend = true;
105+
}
106+
}
107+
101108
function enterHydrationState(fiber: Fiber): boolean {
102109
if (!supportsHydration) {
103110
return false;
@@ -110,6 +117,7 @@ function enterHydrationState(fiber: Fiber): boolean {
110117
hydrationParentFiber = fiber;
111118
isHydrating = true;
112119
hydrationErrors = null;
120+
didSuspend = false;
113121
return true;
114122
}
115123

@@ -127,6 +135,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
127135
hydrationParentFiber = fiber;
128136
isHydrating = true;
129137
hydrationErrors = null;
138+
didSuspend = false;
130139
if (treeContext !== null) {
131140
restoreSuspendedTreeContext(fiber, treeContext);
132141
}
@@ -185,6 +194,13 @@ function deleteHydratableInstance(
185194

186195
function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
187196
if (__DEV__) {
197+
if (didSuspend) {
198+
// Inside a boundary that already suspended. We're currently rendering the
199+
// siblings of a suspended node. The mismatch may be due to the missing
200+
// data, so it's probably a false positive.
201+
return;
202+
}
203+
188204
switch (returnFiber.tag) {
189205
case HostRoot: {
190206
const parentContainer = returnFiber.stateNode.containerInfo;
@@ -418,13 +434,15 @@ function prepareToHydrateHostInstance(
418434
}
419435

420436
const instance: Instance = fiber.stateNode;
437+
const shouldWarnIfMismatchDev = !didSuspend;
421438
const updatePayload = hydrateInstance(
422439
instance,
423440
fiber.type,
424441
fiber.memoizedProps,
425442
rootContainerInstance,
426443
hostContext,
427444
fiber,
445+
shouldWarnIfMismatchDev,
428446
);
429447
// TODO: Type this specific to this type of component.
430448
fiber.updateQueue = (updatePayload: any);
@@ -446,7 +464,13 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
446464

447465
const textInstance: TextInstance = fiber.stateNode;
448466
const textContent: string = fiber.memoizedProps;
449-
const shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber);
467+
const shouldWarnIfMismatchDev = !didSuspend;
468+
const shouldUpdate = hydrateTextInstance(
469+
textInstance,
470+
textContent,
471+
fiber,
472+
shouldWarnIfMismatchDev,
473+
);
450474
if (shouldUpdate) {
451475
// We assume that prepareToHydrateHostTextInstance is called in a context where the
452476
// hydration parent is the parent host component of this host text.
@@ -616,6 +640,7 @@ function resetHydrationState(): void {
616640
hydrationParentFiber = null;
617641
nextHydratableInstance = null;
618642
isHydrating = false;
643+
didSuspend = false;
619644
}
620645

621646
export function upgradeHydrationErrorsToRecoverable(): void {

packages/react-reconciler/src/ReactFiberHydrationContext.old.js

+26-1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.old';
8484
let hydrationParentFiber: null | Fiber = null;
8585
let nextHydratableInstance: null | HydratableInstance = null;
8686
let isHydrating: boolean = false;
87+
let didSuspend: boolean = false;
8788

8889
// Hydration errors that were thrown inside this boundary
8990
let hydrationErrors: Array<mixed> | null = null;
@@ -98,6 +99,12 @@ function warnIfHydrating() {
9899
}
99100
}
100101

102+
export function markDidSuspendWhileHydratingDEV() {
103+
if (__DEV__) {
104+
didSuspend = true;
105+
}
106+
}
107+
101108
function enterHydrationState(fiber: Fiber): boolean {
102109
if (!supportsHydration) {
103110
return false;
@@ -110,6 +117,7 @@ function enterHydrationState(fiber: Fiber): boolean {
110117
hydrationParentFiber = fiber;
111118
isHydrating = true;
112119
hydrationErrors = null;
120+
didSuspend = false;
113121
return true;
114122
}
115123

@@ -127,6 +135,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
127135
hydrationParentFiber = fiber;
128136
isHydrating = true;
129137
hydrationErrors = null;
138+
didSuspend = false;
130139
if (treeContext !== null) {
131140
restoreSuspendedTreeContext(fiber, treeContext);
132141
}
@@ -185,6 +194,13 @@ function deleteHydratableInstance(
185194

186195
function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
187196
if (__DEV__) {
197+
if (didSuspend) {
198+
// Inside a boundary that already suspended. We're currently rendering the
199+
// siblings of a suspended node. The mismatch may be due to the missing
200+
// data, so it's probably a false positive.
201+
return;
202+
}
203+
188204
switch (returnFiber.tag) {
189205
case HostRoot: {
190206
const parentContainer = returnFiber.stateNode.containerInfo;
@@ -418,13 +434,15 @@ function prepareToHydrateHostInstance(
418434
}
419435

420436
const instance: Instance = fiber.stateNode;
437+
const shouldWarnIfMismatchDev = !didSuspend;
421438
const updatePayload = hydrateInstance(
422439
instance,
423440
fiber.type,
424441
fiber.memoizedProps,
425442
rootContainerInstance,
426443
hostContext,
427444
fiber,
445+
shouldWarnIfMismatchDev,
428446
);
429447
// TODO: Type this specific to this type of component.
430448
fiber.updateQueue = (updatePayload: any);
@@ -446,7 +464,13 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
446464

447465
const textInstance: TextInstance = fiber.stateNode;
448466
const textContent: string = fiber.memoizedProps;
449-
const shouldUpdate = hydrateTextInstance(textInstance, textContent, fiber);
467+
const shouldWarnIfMismatchDev = !didSuspend;
468+
const shouldUpdate = hydrateTextInstance(
469+
textInstance,
470+
textContent,
471+
fiber,
472+
shouldWarnIfMismatchDev,
473+
);
450474
if (shouldUpdate) {
451475
// We assume that prepareToHydrateHostTextInstance is called in a context where the
452476
// hydration parent is the parent host component of this host text.
@@ -616,6 +640,7 @@ function resetHydrationState(): void {
616640
hydrationParentFiber = null;
617641
nextHydratableInstance = null;
618642
isHydrating = false;
643+
didSuspend = false;
619644
}
620645

621646
export function upgradeHydrationErrorsToRecoverable(): void {

0 commit comments

Comments
 (0)