@samitouri / QOS-React / commits / 8a8df5dbdd

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 });