From 105fb082921eb41ea2848793aca3023e25561a14 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 5 Apr 2022 00:04:15 +0100 Subject: [PATCH] Add more tests for suppressHydrationWarning (#24275) * More tests for suppressHydrationWarning * Move suppressHydration tests to new file * Extract more tests * Test name * Test legacy behavior too --- .../src/__tests__/ReactDOMFizzServer-test.js | 92 --- ...actDOMFizzSuppressHydrationWarning-test.js | 698 ++++++++++++++++++ 2 files changed, 698 insertions(+), 92 deletions(-) create mode 100644 packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index d4687ab4d365b..295e99b26a691 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -2811,96 +2811,4 @@ describe('ReactDOMFizzServer', () => { , ); }); - - // @gate experimental - it('suppresses and fixes text mismatches with suppressHydrationWarning', async () => { - function App({isClient}) { - return ( -
- - {isClient ? 'Client Text' : 'Server Text'} - - {isClient ? 2 : 1} - - hello,{isClient ? 'client' : 'server'} - -
- ); - } - await act(async () => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - , - ); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual( -
- Server Text - 1 - - {'hello,'} - {'server'} - -
, - ); - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - // Don't miss a hydration error. There should be none. - Scheduler.unstable_yieldValue(error.message); - }, - }); - expect(Scheduler).toFlushAndYield([]); - // The text mismatch should be *silently* fixed. Even in production. - // The attribute mismatch should be ignored and not fixed. - expect(getVisibleChildren(container)).toEqual( -
- Client Text - 2 - - {'hello,'} - {'client'} - -
, - ); - }); - - // @gate experimental - it('suppresses and does not fix html mismatches with suppressHydrationWarning', async () => { - function App({isClient}) { - return ( -
-

-

