main
js 419 lines 12.1 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @emails react-core
8 */
9
10 'use strict';
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('');
22 doc.open();
23 doc.write(
24 markup ||
25 '<!doctype html><html><meta charset=utf-8><title>test doc</title>',
26 );
27 doc.close();
28 return doc;
29 }
30
31 function normalizeError(msg) {
32 // Take the first sentence to make it easier to assert on.
33 const idx = msg.indexOf('.');
34 if (idx > -1) {
35 return msg.slice(0, idx + 1);
36 }
37 return msg;
38 }
39
40 describe('rendering React components at document', () => {
41 beforeEach(() => {
42 jest.resetModules();
43
44 React = require('react');
45 ReactDOM = require('react-dom');
46 ReactDOMClient = require('react-dom/client');
47 ReactDOMServer = require('react-dom/server');
48 act = require('internal-test-utils').act;
49 assertLog = require('internal-test-utils').assertLog;
50 Scheduler = require('scheduler');
51 });
52
53 describe('with new explicit hydration API', () => {
54 it('should be able to adopt server markup', async () => {
55 class Root extends React.Component {
56 render() {
57 return (
58 <html>
59 <head>
60 <title>Hello World</title>
61 </head>
62 <body>{'Hello ' + this.props.hello}</body>
63 </html>
64 );
65 }
66 }
67
68 const markup = ReactDOMServer.renderToString(<Root hello="world" />);
69 expect(markup).not.toContain('DOCTYPE');
70 expect(markup).not.toContain('rel="expect"');
71 const testDocument = getTestDocument(markup);
72 const body = testDocument.body;
73
74 let root;
75 await act(() => {
76 root = ReactDOMClient.hydrateRoot(testDocument, <Root hello="world" />);
77 });
78 expect(testDocument.body.innerHTML).toBe('Hello world');
79
80 await act(() => {
81 root.render(<Root hello="moon" />);
82 });
83 expect(testDocument.body.innerHTML).toBe('Hello moon');
84
85 expect(body === testDocument.body).toBe(true);
86 });
87
88 it('should be able to unmount component from document node, but leaves singleton nodes intact', async () => {
89 class Root extends React.Component {
90 render() {
91 return (
92 <html>
93 <head>
94 <title>Hello World</title>
95 </head>
96 <body>Hello world</body>
97 </html>
98 );
99 }
100 }
101
102 const markup = ReactDOMServer.renderToString(<Root />);
103 const testDocument = getTestDocument(markup);
104 let root;
105 await act(() => {
106 root = ReactDOMClient.hydrateRoot(testDocument, <Root />);
107 });
108 expect(testDocument.body.innerHTML).toBe('Hello world');
109
110 const originalDocEl = testDocument.documentElement;
111 const originalHead = testDocument.head;
112 const originalBody = testDocument.body;
113
114 // When we unmount everything is removed except the singleton nodes of html, head, and body
115 root.unmount();
116 expect(testDocument.firstChild).toBe(originalDocEl);
117 expect(testDocument.head).toBe(originalHead);
118 expect(testDocument.body).toBe(originalBody);
119 expect(originalBody.innerHTML).toBe('');
120 expect(originalHead.innerHTML).toBe('');
121 });
122
123 it('should not be able to switch root constructors', async () => {
124 class Component extends React.Component {
125 render() {
126 return (
127 <html>
128 <head>
129 <title>Hello World</title>
130 </head>
131 <body>Hello world</body>
132 </html>
133 );
134 }
135 }
136
137 class Component2 extends React.Component {
138 render() {
139 return (
140 <html>
141 <head>
142 <title>Hello World</title>
143 </head>
144 <body>Goodbye world</body>
145 </html>
146 );
147 }
148 }
149
150 const markup = ReactDOMServer.renderToString(<Component />);
151 const testDocument = getTestDocument(markup);
152
153 let root;
154 await act(() => {
155 root = ReactDOMClient.hydrateRoot(testDocument, <Component />);
156 });
157
158 expect(testDocument.body.innerHTML).toBe('Hello world');
159
160 await act(() => {
161 root.render(<Component2 />);
162 });
163
164 expect(testDocument.body.innerHTML).toBe('Goodbye world');
165 });
166
167 it('should be able to mount into document', async () => {
168 class Component extends React.Component {
169 render() {
170 return (
171 <html>
172 <head>
173 <title>Hello World</title>
174 </head>
175 <body>{this.props.text}</body>
176 </html>
177 );
178 }
179 }
180
181 const markup = ReactDOMServer.renderToString(
182 <Component text="Hello world" />,
183 );
184 const testDocument = getTestDocument(markup);
185
186 await act(() => {
187 ReactDOMClient.hydrateRoot(
188 testDocument,
189 <Component text="Hello world" />,
190 );
191 });
192
193 expect(testDocument.body.innerHTML).toBe('Hello world');
194 });
195
196 it('cannot render over an existing text child at the root', async () => {
197 const container = document.createElement('div');
198 container.textContent = 'potato';
199
200 ReactDOM.flushSync(() => {
201 ReactDOMClient.hydrateRoot(container, <div>parsnip</div>, {
202 onRecoverableError: error => {
203 Scheduler.log(
204 'onRecoverableError: ' + normalizeError(error.message),
205 );
206 if (error.cause) {
207 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
208 }
209 },
210 });
211 });
212
213 assertLog([
214 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
215 ]);
216
217 // This creates an unfortunate double text case.
218 expect(container.textContent).toBe('parsnip');
219 });
220
221 it('renders over an existing nested text child without throwing', async () => {
222 const container = document.createElement('div');
223 const wrapper = document.createElement('div');
224 wrapper.textContent = 'potato';
225 container.appendChild(wrapper);
226 ReactDOM.flushSync(() => {
227 ReactDOMClient.hydrateRoot(
228 container,
229 <div>
230 <div>parsnip</div>
231 </div>,
232 {
233 onRecoverableError: error => {
234 Scheduler.log(
235 'onRecoverableError: ' + normalizeError(error.message),
236 );
237 if (error.cause) {
238 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
239 }
240 },
241 },
242 );
243 });
244
245 assertLog([
246 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
247 ]);
248 expect(container.textContent).toBe('parsnip');
249 });
250
251 it('removes hoisted <title> when hiding an Activity boundary', async () => {
252 const Activity = React.Activity;
253
254 function App({mode, titleText}) {
255 return (
256 <html>
257 <head>
258 <Activity mode={mode}>
259 <title>{titleText}</title>
260 </Activity>
261 </head>
262 <body>Hello</body>
263 </html>
264 );
265 }
266
267 const testDocument = getTestDocument(
268 '<!doctype html><html><head></head><body></body></html>',
269 );
270 const root = ReactDOMClient.createRoot(testDocument);
271
272 await act(() => root.render(<App mode="visible" titleText="A" />));
273 expect(testDocument.head.querySelector('title').textContent).toBe('A');
274
275 await act(() => root.render(<App mode="hidden" titleText="A" />));
276 expect(testDocument.head.querySelector('title')).toBe(null);
277
278 await act(() => root.render(<App mode="visible" titleText="B" />));
279 expect(testDocument.head.querySelector('title').textContent).toBe('B');
280 });
281
282 it('does not unmount a hoistable that was never mounted when reappearing', async () => {
283 const Activity = React.Activity;
284
285 function App({mode, showTitle}) {
286 return (
287 <html>
288 <head>
289 <Activity mode={mode}>
290 {showTitle ? <title>Title</title> : null}
291 </Activity>
292 </head>
293 <body>Hello</body>
294 </html>
295 );
296 }
297
298 const testDocument = getTestDocument(
299 '<!doctype html><html><head></head><body></body></html>',
300 );
301 const root = ReactDOMClient.createRoot(testDocument);
302
303 await act(() => root.render(<App mode="hidden" showTitle={true} />));
304 expect(testDocument.head.querySelector('title')).toBe(null);
305
306 await act(() => root.render(<App mode="visible" showTitle={false} />));
307 expect(testDocument.head.querySelector('title')).toBe(null);
308 });
309
310 it('removes hoistables deleted in the same commit that hides an Activity', async () => {
311 const Activity = React.Activity;
312
313 function App({mode, showTitle}) {
314 return (
315 <html>
316 <head>
317 <Activity mode={mode}>
318 {showTitle ? <title>Title</title> : null}
319 </Activity>
320 </head>
321 <body>Hello</body>
322 </html>
323 );
324 }
325
326 const testDocument = getTestDocument(
327 '<!doctype html><html><head></head><body></body></html>',
328 );
329 const root = ReactDOMClient.createRoot(testDocument);
330
331 await act(() => root.render(<App mode="visible" showTitle={true} />));
332 expect(testDocument.head.querySelector('title')).not.toBe(null);
333
334 await act(() => root.render(<App mode="hidden" showTitle={false} />));
335 expect(testDocument.head.querySelector('title')).toBe(null);
336 });
337
338 it('should give helpful errors on state desync', async () => {
339 class Component extends React.Component {
340 render() {
341 return (
342 <html>
343 <head>
344 <title>Hello World</title>
345 </head>
346 <body>{this.props.text}</body>
347 </html>
348 );
349 }
350 }
351
352 const markup = ReactDOMServer.renderToString(
353 <Component text="Goodbye world" />,
354 );
355 const testDocument = getTestDocument(markup);
356
357 ReactDOM.flushSync(() => {
358 ReactDOMClient.hydrateRoot(
359 testDocument,
360 <Component text="Hello world" />,
361 {
362 onRecoverableError: error => {
363 Scheduler.log(
364 'onRecoverableError: ' + normalizeError(error.message),
365 );
366 if (error.cause) {
367 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
368 }
369 },
370 },
371 );
372 });
373
374 assertLog([
375 "onRecoverableError: Hydration failed because the server rendered text didn't match the client.",
376 ]);
377 expect(testDocument.body.innerHTML).toBe('Hello world');
378 });
379
380 it('should render w/ no markup to full document', async () => {
381 const testDocument = getTestDocument();
382
383 class Component extends React.Component {
384 render() {
385 return (
386 <html>
387 <head>
388 <title>Hello World</title>
389 </head>
390 <body>{this.props.text}</body>
391 </html>
392 );
393 }
394 }
395
396 // with float the title no longer is a hydration mismatch so we get an error on the body mismatch
397 ReactDOM.flushSync(() => {
398 ReactDOMClient.hydrateRoot(
399 testDocument,
400 <Component text="Hello world" />,
401 {
402 onRecoverableError: error => {
403 Scheduler.log(
404 'onRecoverableError: ' + normalizeError(error.message),
405 );
406 if (error.cause) {
407 Scheduler.log('Cause: ' + normalizeError(error.cause.message));
408 }
409 },
410 },
411 );
412 });
413 assertLog([
414 "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.",
415 ]);
416 expect(testDocument.body.innerHTML).toBe('Hello world');
417 });
418 });
419 });