main
js 3,640 lines 110 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 reactcore
8 */
9
10 'use strict';
11
12 let React;
13 let ReactDOMClient;
14 let ReactDOM;
15 let createPortal;
16 let act;
17 let container;
18 let Fragment;
19 let Activity;
20 let Scheduler;
21 let mockIntersectionObserver;
22 let simulateIntersection;
23 let setClientRects;
24 let mockRangeClientRects;
25 let assertConsoleErrorDev;
26 let assertConsoleWarnDev;
27 let assertLog;
28
29 function Wrapper({children}) {
30 return children;
31 }
32
33 describe('FragmentRefs', () => {
34 beforeEach(() => {
35 jest.resetModules();
36 React = require('react');
37 Fragment = React.Fragment;
38 Activity = React.Activity;
39 ReactDOMClient = require('react-dom/client');
40 ReactDOM = require('react-dom');
41 createPortal = ReactDOM.createPortal;
42 act = require('internal-test-utils').act;
43 Scheduler = require('scheduler');
44 const IntersectionMocks = require('./utils/IntersectionMocks');
45 mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
46 simulateIntersection = IntersectionMocks.simulateIntersection;
47 setClientRects = IntersectionMocks.setClientRects;
48 mockRangeClientRects = IntersectionMocks.mockRangeClientRects;
49 assertConsoleErrorDev =
50 require('internal-test-utils').assertConsoleErrorDev;
51 assertConsoleWarnDev = require('internal-test-utils').assertConsoleWarnDev;
52 assertLog = require('internal-test-utils').assertLog;
53
54 container = document.createElement('div');
55 document.body.innerHTML = '';
56 document.body.appendChild(container);
57 });
58
59 afterEach(() => {
60 document.body.removeChild(container);
61 });
62
63 // @gate enableFragmentRefs
64 it('attaches a ref to Fragment', async () => {
65 const fragmentRef = React.createRef();
66 const root = ReactDOMClient.createRoot(container);
67
68 await act(() =>
69 root.render(
70 <div id="parent">
71 <Fragment ref={fragmentRef}>
72 <div id="child">Hi</div>
73 </Fragment>
74 </div>,
75 ),
76 );
77 expect(container.innerHTML).toEqual(
78 '<div id="parent"><div id="child">Hi</div></div>',
79 );
80
81 expect(fragmentRef.current).not.toBe(null);
82 });
83
84 // @gate enableFragmentRefs
85 it('accepts a ref callback', async () => {
86 let fragmentRef;
87 const root = ReactDOMClient.createRoot(container);
88
89 await act(() => {
90 root.render(
91 <Fragment ref={ref => (fragmentRef = ref)}>
92 <div id="child">Hi</div>
93 </Fragment>,
94 );
95 });
96
97 expect(fragmentRef._fragmentFiber).toBeTruthy();
98 });
99
100 // @gate enableFragmentRefs
101 it('is available in effects', async () => {
102 function Test() {
103 const fragmentRef = React.useRef(null);
104 React.useLayoutEffect(() => {
105 expect(fragmentRef.current).not.toBe(null);
106 });
107 React.useEffect(() => {
108 expect(fragmentRef.current).not.toBe(null);
109 });
110 return (
111 <Fragment ref={fragmentRef}>
112 <div />
113 </Fragment>
114 );
115 }
116
117 const root = ReactDOMClient.createRoot(container);
118 await act(() => root.render(<Test />));
119 });
120
121 // @gate enableFragmentRefs && enableFragmentRefsInstanceHandles
122 it('attaches fragment handles to nodes', async () => {
123 const fragmentParentRef = React.createRef();
124 const fragmentRef = React.createRef();
125
126 function Test({show}) {
127 return (
128 <Fragment ref={fragmentParentRef}>
129 <Fragment ref={fragmentRef}>
130 <div id="childA">A</div>
131 <div id="childB">B</div>
132 </Fragment>
133 <div id="childC">C</div>
134 {show && <div id="childD">D</div>}
135 </Fragment>
136 );
137 }
138
139 const root = ReactDOMClient.createRoot(container);
140 await act(() => root.render(<Test show={false} />));
141
142 const childA = document.querySelector('#childA');
143 const childB = document.querySelector('#childB');
144 const childC = document.querySelector('#childC');
145
146 expect(childA.reactFragments.has(fragmentRef.current)).toBe(true);
147 expect(childB.reactFragments.has(fragmentRef.current)).toBe(true);
148 expect(childC.reactFragments.has(fragmentRef.current)).toBe(false);
149 expect(childA.reactFragments.has(fragmentParentRef.current)).toBe(true);
150 expect(childB.reactFragments.has(fragmentParentRef.current)).toBe(true);
151 expect(childC.reactFragments.has(fragmentParentRef.current)).toBe(true);
152
153 await act(() => root.render(<Test show={true} />));
154
155 const childD = document.querySelector('#childD');
156 expect(childD.reactFragments.has(fragmentRef.current)).toBe(false);
157 expect(childD.reactFragments.has(fragmentParentRef.current)).toBe(true);
158 });
159
160 // @gate enableFragmentRefs
161 it('runs the ref cleanup when an inline ref callback changes identity', async () => {
162 const fragmentInstances = [];
163 let rerender;
164
165 function Test() {
166 const [step, setStep] = React.useState(0);
167 rerender = () => {
168 setStep(p => p + 1);
169 };
170
171 return (
172 <Fragment
173 ref={fragmentInstance => {
174 fragmentInstances.push(fragmentInstance);
175 Scheduler.log(`fragment attach ${step}`);
176 return () => {
177 Scheduler.log(`fragment cleanup ${step}`);
178 };
179 }}>
180 <div
181 id="child"
182 ref={() => {
183 Scheduler.log(`host attach ${step}`);
184 return () => {
185 Scheduler.log(`host cleanup ${step}`);
186 };
187 }}
188 />
189 </Fragment>
190 );
191 }
192
193 const root = ReactDOMClient.createRoot(container);
194 await act(() => root.render(<Test />));
195 assertLog(['fragment attach 0', 'host attach 0']);
196
197 await act(rerender);
198 // Both refs are inlined, so both change identity and are detached before
199 // being re-attached. The Fragment detaches ahead of its children, which is
200 // the same order it uses when the Fragment itself is deleted.
201 assertLog([
202 'fragment cleanup 0',
203 'host cleanup 0',
204 'fragment attach 1',
205 'host attach 1',
206 ]);
207
208 await act(() => root.render(null));
209 // The cleanups created by the final render run on unmount.
210 assertLog(['fragment cleanup 1', 'host cleanup 1']);
211
212 // The same FragmentInstance is handed to every attach, so a callback that
213 // registers event listeners or observers on it can rely on its cleanup to
214 // unregister them again.
215 expect(fragmentInstances).toHaveLength(2);
216 expect(fragmentInstances[0]).toBe(fragmentInstances[1]);
217 });
218
219 // @gate enableFragmentRefs
220 it('runs the ref cleanup when the ref is removed from a mounted Fragment', async () => {
221 function Test({withRef}) {
222 return (
223 <Fragment
224 ref={
225 withRef
226 ? () => {
227 Scheduler.log('attach');
228 return () => {
229 Scheduler.log('cleanup');
230 };
231 }
232 : null
233 }>
234 <div id="child" />
235 </Fragment>
236 );
237 }
238
239 const root = ReactDOMClient.createRoot(container);
240 await act(() => root.render(<Test withRef={true} />));
241 assertLog(['attach']);
242
243 // The Fragment stays mounted and only the ref goes away. commitAttachRef
244 // bails out on a null ref, so the detach is the only thing that can run the
245 // cleanup here.
246 await act(() => root.render(<Test withRef={false} />));
247 assertLog(['cleanup']);
248
249 // Nothing is left to clean up by the time the Fragment is deleted.
250 await act(() => root.render(null));
251 assertLog([]);
252 });
253
254 // @gate enableFragmentRefs
255 it('detaches and reattaches Fragment refs when StrictMode double invokes', async () => {
256 // This one collects its own log rather than using Scheduler.log, because
257 // setIsStrictModeForDevtools disables yield values for the duration of the
258 // double invoke to keep StrictMode tests quiet, which would hide the very
259 // detach and reattach this test is here to observe.
260 const logs = [];
261 let rerender;
262
263 function Test() {
264 const [step, setStep] = React.useState(0);
265 rerender = () => {
266 setStep(p => p + 1);
267 };
268
269 return (
270 <Fragment
271 ref={() => {
272 logs.push(`attach ${step}`);
273 return () => {
274 logs.push(`cleanup ${step}`);
275 };
276 }}>
277 <div id="child" />
278 </Fragment>
279 );
280 }
281
282 const root = ReactDOMClient.createRoot(container);
283 await act(() =>
284 root.render(
285 <React.StrictMode>
286 <Test />
287 </React.StrictMode>,
288 ),
289 );
290 if (__DEV__) {
291 // The double invoke goes through disappearLayoutEffects and
292 // reappearLayoutEffects rather than through the mutation and layout
293 // phases, so it exercises a separate pair of Fragment cases.
294 expect(logs).toEqual(['attach 0', 'cleanup 0', 'attach 0']);
295 } else {
296 expect(logs).toEqual(['attach 0']);
297 }
298
299 // The double invoke only applies to newly mounted fibers, so an update
300 // detaches and reattaches once in both environments.
301 logs.length = 0;
302 await act(rerender);
303 expect(logs).toEqual(['cleanup 0', 'attach 1']);
304 });
305
306 describe('focus methods', () => {
307 describe('focus()', () => {
308 // @gate enableFragmentRefs
309 it('focuses the first focusable child', async () => {
310 const fragmentRef = React.createRef();
311 const root = ReactDOMClient.createRoot(container);
312
313 function Test() {
314 return (
315 <div>
316 <Fragment ref={fragmentRef}>
317 <div id="child-a" />
318 <style>{`#child-c {}`}</style>
319 <a id="child-b" href="/">
320 B
321 </a>
322 <a id="child-c" href="/">
323 C
324 </a>
325 </Fragment>
326 </div>
327 );
328 }
329
330 await act(() => {
331 root.render(<Test />);
332 });
333
334 await act(() => {
335 fragmentRef.current.focus();
336 });
337 expect(document.activeElement.id).toEqual('child-b');
338 document.activeElement.blur();
339 });
340
341 // @gate enableFragmentRefs
342 it('focuses deeply nested focusable children, depth first', async () => {
343 const fragmentRef = React.createRef();
344 const root = ReactDOMClient.createRoot(container);
345
346 function Test() {
347 return (
348 <Fragment ref={fragmentRef}>
349 <div id="child-a">
350 <div tabIndex={0} id="grandchild-a">
351 <a id="greatgrandchild-a" href="/" />
352 </div>
353 </div>
354 <a id="child-b" href="/" />
355 </Fragment>
356 );
357 }
358 await act(() => {
359 root.render(<Test />);
360 });
361 await act(() => {
362 fragmentRef.current.focus();
363 });
364 expect(document.activeElement.id).toEqual('grandchild-a');
365 });
366
367 // @gate enableFragmentRefs
368 it('preserves document order when adding and removing children', async () => {
369 const fragmentRef = React.createRef();
370 const root = ReactDOMClient.createRoot(container);
371
372 function Test({showA, showB}) {
373 return (
374 <Fragment ref={fragmentRef}>
375 {showA && <a href="/" id="child-a" />}
376 {showB && <a href="/" id="child-b" />}
377 </Fragment>
378 );
379 }
380
381 // Render with A as the first focusable child
382 await act(() => {
383 root.render(<Test showA={true} showB={false} />);
384 });
385 await act(() => {
386 fragmentRef.current.focus();
387 });
388 expect(document.activeElement.id).toEqual('child-a');
389 document.activeElement.blur();
390 // A is still the first focusable child, but B is also tracked
391 await act(() => {
392 root.render(<Test showA={true} showB={true} />);
393 });
394 await act(() => {
395 fragmentRef.current.focus();
396 });
397 expect(document.activeElement.id).toEqual('child-a');
398 document.activeElement.blur();
399
400 // B is now the first focusable child
401 await act(() => {
402 root.render(<Test showA={false} showB={true} />);
403 });
404 await act(() => {
405 fragmentRef.current.focus();
406 });
407 expect(document.activeElement.id).toEqual('child-b');
408 document.activeElement.blur();
409 });
410
411 // @gate enableFragmentRefs
412 it('keeps focus on the first focusable child if already focused', async () => {
413 const fragmentRef = React.createRef();
414 const root = ReactDOMClient.createRoot(container);
415
416 function Test() {
417 return (
418 <Fragment ref={fragmentRef}>
419 <a id="child-a" href="/">
420 A
421 </a>
422 <a id="child-b" href="/">
423 B
424 </a>
425 </Fragment>
426 );
427 }
428
429 await act(() => {
430 root.render(<Test />);
431 });
432
433 // Focus the first child manually
434 document.getElementById('child-a').focus();
435 expect(document.activeElement.id).toEqual('child-a');
436
437 // Calling fragment.focus() should keep focus on child-a,
438 // not skip to child-b
439 await act(() => {
440 fragmentRef.current.focus();
441 });
442 expect(document.activeElement.id).toEqual('child-a');
443 document.activeElement.blur();
444 });
445
446 // @gate enableFragmentRefs
447 it('keeps focus on a nested child if already focused', async () => {
448 const fragmentRef = React.createRef();
449 const root = ReactDOMClient.createRoot(container);
450
451 function Test() {
452 return (
453 <Fragment ref={fragmentRef}>
454 <div>
455 <input id="nested-input" />
456 </div>
457 <a id="sibling-link" href="/">
458 Link
459 </a>
460 </Fragment>
461 );
462 }
463
464 await act(() => {
465 root.render(<Test />);
466 });
467
468 // Focus the nested input manually
469 document.getElementById('nested-input').focus();
470 expect(document.activeElement.id).toEqual('nested-input');
471
472 // Calling fragment.focus() should keep focus on nested-input
473 await act(() => {
474 fragmentRef.current.focus();
475 });
476 expect(document.activeElement.id).toEqual('nested-input');
477 document.activeElement.blur();
478 });
479
480 // @gate enableFragmentRefs
481 it('focuses the first focusable child in a fieldset', async () => {
482 const fragmentRef = React.createRef();
483 const root = ReactDOMClient.createRoot(container);
484
485 function Test() {
486 return (
487 <Fragment ref={fragmentRef}>
488 <fieldset>
489 <legend>Shipping</legend>
490 <input id="street" name="street" />
491 <input id="city" name="city" />
492 </fieldset>
493 </Fragment>
494 );
495 }
496
497 await act(() => {
498 root.render(<Test />);
499 });
500 await act(() => {
501 fragmentRef.current.focus();
502 });
503 expect(document.activeElement.id).toEqual('street');
504 document.activeElement.blur();
505 });
506 });
507
508 describe('focusLast()', () => {
509 // @gate enableFragmentRefs
510 it('focuses the last focusable child', async () => {
511 const fragmentRef = React.createRef();
512 const root = ReactDOMClient.createRoot(container);
513
514 function Test() {
515 return (
516 <div>
517 <Fragment ref={fragmentRef}>
518 <a id="child-a" href="/">
519 A
520 </a>
521 <a id="child-b" href="/">
522 B
523 </a>
524 <Wrapper>
525 <a id="child-c" href="/">
526 C
527 </a>
528 </Wrapper>
529 <div id="child-d" />
530 <style id="child-e">{`#child-d {}`}</style>
531 </Fragment>
532 </div>
533 );
534 }
535
536 await act(() => {
537 root.render(<Test />);
538 });
539
540 await act(() => {
541 fragmentRef.current.focusLast();
542 });
543 expect(document.activeElement.id).toEqual('child-c');
544 document.activeElement.blur();
545 });
546
547 // @gate enableFragmentRefs
548 it('focuses deeply nested focusable children, depth first', async () => {
549 const fragmentRef = React.createRef();
550 const root = ReactDOMClient.createRoot(container);
551
552 function Test() {
553 return (
554 <Fragment ref={fragmentRef}>
555 <div id="child-a" href="/">
556 <a id="grandchild-a" href="/" />
557 <a id="grandchild-b" href="/" />
558 </div>
559 <div tabIndex={0} id="child-b">
560 <a id="grandchild-a" href="/" />
561 <a id="grandchild-b" href="/" />
562 </div>
563 </Fragment>
564 );
565 }
566 await act(() => {
567 root.render(<Test />);
568 });
569 await act(() => {
570 fragmentRef.current.focusLast();
571 });
572 expect(document.activeElement.id).toEqual('grandchild-b');
573 });
574 });
575
576 describe('blur()', () => {
577 // @gate enableFragmentRefs
578 it('removes focus from an element inside of the Fragment', async () => {
579 const fragmentRef = React.createRef();
580 const root = ReactDOMClient.createRoot(container);
581
582 function Test() {
583 return (
584 <Fragment ref={fragmentRef}>
585 <a id="child-a" href="/">
586 A
587 </a>
588 </Fragment>
589 );
590 }
591
592 await act(() => {
593 root.render(<Test />);
594 });
595
596 await act(() => {
597 fragmentRef.current.focus();
598 });
599 expect(document.activeElement.id).toEqual('child-a');
600
601 await act(() => {
602 fragmentRef.current.blur();
603 });
604 expect(document.activeElement).toEqual(document.body);
605 });
606
607 // @gate enableFragmentRefs
608 it('removes focus from a nested element inside of the Fragment', async () => {
609 const fragmentRef = React.createRef();
610 const root = ReactDOMClient.createRoot(container);
611
612 function Test() {
613 return (
614 <Fragment ref={fragmentRef}>
615 <div>
616 <input id="nested-input" />
617 </div>
618 </Fragment>
619 );
620 }
621
622 await act(() => {
623 root.render(<Test />);
624 });
625
626 await act(() => {
627 fragmentRef.current.focus();
628 });
629 expect(document.activeElement.id).toEqual('nested-input');
630
631 await act(() => {
632 fragmentRef.current.blur();
633 });
634 expect(document.activeElement).toEqual(document.body);
635 });
636
637 // @gate enableFragmentRefs
638 it('removes focus from a portaled element inside of the Fragment', async () => {
639 const fragmentRef = React.createRef();
640 const root = ReactDOMClient.createRoot(container);
641
642 function Test() {
643 return (
644 <div>
645 <Fragment ref={fragmentRef}>
646 {createPortal(
647 <div>
648 <input id="portaled-input" />
649 </div>,
650 document.body,
651 )}
652 </Fragment>
653 </div>
654 );
655 }
656
657 await act(() => {
658 root.render(<Test />);
659 });
660
661 await act(() => {
662 fragmentRef.current.focus();
663 });
664 expect(document.activeElement.id).toEqual('portaled-input');
665
666 await act(() => {
667 fragmentRef.current.blur();
668 });
669 expect(document.activeElement).toEqual(document.body);
670 });
671
672 // @gate enableFragmentRefs
673 it('does not remove focus from elements outside of the Fragment', async () => {
674 const fragmentRefA = React.createRef();
675 const fragmentRefB = React.createRef();
676 const root = ReactDOMClient.createRoot(container);
677
678 function Test() {
679 return (
680 <Fragment ref={fragmentRefA}>
681 <a id="child-a" href="/">
682 A
683 </a>
684 <Fragment ref={fragmentRefB}>
685 <a id="child-b" href="/">
686 B
687 </a>
688 </Fragment>
689 </Fragment>
690 );
691 }
692
693 await act(() => {
694 root.render(<Test />);
695 });
696
697 await act(() => {
698 fragmentRefA.current.focus();
699 });
700 expect(document.activeElement.id).toEqual('child-a');
701
702 await act(() => {
703 fragmentRefB.current.blur();
704 });
705 expect(document.activeElement.id).toEqual('child-a');
706 });
707 });
708 });
709
710 describe('events', () => {
711 describe('add/remove event listeners', () => {
712 // @gate enableFragmentRefs
713 it('adds and removes event listeners from children', async () => {
714 const parentRef = React.createRef();
715 const fragmentRef = React.createRef();
716 const childARef = React.createRef();
717 const childBRef = React.createRef();
718 const root = ReactDOMClient.createRoot(container);
719
720 let logs = [];
721
722 function handleFragmentRefClicks() {
723 logs.push('fragmentRef');
724 }
725
726 function Test() {
727 React.useEffect(() => {
728 fragmentRef.current.addEventListener(
729 'click',
730 handleFragmentRefClicks,
731 );
732
733 return () => {
734 fragmentRef.current.removeEventListener(
735 'click',
736 handleFragmentRefClicks,
737 );
738 };
739 }, []);
740 return (
741 <div ref={parentRef}>
742 <Fragment ref={fragmentRef}>
743 <>Text</>
744 <div ref={childARef}>A</div>
745 <>
746 <div ref={childBRef}>B</div>
747 </>
748 </Fragment>
749 </div>
750 );
751 }
752
753 await act(() => {
754 root.render(<Test />);
755 });
756
757 childARef.current.addEventListener('click', () => {
758 logs.push('A');
759 });
760
761 childBRef.current.addEventListener('click', () => {
762 logs.push('B');
763 });
764
765 // Clicking on the parent should not trigger any listeners
766 parentRef.current.click();
767 expect(logs).toEqual([]);
768
769 // Clicking a child triggers its own listeners and the Fragment's
770 childARef.current.click();
771 expect(logs).toEqual(['fragmentRef', 'A']);
772
773 logs = [];
774
775 childBRef.current.click();
776 expect(logs).toEqual(['fragmentRef', 'B']);
777
778 logs = [];
779
780 fragmentRef.current.removeEventListener(
781 'click',
782 handleFragmentRefClicks,
783 );
784
785 childARef.current.click();
786 expect(logs).toEqual(['A']);
787
788 logs = [];
789
790 childBRef.current.click();
791 expect(logs).toEqual(['B']);
792 });
793
794 // @gate enableFragmentRefs
795 it('regression: does not detach a registered listener when removing an unregistered one', async () => {
796 const fragmentRef = React.createRef();
797 const childRef = React.createRef();
798 const root = ReactDOMClient.createRoot(container);
799 let logs = [];
800
801 function registeredListener() {
802 logs.push('registered');
803 }
804
805 function unregisteredListener() {
806 logs.push('unregistered');
807 }
808
809 await act(() => {
810 root.render(
811 <Fragment ref={fragmentRef}>
812 <div ref={childRef}>child</div>
813 </Fragment>,
814 );
815 });
816
817 fragmentRef.current.addEventListener('click', registeredListener);
818 childRef.current.click();
819 expect(logs).toEqual(['registered']);
820
821 // Regression: removing a listener that was never added must be a no-op.
822 // It must not detach registered listeners from fragmentInstance,
823 // causing them to stay attached to DOM even after removeEventListener.
824 fragmentRef.current.removeEventListener('click', unregisteredListener);
825 logs = [];
826 childRef.current.click();
827 expect(logs).toEqual(['registered']);
828
829 fragmentRef.current.removeEventListener('click', registeredListener);
830 logs = [];
831 childRef.current.click();
832 expect(logs).toEqual([]);
833 });
834
835 // @gate enableFragmentRefs
836 it('matches listeners by their normalized capture flag', async () => {
837 const fragmentRef = React.createRef();
838 const childRef = React.createRef();
839 const root = ReactDOMClient.createRoot(container);
840 const logs = [];
841
842 function addedWithOmittedOptions() {
843 logs.push('addedWithOmittedOptions');
844 }
845
846 function addedWithCaptureFalse() {
847 logs.push('addedWithCaptureFalse');
848 }
849
850 await act(() => {
851 root.render(
852 <Fragment ref={fragmentRef}>
853 <div ref={childRef}>child</div>
854 </Fragment>,
855 );
856 });
857
858 fragmentRef.current.addEventListener('click', addedWithOmittedOptions);
859 fragmentRef.current.addEventListener('click', addedWithCaptureFalse, {
860 capture: false,
861 });
862
863 // Omitted options and an explicit capture: false are the same
864 // EventTarget listener identity, so each removal should match.
865 fragmentRef.current.removeEventListener(
866 'click',
867 addedWithOmittedOptions,
868 false,
869 );
870 fragmentRef.current.removeEventListener('click', addedWithCaptureFalse);
871
872 childRef.current.click();
873 expect(logs).toEqual([]);
874 });
875
876 // @gate enableFragmentRefs
877 it('adds and removes event listeners from children with multiple fragments', async () => {
878 const fragmentRef = React.createRef();
879 const nestedFragmentRef = React.createRef();
880 const nestedFragmentRef2 = React.createRef();
881 const childARef = React.createRef();
882 const childBRef = React.createRef();
883 const childCRef = React.createRef();
884 const root = ReactDOMClient.createRoot(container);
885
886 await act(() => {
887 root.render(
888 <div>
889 <Fragment ref={fragmentRef}>
890 <div ref={childARef}>A</div>
891 <div>
892 <Fragment ref={nestedFragmentRef}>
893 <div ref={childBRef}>B</div>
894 </Fragment>
895 </div>
896 <Fragment ref={nestedFragmentRef2}>
897 <div ref={childCRef}>C</div>
898 </Fragment>
899 </Fragment>
900 </div>,
901 );
902 });
903
904 let logs = [];
905
906 function handleFragmentRefClicks() {
907 logs.push('fragmentRef');
908 }
909
910 function handleNestedFragmentRefClicks() {
911 logs.push('nestedFragmentRef');
912 }
913
914 function handleNestedFragmentRef2Clicks() {
915 logs.push('nestedFragmentRef2');
916 }
917
918 fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
919 nestedFragmentRef.current.addEventListener(
920 'click',
921 handleNestedFragmentRefClicks,
922 );
923 nestedFragmentRef2.current.addEventListener(
924 'click',
925 handleNestedFragmentRef2Clicks,
926 );
927
928 childBRef.current.click();
929 // Event bubbles to the parent fragment
930 expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
931
932 logs = [];
933
934 childARef.current.click();
935 expect(logs).toEqual(['fragmentRef']);
936
937 logs = [];
938 childCRef.current.click();
939 expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
940
941 logs = [];
942
943 fragmentRef.current.removeEventListener(
944 'click',
945 handleFragmentRefClicks,
946 );
947 nestedFragmentRef.current.removeEventListener(
948 'click',
949 handleNestedFragmentRefClicks,
950 );
951 childCRef.current.click();
952 expect(logs).toEqual(['nestedFragmentRef2']);
953 });
954
955 // @gate enableFragmentRefs
956 it('adds an event listener to a newly added child', async () => {
957 const fragmentRef = React.createRef();
958 const childRef = React.createRef();
959 const root = ReactDOMClient.createRoot(container);
960 let showChild;
961
962 function Component() {
963 const [shouldShowChild, setShouldShowChild] = React.useState(false);
964 showChild = () => {
965 setShouldShowChild(true);
966 };
967
968 return (
969 <div>
970 <Fragment ref={fragmentRef}>
971 <div id="a">A</div>
972 {shouldShowChild && (
973 <div ref={childRef} id="b">
974 B
975 </div>
976 )}
977 </Fragment>
978 </div>
979 );
980 }
981
982 await act(() => {
983 root.render(<Component />);
984 });
985
986 expect(fragmentRef.current).not.toBe(null);
987 expect(childRef.current).toBe(null);
988
989 let hasClicked = false;
990 fragmentRef.current.addEventListener('click', () => {
991 hasClicked = true;
992 });
993
994 await act(() => {
995 showChild();
996 });
997 expect(childRef.current).not.toBe(null);
998
999 childRef.current.click();
1000 expect(hasClicked).toBe(true);
1001 });
1002
1003 // @gate enableFragmentRefs
1004 it('fires a once listener only once across existing children', async () => {
1005 const fragmentRef = React.createRef();
1006 const childARef = React.createRef();
1007 const childBRef = React.createRef();
1008 const root = ReactDOMClient.createRoot(container);
1009
1010 await act(() => {
1011 root.render(
1012 <div>
1013 <Fragment ref={fragmentRef}>
1014 <div ref={childARef} id="a">
1015 A
1016 </div>
1017 <div ref={childBRef} id="b">
1018 B
1019 </div>
1020 </Fragment>
1021 </div>,
1022 );
1023 });
1024
1025 const logs = [];
1026 fragmentRef.current.addEventListener(
1027 'click',
1028 () => {
1029 logs.push('once');
1030 },
1031 {once: true},
1032 );
1033
1034 childARef.current.click();
1035 expect(logs).toEqual(['once']);
1036
1037 logs.length = 0;
1038 childBRef.current.click();
1039 expect(logs).toEqual([]);
1040 });
1041
1042 // @gate enableFragmentRefs
1043 it('does not re-arm a once listener when a new child is inserted', async () => {
1044 const fragmentRef = React.createRef();
1045 const childARef = React.createRef();
1046 const childBRef = React.createRef();
1047 const root = ReactDOMClient.createRoot(container);
1048 let showChildB;
1049
1050 function Component() {
1051 const [shouldShowChildB, setShouldShowChildB] = React.useState(false);
1052 showChildB = () => {
1053 setShouldShowChildB(true);
1054 };
1055
1056 return (
1057 <div>
1058 <Fragment ref={fragmentRef}>
1059 <div ref={childARef} id="a">
1060 A
1061 </div>
1062 {shouldShowChildB && (
1063 <div ref={childBRef} id="b">
1064 B
1065 </div>
1066 )}
1067 </Fragment>
1068 </div>
1069 );
1070 }
1071
1072 await act(() => {
1073 root.render(<Component />);
1074 });
1075
1076 const logs = [];
1077 fragmentRef.current.addEventListener(
1078 'click',
1079 () => {
1080 logs.push('once');
1081 },
1082 {once: true},
1083 );
1084
1085 childARef.current.click();
1086 expect(logs).toEqual(['once']);
1087
1088 await act(() => {
1089 showChildB();
1090 });
1091
1092 logs.length = 0;
1093 childBRef.current.click();
1094 expect(logs).toEqual([]);
1095 });
1096
1097 // @gate enableFragmentRefs && enableFragmentRefsTextNodes
1098 it('adds an event listener to a newly added text child', async () => {
1099 const fragmentRef = React.createRef();
1100 const parentRef = React.createRef();
1101 const root = ReactDOMClient.createRoot(container);
1102 let showText;
1103
1104 function Component() {
1105 const [shouldShowText, setShouldShowText] = React.useState(false);
1106 showText = () => {
1107 setShouldShowText(true);
1108 };
1109
1110 return (
1111 <div ref={parentRef}>
1112 <Fragment ref={fragmentRef}>
1113 {shouldShowText ? 'Hello' : null}
1114 </Fragment>
1115 </div>
1116 );
1117 }
1118
1119 await act(() => {
1120 root.render(<Component />);
1121 });
1122
1123 const logs = [];
1124 fragmentRef.current.addEventListener('click', () => {
1125 logs.push('fragment');
1126 });
1127
1128 await act(() => {
1129 showText();
1130 });
1131
1132 const textNode = Array.from(parentRef.current.childNodes).find(
1133 node => node.nodeType === 3,
1134 );
1135 expect(textNode).not.toBe(undefined);
1136 textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
1137 expect(logs).toEqual(['fragment']);
1138 });
1139
1140 // @gate enableFragmentRefs && enableFragmentRefsTextNodes
1141 it('removes event listeners from a deleted text child', async () => {
1142 const fragmentRef = React.createRef();
1143 const parentRef = React.createRef();
1144 const root = ReactDOMClient.createRoot(container);
1145 let hideText;
1146
1147 function Component() {
1148 const [shouldShowText, setShouldShowText] = React.useState(true);
1149 hideText = () => {
1150 setShouldShowText(false);
1151 };
1152
1153 return (
1154 <div ref={parentRef}>
1155 <Fragment ref={fragmentRef}>
1156 {shouldShowText ? 'Hello' : null}
1157 </Fragment>
1158 </div>
1159 );
1160 }
1161
1162 await act(() => {
1163 root.render(<Component />);
1164 });
1165
1166 const textNode = Array.from(parentRef.current.childNodes).find(
1167 node => node.nodeType === 3,
1168 );
1169 expect(textNode).not.toBe(undefined);
1170
1171 const logs = [];
1172 fragmentRef.current.addEventListener('click', () => {
1173 logs.push('fragment');
1174 });
1175
1176 textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
1177 expect(logs).toEqual(['fragment']);
1178
1179 await act(() => {
1180 hideText();
1181 });
1182
1183 const detachedHost = document.createElement('div');
1184 document.body.appendChild(detachedHost);
1185 detachedHost.appendChild(textNode);
1186
1187 logs.length = 0;
1188 textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
1189 expect(logs).toEqual([]);
1190
1191 document.body.removeChild(detachedHost);
1192 });
1193
1194 // @gate enableFragmentRefs
1195 it('applies event listeners to host children nested within non-host children', async () => {
1196 const fragmentRef = React.createRef();
1197 const childRef = React.createRef();
1198 const nestedChildRef = React.createRef();
1199 const root = ReactDOMClient.createRoot(container);
1200
1201 await act(() => {
1202 root.render(
1203 <div>
1204 <Fragment ref={fragmentRef}>
1205 <div ref={childRef}>Host A</div>
1206 <Wrapper>
1207 <Wrapper>
1208 <Wrapper>
1209 <div ref={nestedChildRef}>Host B</div>
1210 </Wrapper>
1211 </Wrapper>
1212 </Wrapper>
1213 </Fragment>
1214 </div>,
1215 );
1216 });
1217 const logs = [];
1218 fragmentRef.current.addEventListener('click', e => {
1219 logs.push(e.target.textContent);
1220 });
1221
1222 expect(logs).toEqual([]);
1223 childRef.current.click();
1224 expect(logs).toEqual(['Host A']);
1225 nestedChildRef.current.click();
1226 expect(logs).toEqual(['Host A', 'Host B']);
1227 });
1228
1229 // @gate enableFragmentRefs
1230 it('allows adding and cleaning up listeners in effects', async () => {
1231 const root = ReactDOMClient.createRoot(container);
1232
1233 let logs = [];
1234 function logClick(e) {
1235 logs.push(e.currentTarget.id);
1236 }
1237
1238 let rerender;
1239 let removeEventListeners;
1240
1241 function Test() {
1242 const fragmentRef = React.useRef(null);
1243 // eslint-disable-next-line no-unused-vars
1244 const [_, setState] = React.useState(0);
1245 rerender = () => {
1246 setState(p => p + 1);
1247 };
1248 removeEventListeners = () => {
1249 fragmentRef.current.removeEventListener('click', logClick);
1250 };
1251 React.useEffect(() => {
1252 fragmentRef.current.addEventListener('click', logClick);
1253
1254 return removeEventListeners;
1255 });
1256
1257 return (
1258 <Fragment ref={fragmentRef}>
1259 <div id="child-a" />
1260 </Fragment>
1261 );
1262 }
1263
1264 // The event listener was applied
1265 await act(() => root.render(<Test />));
1266 expect(logs).toEqual([]);
1267 document.querySelector('#child-a').click();
1268 expect(logs).toEqual(['child-a']);
1269
1270 // The event listener can be removed and re-added
1271 logs = [];
1272 await act(rerender);
1273 document.querySelector('#child-a').click();
1274 expect(logs).toEqual(['child-a']);
1275 });
1276
1277 // @gate enableFragmentRefs
1278 it('does not apply removed event listeners to new children', async () => {
1279 const root = ReactDOMClient.createRoot(container);
1280 const fragmentRef = React.createRef(null);
1281 function Test() {
1282 return (
1283 <Fragment ref={fragmentRef}>
1284 <div id="child-a" />
1285 </Fragment>
1286 );
1287 }
1288
1289 let logs = [];
1290 function logClick(e) {
1291 logs.push(e.currentTarget.id);
1292 }
1293 await act(() => {
1294 root.render(<Test />);
1295 });
1296 fragmentRef.current.addEventListener('click', logClick);
1297 const childA = document.querySelector('#child-a');
1298 childA.click();
1299 expect(logs).toEqual(['child-a']);
1300
1301 logs = [];
1302 fragmentRef.current.removeEventListener('click', logClick);
1303 childA.click();
1304 expect(logs).toEqual([]);
1305 });
1306
1307 // @gate enableFragmentRefs
1308 it('removes a capture listener registered with boolean when removed with options object', async () => {
1309 const fragmentRef = React.createRef(null);
1310 function Test() {
1311 return (
1312 <Fragment ref={fragmentRef}>
1313 <div id="child-a" />
1314 </Fragment>
1315 );
1316 }
1317 const root = ReactDOMClient.createRoot(container);
1318 await act(() => {
1319 root.render(<Test />);
1320 });
1321
1322 const logs = [];
1323 function logCapture() {
1324 logs.push('capture');
1325 }
1326
1327 // Register with boolean `true` (capture phase)
1328 fragmentRef.current.addEventListener('click', logCapture, true);
1329 document.querySelector('#child-a').click();
1330 expect(logs).toEqual(['capture']);
1331
1332 logs.length = 0;
1333
1334 // Remove with equivalent options object {capture: true}
1335 // Per DOM spec, these are identical - the listener MUST be removed
1336 fragmentRef.current.removeEventListener('click', logCapture, {
1337 capture: true,
1338 });
1339 document.querySelector('#child-a').click();
1340 // Listener should have been removed - logs must remain empty
1341 expect(logs).toEqual([]);
1342 });
1343
1344 // @gate enableFragmentRefs
1345 it('removes a capture listener registered with options object when removed with boolean', async () => {
1346 const fragmentRef = React.createRef(null);
1347 function Test() {
1348 return (
1349 <Fragment ref={fragmentRef}>
1350 <div id="child-b" />
1351 </Fragment>
1352 );
1353 }
1354 const root = ReactDOMClient.createRoot(container);
1355 await act(() => {
1356 root.render(<Test />);
1357 });
1358
1359 const logs = [];
1360 function logCapture() {
1361 logs.push('capture');
1362 }
1363
1364 // Register with options object {capture: true}
1365 fragmentRef.current.addEventListener('click', logCapture, {
1366 capture: true,
1367 });
1368 document.querySelector('#child-b').click();
1369 expect(logs).toEqual(['capture']);
1370
1371 logs.length = 0;
1372
1373 // Remove with boolean `true`
1374 // Per DOM spec, these are identical - the listener MUST be removed
1375 fragmentRef.current.removeEventListener('click', logCapture, true);
1376 document.querySelector('#child-b').click();
1377 // Listener should have been removed - logs must remain empty
1378 expect(logs).toEqual([]);
1379 });
1380
1381 // @gate enableFragmentRefs
1382 it('applies event listeners to portaled children', async () => {
1383 const fragmentRef = React.createRef();
1384 const childARef = React.createRef();
1385 const childBRef = React.createRef();
1386 const root = ReactDOMClient.createRoot(container);
1387
1388 function Test() {
1389 return (
1390 <Fragment ref={fragmentRef}>
1391 <div id="child-a" ref={childARef} />
1392 {createPortal(
1393 <div id="child-b" ref={childBRef} />,
1394 document.body,
1395 )}
1396 </Fragment>
1397 );
1398 }
1399
1400 await act(() => {
1401 root.render(<Test />);
1402 });
1403
1404 const logs = [];
1405 fragmentRef.current.addEventListener('click', e => {
1406 logs.push(e.target.id);
1407 });
1408
1409 childARef.current.click();
1410 expect(logs).toEqual(['child-a']);
1411
1412 logs.length = 0;
1413 childBRef.current.click();
1414 expect(logs).toEqual(['child-b']);
1415 });
1416
1417 // @gate enableFragmentRefs
1418 it('applies event listeners to children portaled in after registration', async () => {
1419 const fragmentRef = React.createRef();
1420 const childARef = React.createRef();
1421 const childBRef = React.createRef();
1422 const root = ReactDOMClient.createRoot(container);
1423 let showChildB;
1424
1425 function Test() {
1426 const [shouldShowChildB, setShouldShowChildB] = React.useState(false);
1427 showChildB = () => {
1428 setShouldShowChildB(true);
1429 };
1430
1431 return (
1432 <Fragment ref={fragmentRef}>
1433 {createPortal(
1434 <>
1435 <div id="child-a" ref={childARef} />
1436 {shouldShowChildB && <div id="child-b" ref={childBRef} />}
1437 </>,
1438 document.body,
1439 )}
1440 </Fragment>
1441 );
1442 }
1443
1444 await act(() => {
1445 root.render(<Test />);
1446 });
1447
1448 const logs = [];
1449 fragmentRef.current.addEventListener('click', e => {
1450 logs.push(e.target.id);
1451 });
1452
1453 childARef.current.click();
1454 expect(logs).toEqual(['child-a']);
1455
1456 // child-b is inserted into the same portal after the listener was
1457 // registered, so it should be treated like its sibling child-a.
1458 await act(() => {
1459 showChildB();
1460 });
1461
1462 logs.length = 0;
1463 childBRef.current.click();
1464 expect(logs).toEqual(['child-b']);
1465 });
1466
1467 describe('with activity', () => {
1468 // @gate enableFragmentRefs
1469 it('does not apply event listeners to hidden trees', async () => {
1470 const parentRef = React.createRef();
1471 const fragmentRef = React.createRef();
1472 const root = ReactDOMClient.createRoot(container);
1473
1474 function Test() {
1475 return (
1476 <div ref={parentRef}>
1477 <Fragment ref={fragmentRef}>
1478 <div>Child 1</div>
1479 <Activity mode="hidden">
1480 <div>Child 2</div>
1481 </Activity>
1482 <div>Child 3</div>
1483 </Fragment>
1484 </div>
1485 );
1486 }
1487
1488 await act(() => {
1489 root.render(<Test />);
1490 });
1491
1492 const logs = [];
1493 fragmentRef.current.addEventListener('click', e => {
1494 logs.push(e.target.textContent);
1495 });
1496
1497 const [child1, child2, child3] = parentRef.current.children;
1498 child1.click();
1499 child2.click();
1500 child3.click();
1501 expect(logs).toEqual(['Child 1', 'Child 3']);
1502 });
1503
1504 // @gate enableFragmentRefs
1505 it('applies event listeners to visible trees', async () => {
1506 const parentRef = React.createRef();
1507 const fragmentRef = React.createRef();
1508 const root = ReactDOMClient.createRoot(container);
1509
1510 function Test() {
1511 return (
1512 <div ref={parentRef}>
1513 <Fragment ref={fragmentRef}>
1514 <div>Child 1</div>
1515 <Activity mode="visible">
1516 <div>Child 2</div>
1517 </Activity>
1518 <div>Child 3</div>
1519 </Fragment>
1520 </div>
1521 );
1522 }
1523
1524 await act(() => {
1525 root.render(<Test />);
1526 });
1527
1528 const logs = [];
1529 fragmentRef.current.addEventListener('click', e => {
1530 logs.push(e.target.textContent);
1531 });
1532
1533 const [child1, child2, child3] = parentRef.current.children;
1534 child1.click();
1535 child2.click();
1536 child3.click();
1537 expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
1538 });
1539
1540 // @gate enableFragmentRefs
1541 it('handles Activity modes switching', async () => {
1542 const fragmentRef = React.createRef();
1543 const fragmentRef2 = React.createRef();
1544 const parentRef = React.createRef();
1545 const root = ReactDOMClient.createRoot(container);
1546
1547 function Test({mode}) {
1548 return (
1549 <div id="parent" ref={parentRef}>
1550 <Fragment ref={fragmentRef}>
1551 <Activity mode={mode}>
1552 <div id="child1">Child</div>
1553 <Fragment ref={fragmentRef2}>
1554 <div id="child2">Child 2</div>
1555 </Fragment>
1556 </Activity>
1557 </Fragment>
1558 </div>
1559 );
1560 }
1561
1562 await act(() => {
1563 root.render(<Test mode="visible" />);
1564 });
1565
1566 let logs = [];
1567 fragmentRef.current.addEventListener('click', () => {
1568 logs.push('clicked 1');
1569 });
1570 fragmentRef2.current.addEventListener('click', () => {
1571 logs.push('clicked 2');
1572 });
1573 parentRef.current.lastChild.click();
1574 expect(logs).toEqual(['clicked 1', 'clicked 2']);
1575
1576 logs = [];
1577 await act(() => {
1578 root.render(<Test mode="hidden" />);
1579 });
1580 parentRef.current.firstChild.click();
1581 parentRef.current.lastChild.click();
1582 expect(logs).toEqual([]);
1583
1584 logs = [];
1585 await act(() => {
1586 root.render(<Test mode="visible" />);
1587 });
1588 parentRef.current.lastChild.click();
1589 // Event order is flipped here because the nested child re-registers first
1590 expect(logs).toEqual(['clicked 2', 'clicked 1']);
1591 });
1592
1593 // @gate enableFragmentRefs && enableFragmentRefsTextNodes
1594 it('does not dispatch fragment events from text children while hidden', async () => {
1595 const parentRef = React.createRef();
1596 const fragmentRef = React.createRef();
1597 const root = ReactDOMClient.createRoot(container);
1598
1599 function Test({mode}) {
1600 return (
1601 <div ref={parentRef}>
1602 <Fragment ref={fragmentRef}>
1603 <Activity mode={mode}>
1604 <div id="child">Element</div>
1605 Text
1606 </Activity>
1607 </Fragment>
1608 </div>
1609 );
1610 }
1611
1612 await act(() => {
1613 root.render(<Test mode="visible" />);
1614 });
1615
1616 const logs = [];
1617 fragmentRef.current.addEventListener('click', e => {
1618 logs.push(
1619 e.target.nodeType === 3
1620 ? 'text'
1621 : e.target.id || e.target.tagName,
1622 );
1623 });
1624
1625 const textNode = Array.from(parentRef.current.childNodes).find(
1626 node => node.nodeType === 3,
1627 );
1628 expect(textNode).not.toBe(undefined);
1629
1630 document.getElementById('child').click();
1631 textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
1632 expect(logs).toEqual(['child', 'text']);
1633
1634 logs.length = 0;
1635 await act(() => {
1636 root.render(<Test mode="hidden" />);
1637 });
1638
1639 document.getElementById('child').click();
1640 textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
1641 expect(logs).toEqual([]);
1642
1643 logs.length = 0;
1644 await act(() => {
1645 root.render(<Test mode="visible" />);
1646 });
1647
1648 document.getElementById('child').click();
1649 textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
1650 expect(logs).toEqual(['child', 'text']);
1651 });
1652 });
1653 });
1654
1655 describe('dispatchEvent()', () => {
1656 // @gate enableFragmentRefs
1657 it('fires events on the host parent if bubbles=true', async () => {
1658 const fragmentRef = React.createRef();
1659 const root = ReactDOMClient.createRoot(container);
1660 let logs = [];
1661
1662 function handleClick(e) {
1663 logs.push([e.type, e.target.id, e.currentTarget.id]);
1664 }
1665
1666 function Test({isMounted}) {
1667 return (
1668 <div onClick={handleClick} id="grandparent">
1669 <div onClick={handleClick} id="parent">
1670 {isMounted && (
1671 <Fragment ref={fragmentRef}>
1672 <div onClick={handleClick} id="child">
1673 Hi
1674 </div>
1675 </Fragment>
1676 )}
1677 </div>
1678 </div>
1679 );
1680 }
1681
1682 await act(() => {
1683 root.render(<Test isMounted={true} />);
1684 });
1685
1686 let isCancelable = !fragmentRef.current.dispatchEvent(
1687 new MouseEvent('click', {bubbles: true}),
1688 );
1689 expect(logs).toEqual([
1690 ['click', 'parent', 'parent'],
1691 ['click', 'parent', 'grandparent'],
1692 ]);
1693 expect(isCancelable).toBe(false);
1694
1695 const fragmentInstanceHandle = fragmentRef.current;
1696 await act(() => {
1697 root.render(<Test isMounted={false} />);
1698 });
1699 logs = [];
1700 isCancelable = !fragmentInstanceHandle.dispatchEvent(
1701 new MouseEvent('click', {bubbles: true}),
1702 );
1703 expect(logs).toEqual([]);
1704 expect(isCancelable).toBe(false);
1705
1706 logs = [];
1707 isCancelable = !fragmentInstanceHandle.dispatchEvent(
1708 new MouseEvent('click', {bubbles: false}),
1709 );
1710 expect(logs).toEqual([]);
1711 expect(isCancelable).toBe(false);
1712 });
1713
1714 // @gate enableFragmentRefs
1715 it('fires events on self, and only self if bubbles=false', async () => {
1716 const fragmentRef = React.createRef();
1717 const root = ReactDOMClient.createRoot(container);
1718 let logs = [];
1719
1720 function handleClick(e) {
1721 logs.push([e.type, e.target.id, e.currentTarget.id]);
1722 }
1723
1724 function Test() {
1725 return (
1726 <div id="parent" onClick={handleClick}>
1727 <Fragment ref={fragmentRef} />
1728 </div>
1729 );
1730 }
1731
1732 await act(() => {
1733 root.render(<Test />);
1734 });
1735
1736 fragmentRef.current.addEventListener('click', handleClick);
1737
1738 fragmentRef.current.dispatchEvent(
1739 new MouseEvent('click', {bubbles: true}),
1740 );
1741 expect(logs).toEqual([
1742 ['click', undefined, undefined],
1743 ['click', 'parent', 'parent'],
1744 ]);
1745
1746 logs = [];
1747
1748 fragmentRef.current.dispatchEvent(
1749 new MouseEvent('click', {bubbles: false}),
1750 );
1751 expect(logs).toEqual([['click', undefined, undefined]]);
1752 });
1753 });
1754 });
1755
1756 describe('observers', () => {
1757 beforeEach(() => {
1758 mockIntersectionObserver();
1759 });
1760
1761 // @gate enableFragmentRefs
1762 it('attaches intersection observers to children', async () => {
1763 let logs = [];
1764 const observer = new IntersectionObserver(entries => {
1765 entries.forEach(entry => {
1766 logs.push(entry.target.id);
1767 });
1768 });
1769 function Test({showB}) {
1770 const fragmentRef = React.useRef(null);
1771 React.useEffect(() => {
1772 fragmentRef.current.observeUsing(observer);
1773 const lastRefValue = fragmentRef.current;
1774 return () => {
1775 lastRefValue.unobserveUsing(observer);
1776 };
1777 }, []);
1778 return (
1779 <div id="parent">
1780 <React.Fragment ref={fragmentRef}>
1781 <div id="childA">A</div>
1782 {showB && <div id="childB">B</div>}
1783 </React.Fragment>
1784 </div>
1785 );
1786 }
1787
1788 function simulateAllChildrenIntersecting() {
1789 const parent = container.firstChild;
1790 if (parent) {
1791 const children = Array.from(parent.children).map(child => {
1792 return [child, {y: 0, x: 0, width: 1, height: 1}, 1];
1793 });
1794 simulateIntersection(...children);
1795 }
1796 }
1797
1798 const root = ReactDOMClient.createRoot(container);
1799 await act(() => root.render(<Test showB={false} />));
1800 simulateAllChildrenIntersecting();
1801 expect(logs).toEqual(['childA']);
1802
1803 // Reveal child and expect it to be observed
1804 logs = [];
1805 await act(() => root.render(<Test showB={true} />));
1806 simulateAllChildrenIntersecting();
1807 expect(logs).toEqual(['childA', 'childB']);
1808
1809 // Hide child and expect it to be unobserved
1810 logs = [];
1811 await act(() => root.render(<Test showB={false} />));
1812 simulateAllChildrenIntersecting();
1813 expect(logs).toEqual(['childA']);
1814
1815 // Unmount component and expect all children to be unobserved
1816 logs = [];
1817 await act(() => root.render(null));
1818 simulateAllChildrenIntersecting();
1819 expect(logs).toEqual([]);
1820 });
1821
1822 // @gate enableFragmentRefs
1823 it('warns when unobserveUsing() is called with an observer that was not observed', async () => {
1824 const fragmentRef = React.createRef();
1825 const observer = new IntersectionObserver(() => {});
1826 const observer2 = new IntersectionObserver(() => {});
1827 function Test() {
1828 return (
1829 <React.Fragment ref={fragmentRef}>
1830 <div />
1831 </React.Fragment>
1832 );
1833 }
1834
1835 const root = ReactDOMClient.createRoot(container);
1836 await act(() => root.render(<Test />));
1837
1838 // Warning when there is no attached observer
1839 fragmentRef.current.unobserveUsing(observer);
1840 assertConsoleErrorDev([
1841 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' +
1842 'instance. First attach the observer with observeUsing()',
1843 ]);
1844
1845 // Warning when the attached observer does not match
1846 fragmentRef.current.observeUsing(observer);
1847 fragmentRef.current.unobserveUsing(observer2);
1848 assertConsoleErrorDev([
1849 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' +
1850 'instance. First attach the observer with observeUsing()',
1851 ]);
1852 });
1853
1854 // @gate enableFragmentRefs && enableFragmentRefsInstanceHandles
1855 it('attaches handles to observed elements to allow caching of observers', async () => {
1856 const targetToCallbackMap = new WeakMap();
1857 let cachedObserver = null;
1858 function createObserverIfNeeded(fragmentInstance, onIntersection) {
1859 const callbacks = targetToCallbackMap.get(fragmentInstance);
1860 targetToCallbackMap.set(
1861 fragmentInstance,
1862 callbacks ? [...callbacks, onIntersection] : [onIntersection],
1863 );
1864 if (cachedObserver !== null) {
1865 return cachedObserver;
1866 }
1867 const observer = new IntersectionObserver(entries => {
1868 entries.forEach(entry => {
1869 const fragmentInstances = entry.target.reactFragments;
1870 if (fragmentInstances) {
1871 Array.from(fragmentInstances).forEach(fInstance => {
1872 const cbs = targetToCallbackMap.get(fInstance) || [];
1873 cbs.forEach(callback => {
1874 callback(entry);
1875 });
1876 });
1877 }
1878
1879 targetToCallbackMap.get(entry.target)?.forEach(callback => {
1880 callback(entry);
1881 });
1882 });
1883 });
1884 cachedObserver = observer;
1885 return observer;
1886 }
1887
1888 function IntersectionObserverFragment({onIntersection, children}) {
1889 const fragmentRef = React.useRef(null);
1890 React.useLayoutEffect(() => {
1891 const observer = createObserverIfNeeded(
1892 fragmentRef.current,
1893 onIntersection,
1894 );
1895 fragmentRef.current.observeUsing(observer);
1896 const lastRefValue = fragmentRef.current;
1897 return () => {
1898 lastRefValue.unobserveUsing(observer);
1899 };
1900 }, []);
1901 return <React.Fragment ref={fragmentRef}>{children}</React.Fragment>;
1902 }
1903
1904 let logs = [];
1905 function logIntersection(id) {
1906 logs.push(`observe: ${id}`);
1907 }
1908
1909 function ChildWithManualIO({id}) {
1910 const divRef = React.useRef(null);
1911 React.useLayoutEffect(() => {
1912 const observer = createObserverIfNeeded(divRef.current, entry => {
1913 logIntersection(id);
1914 });
1915 observer.observe(divRef.current);
1916 return () => {
1917 observer.unobserve(divRef.current);
1918 };
1919 }, []);
1920 return (
1921 <div id={id} ref={divRef}>
1922 {id}
1923 </div>
1924 );
1925 }
1926
1927 function Test() {
1928 return (
1929 <>
1930 <IntersectionObserverFragment
1931 onIntersection={() => logIntersection('grandparent')}>
1932 <IntersectionObserverFragment
1933 onIntersection={() => logIntersection('parentA')}>
1934 <div id="childA">A</div>
1935 </IntersectionObserverFragment>
1936 </IntersectionObserverFragment>
1937 <IntersectionObserverFragment
1938 onIntersection={() => logIntersection('parentB')}>
1939 <div id="childB">B</div>
1940 <ChildWithManualIO id="childC" />
1941 </IntersectionObserverFragment>
1942 </>
1943 );
1944 }
1945
1946 const root = ReactDOMClient.createRoot(container);
1947 await act(() => root.render(<Test />));
1948
1949 simulateIntersection([
1950 container.querySelector('#childA'),
1951 {y: 0, x: 0, width: 1, height: 1},
1952 1,
1953 ]);
1954 expect(logs).toEqual(['observe: grandparent', 'observe: parentA']);
1955
1956 logs = [];
1957
1958 simulateIntersection([
1959 container.querySelector('#childB'),
1960 {y: 0, x: 0, width: 1, height: 1},
1961 1,
1962 ]);
1963 expect(logs).toEqual(['observe: parentB']);
1964
1965 logs = [];
1966 simulateIntersection([
1967 container.querySelector('#childC'),
1968 {y: 0, x: 0, width: 1, height: 1},
1969 1,
1970 ]);
1971 expect(logs).toEqual(['observe: parentB', 'observe: childC']);
1972 });
1973 });
1974
1975 describe('getClientRects', () => {
1976 // @gate enableFragmentRefs
1977 it('returns the bounding client rects of all children', async () => {
1978 const fragmentRef = React.createRef();
1979 const childARef = React.createRef();
1980 const childBRef = React.createRef();
1981 const root = ReactDOMClient.createRoot(container);
1982
1983 function Test() {
1984 return (
1985 <React.Fragment ref={fragmentRef}>
1986 <div ref={childARef} />
1987 <div ref={childBRef} />
1988 </React.Fragment>
1989 );
1990 }
1991
1992 await act(() => root.render(<Test />));
1993 setClientRects(childARef.current, [
1994 {
1995 x: 1,
1996 y: 2,
1997 width: 3,
1998 height: 4,
1999 },
2000 {
2001 x: 5,
2002 y: 6,
2003 width: 7,
2004 height: 8,
2005 },
2006 ]);
2007 setClientRects(childBRef.current, [{x: 9, y: 10, width: 11, height: 12}]);
2008 const clientRects = fragmentRef.current.getClientRects();
2009 expect(clientRects.length).toBe(3);
2010 expect(clientRects[0].left).toBe(1);
2011 expect(clientRects[1].left).toBe(5);
2012 expect(clientRects[2].left).toBe(9);
2013 });
2014 });
2015
2016 describe('getRootNode', () => {
2017 // @gate enableFragmentRefs
2018 it('returns the root node of the parent', async () => {
2019 const fragmentRef = React.createRef();
2020 const root = ReactDOMClient.createRoot(container);
2021
2022 function Test() {
2023 return (
2024 <div>
2025 <React.Fragment ref={fragmentRef}>
2026 <div />
2027 </React.Fragment>
2028 </div>
2029 );
2030 }
2031
2032 await act(() => root.render(<Test />));
2033 expect(fragmentRef.current.getRootNode()).toBe(document);
2034 });
2035
2036 // The desired behavior here is to return the topmost disconnected element when
2037 // fragment + parent are unmounted. Currently we have a pass during unmount that
2038 // recursively cleans up return pointers of the whole tree. We can change this
2039 // with a future refactor. See: https://github.com/facebook/react/pull/32682#discussion_r2008313082
2040 // @gate enableFragmentRefs
2041 it('returns the topmost disconnected element if the fragment and parent are unmounted', async () => {
2042 const containerRef = React.createRef();
2043 const parentRef = React.createRef();
2044 const fragmentRef = React.createRef();
2045 const root = ReactDOMClient.createRoot(container);
2046
2047 function Test({mounted}) {
2048 return (
2049 <div ref={containerRef} id="container">
2050 {mounted && (
2051 <div ref={parentRef} id="parent">
2052 <React.Fragment ref={fragmentRef}>
2053 <div />
2054 </React.Fragment>
2055 </div>
2056 )}
2057 </div>
2058 );
2059 }
2060
2061 await act(() => root.render(<Test mounted={true} />));
2062 expect(fragmentRef.current.getRootNode()).toBe(document);
2063 const fragmentHandle = fragmentRef.current;
2064 await act(() => root.render(<Test mounted={false} />));
2065 // TODO: The commented out assertion is the desired behavior. For now, we return
2066 // the fragment instance itself. This is currently the same behavior if you unmount
2067 // the fragment but not the parent. See context above.
2068 // expect(fragmentHandle.getRootNode().id).toBe(parentRefHandle.id);
2069 expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
2070 });
2071
2072 // @gate enableFragmentRefs
2073 it('returns self when only the fragment was unmounted', async () => {
2074 const fragmentRef = React.createRef();
2075 const parentRef = React.createRef();
2076 const root = ReactDOMClient.createRoot(container);
2077
2078 function Test({mounted}) {
2079 return (
2080 <div ref={parentRef} id="parent">
2081 {mounted && (
2082 <React.Fragment ref={fragmentRef}>
2083 <div />
2084 </React.Fragment>
2085 )}
2086 </div>
2087 );
2088 }
2089
2090 await act(() => root.render(<Test mounted={true} />));
2091 expect(fragmentRef.current.getRootNode()).toBe(document);
2092 const fragmentHandle = fragmentRef.current;
2093 await act(() => root.render(<Test mounted={false} />));
2094 expect(fragmentHandle.getRootNode()).toBe(fragmentHandle);
2095 });
2096 });
2097
2098 describe('compareDocumentPosition', () => {
2099 function expectPosition(position, spec) {
2100 const positionResult = {
2101 following: (position & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
2102 preceding: (position & Node.DOCUMENT_POSITION_PRECEDING) !== 0,
2103 contains: (position & Node.DOCUMENT_POSITION_CONTAINS) !== 0,
2104 containedBy: (position & Node.DOCUMENT_POSITION_CONTAINED_BY) !== 0,
2105 disconnected: (position & Node.DOCUMENT_POSITION_DISCONNECTED) !== 0,
2106 implementationSpecific:
2107 (position & Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC) !== 0,
2108 };
2109 expect(positionResult).toEqual(spec);
2110 }
2111 // @gate enableFragmentRefs
2112 it('returns the relationship between the fragment instance and a given node', async () => {
2113 const fragmentRef = React.createRef();
2114 const beforeRef = React.createRef();
2115 const afterRef = React.createRef();
2116 const middleChildRef = React.createRef();
2117 const firstChildRef = React.createRef();
2118 const lastChildRef = React.createRef();
2119 const containerRef = React.createRef();
2120 const disconnectedElement = document.createElement('div');
2121 const root = ReactDOMClient.createRoot(container);
2122
2123 function Test() {
2124 return (
2125 <div ref={containerRef} id="container">
2126 <div ref={beforeRef} id="before" />
2127 <React.Fragment ref={fragmentRef}>
2128 <div ref={firstChildRef} id="first" />
2129 <div ref={middleChildRef} id="middle" />
2130 <div ref={lastChildRef} id="last" />
2131 </React.Fragment>
2132 <div ref={afterRef} id="after" />
2133 </div>
2134 );
2135 }
2136
2137 await act(() => root.render(<Test />));
2138
2139 // document.body is preceding and contains the fragment
2140 expectPosition(
2141 fragmentRef.current.compareDocumentPosition(document.body),
2142 {
2143 preceding: true,
2144 following: false,
2145 contains: true,
2146 containedBy: false,
2147 disconnected: false,
2148 implementationSpecific: false,
2149 },
2150 );
2151
2152 // beforeRef is preceding the fragment
2153 expectPosition(
2154 fragmentRef.current.compareDocumentPosition(beforeRef.current),
2155 {
2156 preceding: true,
2157 following: false,
2158 contains: false,
2159 containedBy: false,
2160 disconnected: false,
2161 implementationSpecific: false,
2162 },
2163 );
2164
2165 // afterRef is following the fragment
2166 expectPosition(
2167 fragmentRef.current.compareDocumentPosition(afterRef.current),
2168 {
2169 preceding: false,
2170 following: true,
2171 contains: false,
2172 containedBy: false,
2173 disconnected: false,
2174 implementationSpecific: false,
2175 },
2176 );
2177
2178 // firstChildRef is contained by the fragment
2179 expectPosition(
2180 fragmentRef.current.compareDocumentPosition(firstChildRef.current),
2181 {
2182 preceding: false,
2183 following: false,
2184 contains: false,
2185 containedBy: true,
2186 disconnected: false,
2187 implementationSpecific: false,
2188 },
2189 );
2190
2191 // middleChildRef is contained by the fragment
2192 expectPosition(
2193 fragmentRef.current.compareDocumentPosition(middleChildRef.current),
2194 {
2195 preceding: false,
2196 following: false,
2197 contains: false,
2198 containedBy: true,
2199 disconnected: false,
2200 implementationSpecific: false,
2201 },
2202 );
2203
2204 // lastChildRef is contained by the fragment
2205 expectPosition(
2206 fragmentRef.current.compareDocumentPosition(lastChildRef.current),
2207 {
2208 preceding: false,
2209 following: false,
2210 contains: false,
2211 containedBy: true,
2212 disconnected: false,
2213 implementationSpecific: false,
2214 },
2215 );
2216
2217 // containerRef precedes and contains the fragment
2218 expectPosition(
2219 fragmentRef.current.compareDocumentPosition(containerRef.current),
2220 {
2221 preceding: true,
2222 following: false,
2223 contains: true,
2224 containedBy: false,
2225 disconnected: false,
2226 implementationSpecific: false,
2227 },
2228 );
2229
2230 expectPosition(
2231 fragmentRef.current.compareDocumentPosition(disconnectedElement),
2232 {
2233 preceding: false,
2234 following: true,
2235 contains: false,
2236 containedBy: false,
2237 disconnected: true,
2238 implementationSpecific: true,
2239 },
2240 );
2241 });
2242
2243 // @gate enableFragmentRefs
2244 it('handles fragment instances with one child', async () => {
2245 const fragmentRef = React.createRef();
2246 const beforeRef = React.createRef();
2247 const afterRef = React.createRef();
2248 const containerRef = React.createRef();
2249 const onlyChildRef = React.createRef();
2250 const disconnectedElement = document.createElement('div');
2251 const root = ReactDOMClient.createRoot(container);
2252
2253 function Test() {
2254 return (
2255 <div id="container" ref={containerRef}>
2256 <div id="innercontainer">
2257 <div ref={beforeRef} id="before" />
2258 <React.Fragment ref={fragmentRef}>
2259 <div ref={onlyChildRef} id="within" />
2260 </React.Fragment>
2261 <div id="after" ref={afterRef} />
2262 </div>
2263 </div>
2264 );
2265 }
2266
2267 await act(() => root.render(<Test />));
2268 expectPosition(
2269 fragmentRef.current.compareDocumentPosition(beforeRef.current),
2270 {
2271 preceding: true,
2272 following: false,
2273 contains: false,
2274 containedBy: false,
2275 disconnected: false,
2276 implementationSpecific: false,
2277 },
2278 );
2279 expectPosition(
2280 fragmentRef.current.compareDocumentPosition(afterRef.current),
2281 {
2282 preceding: false,
2283 following: true,
2284 contains: false,
2285 containedBy: false,
2286 disconnected: false,
2287 implementationSpecific: false,
2288 },
2289 );
2290 expectPosition(
2291 fragmentRef.current.compareDocumentPosition(onlyChildRef.current),
2292 {
2293 preceding: false,
2294 following: false,
2295 contains: false,
2296 containedBy: true,
2297 disconnected: false,
2298 implementationSpecific: false,
2299 },
2300 );
2301 expectPosition(
2302 fragmentRef.current.compareDocumentPosition(containerRef.current),
2303 {
2304 preceding: true,
2305 following: false,
2306 contains: true,
2307 containedBy: false,
2308 disconnected: false,
2309 implementationSpecific: false,
2310 },
2311 );
2312 expectPosition(
2313 fragmentRef.current.compareDocumentPosition(disconnectedElement),
2314 {
2315 preceding: false,
2316 following: true,
2317 contains: false,
2318 containedBy: false,
2319 disconnected: true,
2320 implementationSpecific: true,
2321 },
2322 );
2323 });
2324
2325 // @gate enableFragmentRefs
2326 it('handles empty fragment instances', async () => {
2327 const fragmentRef = React.createRef();
2328 const beforeParentRef = React.createRef();
2329 const beforeRef = React.createRef();
2330 const afterRef = React.createRef();
2331 const afterParentRef = React.createRef();
2332 const containerRef = React.createRef();
2333 const root = ReactDOMClient.createRoot(container);
2334
2335 function Test() {
2336 return (
2337 <>
2338 <div id="before-container" ref={beforeParentRef} />
2339 <div id="container" ref={containerRef}>
2340 <div id="before" ref={beforeRef} />
2341 <React.Fragment ref={fragmentRef} />
2342 <div id="after" ref={afterRef} />
2343 </div>
2344 <div id="after-container" ref={afterParentRef} />
2345 </>
2346 );
2347 }
2348
2349 await act(() => root.render(<Test />));
2350
2351 expectPosition(
2352 fragmentRef.current.compareDocumentPosition(document.body),
2353 {
2354 preceding: true,
2355 following: false,
2356 contains: true,
2357 containedBy: false,
2358 disconnected: false,
2359 implementationSpecific: true,
2360 },
2361 );
2362 expectPosition(
2363 fragmentRef.current.compareDocumentPosition(beforeRef.current),
2364 {
2365 preceding: true,
2366 following: false,
2367 contains: false,
2368 containedBy: false,
2369 disconnected: false,
2370 implementationSpecific: true,
2371 },
2372 );
2373 expectPosition(
2374 fragmentRef.current.compareDocumentPosition(beforeParentRef.current),
2375 {
2376 preceding: true,
2377 following: false,
2378 contains: false,
2379 containedBy: false,
2380 disconnected: false,
2381 implementationSpecific: true,
2382 },
2383 );
2384 expectPosition(
2385 fragmentRef.current.compareDocumentPosition(afterRef.current),
2386 {
2387 preceding: false,
2388 following: true,
2389 contains: false,
2390 containedBy: false,
2391 disconnected: false,
2392 implementationSpecific: true,
2393 },
2394 );
2395 expectPosition(
2396 fragmentRef.current.compareDocumentPosition(afterParentRef.current),
2397 {
2398 preceding: false,
2399 following: true,
2400 contains: false,
2401 containedBy: false,
2402 disconnected: false,
2403 implementationSpecific: true,
2404 },
2405 );
2406 expectPosition(
2407 fragmentRef.current.compareDocumentPosition(containerRef.current),
2408 {
2409 preceding: false,
2410 following: false,
2411 contains: true,
2412 containedBy: false,
2413 disconnected: false,
2414 implementationSpecific: true,
2415 },
2416 );
2417 });
2418
2419 // @gate enableFragmentRefs
2420 it('handles empty fragments nested inside non-host wrappers', async () => {
2421 const fragmentRef = React.createRef();
2422 const beforeRef = React.createRef();
2423 const afterRef = React.createRef();
2424 const root = ReactDOMClient.createRoot(container);
2425
2426 function Test() {
2427 return (
2428 <div>
2429 <div id="before" ref={beforeRef} />
2430 <Wrapper>
2431 <React.Fragment ref={fragmentRef} />
2432 </Wrapper>
2433 <div id="after" ref={afterRef} />
2434 </div>
2435 );
2436 }
2437
2438 await act(() => root.render(<Test />));
2439
2440 expectPosition(
2441 fragmentRef.current.compareDocumentPosition(beforeRef.current),
2442 {
2443 preceding: true,
2444 following: false,
2445 contains: false,
2446 containedBy: false,
2447 disconnected: false,
2448 implementationSpecific: true,
2449 },
2450 );
2451 expectPosition(
2452 fragmentRef.current.compareDocumentPosition(afterRef.current),
2453 {
2454 preceding: false,
2455 following: true,
2456 contains: false,
2457 containedBy: false,
2458 disconnected: false,
2459 implementationSpecific: true,
2460 },
2461 );
2462 });
2463
2464 // @gate enableFragmentRefs
2465 it('handles nested children', async () => {
2466 const fragmentRef = React.createRef();
2467 const nestedFragmentRef = React.createRef();
2468 const childARef = React.createRef();
2469 const childBRef = React.createRef();
2470 const childCRef = React.createRef();
2471 document.body.appendChild(container);
2472 const root = ReactDOMClient.createRoot(container);
2473
2474 function Child() {
2475 return (
2476 <div ref={childCRef} id="C">
2477 C
2478 </div>
2479 );
2480 }
2481
2482 function Test() {
2483 return (
2484 <React.Fragment ref={fragmentRef}>
2485 <div ref={childARef} id="A">
2486 A
2487 </div>
2488 <React.Fragment ref={nestedFragmentRef}>
2489 <div ref={childBRef} id="B">
2490 B
2491 </div>
2492 </React.Fragment>
2493 <Child />
2494 </React.Fragment>
2495 );
2496 }
2497
2498 await act(() => root.render(<Test />));
2499
2500 expectPosition(
2501 fragmentRef.current.compareDocumentPosition(childARef.current),
2502 {
2503 preceding: false,
2504 following: false,
2505 contains: false,
2506 containedBy: true,
2507 disconnected: false,
2508 implementationSpecific: false,
2509 },
2510 );
2511 expectPosition(
2512 fragmentRef.current.compareDocumentPosition(childBRef.current),
2513 {
2514 preceding: false,
2515 following: false,
2516 contains: false,
2517 containedBy: true,
2518 disconnected: false,
2519 implementationSpecific: false,
2520 },
2521 );
2522 expectPosition(
2523 fragmentRef.current.compareDocumentPosition(childCRef.current),
2524 {
2525 preceding: false,
2526 following: false,
2527 contains: false,
2528 containedBy: true,
2529 disconnected: false,
2530 implementationSpecific: false,
2531 },
2532 );
2533 });
2534
2535 // @gate enableFragmentRefs
2536 it('returns disconnected for comparison with an unmounted fragment instance', async () => {
2537 const fragmentRef = React.createRef();
2538 const containerRef = React.createRef();
2539 const root = ReactDOMClient.createRoot(container);
2540
2541 function Test({mount}) {
2542 return (
2543 <div ref={containerRef}>
2544 {mount && (
2545 <Fragment ref={fragmentRef}>
2546 <div />
2547 </Fragment>
2548 )}
2549 </div>
2550 );
2551 }
2552
2553 await act(() => root.render(<Test mount={true} />));
2554
2555 const fragmentHandle = fragmentRef.current;
2556
2557 expectPosition(
2558 fragmentHandle.compareDocumentPosition(containerRef.current),
2559 {
2560 preceding: true,
2561 following: false,
2562 contains: true,
2563 containedBy: false,
2564 disconnected: false,
2565 implementationSpecific: false,
2566 },
2567 );
2568
2569 await act(() => {
2570 root.render(<Test mount={false} />);
2571 });
2572
2573 expectPosition(
2574 fragmentHandle.compareDocumentPosition(containerRef.current),
2575 {
2576 preceding: false,
2577 following: false,
2578 contains: false,
2579 containedBy: false,
2580 disconnected: true,
2581 implementationSpecific: false,
2582 },
2583 );
2584 });
2585
2586 // @gate enableFragmentRefs
2587 it('compares a root-level Fragment', async () => {
2588 const fragmentRef = React.createRef();
2589 const emptyFragmentRef = React.createRef();
2590 const childRef = React.createRef();
2591 const siblingPrecedingRef = React.createRef();
2592 const siblingFollowingRef = React.createRef();
2593 const root = ReactDOMClient.createRoot(container);
2594
2595 function Test() {
2596 return (
2597 <Fragment>
2598 <div ref={siblingPrecedingRef} />
2599 <Fragment ref={fragmentRef}>
2600 <div ref={childRef} />
2601 </Fragment>
2602 <Fragment ref={emptyFragmentRef} />
2603 <div ref={siblingFollowingRef} />
2604 </Fragment>
2605 );
2606 }
2607
2608 await act(() => root.render(<Test />));
2609
2610 const fragmentInstance = fragmentRef.current;
2611 if (fragmentInstance == null) {
2612 throw new Error('Expected fragment instance to be non-null');
2613 }
2614 const emptyFragmentInstance = emptyFragmentRef.current;
2615 if (emptyFragmentInstance == null) {
2616 throw new Error('Expected empty fragment instance to be non-null');
2617 }
2618
2619 expectPosition(
2620 fragmentInstance.compareDocumentPosition(childRef.current),
2621 {
2622 preceding: false,
2623 following: false,
2624 contains: false,
2625 containedBy: true,
2626 disconnected: false,
2627 implementationSpecific: false,
2628 },
2629 );
2630
2631 expectPosition(
2632 fragmentInstance.compareDocumentPosition(siblingPrecedingRef.current),
2633 {
2634 preceding: true,
2635 following: false,
2636 contains: false,
2637 containedBy: false,
2638 disconnected: false,
2639 implementationSpecific: false,
2640 },
2641 );
2642
2643 expectPosition(
2644 fragmentInstance.compareDocumentPosition(siblingFollowingRef.current),
2645 {
2646 preceding: false,
2647 following: true,
2648 contains: false,
2649 containedBy: false,
2650 disconnected: false,
2651 implementationSpecific: false,
2652 },
2653 );
2654
2655 expectPosition(
2656 emptyFragmentInstance.compareDocumentPosition(childRef.current),
2657 {
2658 preceding: true,
2659 following: false,
2660 contains: false,
2661 containedBy: false,
2662 disconnected: false,
2663 implementationSpecific: true,
2664 },
2665 );
2666
2667 expectPosition(
2668 emptyFragmentInstance.compareDocumentPosition(
2669 siblingPrecedingRef.current,
2670 ),
2671 {
2672 preceding: true,
2673 following: false,
2674 contains: false,
2675 containedBy: false,
2676 disconnected: false,
2677 implementationSpecific: true,
2678 },
2679 );
2680
2681 expectPosition(
2682 emptyFragmentInstance.compareDocumentPosition(
2683 siblingFollowingRef.current,
2684 ),
2685 {
2686 preceding: false,
2687 following: true,
2688 contains: false,
2689 containedBy: false,
2690 disconnected: false,
2691 implementationSpecific: true,
2692 },
2693 );
2694 });
2695
2696 describe('with portals', () => {
2697 // @gate enableFragmentRefs
2698 it('handles portaled elements', async () => {
2699 const fragmentRef = React.createRef();
2700 const portaledSiblingRef = React.createRef();
2701 const portaledChildRef = React.createRef();
2702
2703 function Test() {
2704 return (
2705 <div id="wrapper">
2706 {createPortal(<div ref={portaledSiblingRef} id="A" />, container)}
2707 <Fragment ref={fragmentRef}>
2708 {createPortal(<div ref={portaledChildRef} id="B" />, container)}
2709 <div id="C" />
2710 </Fragment>
2711 </div>
2712 );
2713 }
2714
2715 const root = ReactDOMClient.createRoot(container);
2716 await act(() => root.render(<Test />));
2717
2718 // The sibling is preceding in both the DOM and the React tree
2719 expectPosition(
2720 fragmentRef.current.compareDocumentPosition(
2721 portaledSiblingRef.current,
2722 ),
2723 {
2724 preceding: true,
2725 following: false,
2726 contains: false,
2727 containedBy: false,
2728 disconnected: false,
2729 implementationSpecific: false,
2730 },
2731 );
2732
2733 // The child is contained by in the React tree but not in the DOM
2734 expectPosition(
2735 fragmentRef.current.compareDocumentPosition(portaledChildRef.current),
2736 {
2737 preceding: false,
2738 following: false,
2739 contains: false,
2740 containedBy: false,
2741 disconnected: false,
2742 implementationSpecific: true,
2743 },
2744 );
2745 });
2746
2747 // @gate enableFragmentRefs
2748 it('handles multiple portals to the same element', async () => {
2749 const root = ReactDOMClient.createRoot(container);
2750 const fragmentRef = React.createRef();
2751 const childARef = React.createRef();
2752 const childBRef = React.createRef();
2753 const childCRef = React.createRef();
2754 const childDRef = React.createRef();
2755 const childERef = React.createRef();
2756
2757 function Test() {
2758 const [c, setC] = React.useState(false);
2759 React.useEffect(() => {
2760 setC(true);
2761 });
2762
2763 return (
2764 <>
2765 {createPortal(
2766 <Fragment ref={fragmentRef}>
2767 <div id="A" ref={childARef} />
2768 {c ? (
2769 <div id="C" ref={childCRef}>
2770 <div id="D" ref={childDRef} />
2771 </div>
2772 ) : null}
2773 </Fragment>,
2774 document.body,
2775 )}
2776 {createPortal(<p id="B" ref={childBRef} />, document.body)}
2777 <div id="E" ref={childERef} />
2778 </>
2779 );
2780 }
2781
2782 await act(() => root.render(<Test />));
2783
2784 // Due to effect, order is E / A->B->C->D
2785 expect(document.body.outerHTML).toBe(
2786 '<body>' +
2787 '<div><div id="E"></div></div>' +
2788 '<div id="A"></div>' +
2789 '<p id="B"></p>' +
2790 '<div id="C"><div id="D"></div></div>' +
2791 '</body>',
2792 );
2793
2794 expectPosition(
2795 fragmentRef.current.compareDocumentPosition(document.body),
2796 {
2797 preceding: true,
2798 following: false,
2799 contains: true,
2800 containedBy: false,
2801 disconnected: false,
2802 implementationSpecific: false,
2803 },
2804 );
2805 expectPosition(
2806 fragmentRef.current.compareDocumentPosition(childARef.current),
2807 {
2808 preceding: false,
2809 following: false,
2810 contains: false,
2811 containedBy: true,
2812 disconnected: false,
2813 implementationSpecific: false,
2814 },
2815 );
2816 // Contained by in DOM, but following in React tree
2817 expectPosition(
2818 fragmentRef.current.compareDocumentPosition(childBRef.current),
2819 {
2820 preceding: false,
2821 following: false,
2822 contains: false,
2823 containedBy: false,
2824 disconnected: false,
2825 implementationSpecific: true,
2826 },
2827 );
2828 expectPosition(
2829 fragmentRef.current.compareDocumentPosition(childCRef.current),
2830 {
2831 preceding: false,
2832 following: false,
2833 contains: false,
2834 containedBy: true,
2835 disconnected: false,
2836 implementationSpecific: false,
2837 },
2838 );
2839 expectPosition(
2840 fragmentRef.current.compareDocumentPosition(childDRef.current),
2841 {
2842 preceding: false,
2843 following: false,
2844 contains: false,
2845 containedBy: true,
2846 disconnected: false,
2847 implementationSpecific: false,
2848 },
2849 );
2850 // Preceding DOM but following in React tree
2851 expectPosition(
2852 fragmentRef.current.compareDocumentPosition(childERef.current),
2853 {
2854 preceding: false,
2855 following: false,
2856 contains: false,
2857 containedBy: false,
2858 disconnected: false,
2859 implementationSpecific: true,
2860 },
2861 );
2862 });
2863
2864 // @gate enableFragmentRefs
2865 it('handles empty fragments', async () => {
2866 const fragmentRef = React.createRef();
2867 const childARef = React.createRef();
2868 const childBRef = React.createRef();
2869
2870 function Test() {
2871 return (
2872 <>
2873 <div id="A" ref={childARef} />
2874 {createPortal(<Fragment ref={fragmentRef} />, document.body)}
2875 <div id="B" ref={childBRef} />
2876 </>
2877 );
2878 }
2879
2880 const root = ReactDOMClient.createRoot(container);
2881 await act(() => root.render(<Test />));
2882
2883 expectPosition(
2884 fragmentRef.current.compareDocumentPosition(document.body),
2885 {
2886 preceding: false,
2887 following: false,
2888 contains: true,
2889 containedBy: false,
2890 disconnected: false,
2891 implementationSpecific: true,
2892 },
2893 );
2894 expectPosition(
2895 fragmentRef.current.compareDocumentPosition(childARef.current),
2896 {
2897 preceding: true,
2898 following: false,
2899 contains: false,
2900 containedBy: false,
2901 disconnected: false,
2902 implementationSpecific: true,
2903 },
2904 );
2905 expectPosition(
2906 fragmentRef.current.compareDocumentPosition(childBRef.current),
2907 {
2908 preceding: false,
2909 following: true,
2910 contains: false,
2911 containedBy: false,
2912 disconnected: false,
2913 implementationSpecific: true,
2914 },
2915 );
2916 });
2917
2918 // @gate enableFragmentRefs
2919 it('positions empty portaled fragments against the portal container', async () => {
2920 const fragmentRef = React.createRef();
2921 const reactParentRef = React.createRef();
2922 const portalTarget = document.createElement('div');
2923 portalTarget.id = 'portal-target';
2924 document.body.appendChild(portalTarget);
2925 const root = ReactDOMClient.createRoot(container);
2926
2927 function Test() {
2928 return (
2929 <div id="react-parent" ref={reactParentRef}>
2930 {createPortal(<Fragment ref={fragmentRef} />, portalTarget)}
2931 </div>
2932 );
2933 }
2934
2935 await act(() => root.render(<Test />));
2936
2937 // Empty CDP must use the portal container as parent
2938 expectPosition(
2939 fragmentRef.current.compareDocumentPosition(portalTarget),
2940 {
2941 preceding: false,
2942 following: false,
2943 contains: true,
2944 containedBy: false,
2945 disconnected: false,
2946 implementationSpecific: true,
2947 },
2948 );
2949 expectPosition(
2950 fragmentRef.current.compareDocumentPosition(reactParentRef.current),
2951 {
2952 preceding: true,
2953 following: false,
2954 contains: false,
2955 containedBy: false,
2956 disconnected: false,
2957 implementationSpecific: true,
2958 },
2959 );
2960 });
2961 });
2962 });
2963
2964 describe('scrollIntoView', () => {
2965 function expectLast(arr, test) {
2966 expect(arr[arr.length - 1]).toBe(test);
2967 }
2968 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
2969 it('does not yet support options', async () => {
2970 const fragmentRef = React.createRef();
2971 const root = ReactDOMClient.createRoot(container);
2972 await act(() => {
2973 root.render(<Fragment ref={fragmentRef} />);
2974 });
2975
2976 expect(() => {
2977 fragmentRef.current.scrollIntoView({block: 'start'});
2978 }).toThrow(
2979 'FragmentInstance.scrollIntoView() does not support ' +
2980 'scrollIntoViewOptions. Use the alignToTop boolean instead.',
2981 );
2982 });
2983
2984 describe('with children', () => {
2985 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
2986 it('settles scroll on the first child by default, or if alignToTop=true', async () => {
2987 const fragmentRef = React.createRef();
2988 const childARef = React.createRef();
2989 const childBRef = React.createRef();
2990 const root = ReactDOMClient.createRoot(container);
2991 await act(() => {
2992 root.render(
2993 <React.Fragment ref={fragmentRef}>
2994 <div ref={childARef} id="a">
2995 A
2996 </div>
2997 <div ref={childBRef} id="b">
2998 B
2999 </div>
3000 </React.Fragment>,
3001 );
3002 });
3003
3004 let logs = [];
3005 childARef.current.scrollIntoView = jest.fn().mockImplementation(() => {
3006 logs.push('childA');
3007 });
3008 childBRef.current.scrollIntoView = jest.fn().mockImplementation(() => {
3009 logs.push('childB');
3010 });
3011
3012 // Default call
3013 fragmentRef.current.scrollIntoView();
3014 expectLast(logs, 'childA');
3015 logs = [];
3016 // alignToTop=true
3017 fragmentRef.current.scrollIntoView(true);
3018 expectLast(logs, 'childA');
3019 });
3020
3021 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3022 it('calls scrollIntoView on the last child if alignToTop is false', async () => {
3023 const fragmentRef = React.createRef();
3024 const childARef = React.createRef();
3025 const childBRef = React.createRef();
3026 const root = ReactDOMClient.createRoot(container);
3027 await act(() => {
3028 root.render(
3029 <Fragment ref={fragmentRef}>
3030 <div ref={childARef}>A</div>
3031 <div ref={childBRef}>B</div>
3032 </Fragment>,
3033 );
3034 });
3035
3036 const logs = [];
3037 childARef.current.scrollIntoView = jest.fn().mockImplementation(() => {
3038 logs.push('childA');
3039 });
3040 childBRef.current.scrollIntoView = jest.fn().mockImplementation(() => {
3041 logs.push('childB');
3042 });
3043
3044 fragmentRef.current.scrollIntoView(false);
3045 expectLast(logs, 'childB');
3046 });
3047
3048 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3049 it('handles portaled elements -- same scroll container', async () => {
3050 const fragmentRef = React.createRef();
3051 const childARef = React.createRef();
3052 const childBRef = React.createRef();
3053 const root = ReactDOMClient.createRoot(container);
3054
3055 function Test() {
3056 return (
3057 <Fragment ref={fragmentRef}>
3058 {createPortal(
3059 <div ref={childARef} id="child-a">
3060 A
3061 </div>,
3062 document.body,
3063 )}
3064
3065 <div ref={childBRef} id="child-b">
3066 B
3067 </div>
3068 </Fragment>
3069 );
3070 }
3071
3072 await act(() => {
3073 root.render(<Test />);
3074 });
3075
3076 const logs = [];
3077 childARef.current.scrollIntoView = jest.fn().mockImplementation(() => {
3078 logs.push('childA');
3079 });
3080 childBRef.current.scrollIntoView = jest.fn().mockImplementation(() => {
3081 logs.push('childB');
3082 });
3083
3084 // Default call
3085 fragmentRef.current.scrollIntoView();
3086 expectLast(logs, 'childA');
3087 });
3088
3089 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3090 it('handles portaled elements -- different scroll container', async () => {
3091 const fragmentRef = React.createRef();
3092 const headerChildRef = React.createRef();
3093 const childARef = React.createRef();
3094 const childBRef = React.createRef();
3095 const childCRef = React.createRef();
3096 const scrollContainerRef = React.createRef();
3097 const scrollContainerNestedRef = React.createRef();
3098 const root = ReactDOMClient.createRoot(container);
3099
3100 function Test({mountFragment}) {
3101 return (
3102 <>
3103 <div id="header" style={{position: 'fixed'}}>
3104 <div id="parent-a" />
3105 </div>
3106 <div id="parent-b" />
3107 <div
3108 id="scroll-container"
3109 ref={scrollContainerRef}
3110 style={{overflow: 'scroll'}}>
3111 <div id="parent-c" />
3112 <div
3113 id="scroll-container-nested"
3114 ref={scrollContainerNestedRef}
3115 style={{overflow: 'scroll'}}>
3116 <div id="parent-d" />
3117 </div>
3118 </div>
3119 {mountFragment && (
3120 <Fragment ref={fragmentRef}>
3121 {createPortal(
3122 <div ref={headerChildRef} id="header-content">
3123 Header
3124 </div>,
3125 document.querySelector('#parent-a'),
3126 )}
3127 {createPortal(
3128 <div ref={childARef} id="child-a">
3129 A
3130 </div>,
3131 document.querySelector('#parent-b'),
3132 )}
3133 {createPortal(
3134 <div ref={childBRef} id="child-b">
3135 B
3136 </div>,
3137 document.querySelector('#parent-b'),
3138 )}
3139 {createPortal(
3140 <div ref={childCRef} id="child-c">
3141 C
3142 </div>,
3143 document.querySelector('#parent-c'),
3144 )}
3145 </Fragment>
3146 )}
3147 </>
3148 );
3149 }
3150
3151 await act(() => {
3152 root.render(<Test mountFragment={false} />);
3153 });
3154 // Now that the portal locations exist, mount the fragment
3155 await act(() => {
3156 root.render(<Test mountFragment={true} />);
3157 });
3158
3159 let logs = [];
3160 headerChildRef.current.scrollIntoView = jest.fn(() => {
3161 logs.push('header');
3162 });
3163 childARef.current.scrollIntoView = jest.fn(() => {
3164 logs.push('A');
3165 });
3166 childBRef.current.scrollIntoView = jest.fn(() => {
3167 logs.push('B');
3168 });
3169 childCRef.current.scrollIntoView = jest.fn(() => {
3170 logs.push('C');
3171 });
3172
3173 // Default call
3174 fragmentRef.current.scrollIntoView();
3175 expectLast(logs, 'header');
3176
3177 childARef.current.scrollIntoView.mockClear();
3178 childBRef.current.scrollIntoView.mockClear();
3179 childCRef.current.scrollIntoView.mockClear();
3180
3181 logs = [];
3182
3183 // // alignToTop=false
3184 fragmentRef.current.scrollIntoView(false);
3185 expectLast(logs, 'C');
3186 });
3187 });
3188
3189 describe('without children', () => {
3190 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3191 it('calls scrollIntoView on the next sibling by default, or if alignToTop=true', async () => {
3192 const fragmentRef = React.createRef();
3193 const siblingARef = React.createRef();
3194 const siblingBRef = React.createRef();
3195 const root = ReactDOMClient.createRoot(container);
3196 await act(() => {
3197 root.render(
3198 <div>
3199 <Wrapper>
3200 <div ref={siblingARef} />
3201 </Wrapper>
3202 <Fragment ref={fragmentRef} />
3203 <div ref={siblingBRef} />
3204 </div>,
3205 );
3206 });
3207
3208 siblingARef.current.scrollIntoView = jest.fn();
3209 siblingBRef.current.scrollIntoView = jest.fn();
3210
3211 // Default call
3212 fragmentRef.current.scrollIntoView();
3213 expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(0);
3214 expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
3215
3216 siblingBRef.current.scrollIntoView.mockClear();
3217
3218 // alignToTop=true
3219 fragmentRef.current.scrollIntoView(true);
3220 expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(0);
3221 expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
3222 });
3223
3224 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3225 it('finds host siblings when the empty fragment is nested in a non-host wrapper', async () => {
3226 const fragmentRef = React.createRef();
3227 const beforeRef = React.createRef();
3228 const afterRef = React.createRef();
3229 const root = ReactDOMClient.createRoot(container);
3230 await act(() => {
3231 root.render(
3232 <div>
3233 <div ref={beforeRef} id="before" />
3234 <Wrapper>
3235 <Fragment ref={fragmentRef} />
3236 </Wrapper>
3237 <div ref={afterRef} id="after" />
3238 </div>,
3239 );
3240 });
3241
3242 beforeRef.current.scrollIntoView = jest.fn();
3243 afterRef.current.scrollIntoView = jest.fn();
3244
3245 // Default / alignToTop=true should use the following host sibling,
3246 // even though the empty fragment's fiber.sibling is null.
3247 fragmentRef.current.scrollIntoView();
3248 expect(beforeRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
3249 expect(afterRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
3250
3251 afterRef.current.scrollIntoView.mockClear();
3252
3253 fragmentRef.current.scrollIntoView(false);
3254 expect(beforeRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
3255 expect(afterRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
3256 });
3257
3258 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3259 it('calls scrollIntoView on the prev sibling if alignToTop is false', async () => {
3260 const fragmentRef = React.createRef();
3261 const siblingARef = React.createRef();
3262 const siblingBRef = React.createRef();
3263 const root = ReactDOMClient.createRoot(container);
3264 function C() {
3265 return (
3266 <Wrapper>
3267 <div id="C" ref={siblingARef} />
3268 </Wrapper>
3269 );
3270 }
3271 function Test() {
3272 return (
3273 <div id="A">
3274 <div id="B" />
3275 <C />
3276 <Fragment ref={fragmentRef} />
3277 <div id="D" ref={siblingBRef} />
3278 <div id="E" />
3279 </div>
3280 );
3281 }
3282 await act(() => {
3283 root.render(<Test />);
3284 });
3285
3286 siblingARef.current.scrollIntoView = jest.fn();
3287 siblingBRef.current.scrollIntoView = jest.fn();
3288
3289 // alignToTop=false
3290 fragmentRef.current.scrollIntoView(false);
3291 expect(siblingARef.current.scrollIntoView).toHaveBeenCalledTimes(1);
3292 expect(siblingBRef.current.scrollIntoView).toHaveBeenCalledTimes(0);
3293 });
3294
3295 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3296 it('calls scrollIntoView on the parent if there are no siblings', async () => {
3297 const fragmentRef = React.createRef();
3298 const parentRef = React.createRef();
3299 const root = ReactDOMClient.createRoot(container);
3300 await act(() => {
3301 root.render(
3302 <div ref={parentRef}>
3303 <Wrapper>
3304 <Fragment ref={fragmentRef} />
3305 </Wrapper>
3306 </div>,
3307 );
3308 });
3309
3310 parentRef.current.scrollIntoView = jest.fn();
3311 fragmentRef.current.scrollIntoView();
3312 expect(parentRef.current.scrollIntoView).toHaveBeenCalledTimes(1);
3313 });
3314
3315 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3316 it('scrolls the host element when the fallback target is a ShadowRoot container', async () => {
3317 const fragmentRef = React.createRef();
3318 const host = document.createElement('div');
3319 container.appendChild(host);
3320 const shadowRoot = host.attachShadow({mode: 'open'});
3321 const root = ReactDOMClient.createRoot(shadowRoot);
3322 await act(() => {
3323 root.render(<Fragment ref={fragmentRef} />);
3324 });
3325
3326 // The ShadowRoot's host element marks where the fragment's content
3327 // would appear
3328 host.scrollIntoView = jest.fn();
3329 fragmentRef.current.scrollIntoView();
3330 expect(host.scrollIntoView).toHaveBeenCalledTimes(1);
3331 });
3332
3333 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3334 it('warns without scrolling when the fallback target is a detached DocumentFragment container', async () => {
3335 const fragmentRef = React.createRef();
3336 const root = ReactDOMClient.createRoot(
3337 document.createDocumentFragment(),
3338 );
3339 await act(() => {
3340 root.render(<Fragment ref={fragmentRef} />);
3341 });
3342
3343 expect(() => fragmentRef.current.scrollIntoView()).not.toThrow();
3344 assertConsoleWarnDev(
3345 [
3346 'You are attempting to scroll a FragmentInstance that is only ' +
3347 'mounted inside a detached DocumentFragment. No scroll was ' +
3348 'performed.',
3349 ],
3350 {withoutStack: true},
3351 );
3352 });
3353 });
3354 });
3355
3356 describe('with text nodes', () => {
3357 // @gate enableFragmentRefs && enableFragmentRefsTextNodes
3358 it('getClientRects includes text node bounds', async () => {
3359 const restoreRange = mockRangeClientRects([
3360 {x: 0, y: 0, width: 80, height: 16},
3361 ]);
3362 const fragmentRef = React.createRef();
3363 const root = ReactDOMClient.createRoot(container);
3364
3365 await act(() =>
3366 root.render(
3367 <div>
3368 <Fragment ref={fragmentRef}>Hello World</Fragment>
3369 </div>,
3370 ),
3371 );
3372
3373 const rects = fragmentRef.current.getClientRects();
3374 expect(rects.length).toBe(1);
3375 expect(rects[0].width).toBe(80);
3376 restoreRange();
3377 });
3378
3379 // @gate enableFragmentRefs && enableFragmentRefsTextNodes
3380 it('getClientRects includes both text and element bounds', async () => {
3381 const restoreRange = mockRangeClientRects([
3382 {x: 0, y: 0, width: 60, height: 16},
3383 ]);
3384 const fragmentRef = React.createRef();
3385 const childRef = React.createRef();
3386 const root = ReactDOMClient.createRoot(container);
3387
3388 await act(() =>
3389 root.render(
3390 <div>
3391 <Fragment ref={fragmentRef}>
3392 Text before
3393 <div ref={childRef}>Element</div>
3394 Text after
3395 </Fragment>
3396 </div>,
3397 ),
3398 );
3399
3400 setClientRects(childRef.current, [
3401 {x: 10, y: 10, width: 100, height: 20},
3402 ]);
3403 const rects = fragmentRef.current.getClientRects();
3404 // Should have rects from 2 text nodes + 1 element = 3 total
3405 expect(rects.length).toBe(3);
3406 restoreRange();
3407 });
3408
3409 // @gate enableFragmentRefs
3410 it('compareDocumentPosition works with text children', async () => {
3411 const fragmentRef = React.createRef();
3412 const beforeRef = React.createRef();
3413 const root = ReactDOMClient.createRoot(container);
3414
3415 await act(() =>
3416 root.render(
3417 <div>
3418 <div ref={beforeRef} />
3419 <Fragment ref={fragmentRef}>Text content</Fragment>
3420 </div>,
3421 ),
3422 );
3423
3424 const position = fragmentRef.current.compareDocumentPosition(
3425 beforeRef.current,
3426 );
3427 expect(position & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
3428 });
3429
3430 // @gate enableFragmentRefs
3431 it('focus is a no-op on text-only fragment', async () => {
3432 const fragmentRef = React.createRef();
3433 const root = ReactDOMClient.createRoot(container);
3434
3435 await act(() =>
3436 root.render(
3437 <div>
3438 <Fragment ref={fragmentRef}>Text only content</Fragment>
3439 </div>,
3440 ),
3441 );
3442
3443 // Should not throw or warn - just a silent no-op
3444 fragmentRef.current.focus();
3445 // Test passes if no error is thrown
3446 });
3447
3448 // @gate enableFragmentRefs
3449 it('focusLast is a no-op on text-only fragment', async () => {
3450 const fragmentRef = React.createRef();
3451 const root = ReactDOMClient.createRoot(container);
3452
3453 await act(() =>
3454 root.render(
3455 <div>
3456 <Fragment ref={fragmentRef}>Text only content</Fragment>
3457 </div>,
3458 ),
3459 );
3460
3461 // Should not throw or warn - just a silent no-op
3462 fragmentRef.current.focusLast();
3463 });
3464
3465 // @gate enableFragmentRefs && enableFragmentRefsTextNodes
3466 it('warns when observeUsing is called on text-only fragment', async () => {
3467 mockIntersectionObserver();
3468 const fragmentRef = React.createRef();
3469 const root = ReactDOMClient.createRoot(container);
3470
3471 await act(() =>
3472 root.render(
3473 <div>
3474 <Fragment ref={fragmentRef}>Text only content</Fragment>
3475 </div>,
3476 ),
3477 );
3478
3479 const observer = new IntersectionObserver(() => {});
3480 fragmentRef.current.observeUsing(observer);
3481 assertConsoleErrorDev(
3482 [
3483 'observeUsing() was called on a FragmentInstance with only text children. ' +
3484 'Observers do not work on text nodes.',
3485 ],
3486 {withoutStack: true},
3487 );
3488 });
3489
3490 // @gate enableFragmentRefs && enableFragmentRefsScrollIntoView
3491 it('scrollIntoView works on text-only fragment using Range API', async () => {
3492 const restoreRange = mockRangeClientRects([
3493 {x: 100, y: 200, width: 80, height: 16},
3494 ]);
3495 const fragmentRef = React.createRef();
3496 const root = ReactDOMClient.createRoot(container);
3497
3498 await act(() =>
3499 root.render(
3500 <div>
3501 <Fragment ref={fragmentRef}>Text content</Fragment>
3502 </div>,
3503 ),
3504 );
3505
3506 // Mock window.scrollTo to verify it was called
3507 const originalScrollTo = window.scrollTo;
3508 const scrollToMock = jest.fn();
3509 window.scrollTo = scrollToMock;
3510
3511 fragmentRef.current.scrollIntoView();
3512
3513 // Should have called window.scrollTo for the text node
3514 expect(scrollToMock).toHaveBeenCalled();
3515
3516 window.scrollTo = originalScrollTo;
3517 restoreRange();
3518 });
3519
3520 // @gate enableFragmentRefs && enableFragmentRefsTextNodes && enableFragmentRefsScrollIntoView
3521 it('scrollIntoView scrolls to text siblings of an empty fragment using the Range API', async () => {
3522 const restoreRange = mockRangeClientRects([
3523 {x: 100, y: 200, width: 80, height: 16},
3524 ]);
3525 const fragmentRef = React.createRef();
3526 const parentRef = React.createRef();
3527 const root = ReactDOMClient.createRoot(container);
3528
3529 await act(() =>
3530 root.render(
3531 <div ref={parentRef}>
3532 Text before
3533 <Fragment ref={fragmentRef} />
3534 Text after
3535 </div>,
3536 ),
3537 );
3538
3539 const parentScrollMock = jest.fn();
3540 parentRef.current.scrollIntoView = parentScrollMock;
3541 // Mock window.scrollTo to verify Range-based text scrolling
3542 const originalScrollTo = window.scrollTo;
3543 const scrollToMock = jest.fn();
3544 window.scrollTo = scrollToMock;
3545
3546 // Default call scrolls to the following text sibling
3547 fragmentRef.current.scrollIntoView();
3548 expect(scrollToMock).toHaveBeenCalledTimes(1);
3549 expect(parentScrollMock).toHaveBeenCalledTimes(0);
3550
3551 scrollToMock.mockClear();
3552
3553 // alignToTop=false scrolls to the preceding text sibling
3554 fragmentRef.current.scrollIntoView(false);
3555 expect(scrollToMock).toHaveBeenCalledTimes(1);
3556 expect(parentScrollMock).toHaveBeenCalledTimes(0);
3557
3558 window.scrollTo = originalScrollTo;
3559 restoreRange();
3560 });
3561
3562 // @gate enableFragmentRefs
3563 it('treats passive:true and passive:false as same listener per DOM spec', async () => {
3564 const fragmentRef = React.createRef();
3565 const root = ReactDOMClient.createRoot(container);
3566
3567 await act(() => {
3568 root.render(
3569 <Fragment ref={fragmentRef}>
3570 <div id="child" />
3571 </Fragment>,
3572 );
3573 });
3574
3575 const logs = [];
3576 const handler = () => logs.push('fired');
3577
3578 const child = document.querySelector('#child');
3579 const spy = jest.spyOn(child, 'addEventListener');
3580 // Per DOM spec, listener identity is (type, callback, capture).
3581 // passive is NOT part of the key, so these are the SAME listener.
3582 fragmentRef.current.addEventListener('click', handler, {passive: false});
3583 // Second add is a no-op: same (type, callback, capture) identity.
3584 fragmentRef.current.addEventListener('click', handler, {passive: true});
3585 expect(spy).toHaveBeenCalledTimes(1);
3586 expect(spy).toHaveBeenCalledWith('click', handler, {passive: false});
3587
3588 document.querySelector('#child').click();
3589 // First handler fires once (second add was a no-op).
3590 expect(logs).toEqual(['fired']);
3591
3592 // removeEventListener also ignores passive when matching
3593 fragmentRef.current.removeEventListener('click', handler, {
3594 passive: true,
3595 });
3596
3597 logs.length = 0;
3598 document.querySelector('#child').click();
3599 expect(logs).toEqual([]);
3600 });
3601 // @gate enableFragmentRefs
3602 it('removes a listener registered with passive:false when removed with passive:true', async () => {
3603 const fragmentRef = React.createRef(null);
3604 function Test() {
3605 return (
3606 <>
3607 <div id="child-x" />
3608 </>
3609 );
3610 }
3611 const root = ReactDOMClient.createRoot(container);
3612 await act(() => {
3613 root.render(
3614 <Fragment ref={fragmentRef}>
3615 <Test />
3616 </Fragment>,
3617 );
3618 });
3619 const logs = [];
3620 function handler() {
3621 logs.push('fired');
3622 }
3623 // Register with passive: false
3624 fragmentRef.current.addEventListener('click', handler, {
3625 passive: false,
3626 });
3627 document.querySelector('#child-x').click();
3628 expect(logs).toEqual(['fired']);
3629 logs.length = 0;
3630 // Remove with passive: true - per DOM spec, passive is NOT part of identity
3631 // so this MUST remove the listener regardless of passive mismatch.
3632 fragmentRef.current.removeEventListener('click', handler, {
3633 passive: true,
3634 });
3635 document.querySelector('#child-x').click();
3636 // Listener removed - no more invocations
3637 expect(logs).toEqual([]);
3638 });
3639 });
3640 });