- ); - } - await act(async () => { - const {pipe} = ReactDOMFizzServer.renderToPipeableStream( - , - ); - pipe(writable); - }); - expect(getVisibleChildren(container)).toEqual( -
-

Server HTML

-
, - ); - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - Scheduler.unstable_yieldValue(error.message); - }, - }); - expect(Scheduler).toFlushAndYield([]); - expect(getVisibleChildren(container)).toEqual( -
-

Server HTML

-
, - ); - }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js new file mode 100644 index 0000000000000..f6e6d3222b90a --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js @@ -0,0 +1,698 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +let JSDOM; +let Stream; +let Scheduler; +let React; +let ReactDOMClient; +let ReactDOMFizzServer; +let document; +let writable; +let container; +let buffer = ''; +let hasErrored = false; +let fatalError = undefined; + +describe('ReactDOMFizzServerHydrationWarning', () => { + beforeEach(() => { + jest.resetModules(); + JSDOM = require('jsdom').JSDOM; + Scheduler = require('scheduler'); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + if (__EXPERIMENTAL__) { + ReactDOMFizzServer = require('react-dom/server'); + } + Stream = require('stream'); + + // Test Environment + const jsdom = new JSDOM( + '
', + { + runScripts: 'dangerously', + }, + ); + document = jsdom.window.document; + container = document.getElementById('container'); + + buffer = ''; + hasErrored = false; + + writable = new Stream.PassThrough(); + writable.setEncoding('utf8'); + writable.on('data', chunk => { + buffer += chunk; + }); + writable.on('error', error => { + hasErrored = true; + fatalError = error; + }); + }); + + async function act(callback) { + await callback(); + // Await one turn around the event loop. + // This assumes that we'll flush everything we have so far. + await new Promise(resolve => { + setImmediate(resolve); + }); + if (hasErrored) { + throw fatalError; + } + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. + // We also want to execute any scripts that are embedded. + // We assume that we have now received a proper fragment of HTML. + const bufferedContent = buffer; + buffer = ''; + const fakeBody = document.createElement('body'); + fakeBody.innerHTML = bufferedContent; + while (fakeBody.firstChild) { + const node = fakeBody.firstChild; + if (node.nodeName === 'SCRIPT') { + const script = document.createElement('script'); + script.textContent = node.textContent; + fakeBody.removeChild(node); + container.appendChild(script); + } else { + container.appendChild(node); + } + } + } + + function getVisibleChildren(element) { + const children = []; + let node = element.firstChild; + while (node) { + if (node.nodeType === 1) { + if ( + node.tagName !== 'SCRIPT' && + node.tagName !== 'TEMPLATE' && + node.tagName !== 'template' && + !node.hasAttribute('hidden') && + !node.hasAttribute('aria-hidden') + ) { + const props = {}; + const attributes = node.attributes; + for (let i = 0; i < attributes.length; i++) { + if ( + attributes[i].name === 'id' && + attributes[i].value.includes(':') + ) { + // We assume this is a React added ID that's a non-visual implementation detail. + continue; + } + props[attributes[i].name] = attributes[i].value; + } + props.children = getVisibleChildren(node); + children.push(React.createElement(node.tagName.toLowerCase(), props)); + } + } else if (node.nodeType === 3) { + children.push(node.data); + } + node = node.nextSibling; + } + return children.length === 0 + ? undefined + : children.length === 1 + ? children[0] + : children; + } + + // @gate experimental + it('suppresses and fixes text mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + {isClient ? 'Client Text' : 'Server Text'} + + {isClient ? 2 : 1} +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ Server Text + 1 +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + // Don't miss a hydration error. There should be none. + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + // The text mismatch should be *silently* fixed. Even in production. + expect(getVisibleChildren(container)).toEqual( +
+ Client Text + 2 +
, + ); + }); + + // @gate experimental + it('suppresses and fixes multiple text node mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + {isClient ? 'Client1' : 'Server1'} + {isClient ? 'Client2' : 'Server2'} + +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ + {'Server1'} + {'Server2'} + +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual( +
+ + {'Client1'} + {'Client2'} + +
, + ); + }); + + // @gate experimental + it('errors on text-to-element mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + Hello, {isClient ? Client : 'Server'}! + +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ + {'Hello, '} + {'Server'} + {'!'} + +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(() => { + expect(Scheduler).toFlushAndYield([ + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + }).toErrorDev( + [ + 'An error occurred during hydration. The server HTML was replaced with client content in
.', + ], + {withoutStack: true}, + ); + } else { + // This used to not warn. + expect(Scheduler).toFlushAndYield([]); + } + expect(getVisibleChildren(container)).toEqual( +
+ + Hello, Client! + +
, + ); + }); + + // @gate experimental + it('suppresses and fixes client-only single text node mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + {isClient ? 'Client' : null} + +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual( +
+ {'Client'} +
, + ); + }); + + // TODO: This behavior is not consistent with client-only single text node. + // @gate experimental + it('errors on server-only single text node mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + {isClient ? null : 'Server'} + +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ Server +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(() => { + expect(Scheduler).toFlushAndYield([ + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + }).toErrorDev( + [ + 'An error occurred during hydration. The server HTML was replaced with client content in
.', + ], + {withoutStack: true}, + ); + } else { + // This used to not warn. + expect(Scheduler).toFlushAndYield([]); + } + expect(getVisibleChildren(container)).toEqual( +
+ +
, + ); + }); + + // @gate experimental + it('errors on client-only extra text node mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + Shared + {isClient ? 'Client' : null} + +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ + Shared + +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(() => { + expect(Scheduler).toFlushAndYield([ + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + }).toErrorDev( + [ + 'An error occurred during hydration. The server HTML was replaced with client content in
.', + ], + {withoutStack: true}, + ); + } else { + // This used to not warn. + expect(Scheduler).toFlushAndYield([]); + } + expect(getVisibleChildren(container)).toEqual( +
+ + Shared + {'Client'} + +
, + ); + }); + + // @gate experimental + it('errors on server-only extra text node mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + Shared + {isClient ? null : 'Server'} + +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ + SharedServer + +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(() => { + expect(Scheduler).toFlushAndYield([ + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + }).toErrorDev( + [ + 'An error occurred during hydration. The server HTML was replaced with client content in
.', + ], + {withoutStack: true}, + ); + } else { + // This used to not warn. + expect(Scheduler).toFlushAndYield([]); + } + expect(getVisibleChildren(container)).toEqual( +
+ + Shared + +
, + ); + }); + + // @gate experimental + it('errors on element-to-text mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ + Hello, {isClient ? 'Client' : Server}! + +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ + Hello, Server! + +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(() => { + expect(Scheduler).toFlushAndYield([ + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + }).toErrorDev( + [ + 'An error occurred during hydration. The server HTML was replaced with client content in
.', + ], + {withoutStack: true}, + ); + } else { + // This used to not warn. + expect(Scheduler).toFlushAndYield([]); + } + expect(getVisibleChildren(container)).toEqual( +
+ + {'Hello, '} + {'Client'} + {'!'} + +
, + ); + }); + + // @gate experimental + it('suppresses and does not fix attribute mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+ +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+ +
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual( +
+ +
, + ); + }); + + // @gate experimental + it('suppresses and does not fix html mismatches with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+

+

+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+

Server HTML

+
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual( +
+

Server HTML

+
, + ); + }); + + // @gate experimental + it('errors on insertions with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+

Client and server

+ {isClient &&

Client only

} +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+

Client and server

+
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(() => { + expect(Scheduler).toFlushAndYield([ + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + }).toErrorDev( + [ + 'An error occurred during hydration. The server HTML was replaced with client content in
.', + ], + {withoutStack: true}, + ); + } else { + // This used to not warn. + expect(Scheduler).toFlushAndYield([]); + } + expect(getVisibleChildren(container)).toEqual( +
+

Client and server

+

Client only

+
, + ); + }); + + // @gate experimental + it('errors on deletions with suppressHydrationWarning', async () => { + function App({isClient}) { + return ( +
+

Client and server

+ {!isClient &&

Server only

} +
+ ); + } + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + , + ); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual( +
+

Client and server

+

Server only

+
, + ); + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue(error.message); + }, + }); + if (gate(flags => flags.enableClientRenderFallbackOnHydrationMismatch)) { + expect(() => { + expect(Scheduler).toFlushAndYield([ + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + }).toErrorDev( + [ + 'An error occurred during hydration. The server HTML was replaced with client content in
.', + ], + {withoutStack: true}, + ); + } else { + // This used to not warn. + expect(Scheduler).toFlushAndYield([]); + } + expect(getVisibleChildren(container)).toEqual( +
+

Client and server

+
, + ); + }); +});