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 ( +
+
{isClient ? 'client' : 'server'}
+
+ ); + } + 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
.", + "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
in
. + in header (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 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 ( +
+
+ {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 end', () => { + function Mismatch({isClient}) { + return ( +
+
+
+ {isClient &&
} +
+ ); + } + if ( + gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch) + ) { + expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` + Array [ + "Warning: Expected server HTML to contain a matching