main
js 956 lines 31.8 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 findDOMNode;
15 let ReactDOMClient;
16 let ReactDOMServer;
17 let assertConsoleErrorDev;
18 let act;
19
20 describe('ReactDOM', () => {
21 beforeEach(() => {
22 jest.resetModules();
23 React = require('react');
24 ReactDOM = require('react-dom');
25 ReactDOMClient = require('react-dom/client');
26 ReactDOMServer = require('react-dom/server');
27 findDOMNode =
28 ReactDOM.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE
29 .findDOMNode;
30
31 ({act, assertConsoleErrorDev} = require('internal-test-utils'));
32 });
33
34 it('should bubble onSubmit', async () => {
35 const container = document.createElement('div');
36
37 let count = 0;
38 let buttonRef;
39
40 function Parent() {
41 return (
42 <div
43 onSubmit={event => {
44 event.preventDefault();
45 count++;
46 }}>
47 <Child />
48 </div>
49 );
50 }
51
52 function Child() {
53 return (
54 <form>
55 <input type="submit" ref={button => (buttonRef = button)} />
56 </form>
57 );
58 }
59
60 document.body.appendChild(container);
61 const root = ReactDOMClient.createRoot(container);
62 try {
63 await act(() => {
64 root.render(<Parent />);
65 });
66 buttonRef.click();
67 expect(count).toBe(1);
68 } finally {
69 document.body.removeChild(container);
70 }
71 });
72
73 it('allows a DOM element to be used with a string', async () => {
74 const element = React.createElement('div', {className: 'foo'});
75 const container = document.createElement('div');
76 const root = ReactDOMClient.createRoot(container);
77 await act(() => {
78 root.render(element);
79 });
80
81 const node = container.firstChild;
82 expect(node.tagName).toBe('DIV');
83 });
84
85 it('should allow children to be passed as an argument', async () => {
86 const container = document.createElement('div');
87 const root = ReactDOMClient.createRoot(container);
88 await act(() => {
89 root.render(React.createElement('div', null, 'child'));
90 });
91
92 const argNode = container.firstChild;
93 expect(argNode.innerHTML).toBe('child');
94 });
95
96 it('should overwrite props.children with children argument', async () => {
97 const container = document.createElement('div');
98 const root = ReactDOMClient.createRoot(container);
99 await act(() => {
100 root.render(React.createElement('div', {children: 'fakechild'}, 'child'));
101 });
102
103 const conflictNode = container.firstChild;
104 expect(conflictNode.innerHTML).toBe('child');
105 });
106
107 /**
108 * We need to make sure that updates occur to the actual node that's in the
109 * DOM, instead of a stale cache.
110 */
111 it('should purge the DOM cache when removing nodes', async () => {
112 let container = document.createElement('div');
113 let root = ReactDOMClient.createRoot(container);
114 await act(() => {
115 root.render(
116 <div>
117 <div key="theDog" className="dog" />,
118 <div key="theBird" className="bird" />
119 </div>,
120 );
121 });
122 // Warm the cache with theDog
123 container = document.createElement('div');
124 root = ReactDOMClient.createRoot(container);
125 await act(() => {
126 root.render(
127 <div>
128 <div key="theDog" className="dogbeforedelete" />,
129 <div key="theBird" className="bird" />,
130 </div>,
131 );
132 });
133 // Remove theDog - this should purge the cache
134 container = document.createElement('div');
135 root = ReactDOMClient.createRoot(container);
136 await act(() => {
137 root.render(
138 <div>
139 <div key="theBird" className="bird" />,
140 </div>,
141 );
142 });
143 // Now, put theDog back. It's now a different DOM node.
144 container = document.createElement('div');
145 root = ReactDOMClient.createRoot(container);
146 await act(() => {
147 root.render(
148 <div>
149 <div key="theDog" className="dog" />,
150 <div key="theBird" className="bird" />,
151 </div>,
152 );
153 });
154 // Change the className of theDog. It will use the same element
155 container = document.createElement('div');
156 root = ReactDOMClient.createRoot(container);
157 await act(() => {
158 root.render(
159 <div>
160 <div key="theDog" className="bigdog" />,
161 <div key="theBird" className="bird" />,
162 </div>,
163 );
164 });
165
166 const myDiv = container.firstChild;
167 const dog = myDiv.childNodes[0];
168 expect(dog.className).toBe('bigdog');
169 });
170
171 // @gate !disableLegacyMode
172 it('throws in render() if the mount callback in legacy roots is not a function', async () => {
173 spyOnDev(console, 'warn');
174 spyOnDev(console, 'error');
175
176 function Foo() {
177 this.a = 1;
178 this.b = 2;
179 }
180
181 class A extends React.Component {
182 state = {};
183
184 render() {
185 return <div />;
186 }
187 }
188
189 const myDiv = document.createElement('div');
190 await expect(async () => {
191 await act(() => {
192 ReactDOM.render(<A />, myDiv, 'no');
193 });
194 }).rejects.toThrow(
195 'Invalid argument passed as callback. Expected a function. Instead ' +
196 'received: no',
197 );
198 assertConsoleErrorDev([
199 'Expected the last optional `callback` argument to be a function. Instead received: no.',
200 'Expected the last optional `callback` argument to be a function. Instead received: no.',
201 ]);
202
203 await expect(async () => {
204 await act(() => {
205 ReactDOM.render(<A />, myDiv, {foo: 'bar'});
206 });
207 }).rejects.toThrow(
208 'Invalid argument passed as callback. Expected a function. Instead ' +
209 'received: [object Object]',
210 );
211 assertConsoleErrorDev([
212 "Expected the last optional `callback` argument to be a function. Instead received: { foo: 'bar' }",
213 "Expected the last optional `callback` argument to be a function. Instead received: { foo: 'bar' }.",
214 ]);
215
216 await expect(async () => {
217 await act(() => {
218 ReactDOM.render(<A />, myDiv, new Foo());
219 });
220 }).rejects.toThrow(
221 'Invalid argument passed as callback. Expected a function. Instead ' +
222 'received: [object Object]',
223 );
224 assertConsoleErrorDev([
225 'Expected the last optional `callback` argument to be a function. Instead received: Foo { a: 1, b: 2 }.',
226 'Expected the last optional `callback` argument to be a function. Instead received: Foo { a: 1, b: 2 }.',
227 ]);
228 });
229
230 // @gate !disableLegacyMode
231 it('throws in render() if the update callback in legacy roots is not a function', async () => {
232 function Foo() {
233 this.a = 1;
234 this.b = 2;
235 }
236
237 class A extends React.Component {
238 state = {};
239
240 render() {
241 return <div />;
242 }
243 }
244
245 const myDiv = document.createElement('div');
246 ReactDOM.render(<A />, myDiv);
247 await expect(async () => {
248 await act(() => {
249 ReactDOM.render(<A />, myDiv, 'no');
250 });
251 }).rejects.toThrow(
252 'Invalid argument passed as callback. Expected a function. Instead ' +
253 'received: no',
254 );
255 assertConsoleErrorDev([
256 'Expected the last optional `callback` argument to be a function. Instead received: no.',
257 'Expected the last optional `callback` argument to be a function. Instead received: no.',
258 ]);
259
260 ReactDOM.render(<A />, myDiv); // Re-mount
261 await expect(async () => {
262 await act(() => {
263 ReactDOM.render(<A />, myDiv, {foo: 'bar'});
264 });
265 }).rejects.toThrow(
266 'Invalid argument passed as callback. Expected a function. Instead ' +
267 'received: [object Object]',
268 );
269 assertConsoleErrorDev([
270 "Expected the last optional `callback` argument to be a function. Instead received: { foo: 'bar' }.",
271 "Expected the last optional `callback` argument to be a function. Instead received: { foo: 'bar' }.",
272 ]);
273
274 ReactDOM.render(<A />, myDiv); // Re-mount
275 await expect(async () => {
276 await act(() => {
277 ReactDOM.render(<A />, myDiv, new Foo());
278 });
279 }).rejects.toThrow(
280 'Invalid argument passed as callback. Expected a function. Instead ' +
281 'received: [object Object]',
282 );
283 assertConsoleErrorDev([
284 'Expected the last optional `callback` argument to be a function. Instead received: Foo { a: 1, b: 2 }.',
285 'Expected the last optional `callback` argument to be a function. Instead received: Foo { a: 1, b: 2 }.',
286 ]);
287 });
288
289 it('preserves focus', async () => {
290 let input;
291 let input2;
292 class A extends React.Component {
293 render() {
294 return (
295 <div>
296 <input id="one" ref={r => (input = input || r)} />
297 {this.props.showTwo && (
298 <input id="two" ref={r => (input2 = input2 || r)} />
299 )}
300 </div>
301 );
302 }
303
304 componentDidUpdate() {
305 // Focus should have been restored to the original input
306 expect(document.activeElement.id).toBe('one');
307 input2.focus();
308 expect(document.activeElement.id).toBe('two');
309 log.push('input2 focused');
310 }
311 }
312
313 const log = [];
314 const container = document.createElement('div');
315 document.body.appendChild(container);
316 const root = ReactDOMClient.createRoot(container);
317 try {
318 await act(() => {
319 root.render(<A showTwo={false} />);
320 });
321 input.focus();
322
323 // When the second input is added, let's simulate losing focus, which is
324 // something that could happen when manipulating DOM nodes (but is hard to
325 // deterministically force without relying intensely on React DOM
326 // implementation details)
327 const div = container.firstChild;
328 ['appendChild', 'insertBefore'].forEach(name => {
329 const mutator = div[name];
330 div[name] = function () {
331 if (input) {
332 input.blur();
333 expect(document.activeElement.tagName).toBe('BODY');
334 log.push('input2 inserted');
335 }
336 return mutator.apply(this, arguments);
337 };
338 });
339
340 expect(document.activeElement.id).toBe('one');
341 await act(() => {
342 root.render(<A showTwo={true} />);
343 });
344 // input2 gets added, which causes input to get blurred. Then
345 // componentDidUpdate focuses input2 and that should make it down to here,
346 // not get overwritten by focus restoration.
347 expect(document.activeElement.id).toBe('two');
348 expect(log).toEqual(['input2 inserted', 'input2 focused']);
349 } finally {
350 document.body.removeChild(container);
351 }
352 });
353
354 it('calls focus() on autoFocus elements after they have been mounted to the DOM', async () => {
355 const originalFocus = HTMLElement.prototype.focus;
356
357 try {
358 let focusedElement;
359 let inputFocusedAfterMount = false;
360
361 // This test needs to determine that focus is called after mount.
362 // Can't check document.activeElement because PhantomJS is too permissive;
363 // It doesn't require element to be in the DOM to be focused.
364 HTMLElement.prototype.focus = function () {
365 focusedElement = this;
366 inputFocusedAfterMount = !!this.parentNode;
367 };
368
369 const container = document.createElement('div');
370 document.body.appendChild(container);
371 const root = ReactDOMClient.createRoot(container);
372 await act(() => {
373 root.render(
374 <div>
375 <h1>Auto-focus Test</h1>
376 <input autoFocus={true} />
377 <p>The above input should be focused after mount.</p>
378 </div>,
379 );
380 });
381
382 expect(inputFocusedAfterMount).toBe(true);
383 expect(focusedElement.tagName).toBe('INPUT');
384 } finally {
385 HTMLElement.prototype.focus = originalFocus;
386 }
387 });
388
389 it("shouldn't fire duplicate event handler while handling other nested dispatch", async () => {
390 const actual = [];
391
392 class Wrapper extends React.Component {
393 componentDidMount() {
394 this.ref1.click();
395 }
396
397 render() {
398 return (
399 <div>
400 <div
401 onClick={() => {
402 actual.push('1st node clicked');
403 this.ref2.click();
404 }}
405 ref={ref => (this.ref1 = ref)}
406 />
407 <div
408 onClick={ref => {
409 actual.push("2nd node clicked imperatively from 1st's handler");
410 }}
411 ref={ref => (this.ref2 = ref)}
412 />
413 </div>
414 );
415 }
416 }
417
418 const container = document.createElement('div');
419 document.body.appendChild(container);
420 const root = ReactDOMClient.createRoot(container);
421 try {
422 await act(() => {
423 root.render(<Wrapper />);
424 });
425
426 const expected = [
427 '1st node clicked',
428 "2nd node clicked imperatively from 1st's handler",
429 ];
430
431 expect(actual).toEqual(expected);
432 } finally {
433 document.body.removeChild(container);
434 }
435 });
436
437 it('should not crash with devtools installed', async () => {
438 try {
439 global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
440 inject: function () {},
441 onCommitFiberRoot: function () {},
442 onCommitFiberUnmount: function () {},
443 supportsFiber: true,
444 };
445 jest.resetModules();
446 React = require('react');
447 ReactDOM = require('react-dom');
448 class Component extends React.Component {
449 render() {
450 return <div />;
451 }
452 }
453 const root = ReactDOMClient.createRoot(document.createElement('div'));
454 await act(() => {
455 root.render(<Component />);
456 });
457 } finally {
458 delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
459 }
460 });
461
462 it('should not crash calling findDOMNode inside a function component', async () => {
463 class Component extends React.Component {
464 render() {
465 return <div />;
466 }
467 }
468
469 const container = document.createElement('div');
470 let root = ReactDOMClient.createRoot(container);
471 let instance;
472 await act(() => {
473 root.render(<Component ref={current => (instance = current)} />);
474 });
475
476 const App = () => {
477 findDOMNode(instance);
478 return <div />;
479 };
480
481 if (__DEV__) {
482 root = ReactDOMClient.createRoot(document.createElement('div'));
483 await act(() => {
484 root.render(<App />);
485 });
486 }
487 });
488
489 it('reports stacks with re-entrant renderToString() calls on the client', async () => {
490 function Child2(props) {
491 return <span ariaTypo3="no">{props.children}</span>;
492 }
493
494 function App2() {
495 return (
496 <Child2>
497 {ReactDOMServer.renderToString(<blink ariaTypo2="no" />)}
498 </Child2>
499 );
500 }
501
502 function Child() {
503 return (
504 <span ariaTypo4="no">{ReactDOMServer.renderToString(<App2 />)}</span>
505 );
506 }
507
508 function ServerEntry() {
509 return ReactDOMServer.renderToString(<Child />);
510 }
511
512 function App() {
513 return (
514 <div>
515 <span ariaTypo="no" />
516 <ServerEntry />
517 <font ariaTypo5="no" />
518 </div>
519 );
520 }
521
522 const root = ReactDOMClient.createRoot(document.createElement('div'));
523 await act(() => {
524 root.render(<App />);
525 });
526 assertConsoleErrorDev([
527 // ReactDOM(App > div > span)
528 'Invalid ARIA attribute `ariaTypo`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
529 ' in span (at **)\n' +
530 ' in App (at **)',
531 // ReactDOM(App > div > ServerEntry) >>> ReactDOMServer(Child) >>> ReactDOMServer(App2) >>> ReactDOMServer(blink)
532 'Invalid ARIA attribute `ariaTypo2`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
533 ' in blink (at **)\n' +
534 ' in App2 (at **)\n' +
535 ' in Child (at **)\n' +
536 ' in ServerEntry (at **)',
537 // ReactDOM(App > div > ServerEntry) >>> ReactDOMServer(Child) >>> ReactDOMServer(App2 > Child2 > span)
538 'Invalid ARIA attribute `ariaTypo3`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
539 ' in span (at **)\n' +
540 ' in Child2 (at **)\n' +
541 ' in App2 (at **)\n' +
542 ' in Child (at **)\n' +
543 ' in ServerEntry (at **)',
544 // ReactDOM(App > div > ServerEntry) >>> ReactDOMServer(Child > span)
545 'Invalid ARIA attribute `ariaTypo4`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
546 ' in span (at **)\n' +
547 ' in Child (at **)\n' +
548 ' in ServerEntry (at **)',
549
550 // ReactDOM(App > div > font)
551 'Invalid ARIA attribute `ariaTypo5`. ARIA attributes follow the pattern aria-* and must be lowercase.\n' +
552 ' in font (at **)\n' +
553 ' in App (at **)',
554 ]);
555 });
556
557 it('should render root host components into body scope when the container is a Document', async () => {
558 function App({phase}) {
559 return (
560 <>
561 {phase < 1 ? null : <div>..before</div>}
562 {phase < 3 ? <div>before</div> : null}
563 {phase < 2 ? null : <div>before..</div>}
564 <html lang="en">
565 <head data-h="">
566 {phase < 1 ? null : <meta itemProp="" content="..head" />}
567 {phase < 3 ? <meta itemProp="" content="head" /> : null}
568 {phase < 2 ? null : <meta itemProp="" content="head.." />}
569 </head>
570 <body data-b="">
571 {phase < 1 ? null : <div>..inside</div>}
572 {phase < 3 ? <div>inside</div> : null}
573 {phase < 2 ? null : <div>inside..</div>}
574 </body>
575 </html>
576 {phase < 1 ? null : <div>..after</div>}
577 {phase < 3 ? <div>after</div> : null}
578 {phase < 2 ? null : <div>after..</div>}
579 </>
580 );
581 }
582
583 const root = ReactDOMClient.createRoot(document);
584 await act(() => {
585 root.render(<App phase={0} />);
586 });
587 expect(document.documentElement.outerHTML).toBe(
588 '<html lang="en"><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
589 );
590
591 await act(() => {
592 root.render(<App phase={1} />);
593 });
594 expect(document.documentElement.outerHTML).toBe(
595 '<html lang="en"><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body data-b=""><div>..before</div><div>before</div><div>..inside</div><div>inside</div><div>..after</div><div>after</div></body></html>',
596 );
597
598 await act(() => {
599 root.render(<App phase={2} />);
600 });
601 expect(document.documentElement.outerHTML).toBe(
602 '<html lang="en"><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before</div><div>before..</div><div>..inside</div><div>inside</div><div>inside..</div><div>..after</div><div>after</div><div>after..</div></body></html>',
603 );
604
605 await act(() => {
606 root.render(<App phase={3} />);
607 });
608 expect(document.documentElement.outerHTML).toBe(
609 '<html lang="en"><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before..</div><div>..inside</div><div>inside..</div><div>..after</div><div>after..</div></body></html>',
610 );
611
612 await act(() => {
613 root.unmount();
614 });
615 expect(document.documentElement.outerHTML).toBe(
616 '<html><head></head><body></body></html>',
617 );
618 });
619
620 it('should render root host components into body scope when the container is a the <html> tag', async () => {
621 function App({phase}) {
622 return (
623 <>
624 {phase < 1 ? null : <div>..before</div>}
625 {phase < 3 ? <div>before</div> : null}
626 {phase < 2 ? null : <div>before..</div>}
627 <head data-h="">
628 {phase < 1 ? null : <meta itemProp="" content="..head" />}
629 {phase < 3 ? <meta itemProp="" content="head" /> : null}
630 {phase < 2 ? null : <meta itemProp="" content="head.." />}
631 </head>
632 <body data-b="">
633 {phase < 1 ? null : <div>..inside</div>}
634 {phase < 3 ? <div>inside</div> : null}
635 {phase < 2 ? null : <div>inside..</div>}
636 </body>
637 {phase < 1 ? null : <div>..after</div>}
638 {phase < 3 ? <div>after</div> : null}
639 {phase < 2 ? null : <div>after..</div>}
640 </>
641 );
642 }
643
644 const root = ReactDOMClient.createRoot(document.documentElement);
645 await act(() => {
646 root.render(<App phase={0} />);
647 });
648 expect(document.documentElement.outerHTML).toBe(
649 '<html><head data-h=""><meta itemprop="" content="head"></head><body data-b=""><div>before</div><div>inside</div><div>after</div></body></html>',
650 );
651
652 await act(() => {
653 root.render(<App phase={1} />);
654 });
655 expect(document.documentElement.outerHTML).toBe(
656 '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body data-b=""><div>..before</div><div>before</div><div>..inside</div><div>inside</div><div>..after</div><div>after</div></body></html>',
657 );
658
659 await act(() => {
660 root.render(<App phase={2} />);
661 });
662 expect(document.documentElement.outerHTML).toBe(
663 '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before</div><div>before..</div><div>..inside</div><div>inside</div><div>inside..</div><div>..after</div><div>after</div><div>after..</div></body></html>',
664 );
665
666 await act(() => {
667 root.render(<App phase={3} />);
668 });
669 expect(document.documentElement.outerHTML).toBe(
670 '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body data-b=""><div>..before</div><div>before..</div><div>..inside</div><div>inside..</div><div>..after</div><div>after..</div></body></html>',
671 );
672
673 await act(() => {
674 root.unmount();
675 });
676 expect(document.documentElement.outerHTML).toBe(
677 '<html><head></head><body></body></html>',
678 );
679 });
680
681 it('should render root host components into body scope when the container is a the <body> tag', async () => {
682 function App({phase}) {
683 return (
684 <>
685 {phase < 1 ? null : <div>..before</div>}
686 {phase < 3 ? <div>before</div> : null}
687 {phase < 2 ? null : <div>before..</div>}
688 <head data-h="">
689 {phase < 1 ? null : <meta itemProp="" content="..head" />}
690 {phase < 3 ? <meta itemProp="" content="head" /> : null}
691 {phase < 2 ? null : <meta itemProp="" content="head.." />}
692 </head>
693 {phase < 1 ? null : <div>..inside</div>}
694 {phase < 3 ? <div>inside</div> : null}
695 {phase < 2 ? null : <div>inside..</div>}
696 {phase < 1 ? null : <div>..after</div>}
697 {phase < 3 ? <div>after</div> : null}
698 {phase < 2 ? null : <div>after..</div>}
699 </>
700 );
701 }
702
703 const root = ReactDOMClient.createRoot(document.body);
704 await act(() => {
705 root.render(<App phase={0} />);
706 });
707 expect(document.documentElement.outerHTML).toBe(
708 '<html><head data-h=""><meta itemprop="" content="head"></head><body><div>before</div><div>inside</div><div>after</div></body></html>',
709 );
710
711 await act(() => {
712 root.render(<App phase={1} />);
713 });
714 expect(document.documentElement.outerHTML).toBe(
715 '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body><div>..before</div><div>before</div><div>..inside</div><div>inside</div><div>..after</div><div>after</div></body></html>',
716 );
717
718 await act(() => {
719 root.render(<App phase={2} />);
720 });
721 expect(document.documentElement.outerHTML).toBe(
722 '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body><div>..before</div><div>before</div><div>before..</div><div>..inside</div><div>inside</div><div>inside..</div><div>..after</div><div>after</div><div>after..</div></body></html>',
723 );
724
725 await act(() => {
726 root.render(<App phase={3} />);
727 });
728 expect(document.documentElement.outerHTML).toBe(
729 '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body><div>..before</div><div>before..</div><div>..inside</div><div>inside..</div><div>..after</div><div>after..</div></body></html>',
730 );
731
732 await act(() => {
733 root.unmount();
734 });
735 expect(document.documentElement.outerHTML).toBe(
736 '<html><head></head><body></body></html>',
737 );
738 });
739
740 it('should render children of <head> into the document head even when the container is inside the document body', async () => {
741 function App({phase}) {
742 return (
743 <>
744 <div>before</div>
745 <head data-h="">
746 {phase < 1 ? null : <meta itemProp="" content="..head" />}
747 {phase < 3 ? <meta itemProp="" content="head" /> : null}
748 {phase < 2 ? null : <meta itemProp="" content="head.." />}
749 </head>
750 <div>after</div>
751 </>
752 );
753 }
754
755 const container = document.createElement('main');
756 document.body.append(container);
757 const root = ReactDOMClient.createRoot(container);
758 await act(() => {
759 root.render(<App phase={0} />);
760 });
761 expect(document.documentElement.outerHTML).toBe(
762 '<html><head data-h=""><meta itemprop="" content="head"></head><body><main><div>before</div><div>after</div></main></body></html>',
763 );
764
765 // @TODO remove this warning check when we loosen the tag nesting restrictions to allow arbitrary tags at the
766 // root of the application
767 assertConsoleErrorDev([
768 'In HTML, <head> cannot be a child of <main>.\nThis will cause a hydration error.\n' +
769 ' in head (at **)\n' +
770 ' in App (at **)',
771 ]);
772
773 await act(() => {
774 root.render(<App phase={1} />);
775 });
776 expect(document.documentElement.outerHTML).toBe(
777 '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"></head><body><main><div>before</div><div>after</div></main></body></html>',
778 );
779
780 await act(() => {
781 root.render(<App phase={2} />);
782 });
783 expect(document.documentElement.outerHTML).toBe(
784 '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head"><meta itemprop="" content="head.."></head><body><main><div>before</div><div>after</div></main></body></html>',
785 );
786
787 await act(() => {
788 root.render(<App phase={3} />);
789 });
790 expect(document.documentElement.outerHTML).toBe(
791 '<html><head data-h=""><meta itemprop="" content="..head"><meta itemprop="" content="head.."></head><body><main><div>before</div><div>after</div></main></body></html>',
792 );
793
794 await act(() => {
795 root.unmount();
796 });
797 expect(document.documentElement.outerHTML).toBe(
798 '<html><head></head><body><main></main></body></html>',
799 );
800 });
801
802 it('can render a Suspense boundary above the <html> tag', async () => {
803 let suspendOnNewPromise;
804 let resolveCurrentPromise;
805 let currentPromise;
806 function createNewPromise() {
807 currentPromise = new Promise(r => {
808 resolveCurrentPromise = r;
809 });
810 return currentPromise;
811 }
812 createNewPromise();
813 function Comp() {
814 const [promise, setPromise] = React.useState(currentPromise);
815 suspendOnNewPromise = () => {
816 setPromise(createNewPromise());
817 };
818 React.use(promise);
819 return null;
820 }
821
822 const fallback = (
823 <html data-fallback="">
824 <body data-fallback="">
825 <div>fallback</div>
826 </body>
827 </html>
828 );
829
830 const main = (
831 <html lang="en">
832 <head>
833 <meta itemProp="" content="primary" />
834 </head>
835 <body>
836 <div>
837 <Message />
838 </div>
839 </body>
840 </html>
841 );
842
843 let suspendOnNewMessage;
844 let currentMessage;
845 let resolveCurrentMessage;
846 function createNewMessage() {
847 currentMessage = new Promise(r => {
848 resolveCurrentMessage = r;
849 });
850 return currentMessage;
851 }
852 createNewMessage();
853 resolveCurrentMessage('hello world');
854 function Message() {
855 const [pendingMessage, setPendingMessage] =
856 React.useState(currentMessage);
857 suspendOnNewMessage = () => {
858 setPendingMessage(createNewMessage());
859 };
860 return React.use(pendingMessage);
861 }
862
863 function App() {
864 return (
865 <React.Suspense fallback={fallback}>
866 <Comp />
867 {main}
868 </React.Suspense>
869 );
870 }
871
872 const root = ReactDOMClient.createRoot(document);
873 await act(() => {
874 root.render(<App />);
875 });
876 // The initial render is blocked by promiseA so we see the fallback Document
877 expect(document.documentElement.outerHTML).toBe(
878 '<html data-fallback=""><head></head><body data-fallback=""><div>fallback</div></body></html>',
879 );
880
881 await act(() => {
882 resolveCurrentPromise();
883 });
884 // When promiseA resolves we see the primary Document
885 expect(document.documentElement.outerHTML).toBe(
886 '<html lang="en"><head><meta itemprop="" content="primary"></head><body><div>hello world</div></body></html>',
887 );
888
889 await act(() => {
890 suspendOnNewPromise();
891 });
892 // When we switch to rendering ComponentB synchronously we have to put the Document back into fallback
893 // The primary content remains hidden until promiseB resolves
894 expect(document.documentElement.outerHTML).toBe(
895 '<html data-fallback=""><head><meta itemprop="" content="primary" style="display: none;"></head><body data-fallback=""><div style="display: none;">hello world</div><div>fallback</div></body></html>',
896 );
897
898 await act(() => {
899 resolveCurrentPromise();
900 });
901 // When promiseB resolves we see the new primary content inside the primary Document
902 // style attributes stick around after being unhidden by the Suspense boundary
903 expect(document.documentElement.outerHTML).toBe(
904 '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello world</div></body></html>',
905 );
906
907 await act(() => {
908 React.startTransition(() => {
909 suspendOnNewPromise();
910 });
911 });
912 expect(document.documentElement.outerHTML).toBe(
913 '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello world</div></body></html>',
914 );
915
916 await act(() => {
917 resolveCurrentPromise();
918 });
919 expect(document.documentElement.outerHTML).toBe(
920 '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello world</div></body></html>',
921 );
922
923 await act(() => {
924 suspendOnNewMessage();
925 });
926 // When we update the message itself we will be causing updates on the primary content of the Suspense boundary.
927 // The reason we also test for this is to make sure we don't double acquire the document singletons while
928 // disappearing and reappearing layout effects
929 expect(document.documentElement.outerHTML).toBe(
930 '<html data-fallback=""><head><meta itemprop="" content="primary" style="display: none;"></head><body data-fallback=""><div style="display: none;">hello world</div><div>fallback</div></body></html>',
931 );
932
933 await act(() => {
934 resolveCurrentMessage('hello you!');
935 });
936 expect(document.documentElement.outerHTML).toBe(
937 '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello you!</div></body></html>',
938 );
939
940 await act(() => {
941 React.startTransition(() => {
942 suspendOnNewMessage();
943 });
944 });
945 expect(document.documentElement.outerHTML).toBe(
946 '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">hello you!</div></body></html>',
947 );
948
949 await act(() => {
950 resolveCurrentMessage('goodbye!');
951 });
952 expect(document.documentElement.outerHTML).toBe(
953 '<html lang="en"><head><meta itemprop="" content="primary" style=""></head><body><div style="">goodbye!</div></body></html>',
954 );
955 });
956 });