From 415092e147403c3b2ae8d03a9c079dae3be4b022 Mon Sep 17 00:00:00 2001 From: Ricky Date: Wed, 7 Feb 2024 09:54:46 -0500 Subject: [PATCH] Convert ReactRenderDocument to hydrateRoot (#28153) Co-authored-by: Sebastian Silbermann --- .../src/__tests__/ReactRenderDocument-test.js | 208 ++++++++++++++---- 1 file changed, 168 insertions(+), 40 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js index d918e27d3b58c..e828889995ed4 100644 --- a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js +++ b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js @@ -11,7 +11,11 @@ let React; let ReactDOM; +let ReactDOMClient; let ReactDOMServer; +let act; +let Scheduler; +let assertLog; function getTestDocument(markup) { const doc = document.implementation.createHTMLDocument(''); @@ -28,11 +32,15 @@ describe('rendering React components at document', () => { beforeEach(() => { React = require('react'); ReactDOM = require('react-dom'); + ReactDOMClient = require('react-dom/client'); ReactDOMServer = require('react-dom/server'); + act = require('internal-test-utils').act; + assertLog = require('internal-test-utils').assertLog; + Scheduler = require('scheduler'); }); describe('with new explicit hydration API', () => { - it('should be able to adopt server markup', () => { + it('should be able to adopt server markup', async () => { class Root extends React.Component { render() { return ( @@ -51,16 +59,21 @@ describe('rendering React components at document', () => { const testDocument = getTestDocument(markup); const body = testDocument.body; - ReactDOM.hydrate(, testDocument); + let root; + await act(() => { + root = ReactDOMClient.hydrateRoot(testDocument, ); + }); expect(testDocument.body.innerHTML).toBe('Hello world'); - ReactDOM.hydrate(, testDocument); + await act(() => { + root.render(); + }); expect(testDocument.body.innerHTML).toBe('Hello moon'); expect(body === testDocument.body).toBe(true); }); - it('should be able to unmount component from document node, but leaves singleton nodes intact', () => { + it('should be able to unmount component from document node, but leaves singleton nodes intact', async () => { class Root extends React.Component { render() { return ( @@ -76,7 +89,10 @@ describe('rendering React components at document', () => { const markup = ReactDOMServer.renderToString(); const testDocument = getTestDocument(markup); - ReactDOM.hydrate(, testDocument); + let root; + await act(() => { + root = ReactDOMClient.hydrateRoot(testDocument, ); + }); expect(testDocument.body.innerHTML).toBe('Hello world'); const originalDocEl = testDocument.documentElement; @@ -84,7 +100,7 @@ describe('rendering React components at document', () => { const originalBody = testDocument.body; // When we unmount everything is removed except the singleton nodes of html, head, and body - ReactDOM.unmountComponentAtNode(testDocument); + root.unmount(); expect(testDocument.firstChild).toBe(originalDocEl); expect(testDocument.head).toBe(originalHead); expect(testDocument.body).toBe(originalBody); @@ -92,7 +108,7 @@ describe('rendering React components at document', () => { expect(originalHead.firstChild).toEqual(null); }); - it('should not be able to switch root constructors', () => { + it('should not be able to switch root constructors', async () => { class Component extends React.Component { render() { return ( @@ -122,17 +138,21 @@ describe('rendering React components at document', () => { const markup = ReactDOMServer.renderToString(); const testDocument = getTestDocument(markup); - ReactDOM.hydrate(, testDocument); + let root; + await act(() => { + root = ReactDOMClient.hydrateRoot(testDocument, ); + }); expect(testDocument.body.innerHTML).toBe('Hello world'); - // This works but is probably a bad idea. - ReactDOM.hydrate(, testDocument); + await act(() => { + root.render(); + }); expect(testDocument.body.innerHTML).toBe('Goodbye world'); }); - it('should be able to mount into document', () => { + it('should be able to mount into document', async () => { class Component extends React.Component { render() { return ( @@ -151,40 +171,80 @@ describe('rendering React components at document', () => { ); const testDocument = getTestDocument(markup); - ReactDOM.hydrate(, testDocument); + await act(() => { + ReactDOMClient.hydrateRoot( + testDocument, + , + ); + }); expect(testDocument.body.innerHTML).toBe('Hello world'); }); - it('cannot render over an existing text child at the root', () => { + it('cannot render over an existing text child at the root', async () => { const container = document.createElement('div'); container.textContent = 'potato'; - expect(() => ReactDOM.hydrate(
parsnip
, container)).toErrorDev( - 'Expected server HTML to contain a matching
in
.', + + expect(() => { + ReactDOM.flushSync(() => { + ReactDOMClient.hydrateRoot(container,
parsnip
, { + onRecoverableError: error => { + Scheduler.log('Log recoverable error: ' + error.message); + }, + }); + }); + }).toErrorDev( + [ + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', + 'Expected server HTML to contain a matching
in
.', + ], + {withoutStack: 1}, ); + + assertLog([ + 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.', + 'Log recoverable error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); + // This creates an unfortunate double text case. - expect(container.textContent).toBe('potatoparsnip'); + expect(container.textContent).toBe('parsnip'); }); - it('renders over an existing nested text child without throwing', () => { + it('renders over an existing nested text child without throwing', async () => { const container = document.createElement('div'); const wrapper = document.createElement('div'); wrapper.textContent = 'potato'; container.appendChild(wrapper); - expect(() => - ReactDOM.hydrate( -
-
parsnip
-
, - container, - ), - ).toErrorDev( - 'Expected server HTML to contain a matching
in
.', + expect(() => { + ReactDOM.flushSync(() => { + ReactDOMClient.hydrateRoot( + container, +
+
parsnip
+
, + { + onRecoverableError: error => { + Scheduler.log('Log recoverable error: ' + error.message); + }, + }, + ); + }); + }).toErrorDev( + [ + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in
.', + 'Expected server HTML to contain a matching
in
.', + ], + {withoutStack: 1}, ); + + assertLog([ + 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.', + 'Log recoverable error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); expect(container.textContent).toBe('parsnip'); }); - it('should give helpful errors on state desync', () => { + it('should give helpful errors on state desync', async () => { class Component extends React.Component { render() { return ( @@ -203,13 +263,45 @@ describe('rendering React components at document', () => { ); const testDocument = getTestDocument(markup); - expect(() => - ReactDOM.hydrate(, testDocument), - ).toErrorDev('Warning: Text content did not match.'); + const enableClientRenderFallbackOnTextMismatch = gate( + flags => flags.enableClientRenderFallbackOnTextMismatch, + ); + expect(() => { + ReactDOM.flushSync(() => { + ReactDOMClient.hydrateRoot( + testDocument, + , + { + onRecoverableError: error => { + Scheduler.log('Log recoverable error: ' + error.message); + }, + }, + ); + }); + }).toErrorDev( + enableClientRenderFallbackOnTextMismatch + ? [ + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', + 'Warning: Text content did not match.', + ] + : ['Warning: Text content did not match.'], + { + withoutStack: enableClientRenderFallbackOnTextMismatch ? 1 : 0, + }, + ); + + assertLog( + enableClientRenderFallbackOnTextMismatch + ? [ + 'Log recoverable error: Text content does not match server-rendered HTML.', + 'Log recoverable error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ] + : [], + ); expect(testDocument.body.innerHTML).toBe('Hello world'); }); - it('should render w/ no markup to full document', () => { + it('should render w/ no markup to full document', async () => { const testDocument = getTestDocument(); class Component extends React.Component { @@ -227,23 +319,59 @@ describe('rendering React components at document', () => { if (gate(flags => flags.enableFloat)) { // with float the title no longer is a hydration mismatch so we get an error on the body mismatch - expect(() => - ReactDOM.hydrate(, testDocument), - ).toErrorDev( - 'Expected server HTML to contain a matching text node for "Hello world" in ', + expect(() => { + ReactDOM.flushSync(() => { + ReactDOMClient.hydrateRoot( + testDocument, + , + { + onRecoverableError: error => { + Scheduler.log('Log recoverable error: ' + error.message); + }, + }, + ); + }); + }).toErrorDev( + [ + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', + 'Expected server HTML to contain a matching text node for "Hello world" in ', + ], + {withoutStack: 1}, ); + assertLog([ + 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.', + 'Log recoverable error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); } else { // getTestDocument() has an extra that we didn't render. - expect(() => - ReactDOM.hydrate(, testDocument), - ).toErrorDev( - 'Did not expect server HTML to contain a in .', + expect(() => { + ReactDOM.flushSync(() => { + ReactDOMClient.hydrateRoot( + testDocument, + , + { + onRecoverableError: error => { + Scheduler.log('Log recoverable error: ' + error.message); + }, + }, + ); + }); + }).toErrorDev( + [ + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', + 'Warning: Text content did not match. Server: "test doc" Client: "Hello World"', + ], + {withoutStack: 1}, ); + assertLog([ + 'Log recoverable error: Text content does not match server-rendered HTML.', + 'Log recoverable error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ]); } expect(testDocument.body.innerHTML).toBe('Hello world'); }); - it('supports findDOMNode on full-page components', () => { + it('supports findDOMNode on full-page components in legacy mode', () => { const tree = (