Skip to content

Commit

Permalink
Convert ReactRenderDocument to hydrateRoot (facebook#28153)
Browse files Browse the repository at this point in the history
Co-authored-by: Sebastian Silbermann <[email protected]>
  • Loading branch information
2 people authored and AndyPengc12 committed Apr 15, 2024
1 parent e9efab4 commit 415092e
Showing 1 changed file with 168 additions and 40 deletions.
208 changes: 168 additions & 40 deletions packages/react-dom/src/__tests__/ReactRenderDocument-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand All @@ -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 (
Expand All @@ -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 (
Expand All @@ -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 (
Expand Down Expand Up @@ -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 (
Expand All @@ -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 (
Expand All @@ -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 {
Expand All @@ -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>
Expand Down

0 comments on commit 415092e

Please sign in to comment.