Add dispatchEvent to fragment instances (#32813)
`fragmentInstance.dispatchEvent(evt)` calls `element.dispatchEvent(evt)` on the fragment's host parent. This mimics bubbling if the `fragmentInstance` could receive an event itself. If the parent is disconnected, there is a dev warning and no event is dispatched.
Jack Pope committed
May 7, 2025 at 14:00 UTC
8a8df5dbdd57bf63d5156c1a9cba21ac6106b83d
4 files changed
+673
-365
fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js
new
+157
@@ -0,0 +1,157 @@
1
+import TestCase from '../../TestCase';
2
+import Fixture from '../../Fixture';
3
+
4
+const React = window.React;
5
+const {Fragment, useRef, useState} = React;
6
+
7
+function WrapperComponent(props) {
8
+ return props.children;
9
+}
10
+
11
+const initialState = {
12
+ child: false,
13
+ parent: false,
14
+ grandparent: false,
15
+};
16
+
17
+export default function EventListenerCase() {
18
+ const fragmentRef = useRef(null);
19
+ const [clickedState, setClickedState] = useState({...initialState});
20
+ const [fragmentEventFired, setFragmentEventFired] = useState(false);
21
+ const [bubblesState, setBubblesState] = useState(true);
22
+
23
+ function setClick(id) {
24
+ setClickedState(prev => ({...prev, [id]: true}));
25
+ }
26
+
27
+ function fragmentClickHandler(e) {
28
+ setFragmentEventFired(true);
29
+ }
30
+
31
+ return (
32
+ <TestCase title="Event Dispatch">
33
+ <TestCase.Steps>
34
+ <li>
35
+ Each box has regular click handlers, you can click each one to observe
36
+ the status changing through standard bubbling.
37
+ </li>
38
+ <li>Clear the clicked state</li>
39
+ <li>
40
+ Click the "Dispatch click event" button to dispatch a click event on
41
+ the Fragment. The event will be dispatched on the Fragment's parent,
42
+ so the child will not change state.
43
+ </li>
44
+ <li>
45
+ Click the "Add event listener" button to add a click event listener on
46
+ the Fragment. This registers a handler that will turn the child blue
47
+ on click.
48
+ </li>
49
+ <li>
50
+ Now click the "Dispatch click event" button again. You can see that it
51
+ will fire the Fragment's event handler in addition to bubbling the
52
+ click from the parent.
53
+ </li>
54
+ <li>
55
+ If you turn off bubbling, only the Fragment's event handler will be
56
+ called.
57
+ </li>
58
+ </TestCase.Steps>
59
+
60
+ <TestCase.ExpectedResult>
61
+ <p>
62
+ Dispatching an event on a Fragment will forward the dispatch to its
63
+ parent for the standard case. You can observe when dispatching that
64
+ the parent handler is called in additional to bubbling from there. A
65
+ delay is added to make the bubbling more clear.{' '}
66
+ </p>
67
+ <p>
68
+ When there have been event handlers added to the Fragment, the
69
+ Fragment's event handler will be called in addition to bubbling from
70
+ the parent. Without bubbling, only the Fragment's event handler will
71
+ be called.
72
+ </p>
73
+ </TestCase.ExpectedResult>
74
+
75
+ <Fixture>
76
+ <Fixture.Controls>
77
+ <select
78
+ value={bubblesState ? 'true' : 'false'}
79
+ onChange={e => {
80
+ setBubblesState(e.target.value === 'true');
81
+ }}>
82
+ <option value="true">Bubbles: true</option>
83
+ <option value="false">Bubbles: false</option>
84
+ </select>
85
+ <button
86
+ onClick={() => {
87
+ fragmentRef.current.dispatchEvent(
88
+ new MouseEvent('click', {bubbles: bubblesState})
89
+ );
90
+ }}>
91
+ Dispatch click event
92
+ </button>
93
+ <button
94
+ onClick={() => {
95
+ setClickedState({...initialState});
96
+ setFragmentEventFired(false);
97
+ }}>
98
+ Reset clicked state
99
+ </button>
100
+ <button
101
+ onClick={() => {
102
+ fragmentRef.current.addEventListener(
103
+ 'click',
104
+ fragmentClickHandler
105
+ );
106
+ }}>
107
+ Add event listener
108
+ </button>
109
+ <button
110
+ onClick={() => {
111
+ fragmentRef.current.removeEventListener(
112
+ 'click',
113
+ fragmentClickHandler
114
+ );
115
+ }}>
116
+ Remove event listener
117
+ </button>
118
+ </Fixture.Controls>
119
+ <div
120
+ id="grandparent"
121
+ onClick={e => {
122
+ setTimeout(() => {
123
+ setClick('grandparent');
124
+ }, 200);
125
+ }}
126
+ className="card">
127
+ Fragment grandparent - clicked:{' '}
128
+ {clickedState.grandparent ? 'true' : 'false'}
129
+ <div
130
+ id="parent"
131
+ onClick={e => {
132
+ setTimeout(() => {
133
+ setClick('parent');
134
+ }, 100);
135
+ }}
136
+ className="card">
137
+ Fragment parent - clicked: {clickedState.parent ? 'true' : 'false'}
138
+ <Fragment ref={fragmentRef}>
139
+ <div
140
+ style={{
141
+ backgroundColor: fragmentEventFired ? 'lightblue' : 'inherit',
142
+ }}
143
+ id="child"
144
+ className="card"
145
+ onClick={e => {
146
+ setClick('child');
147
+ }}>
148
+ Fragment child - clicked:{' '}
149
+ {clickedState.child ? 'true' : 'false'}
150
+ </div>
151
+ </Fragment>
152
+ </div>
153
+ </div>
154
+ </Fixture>
155
+ </TestCase>
156
+ );
157
+}
fixtures/dom/src/components/fixtures/fragment-refs/index.js
+2
@@ -1,5 +1,6 @@
1
import FixtureSet from '../../FixtureSet';
2
import EventListenerCase from './EventListenerCase';
3
+import EventDispatchCase from './EventDispatchCase';
4
import IntersectionObserverCase from './IntersectionObserverCase';
5
import ResizeObserverCase from './ResizeObserverCase';
6
import FocusCase from './FocusCase';
@@ -11,6 +12,7 @@ export default function FragmentRefsPage() {
12
return (
13
<FixtureSet title="Fragment Refs">
14
<EventListenerCase />
15
+ <EventDispatchCase />
16
<IntersectionObserverCase />
17
<ResizeObserverCase />
18
<FocusCase />
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+38
@@ -2598,6 +2598,7 @@ export type FragmentInstanceType = {
2598
listener: EventListener,
2599
optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2600
): void,
2601
+ dispatchEvent(event: Event): boolean,
2602
focus(focusOptions?: FocusOptions): void,
2603
focusLast(focusOptions?: FocusOptions): void,
2604
blur(): void,
@@ -2695,6 +2696,43 @@ function removeEventListenerFromChild(
2696
return false;
2697
}
2698
// $FlowFixMe[prop-missing]
2699
+FragmentInstance.prototype.dispatchEvent = function (
2700
+ this: FragmentInstanceType,
2701
+ event: Event,
2702
+): boolean {
2703
+ const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
2704
+ if (parentHostFiber === null) {
2705
+ return true;
2706
+ }
2707
+ const parentHostInstance =
2708
+ getInstanceFromHostFiber<Instance>(parentHostFiber);
2709
+ const eventListeners = this._eventListeners;
2710
+ if (
2711
+ (eventListeners !== null && eventListeners.length > 0) ||
2712
+ !event.bubbles
2713
+ ) {
2714
+ const temp = document.createTextNode('');
2715
+ if (eventListeners) {
2716
+ for (let i = 0; i < eventListeners.length; i++) {
2717
+ const {type, listener, optionsOrUseCapture} = eventListeners[i];
2718
+ temp.addEventListener(type, listener, optionsOrUseCapture);
2719
+ }
2720
+ }
2721
+ parentHostInstance.appendChild(temp);
2722
+ const cancelable = temp.dispatchEvent(event);
2723
+ if (eventListeners) {
2724
+ for (let i = 0; i < eventListeners.length; i++) {
2725
+ const {type, listener, optionsOrUseCapture} = eventListeners[i];
2726
+ temp.removeEventListener(type, listener, optionsOrUseCapture);
2727
+ }
2728
+ }
2729
+ parentHostInstance.removeChild(temp);
2730
+ return cancelable;
2731
+ } else {
2732
+ return parentHostInstance.dispatchEvent(event);
2733
+ }
2734
+};
2735
+// $FlowFixMe[prop-missing]
2736
FragmentInstance.prototype.focus = function (
2737
this: FragmentInstanceType,
2738
focusOptions?: FocusOptions,
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+476
-365
@@ -353,410 +353,346 @@ describe('FragmentRefs', () => {
353
});
354
});
355
356
- describe('event listeners', () => {
357
- // @gate enableFragmentRefs
358
- it('adds and removes event listeners from children', async () => {
359
- const parentRef = React.createRef();
360
- const fragmentRef = React.createRef();
361
- const childARef = React.createRef();
362
- const childBRef = React.createRef();
363
- const root = ReactDOMClient.createRoot(container);
364
-
365
- let logs = [];
356
+ describe('events', () => {
357
+ describe('add/remove event listeners', () => {
358
+ // @gate enableFragmentRefs
359
+ it('adds and removes event listeners from children', async () => {
360
+ const parentRef = React.createRef();
361
+ const fragmentRef = React.createRef();
362
+ const childARef = React.createRef();
363
+ const childBRef = React.createRef();
364
+ const root = ReactDOMClient.createRoot(container);
365
367
- function handleFragmentRefClicks() {
368
- logs.push('fragmentRef');
369
- }
366
+ let logs = [];
367
371
- function Test() {
372
- React.useEffect(() => {
373
- fragmentRef.current.addEventListener(
374
- 'click',
375
- handleFragmentRefClicks,
376
- );
368
+ function handleFragmentRefClicks() {
369
+ logs.push('fragmentRef');
370
+ }
371
378
- return () => {
379
- fragmentRef.current.removeEventListener(
372
+ function Test() {
373
+ React.useEffect(() => {
374
+ fragmentRef.current.addEventListener(
375
'click',
376
handleFragmentRefClicks,
377
);
383
- };
384
- }, []);
385
- return (
386
- <div ref={parentRef}>
387
- <Fragment ref={fragmentRef}>
388
- <>Text</>
389
- <div ref={childARef}>A</div>
390
- <>
391
- <div ref={childBRef}>B</div>
392
- </>
393
- </Fragment>
394
- </div>
395
- );
396
- }
378
398
- await act(() => {
399
- root.render(<Test />);
400
- });
379
+ return () => {
380
+ fragmentRef.current.removeEventListener(
381
+ 'click',
382
+ handleFragmentRefClicks,
383
+ );
384
+ };
385
+ }, []);
386
+ return (
387
+ <div ref={parentRef}>
388
+ <Fragment ref={fragmentRef}>
389
+ <>Text</>
390
+ <div ref={childARef}>A</div>
391
+ <>
392
+ <div ref={childBRef}>B</div>
393
+ </>
394
+ </Fragment>
395
+ </div>
396
+ );
397
+ }
398
402
- childARef.current.addEventListener('click', () => {
403
- logs.push('A');
404
- });
399
+ await act(() => {
400
+ root.render(<Test />);
401
+ });
402
406
- childBRef.current.addEventListener('click', () => {
407
- logs.push('B');
408
- });
403
+ childARef.current.addEventListener('click', () => {
404
+ logs.push('A');
405
+ });
406
410
- // Clicking on the parent should not trigger any listeners
411
- parentRef.current.click();
412
- expect(logs).toEqual([]);
407
+ childBRef.current.addEventListener('click', () => {
408
+ logs.push('B');
409
+ });
410
414
- // Clicking a child triggers its own listeners and the Fragment's
415
- childARef.current.click();
416
- expect(logs).toEqual(['fragmentRef', 'A']);
411
+ // Clicking on the parent should not trigger any listeners
412
+ parentRef.current.click();
413
+ expect(logs).toEqual([]);
414
418
- logs = [];
415
+ // Clicking a child triggers its own listeners and the Fragment's
416
+ childARef.current.click();
417
+ expect(logs).toEqual(['fragmentRef', 'A']);
418
420
- childBRef.current.click();
421
- expect(logs).toEqual(['fragmentRef', 'B']);
419
+ logs = [];
420
423
- logs = [];
421
+ childBRef.current.click();
422
+ expect(logs).toEqual(['fragmentRef', 'B']);
423
425
- fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
424
+ logs = [];
425
427
- childARef.current.click();
428
- expect(logs).toEqual(['A']);
426
+ fragmentRef.current.removeEventListener(
427
+ 'click',
428
+ handleFragmentRefClicks,
429
+ );
430
430
- logs = [];
431
+ childARef.current.click();
432
+ expect(logs).toEqual(['A']);
433
432
- childBRef.current.click();
433
- expect(logs).toEqual(['B']);
434
- });
434
+ logs = [];
435
436
- // @gate enableFragmentRefs
437
- it('adds and removes event listeners from children with multiple fragments', async () => {
438
- const fragmentRef = React.createRef();
439
- const nestedFragmentRef = React.createRef();
440
- const nestedFragmentRef2 = React.createRef();
441
- const childARef = React.createRef();
442
- const childBRef = React.createRef();
443
- const childCRef = React.createRef();
444
- const root = ReactDOMClient.createRoot(container);
436
+ childBRef.current.click();
437
+ expect(logs).toEqual(['B']);
438
+ });
439
446
- await act(() => {
447
- root.render(
448
- <div>
449
- <Fragment ref={fragmentRef}>
450
- <div ref={childARef}>A</div>
451
- <div>
452
- <Fragment ref={nestedFragmentRef}>
453
- <div ref={childBRef}>B</div>
440
+ // @gate enableFragmentRefs
441
+ it('adds and removes event listeners from children with multiple fragments', async () => {
442
+ const fragmentRef = React.createRef();
443
+ const nestedFragmentRef = React.createRef();
444
+ const nestedFragmentRef2 = React.createRef();
445
+ const childARef = React.createRef();
446
+ const childBRef = React.createRef();
447
+ const childCRef = React.createRef();
448
+ const root = ReactDOMClient.createRoot(container);
449
+
450
+ await act(() => {
451
+ root.render(
452
+ <div>
453
+ <Fragment ref={fragmentRef}>
454
+ <div ref={childARef}>A</div>
455
+ <div>
456
+ <Fragment ref={nestedFragmentRef}>
457
+ <div ref={childBRef}>B</div>
458
+ </Fragment>
459
+ </div>
460
+ <Fragment ref={nestedFragmentRef2}>
461
+ <div ref={childCRef}>C</div>
462
</Fragment>
455
- </div>
456
- <Fragment ref={nestedFragmentRef2}>
457
- <div ref={childCRef}>C</div>
463
</Fragment>
459
- </Fragment>
460
- </div>,
461
- );
462
- });
464
+ </div>,
465
+ );
466
+ });
467
464
- let logs = [];
468
+ let logs = [];
469
466
- function handleFragmentRefClicks() {
467
- logs.push('fragmentRef');
468
- }
470
+ function handleFragmentRefClicks() {
471
+ logs.push('fragmentRef');
472
+ }
473
470
- function handleNestedFragmentRefClicks() {
471
- logs.push('nestedFragmentRef');
472
- }
474
+ function handleNestedFragmentRefClicks() {
475
+ logs.push('nestedFragmentRef');
476
+ }
477
474
- function handleNestedFragmentRef2Clicks() {
475
- logs.push('nestedFragmentRef2');
476
- }
478
+ function handleNestedFragmentRef2Clicks() {
479
+ logs.push('nestedFragmentRef2');
480
+ }
481
478
- fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
479
- nestedFragmentRef.current.addEventListener(
480
- 'click',
481
- handleNestedFragmentRefClicks,
482
- );
483
- nestedFragmentRef2.current.addEventListener(
484
- 'click',
485
- handleNestedFragmentRef2Clicks,
486
- );
482
+ fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
483
+ nestedFragmentRef.current.addEventListener(
484
+ 'click',
485
+ handleNestedFragmentRefClicks,
486
+ );
487
+ nestedFragmentRef2.current.addEventListener(
488
+ 'click',
489
+ handleNestedFragmentRef2Clicks,
490
+ );
491
488
- childBRef.current.click();
489
- // Event bubbles to the parent fragment
490
- expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
492
+ childBRef.current.click();
493
+ // Event bubbles to the parent fragment
494
+ expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
495
492
- logs = [];
496
+ logs = [];
497
494
- childARef.current.click();
495
- expect(logs).toEqual(['fragmentRef']);
498
+ childARef.current.click();
499
+ expect(logs).toEqual(['fragmentRef']);
500
497
- logs = [];
498
- childCRef.current.click();
499
- expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
501
+ logs = [];
502
+ childCRef.current.click();
503
+ expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
504
501
- logs = [];
505
+ logs = [];
506
503
- fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
504
- nestedFragmentRef.current.removeEventListener(
505
- 'click',
506
- handleNestedFragmentRefClicks,
507
- );
508
- childCRef.current.click();
509
- expect(logs).toEqual(['nestedFragmentRef2']);
510
- });
507
+ fragmentRef.current.removeEventListener(
508
+ 'click',
509
+ handleFragmentRefClicks,
510
+ );
511
+ nestedFragmentRef.current.removeEventListener(
512
+ 'click',
513
+ handleNestedFragmentRefClicks,
514
+ );
515
+ childCRef.current.click();
516
+ expect(logs).toEqual(['nestedFragmentRef2']);
517
+ });
518
512
- // @gate enableFragmentRefs
513
- it('adds an event listener to a newly added child', async () => {
514
- const fragmentRef = React.createRef();
515
- const childRef = React.createRef();
516
- const root = ReactDOMClient.createRoot(container);
517
- let showChild;
519
+ // @gate enableFragmentRefs
520
+ it('adds an event listener to a newly added child', async () => {
521
+ const fragmentRef = React.createRef();
522
+ const childRef = React.createRef();
523
+ const root = ReactDOMClient.createRoot(container);
524
+ let showChild;
525
519
- function Component() {
520
- const [shouldShowChild, setShouldShowChild] = React.useState(false);
521
- showChild = () => {
522
- setShouldShowChild(true);
523
- };
526
+ function Component() {
527
+ const [shouldShowChild, setShouldShowChild] = React.useState(false);
528
+ showChild = () => {
529
+ setShouldShowChild(true);
530
+ };
531
525
- return (
526
- <div>
527
- <Fragment ref={fragmentRef}>
528
- <div id="a">A</div>
529
- {shouldShowChild && (
530
- <div ref={childRef} id="b">
531
- B
532
- </div>
533
- )}
534
- </Fragment>
535
- </div>
536
- );
537
- }
532
+ return (
533
+ <div>
534
+ <Fragment ref={fragmentRef}>
535
+ <div id="a">A</div>
536
+ {shouldShowChild && (
537
+ <div ref={childRef} id="b">
538
+ B
539
+ </div>
540
+ )}
541
+ </Fragment>
542
+ </div>
543
+ );
544
+ }
545
539
- await act(() => {
540
- root.render(<Component />);
541
- });
546
+ await act(() => {
547
+ root.render(<Component />);
548
+ });
549
543
- expect(fragmentRef.current).not.toBe(null);
544
- expect(childRef.current).toBe(null);
550
+ expect(fragmentRef.current).not.toBe(null);
551
+ expect(childRef.current).toBe(null);
552
546
- let hasClicked = false;
547
- fragmentRef.current.addEventListener('click', () => {
548
- hasClicked = true;
549
- });
553
+ let hasClicked = false;
554
+ fragmentRef.current.addEventListener('click', () => {
555
+ hasClicked = true;
556
+ });
557
551
- await act(() => {
552
- showChild();
553
- });
554
- expect(childRef.current).not.toBe(null);
558
+ await act(() => {
559
+ showChild();
560
+ });
561
+ expect(childRef.current).not.toBe(null);
562
556
- childRef.current.click();
557
- expect(hasClicked).toBe(true);
558
- });
563
+ childRef.current.click();
564
+ expect(hasClicked).toBe(true);
565
+ });
566
560
- // @gate enableFragmentRefs
561
- it('applies event listeners to host children nested within non-host children', async () => {
562
- const fragmentRef = React.createRef();
563
- const childRef = React.createRef();
564
- const nestedChildRef = React.createRef();
565
- const root = ReactDOMClient.createRoot(container);
567
+ // @gate enableFragmentRefs
568
+ it('applies event listeners to host children nested within non-host children', async () => {
569
+ const fragmentRef = React.createRef();
570
+ const childRef = React.createRef();
571
+ const nestedChildRef = React.createRef();
572
+ const root = ReactDOMClient.createRoot(container);
573
567
- await act(() => {
568
- root.render(
569
- <div>
570
- <Fragment ref={fragmentRef}>
571
- <div ref={childRef}>Host A</div>
572
- <Wrapper>
574
+ await act(() => {
575
+ root.render(
576
+ <div>
577
+ <Fragment ref={fragmentRef}>
578
+ <div ref={childRef}>Host A</div>
579
<Wrapper>
580
<Wrapper>
575
- <div ref={nestedChildRef}>Host B</div>
581
+ <Wrapper>
582
+ <div ref={nestedChildRef}>Host B</div>
583
+ </Wrapper>
584
</Wrapper>
585
</Wrapper>
578
- </Wrapper>
579
- </Fragment>
580
- </div>,
581
- );
582
- });
583
- const logs = [];
584
- fragmentRef.current.addEventListener('click', e => {
585
- logs.push(e.target.textContent);
586
- });
587
-
588
- expect(logs).toEqual([]);
589
- childRef.current.click();
590
- expect(logs).toEqual(['Host A']);
591
- nestedChildRef.current.click();
592
- expect(logs).toEqual(['Host A', 'Host B']);
593
- });
594
-
595
- // @gate enableFragmentRefs
596
- it('allows adding and cleaning up listeners in effects', async () => {
597
- const root = ReactDOMClient.createRoot(container);
598
-
599
- let logs = [];
600
- function logClick(e) {
601
- logs.push(e.currentTarget.id);
602
- }
603
-
604
- let rerender;
605
- let removeEventListeners;
606
-
607
- function Test() {
608
- const fragmentRef = React.useRef(null);
609
- // eslint-disable-next-line no-unused-vars
610
- const [_, setState] = React.useState(0);
611
- rerender = () => {
612
- setState(p => p + 1);
613
- };
614
- removeEventListeners = () => {
615
- fragmentRef.current.removeEventListener('click', logClick);
616
- };
617
- React.useEffect(() => {
618
- fragmentRef.current.addEventListener('click', logClick);
619
-
620
- return removeEventListeners;
586
+ </Fragment>
587
+ </div>,
588
+ );
589
+ });
590
+ const logs = [];
591
+ fragmentRef.current.addEventListener('click', e => {
592
+ logs.push(e.target.textContent);
593
});
594
623
- return (
624
- <Fragment ref={fragmentRef}>
625
- <div id="child-a" />
626
- </Fragment>
627
- );
628
- }
629
-
630
- // The event listener was applied
631
- await act(() => root.render(<Test />));
632
- expect(logs).toEqual([]);
633
- document.querySelector('#child-a').click();
634
- expect(logs).toEqual(['child-a']);
595
+ expect(logs).toEqual([]);
596
+ childRef.current.click();
597
+ expect(logs).toEqual(['Host A']);
598
+ nestedChildRef.current.click();
599
+ expect(logs).toEqual(['Host A', 'Host B']);
600
+ });
601
636
- // The event listener can be removed and re-added
637
- logs = [];
638
- await act(rerender);
639
- document.querySelector('#child-a').click();
640
- expect(logs).toEqual(['child-a']);
641
- });
602
+ // @gate enableFragmentRefs
603
+ it('allows adding and cleaning up listeners in effects', async () => {
604
+ const root = ReactDOMClient.createRoot(container);
605
643
- // @gate enableFragmentRefs
644
- it('does not apply removed event listeners to new children', async () => {
645
- const root = ReactDOMClient.createRoot(container);
646
- const fragmentRef = React.createRef(null);
647
- function Test() {
648
- return (
649
- <Fragment ref={fragmentRef}>
650
- <div id="child-a" />
651
- </Fragment>
652
- );
653
- }
606
+ let logs = [];
607
+ function logClick(e) {
608
+ logs.push(e.currentTarget.id);
609
+ }
610
655
- let logs = [];
656
- function logClick(e) {
657
- logs.push(e.currentTarget.id);
658
- }
659
- await act(() => {
660
- root.render(<Test />);
661
- });
662
- fragmentRef.current.addEventListener('click', logClick);
663
- const childA = document.querySelector('#child-a');
664
- childA.click();
665
- expect(logs).toEqual(['child-a']);
611
+ let rerender;
612
+ let removeEventListeners;
613
667
- logs = [];
668
- fragmentRef.current.removeEventListener('click', logClick);
669
- childA.click();
670
- expect(logs).toEqual([]);
671
- });
614
+ function Test() {
615
+ const fragmentRef = React.useRef(null);
616
+ // eslint-disable-next-line no-unused-vars
617
+ const [_, setState] = React.useState(0);
618
+ rerender = () => {
619
+ setState(p => p + 1);
620
+ };
621
+ removeEventListeners = () => {
622
+ fragmentRef.current.removeEventListener('click', logClick);
623
+ };
624
+ React.useEffect(() => {
625
+ fragmentRef.current.addEventListener('click', logClick);
626
673
- // @gate enableFragmentRefs
674
- it('applies event listeners to portaled children', async () => {
675
- const fragmentRef = React.createRef();
676
- const childARef = React.createRef();
677
- const childBRef = React.createRef();
678
- const root = ReactDOMClient.createRoot(container);
627
+ return removeEventListeners;
628
+ });
629
680
- function Test() {
681
- return (
682
- <Fragment ref={fragmentRef}>
683
- <div id="child-a" ref={childARef} />
684
- {createPortal(<div id="child-b" ref={childBRef} />, document.body)}
685
- </Fragment>
686
- );
687
- }
630
+ return (
631
+ <Fragment ref={fragmentRef}>
632
+ <div id="child-a" />
633
+ </Fragment>
634
+ );
635
+ }
636
689
- await act(() => {
690
- root.render(<Test />);
691
- });
637
+ // The event listener was applied
638
+ await act(() => root.render(<Test />));
639
+ expect(logs).toEqual([]);
640
+ document.querySelector('#child-a').click();
641
+ expect(logs).toEqual(['child-a']);
642
693
- const logs = [];
694
- fragmentRef.current.addEventListener('click', e => {
695
- logs.push(e.target.id);
643
+ // The event listener can be removed and re-added
644
+ logs = [];
645
+ await act(rerender);
646
+ document.querySelector('#child-a').click();
647
+ expect(logs).toEqual(['child-a']);
648
});
649
698
- childARef.current.click();
699
- expect(logs).toEqual(['child-a']);
700
-
701
- logs.length = 0;
702
- childBRef.current.click();
703
- expect(logs).toEqual(['child-b']);
704
- });
705
-
706
- describe('with activity', () => {
707
- // @gate enableFragmentRefs && enableActivity
708
- it('does not apply event listeners to hidden trees', async () => {
709
- const parentRef = React.createRef();
710
- const fragmentRef = React.createRef();
650
+ // @gate enableFragmentRefs
651
+ it('does not apply removed event listeners to new children', async () => {
652
const root = ReactDOMClient.createRoot(container);
712
-
653
+ const fragmentRef = React.createRef(null);
654
function Test() {
655
return (
715
- <div ref={parentRef}>
716
- <Fragment ref={fragmentRef}>
717
- <div>Child 1</div>
718
- <Activity mode="hidden">
719
- <div>Child 2</div>
720
- </Activity>
721
- <div>Child 3</div>
722
- </Fragment>
723
- </div>
656
+ <Fragment ref={fragmentRef}>
657
+ <div id="child-a" />
658
+ </Fragment>
659
);
660
}
661
662
+ let logs = [];
663
+ function logClick(e) {
664
+ logs.push(e.currentTarget.id);
665
+ }
666
await act(() => {
667
root.render(<Test />);
668
});
669
+ fragmentRef.current.addEventListener('click', logClick);
670
+ const childA = document.querySelector('#child-a');
671
+ childA.click();
672
+ expect(logs).toEqual(['child-a']);
673
731
- const logs = [];
732
- fragmentRef.current.addEventListener('click', e => {
733
- logs.push(e.target.textContent);
734
- });
735
-
736
- const [child1, child2, child3] = parentRef.current.children;
737
- child1.click();
738
- child2.click();
739
- child3.click();
740
- expect(logs).toEqual(['Child 1', 'Child 3']);
674
+ logs = [];
675
+ fragmentRef.current.removeEventListener('click', logClick);
676
+ childA.click();
677
+ expect(logs).toEqual([]);
678
});
679
743
- // @gate enableFragmentRefs && enableActivity
744
- it('applies event listeners to visible trees', async () => {
745
- const parentRef = React.createRef();
680
+ // @gate enableFragmentRefs
681
+ it('applies event listeners to portaled children', async () => {
682
const fragmentRef = React.createRef();
683
+ const childARef = React.createRef();
684
+ const childBRef = React.createRef();
685
const root = ReactDOMClient.createRoot(container);
686
687
function Test() {
688
return (
751
- <div ref={parentRef}>
752
- <Fragment ref={fragmentRef}>
753
- <div>Child 1</div>
754
- <Activity mode="visible">
755
- <div>Child 2</div>
756
- </Activity>
757
- <div>Child 3</div>
758
- </Fragment>
759
- </div>
689
+ <Fragment ref={fragmentRef}>
690
+ <div id="child-a" ref={childARef} />
691
+ {createPortal(
692
+ <div id="child-b" ref={childBRef} />,
693
+ document.body,
694
+ )}
695
+ </Fragment>
696
);
697
}
698
@@ -766,67 +702,242 @@ describe('FragmentRefs', () => {
702
703
const logs = [];
704
fragmentRef.current.addEventListener('click', e => {
769
- logs.push(e.target.textContent);
705
+ logs.push(e.target.id);
706
});
707
772
- const [child1, child2, child3] = parentRef.current.children;
773
- child1.click();
774
- child2.click();
775
- child3.click();
776
- expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
708
+ childARef.current.click();
709
+ expect(logs).toEqual(['child-a']);
710
+
711
+ logs.length = 0;
712
+ childBRef.current.click();
713
+ expect(logs).toEqual(['child-b']);
714
+ });
715
+
716
+ describe('with activity', () => {
717
+ // @gate enableFragmentRefs && enableActivity
718
+ it('does not apply event listeners to hidden trees', async () => {
719
+ const parentRef = React.createRef();
720
+ const fragmentRef = React.createRef();
721
+ const root = ReactDOMClient.createRoot(container);
722
+
723
+ function Test() {
724
+ return (
725
+ <div ref={parentRef}>
726
+ <Fragment ref={fragmentRef}>
727
+ <div>Child 1</div>
728
+ <Activity mode="hidden">
729
+ <div>Child 2</div>
730
+ </Activity>
731
+ <div>Child 3</div>
732
+ </Fragment>
733
+ </div>
734
+ );
735
+ }
736
+
737
+ await act(() => {
738
+ root.render(<Test />);
739
+ });
740
+
741
+ const logs = [];
742
+ fragmentRef.current.addEventListener('click', e => {
743
+ logs.push(e.target.textContent);
744
+ });
745
+
746
+ const [child1, child2, child3] = parentRef.current.children;
747
+ child1.click();
748
+ child2.click();
749
+ child3.click();
750
+ expect(logs).toEqual(['Child 1', 'Child 3']);
751
+ });
752
+
753
+ // @gate enableFragmentRefs && enableActivity
754
+ it('applies event listeners to visible trees', async () => {
755
+ const parentRef = React.createRef();
756
+ const fragmentRef = React.createRef();
757
+ const root = ReactDOMClient.createRoot(container);
758
+
759
+ function Test() {
760
+ return (
761
+ <div ref={parentRef}>
762
+ <Fragment ref={fragmentRef}>
763
+ <div>Child 1</div>
764
+ <Activity mode="visible">
765
+ <div>Child 2</div>
766
+ </Activity>
767
+ <div>Child 3</div>
768
+ </Fragment>
769
+ </div>
770
+ );
771
+ }
772
+
773
+ await act(() => {
774
+ root.render(<Test />);
775
+ });
776
+
777
+ const logs = [];
778
+ fragmentRef.current.addEventListener('click', e => {
779
+ logs.push(e.target.textContent);
780
+ });
781
+
782
+ const [child1, child2, child3] = parentRef.current.children;
783
+ child1.click();
784
+ child2.click();
785
+ child3.click();
786
+ expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
787
+ });
788
+
789
+ // @gate enableFragmentRefs && enableActivity
790
+ it('handles Activity modes switching', async () => {
791
+ const fragmentRef = React.createRef();
792
+ const fragmentRef2 = React.createRef();
793
+ const parentRef = React.createRef();
794
+ const root = ReactDOMClient.createRoot(container);
795
+
796
+ function Test({mode}) {
797
+ return (
798
+ <div id="parent" ref={parentRef}>
799
+ <Fragment ref={fragmentRef}>
800
+ <Activity mode={mode}>
801
+ <div id="child1">Child</div>
802
+ <Fragment ref={fragmentRef2}>
803
+ <div id="child2">Child 2</div>
804
+ </Fragment>
805
+ </Activity>
806
+ </Fragment>
807
+ </div>
808
+ );
809
+ }
810
+
811
+ await act(() => {
812
+ root.render(<Test mode="visible" />);
813
+ });
814
+
815
+ let logs = [];
816
+ fragmentRef.current.addEventListener('click', () => {
817
+ logs.push('clicked 1');
818
+ });
819
+ fragmentRef2.current.addEventListener('click', () => {
820
+ logs.push('clicked 2');
821
+ });
822
+ parentRef.current.lastChild.click();
823
+ expect(logs).toEqual(['clicked 1', 'clicked 2']);
824
+
825
+ logs = [];
826
+ await act(() => {
827
+ root.render(<Test mode="hidden" />);
828
+ });
829
+ parentRef.current.firstChild.click();
830
+ parentRef.current.lastChild.click();
831
+ expect(logs).toEqual([]);
832
+
833
+ logs = [];
834
+ await act(() => {
835
+ root.render(<Test mode="visible" />);
836
+ });
837
+ parentRef.current.lastChild.click();
838
+ // Event order is flipped here because the nested child re-registers first
839
+ expect(logs).toEqual(['clicked 2', 'clicked 1']);
840
+ });
841
});
842
+ });
843
779
- // @gate enableFragmentRefs && enableActivity
780
- it('handles Activity modes switching', async () => {
844
+ describe('dispatchEvent()', () => {
845
+ // @gate enableFragmentRefs
846
+ it('fires events on the host parent if bubbles=true', async () => {
847
const fragmentRef = React.createRef();
782
- const fragmentRef2 = React.createRef();
783
- const parentRef = React.createRef();
848
const root = ReactDOMClient.createRoot(container);
849
+ let logs = [];
850
+
851
+ function handleClick(e) {
852
+ logs.push([e.type, e.target.id, e.currentTarget.id]);
853
+ }
854
786
- function Test({mode}) {
855
+ function Test({isMounted}) {
856
return (
788
- <div id="parent" ref={parentRef}>
789
- <Fragment ref={fragmentRef}>
790
- <Activity mode={mode}>
791
- <div id="child1">Child</div>
792
- <Fragment ref={fragmentRef2}>
793
- <div id="child2">Child 2</div>
857
+ <div onClick={handleClick} id="grandparent">
858
+ <div onClick={handleClick} id="parent">
859
+ {isMounted && (
860
+ <Fragment ref={fragmentRef}>
861
+ <div onClick={handleClick} id="child">
862
+ Hi
863
+ </div>
864
</Fragment>
795
- </Activity>
796
- </Fragment>
865
+ )}
866
+ </div>
867
</div>
868
);
869
}
870
871
await act(() => {
802
- root.render(<Test mode="visible" />);
872
+ root.render(<Test isMounted={true} />);
873
});
874
805
- let logs = [];
806
- fragmentRef.current.addEventListener('click', () => {
807
- logs.push('clicked 1');
808
- });
809
- fragmentRef2.current.addEventListener('click', () => {
810
- logs.push('clicked 2');
811
- });
812
- parentRef.current.lastChild.click();
813
- expect(logs).toEqual(['clicked 1', 'clicked 2']);
875
+ let isCancelable = !fragmentRef.current.dispatchEvent(
876
+ new MouseEvent('click', {bubbles: true}),
877
+ );
878
+ expect(logs).toEqual([
879
+ ['click', 'parent', 'parent'],
880
+ ['click', 'parent', 'grandparent'],
881
+ ]);
882
+ expect(isCancelable).toBe(false);
883
815
- logs = [];
884
+ const fragmentInstanceHandle = fragmentRef.current;
885
await act(() => {
817
- root.render(<Test mode="hidden" />);
886
+ root.render(<Test isMounted={false} />);
887
});
819
- parentRef.current.firstChild.click();
820
- parentRef.current.lastChild.click();
888
+ logs = [];
889
+ isCancelable = !fragmentInstanceHandle.dispatchEvent(
890
+ new MouseEvent('click', {bubbles: true}),
891
+ );
892
expect(logs).toEqual([]);
893
+ expect(isCancelable).toBe(false);
894
895
logs = [];
896
+ isCancelable = !fragmentInstanceHandle.dispatchEvent(
897
+ new MouseEvent('click', {bubbles: false}),
898
+ );
899
+ expect(logs).toEqual([]);
900
+ expect(isCancelable).toBe(false);
901
+ });
902
+
903
+ // @gate enableFragmentRefs
904
+ it('fires events on self, and only self if bubbles=false', async () => {
905
+ const fragmentRef = React.createRef();
906
+ const root = ReactDOMClient.createRoot(container);
907
+ let logs = [];
908
+
909
+ function handleClick(e) {
910
+ logs.push([e.type, e.target.id, e.currentTarget.id]);
911
+ }
912
+
913
+ function Test() {
914
+ return (
915
+ <div id="parent" onClick={handleClick}>
916
+ <Fragment ref={fragmentRef} />
917
+ </div>
918
+ );
919
+ }
920
+
921
await act(() => {
825
- root.render(<Test mode="visible" />);
922
+ root.render(<Test />);
923
});
827
- parentRef.current.lastChild.click();
828
- // Event order is flipped here because the nested child re-registers first
829
- expect(logs).toEqual(['clicked 2', 'clicked 1']);
924
+
925
+ fragmentRef.current.addEventListener('click', handleClick);
926
+
927
+ fragmentRef.current.dispatchEvent(
928
+ new MouseEvent('click', {bubbles: true}),
929
+ );
930
+ expect(logs).toEqual([
931
+ ['click', undefined, undefined],
932
+ ['click', 'parent', 'parent'],
933
+ ]);
934
+
935
+ logs = [];
936
+
937
+ fragmentRef.current.dispatchEvent(
938
+ new MouseEvent('click', {bubbles: false}),
939
+ );
940
+ expect(logs).toEqual([['click', undefined, undefined]]);
941
});
942
});
943
});