diff --git a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
new file mode 100644
index 0000000000000..f79ca0c18b631
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js
@@ -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(
+ ,
+ );
+ container.innerHTML = htmlString;
+ act(() => {
+ ReactDOMClient.hydrateRoot(container, );
+ });
+ return formatConsoleErrors();
+ }
+
+ describe('text mismatch', () => {
+ // @gate __DEV__
+ it('warns when client and server render different text', () => {
+ function Mismatch({isClient}) {
+ return (
+
.",
+ "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 (
+
+ client'
+ : 'server',
+ }}
+ />
+
+ );
+ }
+ expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+ Array [
+ "Warning: Prop \`dangerouslySetInnerHTML\` did not match. Server: \\"
server\\" Client: \\"
client\\"
+ 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 (
+
+
+
+ );
+ }
+ 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 (
+
+
+
+ );
+ }
+ 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 (
+
+
+
+ );
+ }
+ 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 (
+
+
+
+ );
+ }
+ 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 (
+
+
+
+ );
+ }
+ 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 (
+
+ {isClient && }
+
+ );
+ }
+ if (
+ gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+ ) {
+ expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+ Array [
+ "Warning: Expected server HTML to contain a matching
in .
+ in main (at **)
+ in div (at **)
+ in Mismatch (at **)",
+ "Warning: An error occurred during hydration. The server HTML was replaced with client content in
.",
+ "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
in .
+ 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 (
+
+ {isClient && }
+
+
+
+ );
+ }
+ if (
+ gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)
+ ) {
+ expect(testMismatch(Mismatch)).toMatchInlineSnapshot(`
+ Array [
+ "Warning: Expected server HTML to contain a matching