Repository: facebook/react
Pull request: Convert ReactRenderDocument to hydrateRoot


Changed files:
filename: packages/react-dom/src/__tests__/ReactRenderDocument-test.js
patch: @@ -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(<Root hello="world" />, testDocument);
+      let root;
+      await act(() => {
+        root = ReactDOMClient.hydrateRoot(testDocument, <Root hello="world" />);
+      });
       expect(testDocument.body.innerHTML).toBe('Hello world');
 
-      ReactDOM.hydrate(<Root hello="moon" />, testDocument);
+      await act(() => {
+        root.render(<Root hello="moon" />);
+      });
       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,23 +89,26 @@ describe('rendering React components at document', () => {
 
       const markup = ReactDOMServer.renderToString(<Root />);
       const testDocument = getTestDocument(markup);
-      ReactDOM.hydrate(<Root />, testDocument);
+      let root;
+      await act(() => {
+        root = ReactDOMClient.hydrateRoot(testDocument, <Root />);
+      });
       expect(testDocument.body.innerHTML).toBe('Hello world');
 
       const originalDocEl = testDocument.documentElement;
       const originalHead = testDocument.head;
       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);
       expect(originalBody.firstChild).toEqual(null);
       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(<Component />);
       const testDocument = getTestDocument(markup);
 
-      ReactDOM.hydrate(<Component />, testDocument);
+      let root;
+      await act(() => {
+        root = ReactDOMClient.hydrateRoot(testDocument, <Component />);
+      });
 
       expect(testDocument.body.innerHTML).toBe('Hello world');
 
-      // This works but is probably a bad idea.
-      ReactDOM.hydrate(<Component2 />, testDocument);
+      await act(() => {
+        root.render(<Component2 />);
+      });
 
       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(<Component text="Hello world" />, testDocument);
+      await act(() => {
+        ReactDOMClient.hydrateRoot(
+          testDocument,
+          <Component text="Hello world" />,
+        );
+      });
 
       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(<div>parsnip</div>, container)).toErrorDev(
-        'Expected server HTML to contain a matching <div> in <div>.',
+
+      expect(() => {
+        ReactDOM.flushSync(() => {
+          ReactDOMClient.hydrateRoot(container, <div>parsnip</div>, {
+            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 <div>.',
+          'Expected server HTML to contain a matching <div> in <div>.',
+        ],
+        {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(
-          <div>
-            <div>parsnip</div>
-          </div>,
-          container,
-        ),
-      ).toErrorDev(
-        'Expected server HTML to contain a matching <div> in <div>.',
+      expect(() => {
+        ReactDOM.flushSync(() => {
+          ReactDOMClient.hydrateRoot(
+            container,
+            <div>
+              <div>parsnip</div>
+            </div>,
+            {
+              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 <div>.',
+          'Expected server HTML to contain a matching <div> in <div>.',
+        ],
+        {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(<Component text="Hello world" />, testDocument),
-      ).toErrorDev('Warning: Text content did not match.');
+      const enableClientRenderFallbackOnTextMismatch = gate(
+        flags => flags.enableClientRenderFallbackOnTextMismatch,
+      );
+      expect(() => {
+        ReactDOM.flushSync(() => {
+          ReactDOMClient.hydrateRoot(
+            testDocument,
+            <Component text="Hello world" />,
+            {
+              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(<Component text="Hello world" />, testDocument),
-        ).toErrorDev(
-          'Expected server HTML to contain a matching text node for "Hello world" in <body>',
+        expect(() => {
+          ReactDOM.flushSync(() => {
+            ReactDOMClient.hydrateRoot(
+              testDocument,
+              <Component text="Hello world" />,
+              {
+                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 <body>',
+          ],
+          {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 <meta> that we didn't render.
-        expect(() =>
-          ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
-        ).toErrorDev(
-          'Did not expect server HTML to contain a <meta> in <head>.',
+        expect(() => {
+          ReactDOM.flushSync(() => {
+            ReactDOMClient.hydrateRoot(
+              testDocument,
+              <Component text="Hello world" />,
+              {
+                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 = (
         <html>
           <head>