Convert ReactRenderDocument to hydrateRoot (#28153)
Co-authored-by: Sebastian Silbermann <sebastian.silbermann@klarna.com>
Ricky committed
Feb 7, 2024 at 09:54 UTC
2cd19ed1dd86889bc8038fb8b541699e3965ea5c
1 file changed
+168
-40
packages/react-dom/src/__tests__/ReactRenderDocument-test.js
+168
-40
@@ -11,7 +11,11 @@
11
12
let React;
13
let ReactDOM;
14
+let ReactDOMClient;
15
let ReactDOMServer;
16
+let act;
17
+let Scheduler;
18
+let assertLog;
19
20
function getTestDocument(markup) {
21
const doc = document.implementation.createHTMLDocument('');
@@ -28,11 +32,15 @@ describe('rendering React components at document', () => {
32
beforeEach(() => {
33
React = require('react');
34
ReactDOM = require('react-dom');
35
+ ReactDOMClient = require('react-dom/client');
36
ReactDOMServer = require('react-dom/server');
37
+ act = require('internal-test-utils').act;
38
+ assertLog = require('internal-test-utils').assertLog;
39
+ Scheduler = require('scheduler');
40
});
41
42
describe('with new explicit hydration API', () => {
35
- it('should be able to adopt server markup', () => {
43
+ it('should be able to adopt server markup', async () => {
44
class Root extends React.Component {
45
render() {
46
return (
@@ -51,16 +59,21 @@ describe('rendering React components at document', () => {
59
const testDocument = getTestDocument(markup);
60
const body = testDocument.body;
61
54
- ReactDOM.hydrate(<Root hello="world" />, testDocument);
62
+ let root;
63
+ await act(() => {
64
+ root = ReactDOMClient.hydrateRoot(testDocument, <Root hello="world" />);
65
+ });
66
expect(testDocument.body.innerHTML).toBe('Hello world');
67
57
- ReactDOM.hydrate(<Root hello="moon" />, testDocument);
68
+ await act(() => {
69
+ root.render(<Root hello="moon" />);
70
+ });
71
expect(testDocument.body.innerHTML).toBe('Hello moon');
72
73
expect(body === testDocument.body).toBe(true);
74
});
75
63
- it('should be able to unmount component from document node, but leaves singleton nodes intact', () => {
76
+ it('should be able to unmount component from document node, but leaves singleton nodes intact', async () => {
77
class Root extends React.Component {
78
render() {
79
return (
@@ -76,7 +89,10 @@ describe('rendering React components at document', () => {
89
90
const markup = ReactDOMServer.renderToString(<Root />);
91
const testDocument = getTestDocument(markup);
79
- ReactDOM.hydrate(<Root />, testDocument);
92
+ let root;
93
+ await act(() => {
94
+ root = ReactDOMClient.hydrateRoot(testDocument, <Root />);
95
+ });
96
expect(testDocument.body.innerHTML).toBe('Hello world');
97
98
const originalDocEl = testDocument.documentElement;
@@ -84,7 +100,7 @@ describe('rendering React components at document', () => {
100
const originalBody = testDocument.body;
101
102
// When we unmount everything is removed except the singleton nodes of html, head, and body
87
- ReactDOM.unmountComponentAtNode(testDocument);
103
+ root.unmount();
104
expect(testDocument.firstChild).toBe(originalDocEl);
105
expect(testDocument.head).toBe(originalHead);
106
expect(testDocument.body).toBe(originalBody);
@@ -92,7 +108,7 @@ describe('rendering React components at document', () => {
108
expect(originalHead.firstChild).toEqual(null);
109
});
110
95
- it('should not be able to switch root constructors', () => {
111
+ it('should not be able to switch root constructors', async () => {
112
class Component extends React.Component {
113
render() {
114
return (
@@ -122,17 +138,21 @@ describe('rendering React components at document', () => {
138
const markup = ReactDOMServer.renderToString(<Component />);
139
const testDocument = getTestDocument(markup);
140
125
- ReactDOM.hydrate(<Component />, testDocument);
141
+ let root;
142
+ await act(() => {
143
+ root = ReactDOMClient.hydrateRoot(testDocument, <Component />);
144
+ });
145
146
expect(testDocument.body.innerHTML).toBe('Hello world');
147
129
- // This works but is probably a bad idea.
130
- ReactDOM.hydrate(<Component2 />, testDocument);
148
+ await act(() => {
149
+ root.render(<Component2 />);
150
+ });
151
152
expect(testDocument.body.innerHTML).toBe('Goodbye world');
153
});
154
135
- it('should be able to mount into document', () => {
155
+ it('should be able to mount into document', async () => {
156
class Component extends React.Component {
157
render() {
158
return (
@@ -151,40 +171,80 @@ describe('rendering React components at document', () => {
171
);
172
const testDocument = getTestDocument(markup);
173
154
- ReactDOM.hydrate(<Component text="Hello world" />, testDocument);
174
+ await act(() => {
175
+ ReactDOMClient.hydrateRoot(
176
+ testDocument,
177
+ <Component text="Hello world" />,
178
+ );
179
+ });
180
181
expect(testDocument.body.innerHTML).toBe('Hello world');
182
});
183
159
- it('cannot render over an existing text child at the root', () => {
184
+ it('cannot render over an existing text child at the root', async () => {
185
const container = document.createElement('div');
186
container.textContent = 'potato';
162
- expect(() => ReactDOM.hydrate(<div>parsnip</div>, container)).toErrorDev(
163
- 'Expected server HTML to contain a matching <div> in <div>.',
187
+
188
+ expect(() => {
189
+ ReactDOM.flushSync(() => {
190
+ ReactDOMClient.hydrateRoot(container, <div>parsnip</div>, {
191
+ onRecoverableError: error => {
192
+ Scheduler.log('Log recoverable error: ' + error.message);
193
+ },
194
+ });
195
+ });
196
+ }).toErrorDev(
197
+ [
198
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.',
199
+ 'Expected server HTML to contain a matching <div> in <div>.',
200
+ ],
201
+ {withoutStack: 1},
202
);
203
+
204
+ assertLog([
205
+ 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
206
+ '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.',
207
+ ]);
208
+
209
// This creates an unfortunate double text case.
166
- expect(container.textContent).toBe('potatoparsnip');
210
+ expect(container.textContent).toBe('parsnip');
211
});
212
169
- it('renders over an existing nested text child without throwing', () => {
213
+ it('renders over an existing nested text child without throwing', async () => {
214
const container = document.createElement('div');
215
const wrapper = document.createElement('div');
216
wrapper.textContent = 'potato';
217
container.appendChild(wrapper);
174
- expect(() =>
175
- ReactDOM.hydrate(
176
- <div>
177
- <div>parsnip</div>
178
- </div>,
179
- container,
180
- ),
181
- ).toErrorDev(
182
- 'Expected server HTML to contain a matching <div> in <div>.',
218
+ expect(() => {
219
+ ReactDOM.flushSync(() => {
220
+ ReactDOMClient.hydrateRoot(
221
+ container,
222
+ <div>
223
+ <div>parsnip</div>
224
+ </div>,
225
+ {
226
+ onRecoverableError: error => {
227
+ Scheduler.log('Log recoverable error: ' + error.message);
228
+ },
229
+ },
230
+ );
231
+ });
232
+ }).toErrorDev(
233
+ [
234
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <div>.',
235
+ 'Expected server HTML to contain a matching <div> in <div>.',
236
+ ],
237
+ {withoutStack: 1},
238
);
239
+
240
+ assertLog([
241
+ 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
242
+ '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.',
243
+ ]);
244
expect(container.textContent).toBe('parsnip');
245
});
246
187
- it('should give helpful errors on state desync', () => {
247
+ it('should give helpful errors on state desync', async () => {
248
class Component extends React.Component {
249
render() {
250
return (
@@ -203,13 +263,45 @@ describe('rendering React components at document', () => {
263
);
264
const testDocument = getTestDocument(markup);
265
206
- expect(() =>
207
- ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
208
- ).toErrorDev('Warning: Text content did not match.');
266
+ const enableClientRenderFallbackOnTextMismatch = gate(
267
+ flags => flags.enableClientRenderFallbackOnTextMismatch,
268
+ );
269
+ expect(() => {
270
+ ReactDOM.flushSync(() => {
271
+ ReactDOMClient.hydrateRoot(
272
+ testDocument,
273
+ <Component text="Hello world" />,
274
+ {
275
+ onRecoverableError: error => {
276
+ Scheduler.log('Log recoverable error: ' + error.message);
277
+ },
278
+ },
279
+ );
280
+ });
281
+ }).toErrorDev(
282
+ enableClientRenderFallbackOnTextMismatch
283
+ ? [
284
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
285
+ 'Warning: Text content did not match.',
286
+ ]
287
+ : ['Warning: Text content did not match.'],
288
+ {
289
+ withoutStack: enableClientRenderFallbackOnTextMismatch ? 1 : 0,
290
+ },
291
+ );
292
+
293
+ assertLog(
294
+ enableClientRenderFallbackOnTextMismatch
295
+ ? [
296
+ 'Log recoverable error: Text content does not match server-rendered HTML.',
297
+ '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.',
298
+ ]
299
+ : [],
300
+ );
301
expect(testDocument.body.innerHTML).toBe('Hello world');
302
});
303
212
- it('should render w/ no markup to full document', () => {
304
+ it('should render w/ no markup to full document', async () => {
305
const testDocument = getTestDocument();
306
307
class Component extends React.Component {
@@ -227,23 +319,59 @@ describe('rendering React components at document', () => {
319
320
if (gate(flags => flags.enableFloat)) {
321
// with float the title no longer is a hydration mismatch so we get an error on the body mismatch
230
- expect(() =>
231
- ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
232
- ).toErrorDev(
233
- 'Expected server HTML to contain a matching text node for "Hello world" in <body>',
322
+ expect(() => {
323
+ ReactDOM.flushSync(() => {
324
+ ReactDOMClient.hydrateRoot(
325
+ testDocument,
326
+ <Component text="Hello world" />,
327
+ {
328
+ onRecoverableError: error => {
329
+ Scheduler.log('Log recoverable error: ' + error.message);
330
+ },
331
+ },
332
+ );
333
+ });
334
+ }).toErrorDev(
335
+ [
336
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
337
+ 'Expected server HTML to contain a matching text node for "Hello world" in <body>',
338
+ ],
339
+ {withoutStack: 1},
340
);
341
+ assertLog([
342
+ 'Log recoverable error: Hydration failed because the initial UI does not match what was rendered on the server.',
343
+ '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.',
344
+ ]);
345
} else {
346
// getTestDocument() has an extra <meta> that we didn't render.
237
- expect(() =>
238
- ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
239
- ).toErrorDev(
240
- 'Did not expect server HTML to contain a <meta> in <head>.',
347
+ expect(() => {
348
+ ReactDOM.flushSync(() => {
349
+ ReactDOMClient.hydrateRoot(
350
+ testDocument,
351
+ <Component text="Hello world" />,
352
+ {
353
+ onRecoverableError: error => {
354
+ Scheduler.log('Log recoverable error: ' + error.message);
355
+ },
356
+ },
357
+ );
358
+ });
359
+ }).toErrorDev(
360
+ [
361
+ 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
362
+ 'Warning: Text content did not match. Server: "test doc" Client: "Hello World"',
363
+ ],
364
+ {withoutStack: 1},
365
);
366
+ assertLog([
367
+ 'Log recoverable error: Text content does not match server-rendered HTML.',
368
+ '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.',
369
+ ]);
370
}
371
expect(testDocument.body.innerHTML).toBe('Hello world');
372
});
373
246
- it('supports findDOMNode on full-page components', () => {
374
+ it('supports findDOMNode on full-page components in legacy mode', () => {
375
const tree = (
376
<html>
377
<head>