main
js 1,354 lines 36.7 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 * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9 */
10
11 'use strict';
12
13 let JSDOM;
14 let Stream;
15 let React;
16 let ReactDOM;
17 let ReactDOMClient;
18 let ReactDOMFizzServer;
19 let document;
20 let writable;
21 let container;
22 let buffer = '';
23 let hasErrored = false;
24 let fatalError = undefined;
25 let waitForAll;
26 let assertConsoleErrorDev;
27
28 function normalizeError(msg) {
29 // Take the first sentence to make it easier to assert on.
30 const idx = msg.indexOf('.');
31 if (idx > -1) {
32 return msg.slice(0, idx + 1);
33 }
34 return msg;
35 }
36
37 describe('ReactDOM HostSingleton', () => {
38 beforeEach(() => {
39 jest.resetModules();
40 JSDOM = require('jsdom').JSDOM;
41 React = require('react');
42 ReactDOM = require('react-dom');
43 ReactDOMClient = require('react-dom/client');
44 ReactDOMFizzServer = require('react-dom/server');
45 Stream = require('stream');
46
47 const InternalTestUtils = require('internal-test-utils');
48 waitForAll = InternalTestUtils.waitForAll;
49 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
50
51 // Test Environment
52 const jsdom = new JSDOM(
53 '<!DOCTYPE html><html><head></head><body><div id="container">',
54 {
55 runScripts: 'dangerously',
56 },
57 );
58 document = jsdom.window.document;
59 container = document.getElementById('container');
60
61 buffer = '';
62 hasErrored = false;
63
64 writable = new Stream.PassThrough();
65 writable.setEncoding('utf8');
66 writable.on('data', chunk => {
67 buffer += chunk;
68 });
69 writable.on('error', error => {
70 hasErrored = true;
71 fatalError = error;
72 });
73 });
74
75 async function actIntoEmptyDocument(callback) {
76 await callback();
77 // Await one turn around the event loop.
78 // This assumes that we'll flush everything we have so far.
79 await new Promise(resolve => {
80 setImmediate(resolve);
81 });
82 if (hasErrored) {
83 throw fatalError;
84 }
85
86 const bufferedContent = buffer;
87 buffer = '';
88
89 const jsdom = new JSDOM(bufferedContent, {
90 runScripts: 'dangerously',
91 });
92 document = jsdom.window.document;
93 container = document;
94 }
95
96 function getVisibleChildren(element) {
97 const children = [];
98 let node = element.firstChild;
99 while (node) {
100 if (node.nodeType === 1) {
101 const el: Element = (node: any);
102 if (
103 (el.tagName !== 'SCRIPT' &&
104 el.tagName !== 'TEMPLATE' &&
105 el.tagName !== 'template' &&
106 !el.hasAttribute('hidden') &&
107 !el.hasAttribute('aria-hidden') &&
108 // Ignore the render blocking expect
109 (node.getAttribute('rel') !== 'expect' ||
110 node.getAttribute('blocking') !== 'render')) ||
111 el.hasAttribute('data-meaningful')
112 ) {
113 const props = {};
114 const attributes = node.attributes;
115 for (let i = 0; i < attributes.length; i++) {
116 if (
117 attributes[i].name === 'id' &&
118 attributes[i].value.includes(':')
119 ) {
120 // We assume this is a React added ID that's a non-visual implementation detail.
121 continue;
122 }
123 props[attributes[i].name] = attributes[i].value;
124 }
125 props.children = getVisibleChildren(node);
126 children.push(React.createElement(node.tagName.toLowerCase(), props));
127 }
128 } else if (node.nodeType === 3) {
129 children.push(node.data);
130 }
131 node = node.nextSibling;
132 }
133 return children.length === 0
134 ? undefined
135 : children.length === 1
136 ? children[0]
137 : children;
138 }
139
140 it('warns if you render the same singleton twice at the same time', async () => {
141 const root = ReactDOMClient.createRoot(document);
142 root.render(
143 <html>
144 <head lang="en">
145 <title>Hello</title>
146 </head>
147 <body />
148 </html>,
149 );
150 await waitForAll([]);
151 expect(getVisibleChildren(document)).toEqual(
152 <html>
153 <head lang="en">
154 <title>Hello</title>
155 </head>
156 <body />
157 </html>,
158 );
159 root.render(
160 <html>
161 <head lang="en">
162 <title>Hello</title>
163 </head>
164 <head lang="es" data-foo="foo">
165 <title>Hola</title>
166 </head>
167 <body />
168 </html>,
169 );
170 await waitForAll([]);
171 assertConsoleErrorDev([
172 'You are mounting a new head component when a previous one has not first unmounted. ' +
173 'It is an error to render more than one head component at a time and attributes and ' +
174 'children of these components will likely fail in unpredictable ways. ' +
175 'Please only render a single instance of <head> and if you need to mount a new one, ' +
176 'ensure any previous ones have unmounted first.\n' +
177 ' in head (at **)',
178 ]);
179 expect(getVisibleChildren(document)).toEqual(
180 <html>
181 <head lang="es" data-foo="foo">
182 <title>Hola</title>
183 <title>Hello</title>
184 </head>
185 <body />
186 </html>,
187 );
188
189 root.render(
190 <html>
191 {null}
192 {null}
193 <head lang="fr">
194 <title>Bonjour</title>
195 </head>
196 <body />
197 </html>,
198 );
199 await waitForAll([]);
200 expect(getVisibleChildren(document)).toEqual(
201 <html>
202 <head lang="fr">
203 <title>Bonjour</title>
204 </head>
205 <body />
206 </html>,
207 );
208
209 root.render(
210 <html>
211 <head lang="en">
212 <title>Hello</title>
213 </head>
214 <body />
215 </html>,
216 );
217 await waitForAll([]);
218 expect(getVisibleChildren(document)).toEqual(
219 <html>
220 <head lang="en">
221 <title>Hello</title>
222 </head>
223 <body />
224 </html>,
225 );
226 });
227
228 it('resets property-backed state when a singleton is released', async () => {
229 const root = ReactDOMClient.createRoot(document);
230 const head = document.head;
231 const body = document.body;
232
233 root.render(
234 <html>
235 <head onClick={() => {}} />
236 <body
237 data-react-owned="true"
238 onClick={() => {}}
239 style={{color: 'red'}}
240 dangerouslySetInnerHTML={{__html: '<div>managed content</div>'}}
241 />
242 </html>,
243 );
244 await waitForAll([]);
245
246 expect(document.body).toBe(body);
247 expect(head.onclick).not.toBe(null);
248 expect(body.onclick).not.toBe(null);
249 expect(body.textContent).toBe('managed content');
250 expect(body.getAttribute('data-react-owned')).toBe('true');
251 expect(body.style.color).toBe('red');
252
253 // Simulate an inline script or third-party code adding its own attribute,
254 // style, and click listener while React owns the singleton.
255 const externalClickHandler = jest.fn();
256 body.setAttribute('data-external', 'true');
257 body.style.backgroundColor = 'blue';
258 body.onclick = externalClickHandler;
259
260 root.render(<html />);
261 await waitForAll([]);
262
263 expect(document.head).toBe(head);
264 expect(document.body).toBe(body);
265 expect(head.onclick).toBe(null);
266 expect(body.onclick).toBe(externalClickHandler);
267 expect(body.textContent).toBe('');
268 expect(body.hasAttribute('data-react-owned')).toBe(false);
269 expect(body.getAttribute('data-external')).toBe('true');
270 expect(body.style.color).toBe('');
271 expect(body.style.backgroundColor).toBe('blue');
272 });
273
274 // @gate TODO
275 it('clears dangerouslySetInnerHTML when it becomes undefined', async () => {
276 const root = ReactDOMClient.createRoot(document);
277 const body = document.body;
278 const undefinedHTML = undefined;
279
280 root.render(
281 <html>
282 <head />
283 <body
284 dangerouslySetInnerHTML={{__html: '<div>managed content</div>'}}
285 />
286 </html>,
287 );
288 await waitForAll([]);
289 expect(body.textContent).toBe('managed content');
290
291 root.render(
292 <html>
293 <head />
294 <body dangerouslySetInnerHTML={undefinedHTML} />
295 </html>,
296 );
297 await waitForAll([]);
298
299 expect(body.textContent).toBe('');
300 });
301
302 // @gate TODO
303 it('clears dangerouslySetInnerHTML when __html becomes undefined', async () => {
304 const root = ReactDOMClient.createRoot(document);
305 const body = document.body;
306
307 root.render(
308 <html>
309 <head />
310 <body
311 dangerouslySetInnerHTML={{__html: '<div>managed content</div>'}}
312 />
313 </html>,
314 );
315 await waitForAll([]);
316 expect(body.textContent).toBe('managed content');
317
318 root.render(
319 <html>
320 <head />
321 <body dangerouslySetInnerHTML={{__html: undefined}} />
322 </html>,
323 );
324 await waitForAll([]);
325
326 expect(body.textContent).toBe('');
327 });
328
329 it('updates dangerouslySetInnerHTML on a singleton', async () => {
330 const root = ReactDOMClient.createRoot(document);
331 const body = document.body;
332
333 root.render(
334 <html>
335 <head />
336 <body dangerouslySetInnerHTML={{__html: '<div>first</div>'}} />
337 </html>,
338 );
339 await waitForAll([]);
340 expect(body.innerHTML).toBe('<div>first</div>');
341
342 root.render(
343 <html>
344 <head />
345 <body dangerouslySetInnerHTML={{__html: '<span>second</span>'}} />
346 </html>,
347 );
348 await waitForAll([]);
349
350 expect(body.innerHTML).toBe('<span>second</span>');
351 });
352
353 it('replaces singleton children with dangerouslySetInnerHTML', async () => {
354 const root = ReactDOMClient.createRoot(document);
355 const body = document.body;
356
357 root.render(
358 <html>
359 <head />
360 <body>
361 <div>managed child</div>
362 </body>
363 </html>,
364 );
365 await waitForAll([]);
366 expect(body.innerHTML).toBe('<div>managed child</div>');
367
368 root.render(
369 <html>
370 <head />
371 <body dangerouslySetInnerHTML={{__html: '<span>managed HTML</span>'}} />
372 </html>,
373 );
374 await waitForAll([]);
375
376 expect(body.innerHTML).toBe('<span>managed HTML</span>');
377 });
378
379 // @gate TODO
380 it('replaces dangerouslySetInnerHTML with singleton children', async () => {
381 const root = ReactDOMClient.createRoot(document);
382 const body = document.body;
383
384 root.render(
385 <html>
386 <head />
387 <body
388 dangerouslySetInnerHTML={{__html: '<div>managed content</div>'}}
389 />
390 </html>,
391 );
392 await waitForAll([]);
393 expect(body.innerHTML).toBe('<div>managed content</div>');
394
395 root.render(
396 <html>
397 <head />
398 <body>
399 <span>managed child</span>
400 </body>
401 </html>,
402 );
403 await waitForAll([]);
404
405 expect(body.innerHTML).toBe('<span>managed child</span>');
406 });
407
408 // @gate TODO
409 it('preserves imperative attributes when acquiring a singleton', async () => {
410 const body = document.body;
411 body.setAttribute('data-external', 'true');
412
413 const root = ReactDOMClient.createRoot(document);
414 root.render(
415 <html>
416 <head />
417 <body />
418 </html>,
419 );
420 await waitForAll([]);
421
422 expect(document.body).toBe(body);
423 expect(body.getAttribute('data-external')).toBe('true');
424 });
425
426 // @gate TODO
427 it('preserves imperative attributes when clearing a preamble contribution', async () => {
428 const body = document.body;
429 body.setAttribute('data-react-owned', 'true');
430 body.setAttribute('data-external', 'true');
431 // This is the shape Fizz emits when a completed Suspense boundary
432 // contributes props to the body singleton.
433 body.innerHTML = '<!--$--><!--body--><div>server</div><!--/$-->';
434
435 ReactDOMClient.hydrateRoot(
436 document,
437 <html>
438 <head />
439 <body data-react-owned="true" suppressHydrationWarning={true}>
440 <React.Suspense fallback={null}>
441 <span>client</span>
442 </React.Suspense>
443 </body>
444 </html>,
445 {
446 onRecoverableError() {},
447 },
448 );
449 await waitForAll([]);
450
451 expect(body.textContent).toBe('client');
452 expect(body.getAttribute('data-external')).toBe('true');
453 });
454
455 it('does not duplicate native listeners when a singleton is reacquired', async () => {
456 const root = ReactDOMClient.createRoot(document);
457 const body = document.body;
458 const onScroll = jest.fn();
459
460 root.render(
461 <html>
462 <head />
463 <body onScroll={onScroll} />
464 </html>,
465 );
466 await waitForAll([]);
467
468 body.dispatchEvent(new document.defaultView.Event('scroll'));
469 expect(onScroll).toHaveBeenCalledTimes(1);
470
471 root.render(
472 <html>
473 <head />
474 </html>,
475 );
476 await waitForAll([]);
477
478 body.dispatchEvent(new document.defaultView.Event('scroll'));
479 expect(onScroll).toHaveBeenCalledTimes(1);
480
481 root.render(
482 <html>
483 <head />
484 <body onScroll={onScroll} />
485 </html>,
486 );
487 await waitForAll([]);
488
489 expect(document.body).toBe(body);
490 body.dispatchEvent(new document.defaultView.Event('scroll'));
491 expect(onScroll).toHaveBeenCalledTimes(2);
492 });
493
494 // @gate __DEV__
495 it('does not release or reacquire singletons when double invoking effects during hydration', async () => {
496 const effectLog = [];
497 const html = '<span id="managed">managed content</span>';
498
499 function Effect() {
500 React.useLayoutEffect(() => {
501 effectLog.push('mount');
502 return () => {
503 effectLog.push('unmount');
504 };
505 }, []);
506 return <meta name="strict-effect" />;
507 }
508
509 await actIntoEmptyDocument(() => {
510 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
511 <html>
512 <head>
513 <meta name="strict-effect" />
514 </head>
515 <body dangerouslySetInnerHTML={{__html: html}} />
516 </html>,
517 );
518 pipe(writable);
519 });
520 const serverBodyHTML = document.body.innerHTML;
521 const managedElement = document.getElementById('managed');
522
523 ReactDOMClient.hydrateRoot(
524 document,
525 <React.StrictMode>
526 <html>
527 <head>
528 <Effect />
529 </head>
530 <body dangerouslySetInnerHTML={{__html: serverBodyHTML}} />
531 </html>
532 </React.StrictMode>,
533 );
534 await waitForAll([]);
535
536 // The Strict Mode effects are still double invoked.
537 expect(effectLog).toEqual(['mount', 'unmount', 'mount']);
538 // Hydrating a matching tree should preserve the server-rendered nodes.
539 expect(document.getElementById('managed')).toBe(managedElement);
540 });
541
542 it('renders into html, head, and body persistently so the node identities never change and extraneous styles are retained', async () => {
543 // Server render some html that will get replaced with a client render
544 await actIntoEmptyDocument(() => {
545 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
546 <html data-foo="foo">
547 <head data-bar="bar">
548 <link rel="stylesheet" href="resource" />
549 <title>a server title</title>
550 <link rel="stylesheet" href="3rdparty" />
551 <link rel="stylesheet" href="3rdparty2" />
552 </head>
553 <body data-baz="baz">
554 <div>hello world</div>
555 <style>
556 {`
557 body: {
558 background-color: red;
559 }`}
560 </style>
561 <div>goodbye</div>
562 </body>
563 </html>,
564 );
565 pipe(writable);
566 });
567 expect(getVisibleChildren(document)).toEqual(
568 <html data-foo="foo">
569 <head data-bar="bar">
570 <title>a server title</title>
571 <link rel="stylesheet" href="resource" />
572 <link rel="stylesheet" href="3rdparty" />
573 <link rel="stylesheet" href="3rdparty2" />
574 </head>
575 <body data-baz="baz">
576 <div>hello world</div>
577 <style>
578 {`
579 body: {
580 background-color: red;
581 }`}
582 </style>
583 <div>goodbye</div>
584 </body>
585 </html>,
586 );
587 const {documentElement, head, body} = document;
588 const persistentElements = [documentElement, head, body];
589
590 // Render into the document completely different html. Observe that styles
591 // are retained as are html, body, and head referential identities. Because this was
592 // server rendered and we are not hydrating we lose the semantic placement of the original
593 // head contents and everything gets preprended. In a future update we might emit an insertion
594 // edge from the server and make client rendering reslilient to interstitial placement
595 const root = ReactDOMClient.createRoot(document);
596 root.render(
597 <html data-client-foo="foo">
598 <head>
599 <title>a client title</title>
600 </head>
601 <body data-client-baz="baz">
602 <div>hello client</div>
603 </body>
604 </html>,
605 );
606 await waitForAll([]);
607 expect(persistentElements).toEqual([
608 document.documentElement,
609 document.head,
610 document.body,
611 ]);
612 // Similar to Hydration we don't reset attributes on the instance itself even on a fresh render.
613 expect(getVisibleChildren(document)).toEqual(
614 <html data-client-foo="foo">
615 <head>
616 <link rel="stylesheet" href="resource" />
617 <link rel="stylesheet" href="3rdparty" />
618 <link rel="stylesheet" href="3rdparty2" />
619 <title>a client title</title>
620 </head>
621 <body data-client-baz="baz">
622 <style>
623 {`
624 body: {
625 background-color: red;
626 }`}
627 </style>
628 <div>hello client</div>
629 </body>
630 </html>,
631 );
632
633 // Render new children and assert they append in the correct locations
634 root.render(
635 <html data-client-foo="foo">
636 <head>
637 <title>a client title</title>
638 <meta />
639 </head>
640 <body data-client-baz="baz">
641 <p>hello client again</p>
642 <div>hello client</div>
643 </body>
644 </html>,
645 );
646 await waitForAll([]);
647 expect(persistentElements).toEqual([
648 document.documentElement,
649 document.head,
650 document.body,
651 ]);
652 expect(getVisibleChildren(document)).toEqual(
653 <html data-client-foo="foo">
654 <head>
655 <link rel="stylesheet" href="resource" />
656 <link rel="stylesheet" href="3rdparty" />
657 <link rel="stylesheet" href="3rdparty2" />
658 <title>a client title</title>
659 <meta />
660 </head>
661 <body data-client-baz="baz">
662 <style>
663 {`
664 body: {
665 background-color: red;
666 }`}
667 </style>
668 <p>hello client again</p>
669 <div>hello client</div>
670 </body>
671 </html>,
672 );
673
674 // Remove some children
675 root.render(
676 <html data-client-foo="foo">
677 <head>
678 <title>a client title</title>
679 </head>
680 <body data-client-baz="baz">
681 <p>hello client again</p>
682 </body>
683 </html>,
684 );
685 await waitForAll([]);
686 expect(persistentElements).toEqual([
687 document.documentElement,
688 document.head,
689 document.body,
690 ]);
691 expect(getVisibleChildren(document)).toEqual(
692 <html data-client-foo="foo">
693 <head>
694 <link rel="stylesheet" href="resource" />
695 <link rel="stylesheet" href="3rdparty" />
696 <link rel="stylesheet" href="3rdparty2" />
697 <title>a client title</title>
698 </head>
699 <body data-client-baz="baz">
700 <style>
701 {`
702 body: {
703 background-color: red;
704 }`}
705 </style>
706 <p>hello client again</p>
707 </body>
708 </html>,
709 );
710
711 // Remove a persistent component
712 // @TODO figure out whether to clean up attributes. restoring them is likely
713 // not possible.
714 root.render(
715 <html data-client-foo="foo">
716 <head>
717 <title>a client title</title>
718 </head>
719 </html>,
720 );
721 await waitForAll([]);
722 expect(persistentElements).toEqual([
723 document.documentElement,
724 document.head,
725 document.body,
726 ]);
727 expect(getVisibleChildren(document)).toEqual(
728 <html data-client-foo="foo">
729 <head>
730 <link rel="stylesheet" href="resource" />
731 <link rel="stylesheet" href="3rdparty" />
732 <link rel="stylesheet" href="3rdparty2" />
733 <title>a client title</title>
734 </head>
735 <body>
736 <style>
737 {`
738 body: {
739 background-color: red;
740 }`}
741 </style>
742 </body>
743 </html>,
744 );
745
746 // unmount the root
747 root.unmount();
748 await waitForAll([]);
749 expect(persistentElements).toEqual([
750 document.documentElement,
751 document.head,
752 document.body,
753 ]);
754 expect(getVisibleChildren(document)).toEqual(
755 <html>
756 <head>
757 <link rel="stylesheet" href="resource" />
758 <link rel="stylesheet" href="3rdparty" />
759 <link rel="stylesheet" href="3rdparty2" />
760 </head>
761 <body>
762 <style>
763 {`
764 body: {
765 background-color: red;
766 }`}
767 </style>
768 </body>
769 </html>,
770 );
771
772 // Now let's hydrate the document with known mismatching content
773 // We assert that the identities of html, head, and body still haven't changed
774 // and that the embedded styles are still retained
775 const hydrationErrors = [];
776 let hydrateRoot = ReactDOMClient.hydrateRoot(
777 document,
778 <html data-client-foo="foo">
779 <head>
780 <title>a client title</title>
781 </head>
782 <body data-client-baz="baz">
783 <div>hello client</div>
784 </body>
785 </html>,
786 {
787 onRecoverableError(error, errorInfo) {
788 hydrationErrors.push([
789 normalizeError(error.message),
790 errorInfo.componentStack
791 ? errorInfo.componentStack.split('\n')[1].trim()
792 : null,
793 ]);
794 },
795 },
796 );
797 await waitForAll([]);
798 expect(hydrationErrors).toEqual([
799 [
800 "Hydration failed because the server rendered HTML didn't match the client.",
801 'at div (<anonymous>)',
802 ],
803 ]);
804 expect(persistentElements).toEqual([
805 document.documentElement,
806 document.head,
807 document.body,
808 ]);
809 expect(getVisibleChildren(document)).toEqual(
810 <html data-client-foo="foo">
811 <head>
812 <link rel="stylesheet" href="resource" />
813 <link rel="stylesheet" href="3rdparty" />
814 <link rel="stylesheet" href="3rdparty2" />
815 <title>a client title</title>
816 </head>
817 <body data-client-baz="baz">
818 <style>
819 {`
820 body: {
821 background-color: red;
822 }`}
823 </style>
824 <div>hello client</div>
825 </body>
826 </html>,
827 );
828
829 // Reset the tree
830 hydrationErrors.length = 0;
831 hydrateRoot.unmount();
832
833 // Now we try hydrating again with matching nodes and we ensure
834 // the retained styles are bound to the hydrated fibers
835 const link = document.querySelector('link[rel="stylesheet"]');
836 const style = document.querySelector('style');
837 hydrateRoot = ReactDOMClient.hydrateRoot(
838 document,
839 <html data-client-foo="foo">
840 <head>
841 <link rel="stylesheet" href="resource" />
842 <link rel="stylesheet" href="3rdparty" />
843 <link rel="stylesheet" href="3rdparty2" />
844 </head>
845 <body data-client-baz="baz">
846 <style>
847 {`
848 body: {
849 background-color: red;
850 }`}
851 </style>
852 </body>
853 </html>,
854 {
855 onRecoverableError(error, errorInfo) {
856 hydrationErrors.push([
857 error.message,
858 errorInfo.componentStack
859 ? errorInfo.componentStack.split('\n')[1].trim()
860 : null,
861 ]);
862 },
863 },
864 );
865 expect(hydrationErrors).toEqual([]);
866 await waitForAll([]);
867 assertConsoleErrorDev([
868 "A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. " +
869 "This won't be patched up. This can happen if a SSR-ed Client Component used:\n" +
870 '\n' +
871 "- A server/client branch `if (typeof window !== 'undefined')`.\n" +
872 "- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.\n" +
873 "- Date formatting in a user's locale which doesn't match the server.\n" +
874 '- External changing data without sending a snapshot of it along with the HTML.\n' +
875 '- Invalid HTML tag nesting.\n\nIt can also happen if the client has a browser extension installed ' +
876 'which messes with the HTML before React loaded.\n' +
877 '\n' +
878 'https://react.dev/link/hydration-mismatch\n' +
879 '\n' +
880 ' <html\n' +
881 '+ data-client-foo="foo"\n' +
882 '- data-client-foo={null}\n' +
883 ' >\n' +
884 ' <head>\n' +
885 ' <body\n' +
886 '+ data-client-baz="baz"\n' +
887 '- data-client-baz={null}\n' +
888 ' >\n' +
889 '\n in body (at **)',
890 ]);
891 expect(persistentElements).toEqual([
892 document.documentElement,
893 document.head,
894 document.body,
895 ]);
896 expect([link, style]).toEqual([
897 document.querySelector('link[rel="stylesheet"]'),
898 document.querySelector('style'),
899 ]);
900 expect(getVisibleChildren(document)).toEqual(
901 <html>
902 <head>
903 <link rel="stylesheet" href="resource" />
904 <link rel="stylesheet" href="3rdparty" />
905 <link rel="stylesheet" href="3rdparty2" />
906 </head>
907 <body>
908 <style>
909 {`
910 body: {
911 background-color: red;
912 }`}
913 </style>
914 </body>
915 </html>,
916 );
917
918 // We unmount a final time and observe that still we retain our persistent nodes
919 // but they style contents which matched in hydration is removed
920 hydrateRoot.unmount();
921 expect(persistentElements).toEqual([
922 document.documentElement,
923 document.head,
924 document.body,
925 ]);
926 expect(getVisibleChildren(document)).toEqual(
927 <html>
928 <head />
929 <body />
930 </html>,
931 );
932 });
933
934 // This test is not supported in this implementation. If we reintroduce insertion edge we should revisit
935 // eslint-disable-next-line jest/no-disabled-tests
936 it.skip('is able to maintain insertions in head and body between tree-adjacent Nodes', async () => {
937 // Server render some html and hydrate on the client
938 await actIntoEmptyDocument(() => {
939 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
940 <html>
941 <head>
942 <title>title</title>
943 </head>
944 <body>
945 <div>hello</div>
946 </body>
947 </html>,
948 );
949 pipe(writable);
950 });
951 const root = ReactDOMClient.hydrateRoot(
952 document,
953 <html>
954 <head>
955 <title>title</title>
956 </head>
957 <body>
958 <div>hello</div>
959 </body>
960 </html>,
961 );
962 await waitForAll([]);
963
964 // We construct and insert some artificial stylesheets mimicing what a 3rd party script might do
965 // In the future we could hydrate with these already in the document but the rules are restrictive
966 // still so it would fail and fall back to client rendering
967 const [a, b, c, d, e, f, g, h] = 'abcdefgh'.split('').map(letter => {
968 const link = document.createElement('link');
969 link.rel = 'stylesheet';
970 link.href = letter;
971 return link;
972 });
973
974 const head = document.head;
975 const title = head.firstChild;
976 head.insertBefore(a, title);
977 head.insertBefore(b, title);
978 head.appendChild(c);
979 head.appendChild(d);
980
981 const bodyContent = document.body.firstChild;
982 const body = document.body;
983 body.insertBefore(e, bodyContent);
984 body.insertBefore(f, bodyContent);
985 body.appendChild(g);
986 body.appendChild(h);
987
988 expect(getVisibleChildren(document)).toEqual(
989 <html>
990 <head>
991 <link rel="stylesheet" href="a" />
992 <link rel="stylesheet" href="b" />
993 <title>title</title>
994 <link rel="stylesheet" href="c" />
995 <link rel="stylesheet" href="d" />
996 </head>
997 <body>
998 <link rel="stylesheet" href="e" />
999 <link rel="stylesheet" href="f" />
1000 <div>hello</div>
1001 <link rel="stylesheet" href="g" />
1002 <link rel="stylesheet" href="h" />
1003 </body>
1004 </html>,
1005 );
1006
1007 // Unmount head and change children of body
1008 root.render(
1009 <html>
1010 {null}
1011 <body>
1012 <div>hello</div>
1013 <div>world</div>
1014 </body>
1015 </html>,
1016 );
1017
1018 await waitForAll([]);
1019 expect(getVisibleChildren(document)).toEqual(
1020 <html>
1021 <head>
1022 <link rel="stylesheet" href="a" />
1023 <link rel="stylesheet" href="b" />
1024 <link rel="stylesheet" href="c" />
1025 <link rel="stylesheet" href="d" />
1026 </head>
1027 <body>
1028 <link rel="stylesheet" href="e" />
1029 <link rel="stylesheet" href="f" />
1030 <div>hello</div>
1031 <div>world</div>
1032 <link rel="stylesheet" href="g" />
1033 <link rel="stylesheet" href="h" />
1034 </body>
1035 </html>,
1036 );
1037
1038 // Mount new head and unmount body
1039 root.render(
1040 <html>
1041 <head>
1042 <title>a new title</title>
1043 </head>
1044 </html>,
1045 );
1046 await waitForAll([]);
1047 expect(getVisibleChildren(document)).toEqual(
1048 <html>
1049 <head>
1050 <title>a new title</title>
1051 <link rel="stylesheet" href="a" />
1052 <link rel="stylesheet" href="b" />
1053 <link rel="stylesheet" href="c" />
1054 <link rel="stylesheet" href="d" />
1055 </head>
1056 <body>
1057 <link rel="stylesheet" href="e" />
1058 <link rel="stylesheet" href="f" />
1059 <link rel="stylesheet" href="g" />
1060 <link rel="stylesheet" href="h" />
1061 </body>
1062 </html>,
1063 );
1064 });
1065
1066 it('clears persistent head and body when html is the container', async () => {
1067 await actIntoEmptyDocument(() => {
1068 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1069 <html>
1070 <head>
1071 <link rel="stylesheet" href="headbefore" />
1072 <title>this should be removed</title>
1073 <link rel="stylesheet" href="headafter" />
1074 <script data-meaningful="">true</script>
1075 </head>
1076 <body>
1077 <link rel="stylesheet" href="bodybefore" />
1078 <div>this should be removed</div>
1079 <link rel="stylesheet" href="bodyafter" />
1080 <script data-meaningful="">true</script>
1081 </body>
1082 </html>,
1083 );
1084 pipe(writable);
1085 });
1086 container = document.documentElement;
1087
1088 const root = ReactDOMClient.createRoot(container);
1089 root.render(
1090 <>
1091 <head>
1092 <title>something new</title>
1093 </head>
1094 <body>
1095 <div>something new</div>
1096 </body>
1097 </>,
1098 );
1099 await waitForAll([]);
1100 expect(getVisibleChildren(document)).toEqual(
1101 <html>
1102 <head>
1103 <link rel="stylesheet" href="headbefore" />
1104 <link rel="stylesheet" href="headafter" />
1105 <script data-meaningful="">true</script>
1106 <title>something new</title>
1107 </head>
1108 <body>
1109 <link rel="stylesheet" href="bodybefore" />
1110 <link rel="stylesheet" href="bodyafter" />
1111 <script data-meaningful="">true</script>
1112 <div>something new</div>
1113 </body>
1114 </html>,
1115 );
1116 });
1117
1118 it('clears persistent head when it is the container', async () => {
1119 await actIntoEmptyDocument(() => {
1120 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1121 <html>
1122 <head>
1123 <link rel="stylesheet" href="before" />
1124 <title>this should be removed</title>
1125 <link rel="stylesheet" href="after" />
1126 </head>
1127 <body />
1128 </html>,
1129 );
1130 pipe(writable);
1131 });
1132 container = document.head;
1133
1134 const root = ReactDOMClient.createRoot(container);
1135 root.render(<title>something new</title>);
1136 await waitForAll([]);
1137 expect(getVisibleChildren(document)).toEqual(
1138 <html>
1139 <head>
1140 <link rel="stylesheet" href="before" />
1141 <link rel="stylesheet" href="after" />
1142 <title>something new</title>
1143 </head>
1144 <body />
1145 </html>,
1146 );
1147 });
1148
1149 it('clears persistent body when it is the container', async () => {
1150 await actIntoEmptyDocument(() => {
1151 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1152 <html>
1153 <head />
1154 <body>
1155 <link rel="stylesheet" href="before" />
1156 <div>this should be removed</div>
1157 <link rel="stylesheet" href="after" />
1158 </body>
1159 </html>,
1160 );
1161 pipe(writable);
1162 });
1163 container = document.body;
1164
1165 const root = ReactDOMClient.createRoot(container);
1166 root.render(<div>something new</div>);
1167 await waitForAll([]);
1168 expect(getVisibleChildren(document)).toEqual(
1169 <html>
1170 <head />
1171 <body>
1172 <link rel="stylesheet" href="before" />
1173 <link rel="stylesheet" href="after" />
1174 <div>something new</div>
1175 </body>
1176 </html>,
1177 );
1178 });
1179
1180 it('renders single Text children into HostSingletons correctly', async () => {
1181 await actIntoEmptyDocument(() => {
1182 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1183 <html>
1184 <head />
1185 <body>foo</body>
1186 </html>,
1187 );
1188 pipe(writable);
1189 });
1190
1191 let root = ReactDOMClient.hydrateRoot(
1192 document,
1193 <html>
1194 <head />
1195 <body>foo</body>
1196 </html>,
1197 );
1198 await waitForAll([]);
1199 expect(getVisibleChildren(document)).toEqual(
1200 <html>
1201 <head />
1202 <body>foo</body>
1203 </html>,
1204 );
1205
1206 root.render(
1207 <html>
1208 <head />
1209 <body>bar</body>
1210 </html>,
1211 );
1212 await waitForAll([]);
1213 expect(getVisibleChildren(document)).toEqual(
1214 <html>
1215 <head />
1216 <body>bar</body>
1217 </html>,
1218 );
1219
1220 root.unmount();
1221
1222 root = ReactDOMClient.createRoot(document);
1223 root.render(
1224 <html>
1225 <head />
1226 <body>baz</body>
1227 </html>,
1228 );
1229 await waitForAll([]);
1230 expect(getVisibleChildren(document)).toEqual(
1231 <html>
1232 <head />
1233 <body>baz</body>
1234 </html>,
1235 );
1236 });
1237
1238 it('supports going from single text child to many children back to single text child in body', async () => {
1239 const root = ReactDOMClient.createRoot(document);
1240 root.render(
1241 <html>
1242 <head />
1243 <body>foo</body>
1244 </html>,
1245 );
1246 await waitForAll([]);
1247 expect(getVisibleChildren(document)).toEqual(
1248 <html>
1249 <head />
1250 <body>foo</body>
1251 </html>,
1252 );
1253
1254 root.render(
1255 <html>
1256 <head />
1257 <body>
1258 <div>foo</div>
1259 </body>
1260 </html>,
1261 );
1262 await waitForAll([]);
1263 expect(getVisibleChildren(document)).toEqual(
1264 <html>
1265 <head />
1266 <body>
1267 <div>foo</div>
1268 </body>
1269 </html>,
1270 );
1271
1272 root.render(
1273 <html>
1274 <head />
1275 <body>foo</body>
1276 </html>,
1277 );
1278 await waitForAll([]);
1279 expect(getVisibleChildren(document)).toEqual(
1280 <html>
1281 <head />
1282 <body>foo</body>
1283 </html>,
1284 );
1285
1286 root.render(
1287 <html>
1288 <head />
1289 <body>
1290 <div>foo</div>
1291 </body>
1292 </html>,
1293 );
1294 await waitForAll([]);
1295 expect(getVisibleChildren(document)).toEqual(
1296 <html>
1297 <head />
1298 <body>
1299 <div>foo</div>
1300 </body>
1301 </html>,
1302 );
1303 });
1304
1305 it('allows for hydrating without a head', async () => {
1306 await actIntoEmptyDocument(() => {
1307 const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1308 <html>
1309 <body>foo</body>
1310 </html>,
1311 );
1312 pipe(writable);
1313 });
1314
1315 expect(getVisibleChildren(document)).toEqual(
1316 <html>
1317 <head />
1318 <body>foo</body>
1319 </html>,
1320 );
1321
1322 ReactDOMClient.hydrateRoot(
1323 document,
1324 <html>
1325 <body>foo</body>
1326 </html>,
1327 );
1328 await waitForAll([]);
1329 expect(getVisibleChildren(document)).toEqual(
1330 <html>
1331 <head />
1332 <body>foo</body>
1333 </html>,
1334 );
1335 });
1336
1337 // https://github.com/facebook/react/issues/26128
1338 // @gate !disableLegacyMode
1339 it('(#26128) does not throw when rendering at body in legacy mode', async () => {
1340 ReactDOM.render(<div />, document.body);
1341 });
1342
1343 // https://github.com/facebook/react/issues/26128
1344 // @gate !disableLegacyMode
1345 it('(#26128) does not throw when rendering at <html> in legacy mode', async () => {
1346 ReactDOM.render(<body />, document.documentElement);
1347 });
1348
1349 // https://github.com/facebook/react/issues/26128
1350 // @gate !disableLegacyMode
1351 it('(#26128) does not throw when rendering at document in legacy mode', async () => {
1352 ReactDOM.render(<html />, document);
1353 });
1354 });