Repository: facebook/react
Pull request: Test suite for hydration diff warnings

In preparation for https://github.com/facebook/react/pull/24167.

If we're doing something more ambitious like https://github.com/facebook/react/pull/24167#discussion_r835578970 I want to have good coverage of different cases and make sure the right thing is being printed. There are existing tests in different places but I want to have an exhaustive suite dedicated just to this, and ideally it should be easy to iterate on. So I made these inline snapshots.

This just documents the current behavior. Follow-up will change the messages.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
patch: @@ -0,0 +1,1329 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+'use strict';
+
+let React;
+let ReactDOMClient;
+let ReactDOMServer;
+let act;
+
+const util = require('util');
+const realConsoleError = console.error;
+
+describe('ReactDOMServerHydration', () => {
+  let container;
+
+  beforeEach(() => {
+    jest.resetModules();
+    React = require('react');
+    ReactDOMClient = require('react-dom/client');
+    ReactDOMServer = require('react-dom/server');
+    act = require('react-dom/test-utils').act;
+
+    console.error = jest.fn();
+    container = document.createElement('div');
+    document.body.appendChild(container);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(container);
+    console.error = realConsoleError;
+  });
+
+  function normalizeCodeLocInfo(str) {
+    return (
+      typeof str === 'string' &&
+      str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function(m, name) {
+        return '\n    in ' + name + ' (at **)';
+      })
+    );
+  }
+
+  function formatMessage(args) {
+    const [format, ...rest] = args;
+    if (format instanceof Error) {
+      return 'Caught [' + format.message + ']';
+    }
+    if (format.indexOf('Error: Uncaught [') === 0) {
+      // Ignore errors captured by jsdom and their stacks.
+      // We only want console errors in this suite.
+      return null;
+    }
+    rest[rest.length - 1] = normalizeCodeLocInfo(rest[rest.length - 1]);
+    return util.format(format, ...rest);
+  }
+
+  function formatConsoleErrors() {
+    return console.error.mock.calls.map(formatMessage).filter(Boolean);
+  }
+
+  function testMismatch(Mismatch) {
+    const htmlString = ReactDOMServer.renderToString(
+      <Mismatch isClient={false} />,
+    );
+    container.innerHTML = htmlString;
+    act(() => {
+      ReactDOMClient.hydrateRoot(container, <Mismatch isClient={true} />);
+    });
+    return formatConsoleErrors();
+  }
+
+  describe('text mismatch', () => {
+    // @gate __DEV__
+    it('warns when client and server render different text', () => {
+      function Mismatch({isClient}) {
+        return (
+          <div className="parent">
+            <main className="child">{isClient ? 'client' : 'server'}</main>
+          </div>
+        );
+      }
+      if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
+        expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+          Array [
+            "Warning: Text content did not match. Server: \\"server\\" Client: \\"client\\"
+              in main (at **)
+              in div (at **)
+              in Mismatch (at **)",
+            "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+            "Caught [Text content does not match server-rendered HTML.]",
+            "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+          ]
+        `);
+      } else {
+        expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+          Array [
+            "Warning: Text content did not match. Server: \\"server\\" Client: \\"client\\"
+              in main (at **)
+              in div (at **)
+              in Mismatch (at **)",
+          ]
+        `);
+      }
+    });
+
+    // @gate __DEV__
+    it('warns when client and server render different html', () => {
+      function Mismatch({isClient}) {
+        return (
+          <div className="parent">
+            <main
+              className="child"
+              dangerouslySetInnerHTML={{
+                __html: isClient
+                  ? '<span>client</span>'
+                  : '<span>server</span>',
+              }}
+            />
+          </div>
+        );
+      }
+      expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+        Array [
+          "Warning: Prop \`dangerouslySetInnerHTML\` did not match. Server: \\"<span>server</span>\\" Client: \\"<span>client</span>\\"
+            in main (at **)
+            in div (at **)
+            in Mismatch (at **)",
+        ]
+      `);
+    });
+  });
+
+  describe('attribute mismatch', () => {
+    // @gate __DEV__
+    it('warns when client and server render different attributes', () => {
+      function Mismatch({isClient}) {
+        return (
+          <div className="parent">
+            <main
+              className={isClient ? 'child client' : 'child server'}
+              dir={isClient ? 'ltr' : 'rtl'}
+            />
+          </div>
+        );
+      }
+      expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+        Array [
+          "Warning: Prop \`className\` did not match. Server: \\"child server\\" Client: \\"child client\\"
+            in main (at **)
+            in div (at **)
+            in Mismatch (at **)",
+        ]
+      `);
+    });
+
+    // @gate __DEV__
+    it('warns when client renders extra attributes', () => {
+      function Mismatch({isClient}) {
+        return (
+          <div className="parent">
+            <main
+              className="child"
+              tabIndex={isClient ? 1 : null}
+              dir={isClient ? 'ltr' : null}
+            />
+          </div>
+        );
+      }
+      expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+        Array [
+          "Warning: Prop \`tabIndex\` did not match. Server: \\"null\\" Client: \\"1\\"
+            in main (at **)
+            in div (at **)
+            in Mismatch (at **)",
+        ]
+      `);
+    });
+
+    // @gate __DEV__
+    it('warns when server renders extra attributes', () => {
+      function Mismatch({isClient}) {
+        return (
+          <div className="parent">
+            <main
+              className="child"
+              tabIndex={isClient ? null : 1}
+              dir={isClient ? null : 'rtl'}
+            />
+          </div>
+        );
+      }
+      expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+        Array [
+          "Warning: Extra attributes from the server: tabindex,dir
+            in main (at **)
+            in div (at **)
+            in Mismatch (at **)",
+        ]
+      `);
+    });
+
+    // @gate __DEV__
+    it('warns when both client and server render extra attributes', () => {
+      function Mismatch({isClient}) {
+        return (
+          <div className="parent">
+            <main
+              className="child"
+              tabIndex={isClient ? 1 : null}
+              dir={isClient ? null : 'rtl'}
+            />
+          </div>
+        );
+      }
+      expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+        Array [
+          "Warning: Prop \`tabIndex\` did not match. Server: \\"null\\" Client: \\"1\\"
+            in main (at **)
+            in div (at **)
+            in Mismatch (at **)",
+        ]
+      `);
+    });
+
+    // @gate __DEV__
+    it('warns when client and server render different styles', () => {
+      function Mismatch({isClient}) {
+        return (
+          <div className="parent">
+            <main
+              className="child"
+              style={{
+                opacity: isClient ? 1 : 0,
+              }}
+            />
+          </div>
+        );
+      }
+      expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+        Array [
+          "Warning: Prop \`style\` did not match. Server: \\"opacity:0\\" Client: \\"opacity:1\\"
+            in main (at **)
+            in div (at **)
+            in Mismatch (at **)",
+        ]
+      `);
+    });
+  });
+
+  describe('extra nodes on the client', () => {
+    describe('extra elements on the client', () => {
+      // @gate __DEV__
+      it('warns when client renders an extra element as only child', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {isClient && <main className="only" />}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <main> in <div>.
+                in main (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <main> in <div>.
+                in main (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when client renders an extra element in the beginning', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {isClient && <header className="1" />}
+              <main className="2" />
+              <footer className="3" />
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <header> in <div>.
+                in header (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <header> in <div>.
+                in header (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when client renders an extra element in the middle', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <header className="1" />
+              {isClient && <main className="2" />}
+              <footer className="3" />
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <main> in <div>.
+                in main (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <main> in <div>.
+                in main (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when client renders an extra element in the end', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <header className="1" />
+              <main className="2" />
+              {isClient && <footer className="3" />}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <footer> in <div>.
+                in footer (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <footer> in <div>.
+                in footer (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+    });
+
+    describe('extra text nodes on the client', () => {
+      // @gate __DEV__
+      it('warns when client renders an extra text node as only child', () => {
+        function Mismatch({isClient}) {
+          return <div className="parent">{isClient && 'only'}</div>;
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Text content did not match. Server: \\"\\" Client: \\"only\\"
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Text content does not match server-rendered HTML.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Text content did not match. Server: \\"\\" Client: \\"only\\"
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when client renders an extra text node in the beginning', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <header className="1" />
+              {isClient && 'second'}
+              <footer className="3" />
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching text node for \\"second\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching text node for \\"second\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when client renders an extra text node in the beginning', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {isClient && 'first'}
+              <main className="2" />
+              <footer className="3" />
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching text node for \\"first\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching text node for \\"first\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when client renders an extra text node in the end', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <header className="1" />
+              <main className="2" />
+              {isClient && 'third'}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching text node for \\"third\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching text node for \\"third\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+    });
+  });
+
+  describe('extra nodes on the server', () => {
+    describe('extra elements on the server', () => {
+      // @gate __DEV__
+      it('warns when server renders an extra element as only child', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {!isClient && <main className="only" />}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <main> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <main> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra element in the beginning', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {!isClient && <header className="1" />}
+              <main className="2" />
+              <footer className="3" />
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <main> in <div>.
+                in main (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <header> in <div>.
+                in main (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra element in the middle', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <header className="1" />
+              {!isClient && <main className="2" />}
+              <footer className="3" />
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <footer> in <div>.
+                in footer (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <main> in <div>.
+                in footer (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra element in the end', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <header className="1" />
+              <main className="2" />
+              {!isClient && <footer className="3" />}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <footer> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <footer> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+    });
+
+    describe('extra text nodes on the server', () => {
+      // @gate __DEV__
+      it('warns when server renders an extra text node as only child', () => {
+        function Mismatch({isClient}) {
+          return <div className="parent">{!isClient && 'only'}</div>;
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain the text node \\"only\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain the text node \\"only\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra text node in the beginning', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {!isClient && 'first'}
+              <main className="2" />
+              <footer className="3" />
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <main> in <div>.
+                in main (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain the text node \\"first\\" in <div>.
+                in main (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra text node in the middle', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <header className="1" />
+              {!isClient && 'second'}
+              <footer className="3" />
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <footer> in <div>.
+                in footer (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain the text node \\"second\\" in <div>.
+                in footer (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra text node in the end', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <header className="1" />
+              <main className="2" />
+              {!isClient && 'third'}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain the text node \\"third\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain the text node \\"third\\" in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+    });
+  });
+
+  describe('special nodes', () => {
+    describe('Suspense', () => {
+      function Never() {
+        throw new Promise(resolve => {});
+      }
+
+      // @gate __DEV__
+      it('warns when client renders an extra Suspense node in content mode', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {isClient && (
+                <React.Suspense fallback={<p>Loading...</p>}>
+                  <main className="only" />
+                </React.Suspense>
+              )}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          // TODO: This message doesn't seem to have any useful details.
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`Array []`);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra Suspense node in content mode', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {!isClient && (
+                <React.Suspense fallback={<p>Loading...</p>}>
+                  <main className="only" />
+                </React.Suspense>
+              )}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <main> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <main> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when client renders an extra Suspense node in fallback mode', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {isClient && (
+                <React.Suspense fallback={<p>Loading...</p>}>
+                  <main className="only" />
+                  <Never />
+                </React.Suspense>
+              )}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          // TODO: This message doesn't seem to have any useful details.
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`Array []`);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra Suspense node in fallback mode', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {!isClient && (
+                <React.Suspense fallback={<p>Loading...</p>}>
+                  <main className="only" />
+                  <Never />
+                </React.Suspense>
+              )}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <p> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <p> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when client renders an extra node inside Suspense content', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <React.Suspense fallback={<p>Loading...</p>}>
+                <header className="1" />
+                {isClient && <main className="second" />}
+                <footer className="3" />
+              </React.Suspense>
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <main> in <div>.
+                in main (at **)
+                in Suspense (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating this Suspense boundary. Switched to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <main> in <div>.
+                in main (at **)
+                in Suspense (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra node inside Suspense content', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <React.Suspense fallback={<p>Loading...</p>}>
+                <header className="1" />
+                {!isClient && <main className="second" />}
+                <footer className="3" />
+              </React.Suspense>
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <footer> in <div>.
+                in footer (at **)
+                in Suspense (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating this Suspense boundary. Switched to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <main> in <div>.
+                in footer (at **)
+                in Suspense (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when client renders an extra node inside Suspense fallback', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <React.Suspense
+                fallback={
+                  <>
+                    <p>Loading...</p>
+                    {isClient && <br />}
+                  </>
+                }>
+                <main className="only" />
+                <Never />
+              </React.Suspense>
+            </div>
+          );
+        }
+        // TODO: Why does this not show a fallback mismatch?
+        // And why is this message different from the other ones?
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Caught [The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Caught [The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.]",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra node inside Suspense fallback', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              <React.Suspense
+                fallback={
+                  <>
+                    <p>Loading...</p>
+                    {!isClient && <br />}
+                  </>
+                }>
+                <main className="only" />
+                <Never />
+              </React.Suspense>
+            </div>
+          );
+        }
+        // TODO: Why does this not show a fallback mismatch?
+        // And why is this message different from the other ones?
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Caught [The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Caught [The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.]",
+            ]
+          `);
+        }
+      });
+    });
+
+    describe('Fragment', () => {
+      // @gate __DEV__
+      it('warns when client renders an extra Fragment node', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {isClient && (
+                <>
+                  <header className="1" />
+                  <main className="2" />
+                  <footer className="3" />
+                </>
+              )}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <header> in <div>.
+                in header (at **)
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Expected server HTML to contain a matching <header> in <div>.
+                in header (at **)
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+
+      // @gate __DEV__
+      it('warns when server renders an extra Fragment node', () => {
+        function Mismatch({isClient}) {
+          return (
+            <div className="parent">
+              {!isClient && (
+                <>
+                  <header className="1" />
+                  <main className="2" />
+                  <footer className="3" />
+                </>
+              )}
+            </div>
+          );
+        }
+        if (
+          gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+        ) {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <header> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+              "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+              "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+              "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+            ]
+          `);
+        } else {
+          expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+            Array [
+              "Warning: Did not expect server HTML to contain a <header> in <div>.
+                in div (at **)
+                in Mismatch (at **)",
+            ]
+          `);
+        }
+      });
+    });
+  });
+
+  describe('misc cases', () => {
+    // @gate __DEV__
+    it('warns when client renders an extra node deeper in the tree', () => {
+      function Mismatch({isClient}) {
+        return isClient ? <ProfileSettings /> : <MediaSettings />;
+      }
+
+      function ProfileSettings() {
+        return (
+          <div className="parent">
+            <input />
+            <Panel type="profile" />
+          </div>
+        );
+      }
+
+      function MediaSettings() {
+        return (
+          <div className="parent">
+            <input />
+            <Panel type="media" />
+          </div>
+        );
+      }
+
+      function Panel({type}) {
+        return (
+          <>
+            <header className="1" />
+            <main className="2" />
+            {type === 'profile' && <footer className="3" />}
+          </>
+        );
+      }
+
+      if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
+        expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+          Array [
+            "Warning: Expected server HTML to contain a matching <footer> in <div>.
+              in footer (at **)
+              in Panel (at **)
+              in div (at **)
+              in ProfileSettings (at **)
+              in Mismatch (at **)",
+            "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+            "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+            "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+          ]
+        `);
+      } else {
+        expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+          Array [
+            "Warning: Expected server HTML to contain a matching <footer> in <div>.
+              in footer (at **)
+              in Panel (at **)
+              in div (at **)
+              in ProfileSettings (at **)
+              in Mismatch (at **)",
+          ]
+        `);
+      }
+    });
+
+    // @gate __DEV__
+    it('warns when server renders an extra node deeper in the tree', () => {
+      function Mismatch({isClient}) {
+        return isClient ? <ProfileSettings /> : <MediaSettings />;
+      }
+
+      function ProfileSettings() {
+        return (
+          <div className="parent">
+            <input />
+            <Panel type="profile" />
+          </div>
+        );
+      }
+
+      function MediaSettings() {
+        return (
+          <div className="parent">
+            <input />
+            <Panel type="media" />
+          </div>
+        );
+      }
+
+      function Panel({type}) {
+        return (
+          <>
+            <header className="1" />
+            <main className="2" />
+            {type !== 'profile' && <footer className="3" />}
+          </>
+        );
+      }
+
+      if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) {
+        expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+          Array [
+            "Warning: Did not expect server HTML to contain a <footer> in <div>.
+              in div (at **)
+              in ProfileSettings (at **)
+              in Mismatch (at **)",
+            "Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.",
+            "Caught [Hydration failed because the initial UI does not match what was rendered on the server.]",
+            "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]",
+          ]
+        `);
+      } else {
+        expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+          Array [
+            "Warning: Did not expect server HTML to contain a <footer> in <div>.
+              in div (at **)
+              in ProfileSettings (at **)
+              in Mismatch (at **)",
+          ]
+        `);
+      }
+    });
+  });
+});