Repository: facebook/react
Pull request: Don't mute hydration errors forcing client render

*tldr: `suppressHydrationWarning` shouldn't mute warnings that force a client render.*

Before 18 + `createRoot`, all hydration mismatches except prop mismatches were patched up. Prop mismatches were warned about but not patched up. And `suppressHydrationWarning` on the parent was a universal way to suppress messages — about all kinds of mismatches.

In 18 + `createRoot`, mismatches always revert to a clean render up to the closest `<Suspense>` above except for one case. The exception is `suppressHydrationWarning`: it enables patching for text mismatches.

However, this means that you can't debug other kinds of mismatches inside `suppressHydrationWarning`. Insertions and deletions now revert to client render but we still mute the warning because in 17 it was harmless to mute. We've introduced limited production behavior for the prop, but we haven't adjusted the warning messages themselves to show for the case where mismatches are *not* patched up.

This fixes the issue.



Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js
patch: @@ -248,9 +248,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
         ]);
       }).toErrorDev(
         [
+          'Expected server HTML to contain a matching <span> in <span>',
           'An error occurred during hydration. The server HTML was replaced with client content in <div>.',
         ],
-        {withoutStack: true},
+        {withoutStack: 1},
       );
     } else {
       // This used to not warn.
@@ -336,9 +337,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
         ]);
       }).toErrorDev(
         [
+          'Did not expect server HTML to contain the text node "Server" in <span>',
           'An error occurred during hydration. The server HTML was replaced with client content in <div>.',
         ],
-        {withoutStack: true},
+        {withoutStack: 1},
       );
     } else {
       // This used to not warn.
@@ -389,9 +391,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
         ]);
       }).toErrorDev(
         [
+          'Expected server HTML to contain a matching text node for "Client" in <span>.',
           'An error occurred during hydration. The server HTML was replaced with client content in <div>.',
         ],
-        {withoutStack: true},
+        {withoutStack: 1},
       );
     } else {
       // This used to not warn.
@@ -445,9 +448,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
         ]);
       }).toErrorDev(
         [
+          'Did not expect server HTML to contain the text node "Server" in <span>.',
           'An error occurred during hydration. The server HTML was replaced with client content in <div>.',
         ],
-        {withoutStack: true},
+        {withoutStack: 1},
       );
     } else {
       // This used to not warn.
@@ -500,9 +504,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
         ]);
       }).toErrorDev(
         [
+          'Expected server HTML to contain a matching text node for "Client" in <span>.',
           'An error occurred during hydration. The server HTML was replaced with client content in <div>.',
         ],
-        {withoutStack: true},
+        {withoutStack: 1},
       );
     } else {
       // This used to not warn.
@@ -630,9 +635,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
         ]);
       }).toErrorDev(
         [
+          'Expected server HTML to contain a matching <p> in <div>.',
           'An error occurred during hydration. The server HTML was replaced with client content in <div>.',
         ],
-        {withoutStack: true},
+        {withoutStack: 1},
       );
     } else {
       // This used to not warn.
@@ -681,9 +687,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => {
         ]);
       }).toErrorDev(
         [
+          'Did not expect server HTML to contain a <p> in <div>.',
           'An error occurred during hydration. The server HTML was replaced with client content in <div>.',
         ],
-        {withoutStack: true},
+        {withoutStack: 1},
       );
     } else {
       // This used to not warn.

filename: packages/react-dom/src/client/ReactDOMHostConfig.js
patch: @@ -62,6 +62,7 @@ import dangerousStyleValue from '../shared/dangerousStyleValue';
 import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying';
 
 import {
+  enableClientRenderFallbackOnHydrationMismatch,
   enableSuspenseServerRenderer,
   enableCreateEventHandleAPI,
   enableScopeAPI,
@@ -1004,14 +1005,20 @@ export function didNotHydrateInstance(
   parentProps: Props,
   parentInstance: Instance,
   instance: HydratableInstance,
+  isConcurrentMode: boolean,
 ) {
-  if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
-    if (instance.nodeType === ELEMENT_NODE) {
-      warnForDeletedHydratableElement(parentInstance, (instance: any));
-    } else if (instance.nodeType === COMMENT_NODE) {
-      // TODO: warnForDeletedHydratableSuspenseBoundary
-    } else {
-      warnForDeletedHydratableText(parentInstance, (instance: any));
+  if (__DEV__) {
+    if (
+      (enableClientRenderFallbackOnHydrationMismatch && isConcurrentMode) ||
+      parentProps[SUPPRESS_HYDRATION_WARNING] !== true
+    ) {
+      if (instance.nodeType === ELEMENT_NODE) {
+        warnForDeletedHydratableElement(parentInstance, (instance: any));
+      } else if (instance.nodeType === COMMENT_NODE) {
+        // TODO: warnForDeletedHydratableSuspenseBoundary
+      } else {
+        warnForDeletedHydratableText(parentInstance, (instance: any));
+      }
     }
   }
 }
@@ -1082,9 +1089,15 @@ export function didNotFindHydratableInstance(
   parentInstance: Instance,
   type: string,
   props: Props,
+  isConcurrentMode: boolean,
 ) {
-  if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
-    warnForInsertedHydratedElement(parentInstance, type, props);
+  if (__DEV__) {
+    if (
+      (enableClientRenderFallbackOnHydrationMismatch && isConcurrentMode) ||
+      parentProps[SUPPRESS_HYDRATION_WARNING] !== true
+    ) {
+      warnForInsertedHydratedElement(parentInstance, type, props);
+    }
   }
 }
 
@@ -1093,9 +1106,15 @@ export function didNotFindHydratableTextInstance(
   parentProps: Props,
   parentInstance: Instance,
   text: string,
+  isConcurrentMode: boolean,
 ) {
-  if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
-    warnForInsertedHydratedText(parentInstance, text);
+  if (__DEV__) {
+    if (
+      (enableClientRenderFallbackOnHydrationMismatch && isConcurrentMode) ||
+      parentProps[SUPPRESS_HYDRATION_WARNING] !== true
+    ) {
+      warnForInsertedHydratedText(parentInstance, text);
+    }
   }
 }
 
@@ -1104,7 +1123,7 @@ export function didNotFindHydratableSuspenseInstance(
   parentProps: Props,
   parentInstance: Instance,
 ) {
-  if (__DEV__ && parentProps[SUPPRESS_HYDRATION_WARNING] !== true) {
+  if (__DEV__) {
     // TODO: warnForInsertedHydratedSuspense(parentInstance);
   }
 }

filename: packages/react-reconciler/src/ReactFiberHydrationContext.new.js
patch: @@ -148,28 +148,34 @@ function warnUnhydratedInstance(
 ) {
   if (__DEV__) {
     switch (returnFiber.tag) {
-      case HostRoot:
+      case HostRoot: {
         didNotHydrateInstanceWithinContainer(
           returnFiber.stateNode.containerInfo,
           instance,
         );
         break;
-      case HostComponent:
+      }
+      case HostComponent: {
+        const isConcurrentMode = (returnFiber.mode & ConcurrentMode) !== NoMode;
         didNotHydrateInstance(
           returnFiber.type,
           returnFiber.memoizedProps,
           returnFiber.stateNode,
           instance,
+          // TODO: Delete this argument when we remove the legacy root API.
+          isConcurrentMode,
         );
         break;
-      case SuspenseComponent:
+      }
+      case SuspenseComponent: {
         const suspenseState: SuspenseState = returnFiber.memoizedState;
         if (suspenseState.dehydrated !== null)
           didNotHydrateInstanceWithinSuspenseInstance(
             suspenseState.dehydrated,
             instance,
           );
         break;
+      }
     }
   }
 }
@@ -234,33 +240,44 @@ function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
         const parentProps = returnFiber.memoizedProps;
         const parentInstance = returnFiber.stateNode;
         switch (fiber.tag) {
-          case HostComponent:
+          case HostComponent: {
             const type = fiber.type;
             const props = fiber.pendingProps;
+            const isConcurrentMode =
+              (returnFiber.mode & ConcurrentMode) !== NoMode;
             didNotFindHydratableInstance(
               parentType,
               parentProps,
               parentInstance,
               type,
               props,
+              // TODO: Delete this argument when we remove the legacy root API.
+              isConcurrentMode,
             );
             break;
-          case HostText:
+          }
+          case HostText: {
             const text = fiber.pendingProps;
+            const isConcurrentMode =
+              (returnFiber.mode & ConcurrentMode) !== NoMode;
             didNotFindHydratableTextInstance(
               parentType,
               parentProps,
               parentInstance,
               text,
+              // TODO: Delete this argument when we remove the legacy root API.
+              isConcurrentMode,
             );
             break;
-          case SuspenseComponent:
+          }
+          case SuspenseComponent: {
             didNotFindHydratableSuspenseInstance(
               parentType,
               parentProps,
               parentInstance,
             );
             break;
+          }
         }
         break;
       }
@@ -476,10 +493,11 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
     // hydration parent is the parent host component of this host text.
     const returnFiber = hydrationParentFiber;
     if (returnFiber !== null) {
-      const isConcurrentMode = (returnFiber.mode & ConcurrentMode) !== NoMode;
       switch (returnFiber.tag) {
         case HostRoot: {
           const parentContainer = returnFiber.stateNode.containerInfo;
+          const isConcurrentMode =
+            (returnFiber.mode & ConcurrentMode) !== NoMode;
           didNotMatchHydratedContainerTextInstance(
             parentContainer,
             textInstance,
@@ -493,6 +511,8 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
           const parentType = returnFiber.type;
           const parentProps = returnFiber.memoizedProps;
           const parentInstance = returnFiber.stateNode;
+          const isConcurrentMode =
+            (returnFiber.mode & ConcurrentMode) !== NoMode;
           didNotMatchHydratedTextInstance(
             parentType,
             parentProps,

filename: packages/react-reconciler/src/ReactFiberHydrationContext.old.js
patch: @@ -148,28 +148,34 @@ function warnUnhydratedInstance(
 ) {
   if (__DEV__) {
     switch (returnFiber.tag) {
-      case HostRoot:
+      case HostRoot: {
         didNotHydrateInstanceWithinContainer(
           returnFiber.stateNode.containerInfo,
           instance,
         );
         break;
-      case HostComponent:
+      }
+      case HostComponent: {
+        const isConcurrentMode = (returnFiber.mode & ConcurrentMode) !== NoMode;
         didNotHydrateInstance(
           returnFiber.type,
           returnFiber.memoizedProps,
           returnFiber.stateNode,
           instance,
+          // TODO: Delete this argument when we remove the legacy root API.
+          isConcurrentMode,
         );
         break;
-      case SuspenseComponent:
+      }
+      case SuspenseComponent: {
         const suspenseState: SuspenseState = returnFiber.memoizedState;
         if (suspenseState.dehydrated !== null)
           didNotHydrateInstanceWithinSuspenseInstance(
             suspenseState.dehydrated,
             instance,
           );
         break;
+      }
     }
   }
 }
@@ -234,33 +240,44 @@ function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
         const parentProps = returnFiber.memoizedProps;
         const parentInstance = returnFiber.stateNode;
         switch (fiber.tag) {
-          case HostComponent:
+          case HostComponent: {
             const type = fiber.type;
             const props = fiber.pendingProps;
+            const isConcurrentMode =
+              (returnFiber.mode & ConcurrentMode) !== NoMode;
             didNotFindHydratableInstance(
               parentType,
               parentProps,
               parentInstance,
               type,
               props,
+              // TODO: Delete this argument when we remove the legacy root API.
+              isConcurrentMode,
             );
             break;
-          case HostText:
+          }
+          case HostText: {
             const text = fiber.pendingProps;
+            const isConcurrentMode =
+              (returnFiber.mode & ConcurrentMode) !== NoMode;
             didNotFindHydratableTextInstance(
               parentType,
               parentProps,
               parentInstance,
               text,
+              // TODO: Delete this argument when we remove the legacy root API.
+              isConcurrentMode,
             );
             break;
-          case SuspenseComponent:
+          }
+          case SuspenseComponent: {
             didNotFindHydratableSuspenseInstance(
               parentType,
               parentProps,
               parentInstance,
             );
             break;
+          }
         }
         break;
       }
@@ -476,10 +493,11 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
     // hydration parent is the parent host component of this host text.
     const returnFiber = hydrationParentFiber;
     if (returnFiber !== null) {
-      const isConcurrentMode = (returnFiber.mode & ConcurrentMode) !== NoMode;
       switch (returnFiber.tag) {
         case HostRoot: {
           const parentContainer = returnFiber.stateNode.containerInfo;
+          const isConcurrentMode =
+            (returnFiber.mode & ConcurrentMode) !== NoMode;
           didNotMatchHydratedContainerTextInstance(
             parentContainer,
             textInstance,
@@ -493,6 +511,8 @@ function prepareToHydrateHostTextInstance(fiber: Fiber): boolean {
           const parentType = returnFiber.type;
           const parentProps = returnFiber.memoizedProps;
           const parentInstance = returnFiber.stateNode;
+          const isConcurrentMode =
+            (returnFiber.mode & ConcurrentMode) !== NoMode;
           didNotMatchHydratedTextInstance(
             parentType,
             parentProps,