@samitouri / QOS-React-2 / commits / f468816ef1

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.