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>
Andrew Clark committed
Feb 25, 2022 at 18:10 UTC
f468816ef1000b2fe0086feabc0115b86f299cad
7 files changed
+161
-15
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+66
@@ -301,6 +301,72 @@ describe('ReactDOMFizzServer', () => {
301
);
302
});
303
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
+
370
// @gate experimental
371
it('should support nonce scripts', async () => {
372
CSPnonce = 'R4nd0m';
packages/react-dom/src/client/ReactDOMComponent.js
+20
-11
@@ -230,6 +230,7 @@ export function checkForUnmatchedText(
230
serverText: string,
231
clientText: string | number,
232
isConcurrentMode: boolean,
233
+ shouldWarnDev: boolean,
234
) {
235
const normalizedClientText = normalizeMarkupForTextOrAttribute(clientText);
236
const normalizedServerText = normalizeMarkupForTextOrAttribute(serverText);
@@ -237,14 +238,16 @@ export function checkForUnmatchedText(
238
return;
239
}
240
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
+ }
251
}
252
}
253
@@ -866,6 +869,7 @@ export function diffHydratedProperties(
869
parentNamespace: string,
870
rootContainerElement: Element | Document,
871
isConcurrentMode: boolean,
872
+ shouldWarnDev: boolean,
873
): null | Array<mixed> {
874
let isCustomComponentTag;
875
let extraAttributeNames: Set<string>;
@@ -985,6 +989,7 @@ export function diffHydratedProperties(
989
domElement.textContent,
990
nextProp,
991
isConcurrentMode,
992
+ shouldWarnDev,
993
);
994
}
995
updatePayload = [CHILDREN, nextProp];
@@ -996,6 +1001,7 @@ export function diffHydratedProperties(
1001
domElement.textContent,
1002
nextProp,
1003
isConcurrentMode,
1004
+ shouldWarnDev,
1005
);
1006
}
1007
updatePayload = [CHILDREN, '' + nextProp];
@@ -1011,6 +1017,7 @@ export function diffHydratedProperties(
1017
}
1018
}
1019
} else if (
1020
+ shouldWarnDev &&
1021
__DEV__ &&
1022
// Convince Flow we've calculated it (it's DEV-only in this method.)
1023
typeof isCustomComponentTag === 'boolean'
@@ -1142,10 +1149,12 @@ export function diffHydratedProperties(
1149
}
1150
1151
if (__DEV__) {
1145
- // $FlowFixMe - Should be inferred as not undefined.
1146
- if (extraAttributeNames.size > 0 && !suppressHydrationWarning) {
1152
+ if (shouldWarnDev) {
1153
// $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
+ }
1158
}
1159
}
1160
packages/react-dom/src/client/ReactDOMHostConfig.js
+17
-2
@@ -786,6 +786,7 @@ export function hydrateInstance(
786
rootContainerInstance: Container,
787
hostContext: HostContext,
788
internalInstanceHandle: Object,
789
+ shouldWarnDev: boolean,
790
): null | Array<mixed> {
791
precacheFiberNode(internalInstanceHandle, instance);
792
// TODO: Possibly defer this until the commit phase where all the events
@@ -811,6 +812,7 @@ export function hydrateInstance(
812
parentNamespace,
813
rootContainerInstance,
814
isConcurrentMode,
815
+ shouldWarnDev,
816
);
817
}
818
@@ -818,6 +820,7 @@ export function hydrateTextInstance(
820
textInstance: TextInstance,
821
text: string,
822
internalInstanceHandle: Object,
823
+ shouldWarnDev: boolean,
824
): boolean {
825
precacheFiberNode(internalInstanceHandle, textInstance);
826
@@ -924,7 +927,13 @@ export function didNotMatchHydratedContainerTextInstance(
927
text: string,
928
isConcurrentMode: boolean,
929
) {
927
- checkForUnmatchedText(textInstance.nodeValue, text, isConcurrentMode);
930
+ const shouldWarnDev = true;
931
+ checkForUnmatchedText(
932
+ textInstance.nodeValue,
933
+ text,
934
+ isConcurrentMode,
935
+ shouldWarnDev,
936
+ );
937
}
938
939
export function didNotMatchHydratedTextInstance(
@@ -936,7 +945,13 @@ export function didNotMatchHydratedTextInstance(
945
isConcurrentMode: boolean,
946
) {
947
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
+ );
955
}
956
}
957
packages/react-reconciler/src/ReactFiberHydrationContext.new.js
+26
-1
@@ -84,6 +84,7 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.new';
84
let hydrationParentFiber: null | Fiber = null;
85
let nextHydratableInstance: null | HydratableInstance = null;
86
let isHydrating: boolean = false;
87
+let didSuspend: boolean = false;
88
89
// Hydration errors that were thrown inside this boundary
90
let hydrationErrors: Array<mixed> | null = null;
@@ -98,6 +99,12 @@ function warnIfHydrating() {
99
}
100
}
101
102
+export function markDidSuspendWhileHydratingDEV() {
103
+ if (__DEV__) {
104
+ didSuspend = true;
105
+ }
106
+}
107
+
108
function enterHydrationState(fiber: Fiber): boolean {
109
if (!supportsHydration) {
110
return false;
@@ -110,6 +117,7 @@ function enterHydrationState(fiber: Fiber): boolean {
117
hydrationParentFiber = fiber;
118
isHydrating = true;
119
hydrationErrors = null;
120
+ didSuspend = false;
121
return true;
122
}
123
@@ -127,6 +135,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
135
hydrationParentFiber = fiber;
136
isHydrating = true;
137
hydrationErrors = null;
138
+ didSuspend = false;
139
if (treeContext !== null) {
140
restoreSuspendedTreeContext(fiber, treeContext);
141
}
@@ -185,6 +194,13 @@ function deleteHydratableInstance(
194
195
function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
196
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
+
204
switch (returnFiber.tag) {
205
case HostRoot: {
206
const parentContainer = returnFiber.stateNode.containerInfo;
@@ -418,6 +434,7 @@ function prepareToHydrateHostInstance(
434
}
435
436
const instance: Instance = fiber.stateNode;
437
+ const shouldWarnIfMismatchDev = !didSuspend;
438
const updatePayload = hydrateInstance(
439
instance,
440
fiber.type,
@@ -425,6 +442,7 @@ function prepareToHydrateHostInstance(
442
rootContainerInstance,
443
hostContext,
444
fiber,
445
+ shouldWarnIfMismatchDev,
446
);
447
// TODO: Type this specific to this type of component.
448
fiber.updateQueue = (updatePayload: any);
@@ -446,7 +464,13 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
464
465
const textInstance: TextInstance = fiber.stateNode;
466
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
+ );
474
if (shouldUpdate) {
475
// We assume that prepareToHydrateHostTextInstance is called in a context where the
476
// hydration parent is the parent host component of this host text.
@@ -616,6 +640,7 @@ function resetHydrationState(): void {
640
hydrationParentFiber = null;
641
nextHydratableInstance = null;
642
isHydrating = false;
643
+ didSuspend = false;
644
}
645
646
export function upgradeHydrationErrorsToRecoverable(): void {
packages/react-reconciler/src/ReactFiberHydrationContext.old.js
+26
-1
@@ -84,6 +84,7 @@ import {queueRecoverableErrors} from './ReactFiberWorkLoop.old';
84
let hydrationParentFiber: null | Fiber = null;
85
let nextHydratableInstance: null | HydratableInstance = null;
86
let isHydrating: boolean = false;
87
+let didSuspend: boolean = false;
88
89
// Hydration errors that were thrown inside this boundary
90
let hydrationErrors: Array<mixed> | null = null;
@@ -98,6 +99,12 @@ function warnIfHydrating() {
99
}
100
}
101
102
+export function markDidSuspendWhileHydratingDEV() {
103
+ if (__DEV__) {
104
+ didSuspend = true;
105
+ }
106
+}
107
+
108
function enterHydrationState(fiber: Fiber): boolean {
109
if (!supportsHydration) {
110
return false;
@@ -110,6 +117,7 @@ function enterHydrationState(fiber: Fiber): boolean {
117
hydrationParentFiber = fiber;
118
isHydrating = true;
119
hydrationErrors = null;
120
+ didSuspend = false;
121
return true;
122
}
123
@@ -127,6 +135,7 @@ function reenterHydrationStateFromDehydratedSuspenseInstance(
135
hydrationParentFiber = fiber;
136
isHydrating = true;
137
hydrationErrors = null;
138
+ didSuspend = false;
139
if (treeContext !== null) {
140
restoreSuspendedTreeContext(fiber, treeContext);
141
}
@@ -185,6 +194,13 @@ function deleteHydratableInstance(
194
195
function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
196
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
+
204
switch (returnFiber.tag) {
205
case HostRoot: {
206
const parentContainer = returnFiber.stateNode.containerInfo;
@@ -418,6 +434,7 @@ function prepareToHydrateHostInstance(
434
}
435
436
const instance: Instance = fiber.stateNode;
437
+ const shouldWarnIfMismatchDev = !didSuspend;
438
const updatePayload = hydrateInstance(
439
instance,
440
fiber.type,
@@ -425,6 +442,7 @@ function prepareToHydrateHostInstance(
442
rootContainerInstance,
443
hostContext,
444
fiber,
445
+ shouldWarnIfMismatchDev,
446
);
447
// TODO: Type this specific to this type of component.
448
fiber.updateQueue = (updatePayload: any);
@@ -446,7 +464,13 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
464
465
const textInstance: TextInstance = fiber.stateNode;
466
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
+ );
474
if (shouldUpdate) {
475
// We assume that prepareToHydrateHostTextInstance is called in a context where the
476
// hydration parent is the parent host component of this host text.
@@ -616,6 +640,7 @@ function resetHydrationState(): void {
640
hydrationParentFiber = null;
641
nextHydratableInstance = null;
642
isHydrating = false;
643
+ didSuspend = false;
644
}
645
646
export function upgradeHydrationErrorsToRecoverable(): void {
packages/react-reconciler/src/ReactFiberThrow.new.js
+3
@@ -83,6 +83,7 @@ import {
83
} from './ReactFiberLane.new';
84
import {
85
getIsHydrating,
86
+ markDidSuspendWhileHydratingDEV,
87
queueHydrationError,
88
} from './ReactFiberHydrationContext.new';
89
@@ -513,6 +514,8 @@ function throwException(
514
} else {
515
// This is a regular error, not a Suspense wakeable.
516
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
517
+ markDidSuspendWhileHydratingDEV();
518
+
519
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
520
// If the error was thrown during hydration, we may be able to recover by
521
// discarding the dehydrated content and switching to a client render.
packages/react-reconciler/src/ReactFiberThrow.old.js
+3
@@ -83,6 +83,7 @@ import {
83
} from './ReactFiberLane.old';
84
import {
85
getIsHydrating,
86
+ markDidSuspendWhileHydratingDEV,
87
queueHydrationError,
88
} from './ReactFiberHydrationContext.old';
89
@@ -513,6 +514,8 @@ function throwException(
514
} else {
515
// This is a regular error, not a Suspense wakeable.
516
if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) {
517
+ markDidSuspendWhileHydratingDEV();
518
+
519
const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber);
520
// If the error was thrown during hydration, we may be able to recover by
521
// discarding the dehydrated content and switching to a client render.