Repository: facebook/react
Pull request: Add back warning with component stack on Hydration mismatch

<!--
  Thanks for submitting a pull request!
  We appreciate you spending the time to work on these changes. Please provide enough information so that others can review your pull request. The three fields below are mandatory.

  Before submitting a pull request, please make sure the following is done:

  1. Fork [the repository](https://github.com/facebook/react) and create your branch from `main`.
  2. Run `yarn` in the repository root.
  3. If you've fixed a bug or added code that should be tested, add tests!
  4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch TestName` is helpful in development.
  5. Run `yarn test --prod` to test in the production environment. It supports the same options as `yarn test`.
  6. If you need a debugger, run `yarn debug-test --watch TestName`, open `chrome://inspect`, and press "Inspect".
  7. Format your code with [prettier](https://github.com/prettier/prettier) (`yarn prettier`).
  8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

  Learn more about contributing: https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

When we changed our strategy for handling hydration mismatches to throwing we started throwing before the warnings were fired. This PR fixes that so the original warnings still fire with component stacks making it easier to debug the mismatches.

<!--
 Explain the **motivation** for making this change. What existing problem does the pull request solve?
-->

## How did you test this change?

jest

<!--
  Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes the user interface.
  How exactly did you verify that your PR solves the issue you wanted to solve?
  If you leave this empty, your PR will very likely be closed.
-->


Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -1740,8 +1740,13 @@ describe('ReactDOMFizzServer', () => {
             'The server HTML was replaced with client content',
         ]);
       }).toErrorDev(
-        'Warning: An error occurred during hydration. The server HTML was replaced with client content',
-        {withoutStack: true},
+        [
+          'Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.',
+          'Warning: Expected server HTML to contain a matching <div> in <div>.\n' +
+            '    in div (at **)\n' +
+            '    in App (at **)',
+        ],
+        {withoutStack: 1},
       );
       expect(getVisibleChildren(container)).toEqual(<div>client</div>);
     } else {
@@ -1833,8 +1838,13 @@ describe('ReactDOMFizzServer', () => {
             'The server HTML was replaced with client content',
         ]);
       }).toErrorDev(
-        'Warning: An error occurred during hydration. The server HTML was replaced with client content',
-        {withoutStack: true},
+        [
+          'Warning: An error occurred during hydration. The server HTML was replaced with client content',
+          'Warning: Expected server HTML to contain a matching <div> in <div>.\n' +
+            '    in div (at **)\n' +
+            '    in App (at **)',
+        ],
+        {withoutStack: 1},
       );
       expect(getVisibleChildren(container)).toEqual(<div>client</div>);
     } else {

filename: packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js
patch: @@ -19,6 +19,15 @@ let SuspenseList;
 let act;
 let IdleEventPriority;
 
+function normalizeCodeLocInfo(strOrErr) {
+  if (strOrErr && strOrErr.replace) {
+    return strOrErr.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) {
+      return '\n    in ' + name + ' (at **)';
+    });
+  }
+  return strOrErr;
+}
+
 function dispatchMouseEvent(to, from) {
   if (!to) {
     to = null;
@@ -240,6 +249,12 @@ describe('ReactDOMServerPartialHydration', () => {
 
   // @gate enableClientRenderFallbackOnHydrationMismatch
   it('falls back to client rendering boundary on mismatch', async () => {
+    // We can't use the toErrorDev helper here because this is async.
+    const originalConsoleError = console.error;
+    const mockError = jest.fn();
+    console.error = (...args) => {
+      mockError(...args.map(normalizeCodeLocInfo));
+    };
     let client = false;
     let suspend = false;
     let resolve;
@@ -276,70 +291,86 @@ describe('ReactDOMServerPartialHydration', () => {
         </Suspense>
       );
     }
-    const finalHTML = ReactDOMServer.renderToString(<App />);
-    const container = document.createElement('div');
-    container.innerHTML = finalHTML;
-    expect(Scheduler).toHaveYielded([
-      'Hello',
-      'Component',
-      'Component',
-      'Component',
-      'Component',
-    ]);
+    try {
+      const finalHTML = ReactDOMServer.renderToString(<App />);
+      const container = document.createElement('div');
+      container.innerHTML = finalHTML;
+      expect(Scheduler).toHaveYielded([
+        'Hello',
+        'Component',
+        'Component',
+        'Component',
+        'Component',
+      ]);
 
-    expect(container.innerHTML).toBe(
-      '<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
-    );
+      expect(container.innerHTML).toBe(
+        '<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
+      );
 
-    suspend = true;
-    client = true;
+      suspend = true;
+      client = true;
 
-    ReactDOM.hydrateRoot(container, <App />, {
-      onRecoverableError(error) {
-        Scheduler.unstable_yieldValue(error.message);
-      },
-    });
-    expect(Scheduler).toFlushAndYield([
-      'Suspend',
-      'Component',
-      'Component',
-      'Component',
-      'Component',
-    ]);
-    jest.runAllTimers();
+      ReactDOM.hydrateRoot(container, <App />, {
+        onRecoverableError(error) {
+          Scheduler.unstable_yieldValue(error.message);
+        },
+      });
+      expect(Scheduler).toFlushAndYield([
+        'Suspend',
+        'Component',
+        'Component',
+        'Component',
+        'Component',
+      ]);
+      jest.runAllTimers();
 
-    // Unchanged
-    expect(container.innerHTML).toBe(
-      '<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
-    );
+      // Unchanged
+      expect(container.innerHTML).toBe(
+        '<!--$-->Hello<div>Component</div><div>Component</div><div>Component</div><div>Component</div><!--/$-->',
+      );
 
-    suspend = false;
-    resolve();
-    await promise;
+      suspend = false;
+      resolve();
+      await promise;
+      expect(Scheduler).toFlushAndYield([
+        // first pass, mismatches at end
+        'Hello',
+        'Component',
+        'Component',
+        'Component',
+        'Component',
+
+        // second pass as client render
+        'Hello',
+        'Component',
+        'Component',
+        'Component',
+        'Component',
+
+        // Hydration mismatch is logged
+        'An error occurred during hydration. The server HTML was replaced with client content',
+      ]);
 
-    expect(Scheduler).toFlushAndYield([
-      // first pass, mismatches at end
-      'Hello',
-      'Component',
-      'Component',
-      'Component',
-      'Component',
-
-      // second pass as client render
-      'Hello',
-      'Component',
-      'Component',
-      'Component',
-      'Component',
-
-      // Hydration mismatch is logged
-      'An error occurred during hydration. The server HTML was replaced with client content',
-    ]);
+      // Client rendered - suspense comment nodes removed
+      expect(container.innerHTML).toBe(
+        'Hello<div>Component</div><div>Component</div><div>Component</div><article>Mismatch</article>',
+      );
 
-    // Client rendered - suspense comment nodes removed
-    expect(container.innerHTML).toBe(
-      'Hello<div>Component</div><div>Component</div><div>Component</div><article>Mismatch</article>',
-    );
+      if (__DEV__) {
+        expect(mockError.mock.calls[0]).toEqual([
+          'Warning: Expected server HTML to contain a matching <%s> in <%s>.%s',
+          'div',
+          'div',
+          '\n' +
+            '    in div (at **)\n' +
+            '    in Component (at **)\n' +
+            '    in Suspense (at **)\n' +
+            '    in App (at **)',
+        ]);
+      }
+    } finally {
+      console.error = originalConsoleError;
+    }
   });
 
   it('calls the hydration callbacks after hydration or deletion', async () => {
@@ -493,21 +524,14 @@ describe('ReactDOMServerPartialHydration', () => {
   });
 
   it('recovers with client render when server rendered additional nodes at suspense root after unsuspending', async () => {
-    spyOnDev(console, 'error');
-    const ref = React.createRef();
-    function App({hasB}) {
-      return (
-        <div>
-          <Suspense fallback="Loading...">
-            <Suspender />
-            <span ref={ref}>A</span>
-            {hasB ? <span>B</span> : null}
-          </Suspense>
-          <div>Sibling</div>
-        </div>
-      );
-    }
+    // We can't use the toErrorDev helper here because this is async.
+    const originalConsoleError = console.error;
+    const mockError = jest.fn();
+    console.error = (...args) => {
+      mockError(...args.map(normalizeCodeLocInfo));
+    };
 
+    const ref = React.createRef();
     let shouldSuspend = false;
     let resolve;
     const promise = new Promise(res => {
@@ -522,37 +546,61 @@ describe('ReactDOMServerPartialHydration', () => {
       }
       return <></>;
     }
+    function App({hasB}) {
+      return (
+        <div>
+          <Suspense fallback="Loading...">
+            <Suspender />
+            <span ref={ref}>A</span>
+            {hasB ? <span>B</span> : null}
+          </Suspense>
+          <div>Sibling</div>
+        </div>
+      );
+    }
+    try {
+      const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
 
-    const finalHTML = ReactDOMServer.renderToString(<App hasB={true} />);
-
-    const container = document.createElement('div');
-    container.innerHTML = finalHTML;
+      const container = document.createElement('div');
+      container.innerHTML = finalHTML;
 
-    const span = container.getElementsByTagName('span')[0];
+      const span = container.getElementsByTagName('span')[0];
 
-    expect(container.innerHTML).toContain('<span>A</span>');
-    expect(container.innerHTML).toContain('<span>B</span>');
-    expect(ref.current).toBe(null);
+      expect(container.innerHTML).toContain('<span>A</span>');
+      expect(container.innerHTML).toContain('<span>B</span>');
+      expect(ref.current).toBe(null);
 
-    shouldSuspend = true;
-    act(() => {
-      ReactDOM.hydrateRoot(container, <App hasB={false} />);
-    });
+      shouldSuspend = true;
+      act(() => {
+        ReactDOM.hydrateRoot(container, <App hasB={false} />);
+      });
 
-    // await expect(async () => {
-    resolve();
-    await promise;
-    Scheduler.unstable_flushAll();
-    await null;
-    jest.runAllTimers();
-    // }).toErrorDev('Did not expect server HTML to contain a <span> in <div>');
+      resolve();
+      await promise;
+      Scheduler.unstable_flushAll();
+      await null;
+      jest.runAllTimers();
 
-    expect(container.innerHTML).toContain('<span>A</span>');
-    expect(container.innerHTML).not.toContain('<span>B</span>');
-    if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
-      expect(ref.current).not.toBe(span);
-    } else {
-      expect(ref.current).toBe(span);
+      expect(container.innerHTML).toContain('<span>A</span>');
+      expect(container.innerHTML).not.toContain('<span>B</span>');
+      if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
+        expect(ref.current).not.toBe(span);
+      } else {
+        expect(ref.current).toBe(span);
+      }
+      if (__DEV__) {
+        expect(mockError).toHaveBeenCalledWith(
+          'Warning: Did not expect server HTML to contain a <%s> in <%s>.%s',
+          'span',
+          'div',
+          '\n' +
+            '    in Suspense (at **)\n' +
+            '    in div (at **)\n' +
+            '    in App (at **)',
+        );
+      }
+    } finally {
+      console.error = originalConsoleError;
     }
   });
 
@@ -3179,9 +3227,14 @@ describe('ReactDOMServerPartialHydration', () => {
         });
       });
     }).toErrorDev(
-      'Warning: An error occurred during hydration. ' +
-        'The server HTML was replaced with client content in <div>.',
-      {withoutStack: true},
+      [
+        'Warning: An error occurred during hydration. ' +
+          'The server HTML was replaced with client content in <div>.',
+        'Warning: Expected server HTML to contain a matching <span> in <div>.\n' +
+          '    in span (at **)\n' +
+          '    in App (at **)',
+      ],
+      {withoutStack: 1},
     );
     expect(Scheduler).toHaveYielded([
       'Log recoverable error: An error occurred during hydration. The server ' +

filename: packages/react-reconciler/src/ReactFiberHydrationContext.new.js
patch: @@ -183,8 +183,7 @@ function deleteHydratableInstance(
   }
 }
 
-function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
-  fiber.flags = (fiber.flags & ~Hydrating) | Placement;
+function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
   if (__DEV__) {
     switch (returnFiber.tag) {
       case HostRoot: {
@@ -283,6 +282,10 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
     }
   }
 }
+function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
+  fiber.flags = (fiber.flags & ~Hydrating) | Placement;
+  warnNonhydratedInstance(returnFiber, fiber);
+}
 
 function tryHydrate(fiber, nextInstance) {
   switch (fiber.tag) {
@@ -353,12 +356,10 @@ function shouldClientRenderOnMismatch(fiber: Fiber) {
   );
 }
 
-function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) {
-  if (shouldClientRenderOnMismatch(fiber)) {
-    throw new Error(
-      'An error occurred during hydration. The server HTML was replaced with client content',
-    );
-  }
+function throwOnHydrationMismatch(fiber: Fiber) {
+  throw new Error(
+    'An error occurred during hydration. The server HTML was replaced with client content',
+  );
 }
 
 function tryToClaimNextHydratableInstance(fiber: Fiber): void {
@@ -367,7 +368,10 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
   }
   let nextInstance = nextHydratableInstance;
   if (!nextInstance) {
-    throwOnHydrationMismatchIfConcurrentMode(fiber);
+    if (shouldClientRenderOnMismatch(fiber)) {
+      warnNonhydratedInstance((hydrationParentFiber: any), fiber);
+      throwOnHydrationMismatch(fiber);
+    }
     // Nothing to hydrate. Make it an insertion.
     insertNonHydratedInstance((hydrationParentFiber: any), fiber);
     isHydrating = false;
@@ -376,7 +380,10 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
   }
   const firstAttemptedInstance = nextInstance;
   if (!tryHydrate(fiber, nextInstance)) {
-    throwOnHydrationMismatchIfConcurrentMode(fiber);
+    if (shouldClientRenderOnMismatch(fiber)) {
+      warnNonhydratedInstance((hydrationParentFiber: any), fiber);
+      throwOnHydrationMismatch(fiber);
+    }
     // If we can't hydrate this instance let's try the next one.
     // We use this as a heuristic. It's based on intuition and not data so it
     // might be flawed or unnecessary.
@@ -565,7 +572,7 @@ function popHydrationState(fiber: Fiber): boolean {
     if (nextInstance) {
       if (shouldClientRenderOnMismatch(fiber)) {
         warnIfUnhydratedTailNodes(fiber);
-        throwOnHydrationMismatchIfConcurrentMode(fiber);
+        throwOnHydrationMismatch(fiber);
       } else {
         while (nextInstance) {
           deleteHydratableInstance(fiber, nextInstance);

filename: packages/react-reconciler/src/ReactFiberHydrationContext.old.js
patch: @@ -183,8 +183,7 @@ function deleteHydratableInstance(
   }
 }
 
-function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
-  fiber.flags = (fiber.flags & ~Hydrating) | Placement;
+function warnNonhydratedInstance(returnFiber: Fiber, fiber: Fiber) {
   if (__DEV__) {
     switch (returnFiber.tag) {
       case HostRoot: {
@@ -283,6 +282,10 @@ function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
     }
   }
 }
+function insertNonHydratedInstance(returnFiber: Fiber, fiber: Fiber) {
+  fiber.flags = (fiber.flags & ~Hydrating) | Placement;
+  warnNonhydratedInstance(returnFiber, fiber);
+}
 
 function tryHydrate(fiber, nextInstance) {
   switch (fiber.tag) {
@@ -353,12 +356,10 @@ function shouldClientRenderOnMismatch(fiber: Fiber) {
   );
 }
 
-function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) {
-  if (shouldClientRenderOnMismatch(fiber)) {
-    throw new Error(
-      'An error occurred during hydration. The server HTML was replaced with client content',
-    );
-  }
+function throwOnHydrationMismatch(fiber: Fiber) {
+  throw new Error(
+    'An error occurred during hydration. The server HTML was replaced with client content',
+  );
 }
 
 function tryToClaimNextHydratableInstance(fiber: Fiber): void {
@@ -367,7 +368,10 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
   }
   let nextInstance = nextHydratableInstance;
   if (!nextInstance) {
-    throwOnHydrationMismatchIfConcurrentMode(fiber);
+    if (shouldClientRenderOnMismatch(fiber)) {
+      warnNonhydratedInstance((hydrationParentFiber: any), fiber);
+      throwOnHydrationMismatch(fiber);
+    }
     // Nothing to hydrate. Make it an insertion.
     insertNonHydratedInstance((hydrationParentFiber: any), fiber);
     isHydrating = false;
@@ -376,7 +380,10 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
   }
   const firstAttemptedInstance = nextInstance;
   if (!tryHydrate(fiber, nextInstance)) {
-    throwOnHydrationMismatchIfConcurrentMode(fiber);
+    if (shouldClientRenderOnMismatch(fiber)) {
+      warnNonhydratedInstance((hydrationParentFiber: any), fiber);
+      throwOnHydrationMismatch(fiber);
+    }
     // If we can't hydrate this instance let's try the next one.
     // We use this as a heuristic. It's based on intuition and not data so it
     // might be flawed or unnecessary.
@@ -565,7 +572,7 @@ function popHydrationState(fiber: Fiber): boolean {
     if (nextInstance) {
       if (shouldClientRenderOnMismatch(fiber)) {
         warnIfUnhydratedTailNodes(fiber);
-        throwOnHydrationMismatchIfConcurrentMode(fiber);
+        throwOnHydrationMismatch(fiber);
       } else {
         while (nextInstance) {
           deleteHydratableInstance(fiber, nextInstance);