Repository: facebook/react
Pull request: Add dispatchEvent to fragment instances

`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.

Changed files:
filename: fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js
patch: @@ -0,0 +1,157 @@
+import TestCase from '../../TestCase';
+import Fixture from '../../Fixture';
+
+const React = window.React;
+const {Fragment, useRef, useState} = React;
+
+function WrapperComponent(props) {
+  return props.children;
+}
+
+const initialState = {
+  child: false,
+  parent: false,
+  grandparent: false,
+};
+
+export default function EventListenerCase() {
+  const fragmentRef = useRef(null);
+  const [clickedState, setClickedState] = useState({...initialState});
+  const [fragmentEventFired, setFragmentEventFired] = useState(false);
+  const [bubblesState, setBubblesState] = useState(true);
+
+  function setClick(id) {
+    setClickedState(prev => ({...prev, [id]: true}));
+  }
+
+  function fragmentClickHandler(e) {
+    setFragmentEventFired(true);
+  }
+
+  return (
+    <TestCase title="Event Dispatch">
+      <TestCase.Steps>
+        <li>
+          Each box has regular click handlers, you can click each one to observe
+          the status changing through standard bubbling.
+        </li>
+        <li>Clear the clicked state</li>
+        <li>
+          Click the "Dispatch click event" button to dispatch a click event on
+          the Fragment. The event will be dispatched on the Fragment's parent,
+          so the child will not change state.
+        </li>
+        <li>
+          Click the "Add event listener" button to add a click event listener on
+          the Fragment. This registers a handler that will turn the child blue
+          on click.
+        </li>
+        <li>
+          Now click the "Dispatch click event" button again. You can see that it
+          will fire the Fragment's event handler in addition to bubbling the
+          click from the parent.
+        </li>
+        <li>
+          If you turn off bubbling, only the Fragment's event handler will be
+          called.
+        </li>
+      </TestCase.Steps>
+
+      <TestCase.ExpectedResult>
+        <p>
+          Dispatching an event on a Fragment will forward the dispatch to its
+          parent for the standard case. You can observe when dispatching that
+          the parent handler is called in additional to bubbling from there. A
+          delay is added to make the bubbling more clear.{' '}
+        </p>
+        <p>
+          When there have been event handlers added to the Fragment, the
+          Fragment's event handler will be called in addition to bubbling from
+          the parent. Without bubbling, only the Fragment's event handler will
+          be called.
+        </p>
+      </TestCase.ExpectedResult>
+
+      <Fixture>
+        <Fixture.Controls>
+          <select
+            value={bubblesState ? 'true' : 'false'}
+            onChange={e => {
+              setBubblesState(e.target.value === 'true');
+            }}>
+            <option value="true">Bubbles: true</option>
+            <option value="false">Bubbles: false</option>
+          </select>
+          <button
+            onClick={() => {
+              fragmentRef.current.dispatchEvent(
+                new MouseEvent('click', {bubbles: bubblesState})
+              );
+            }}>
+            Dispatch click event
+          </button>
+          <button
+            onClick={() => {
+              setClickedState({...initialState});
+              setFragmentEventFired(false);
+            }}>
+            Reset clicked state
+          </button>
+          <button
+            onClick={() => {
+              fragmentRef.current.addEventListener(
+                'click',
+                fragmentClickHandler
+              );
+            }}>
+            Add event listener
+          </button>
+          <button
+            onClick={() => {
+              fragmentRef.current.removeEventListener(
+                'click',
+                fragmentClickHandler
+              );
+            }}>
+            Remove event listener
+          </button>
+        </Fixture.Controls>
+        <div
+          id="grandparent"
+          onClick={e => {
+            setTimeout(() => {
+              setClick('grandparent');
+            }, 200);
+          }}
+          className="card">
+          Fragment grandparent - clicked:{' '}
+          {clickedState.grandparent ? 'true' : 'false'}
+          <div
+            id="parent"
+            onClick={e => {
+              setTimeout(() => {
+                setClick('parent');
+              }, 100);
+            }}
+            className="card">
+            Fragment parent - clicked: {clickedState.parent ? 'true' : 'false'}
+            <Fragment ref={fragmentRef}>
+              <div
+                style={{
+                  backgroundColor: fragmentEventFired ? 'lightblue' : 'inherit',
+                }}
+                id="child"
+                className="card"
+                onClick={e => {
+                  setClick('child');
+                }}>
+                Fragment child - clicked:{' '}
+                {clickedState.child ? 'true' : 'false'}
+              </div>
+            </Fragment>
+          </div>
+        </div>
+      </Fixture>
+    </TestCase>
+  );
+}

filename: fixtures/dom/src/components/fixtures/fragment-refs/index.js
patch: @@ -1,5 +1,6 @@
 import FixtureSet from '../../FixtureSet';
 import EventListenerCase from './EventListenerCase';
+import EventDispatchCase from './EventDispatchCase';
 import IntersectionObserverCase from './IntersectionObserverCase';
 import ResizeObserverCase from './ResizeObserverCase';
 import FocusCase from './FocusCase';
@@ -11,6 +12,7 @@ export default function FragmentRefsPage() {
   return (
     <FixtureSet title="Fragment Refs">
       <EventListenerCase />
+      <EventDispatchCase />
       <IntersectionObserverCase />
       <ResizeObserverCase />
       <FocusCase />

filename: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
patch: @@ -2598,6 +2598,7 @@ export type FragmentInstanceType = {
     listener: EventListener,
     optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
   ): void,
+  dispatchEvent(event: Event): boolean,
   focus(focusOptions?: FocusOptions): void,
   focusLast(focusOptions?: FocusOptions): void,
   blur(): void,
@@ -2695,6 +2696,43 @@ function removeEventListenerFromChild(
   return false;
 }
 // $FlowFixMe[prop-missing]
+FragmentInstance.prototype.dispatchEvent = function (
+  this: FragmentInstanceType,
+  event: Event,
+): boolean {
+  const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+  if (parentHostFiber === null) {
+    return true;
+  }
+  const parentHostInstance =
+    getInstanceFromHostFiber<Instance>(parentHostFiber);
+  const eventListeners = this._eventListeners;
+  if (
+    (eventListeners !== null && eventListeners.length > 0) ||
+    !event.bubbles
+  ) {
+    const temp = document.createTextNode('');
+    if (eventListeners) {
+      for (let i = 0; i < eventListeners.length; i++) {
+        const {type, listener, optionsOrUseCapture} = eventListeners[i];
+        temp.addEventListener(type, listener, optionsOrUseCapture);
+      }
+    }
+    parentHostInstance.appendChild(temp);
+    const cancelable = temp.dispatchEvent(event);
+    if (eventListeners) {
+      for (let i = 0; i < eventListeners.length; i++) {
+        const {type, listener, optionsOrUseCapture} = eventListeners[i];
+        temp.removeEventListener(type, listener, optionsOrUseCapture);
+      }
+    }
+    parentHostInstance.removeChild(temp);
+    return cancelable;
+  } else {
+    return parentHostInstance.dispatchEvent(event);
+  }
+};
+// $FlowFixMe[prop-missing]
 FragmentInstance.prototype.focus = function (
   this: FragmentInstanceType,
   focusOptions?: FocusOptions,

filename: packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
patch: @@ -353,410 +353,346 @@ describe('FragmentRefs', () => {
     });
   });
 
-  describe('event listeners', () => {
-    // @gate enableFragmentRefs
-    it('adds and removes event listeners from children', async () => {
-      const parentRef = React.createRef();
-      const fragmentRef = React.createRef();
-      const childARef = React.createRef();
-      const childBRef = React.createRef();
-      const root = ReactDOMClient.createRoot(container);
-
-      let logs = [];
+  describe('events', () => {
+    describe('add/remove event listeners', () => {
+      // @gate enableFragmentRefs
+      it('adds and removes event listeners from children', async () => {
+        const parentRef = React.createRef();
+        const fragmentRef = React.createRef();
+        const childARef = React.createRef();
+        const childBRef = React.createRef();
+        const root = ReactDOMClient.createRoot(container);
 
-      function handleFragmentRefClicks() {
-        logs.push('fragmentRef');
-      }
+        let logs = [];
 
-      function Test() {
-        React.useEffect(() => {
-          fragmentRef.current.addEventListener(
-            'click',
-            handleFragmentRefClicks,
-          );
+        function handleFragmentRefClicks() {
+          logs.push('fragmentRef');
+        }
 
-          return () => {
-            fragmentRef.current.removeEventListener(
+        function Test() {
+          React.useEffect(() => {
+            fragmentRef.current.addEventListener(
               'click',
               handleFragmentRefClicks,
             );
-          };
-        }, []);
-        return (
-          <div ref={parentRef}>
-            <Fragment ref={fragmentRef}>
-              <>Text</>
-              <div ref={childARef}>A</div>
-              <>
-                <div ref={childBRef}>B</div>
-              </>
-            </Fragment>
-          </div>
-        );
-      }
 
-      await act(() => {
-        root.render(<Test />);
-      });
+            return () => {
+              fragmentRef.current.removeEventListener(
+                'click',
+                handleFragmentRefClicks,
+              );
+            };
+          }, []);
+          return (
+            <div ref={parentRef}>
+              <Fragment ref={fragmentRef}>
+                <>Text</>
+                <div ref={childARef}>A</div>
+                <>
+                  <div ref={childBRef}>B</div>
+                </>
+              </Fragment>
+            </div>
+          );
+        }
 
-      childARef.current.addEventListener('click', () => {
-        logs.push('A');
-      });
+        await act(() => {
+          root.render(<Test />);
+        });
 
-      childBRef.current.addEventListener('click', () => {
-        logs.push('B');
-      });
+        childARef.current.addEventListener('click', () => {
+          logs.push('A');
+        });
 
-      // Clicking on the parent should not trigger any listeners
-      parentRef.current.click();
-      expect(logs).toEqual([]);
+        childBRef.current.addEventListener('click', () => {
+          logs.push('B');
+        });
 
-      // Clicking a child triggers its own listeners and the Fragment's
-      childARef.current.click();
-      expect(logs).toEqual(['fragmentRef', 'A']);
+        // Clicking on the parent should not trigger any listeners
+        parentRef.current.click();
+        expect(logs).toEqual([]);
 
-      logs = [];
+        // Clicking a child triggers its own listeners and the Fragment's
+        childARef.current.click();
+        expect(logs).toEqual(['fragmentRef', 'A']);
 
-      childBRef.current.click();
-      expect(logs).toEqual(['fragmentRef', 'B']);
+        logs = [];
 
-      logs = [];
+        childBRef.current.click();
+        expect(logs).toEqual(['fragmentRef', 'B']);
 
-      fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
+        logs = [];
 
-      childARef.current.click();
-      expect(logs).toEqual(['A']);
+        fragmentRef.current.removeEventListener(
+          'click',
+          handleFragmentRefClicks,
+        );
 
-      logs = [];
+        childARef.current.click();
+        expect(logs).toEqual(['A']);
 
-      childBRef.current.click();
-      expect(logs).toEqual(['B']);
-    });
+        logs = [];
 
-    // @gate enableFragmentRefs
-    it('adds and removes event listeners from children with multiple fragments', async () => {
-      const fragmentRef = React.createRef();
-      const nestedFragmentRef = React.createRef();
-      const nestedFragmentRef2 = React.createRef();
-      const childARef = React.createRef();
-      const childBRef = React.createRef();
-      const childCRef = React.createRef();
-      const root = ReactDOMClient.createRoot(container);
+        childBRef.current.click();
+        expect(logs).toEqual(['B']);
+      });
 
-      await act(() => {
-        root.render(
-          <div>
-            <Fragment ref={fragmentRef}>
-              <div ref={childARef}>A</div>
-              <div>
-                <Fragment ref={nestedFragmentRef}>
-                  <div ref={childBRef}>B</div>
+      // @gate enableFragmentRefs
+      it('adds and removes event listeners from children with multiple fragments', async () => {
+        const fragmentRef = React.createRef();
+        const nestedFragmentRef = React.createRef();
+        const nestedFragmentRef2 = React.createRef();
+        const childARef = React.createRef();
+        const childBRef = React.createRef();
+        const childCRef = React.createRef();
+        const root = ReactDOMClient.createRoot(container);
+
+        await act(() => {
+          root.render(
+            <div>
+              <Fragment ref={fragmentRef}>
+                <div ref={childARef}>A</div>
+                <div>
+                  <Fragment ref={nestedFragmentRef}>
+                    <div ref={childBRef}>B</div>
+                  </Fragment>
+                </div>
+                <Fragment ref={nestedFragmentRef2}>
+                  <div ref={childCRef}>C</div>
                 </Fragment>
-              </div>
-              <Fragment ref={nestedFragmentRef2}>
-                <div ref={childCRef}>C</div>
               </Fragment>
-            </Fragment>
-          </div>,
-        );
-      });
+            </div>,
+          );
+        });
 
-      let logs = [];
+        let logs = [];
 
-      function handleFragmentRefClicks() {
-        logs.push('fragmentRef');
-      }
+        function handleFragmentRefClicks() {
+          logs.push('fragmentRef');
+        }
 
-      function handleNestedFragmentRefClicks() {
-        logs.push('nestedFragmentRef');
-      }
+        function handleNestedFragmentRefClicks() {
+          logs.push('nestedFragmentRef');
+        }
 
-      function handleNestedFragmentRef2Clicks() {
-        logs.push('nestedFragmentRef2');
-      }
+        function handleNestedFragmentRef2Clicks() {
+          logs.push('nestedFragmentRef2');
+        }
 
-      fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
-      nestedFragmentRef.current.addEventListener(
-        'click',
-        handleNestedFragmentRefClicks,
-      );
-      nestedFragmentRef2.current.addEventListener(
-        'click',
-        handleNestedFragmentRef2Clicks,
-      );
+        fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
+        nestedFragmentRef.current.addEventListener(
+          'click',
+          handleNestedFragmentRefClicks,
+        );
+        nestedFragmentRef2.current.addEventListener(
+          'click',
+          handleNestedFragmentRef2Clicks,
+        );
 
-      childBRef.current.click();
-      // Event bubbles to the parent fragment
-      expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
+        childBRef.current.click();
+        // Event bubbles to the parent fragment
+        expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
 
-      logs = [];
+        logs = [];
 
-      childARef.current.click();
-      expect(logs).toEqual(['fragmentRef']);
+        childARef.current.click();
+        expect(logs).toEqual(['fragmentRef']);
 
-      logs = [];
-      childCRef.current.click();
-      expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
+        logs = [];
+        childCRef.current.click();
+        expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
 
-      logs = [];
+        logs = [];
 
-      fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
-      nestedFragmentRef.current.removeEventListener(
-        'click',
-        handleNestedFragmentRefClicks,
-      );
-      childCRef.current.click();
-      expect(logs).toEqual(['nestedFragmentRef2']);
-    });
+        fragmentRef.current.removeEventListener(
+          'click',
+          handleFragmentRefClicks,
+        );
+        nestedFragmentRef.current.removeEventListener(
+          'click',
+          handleNestedFragmentRefClicks,
+        );
+        childCRef.current.click();
+        expect(logs).toEqual(['nestedFragmentRef2']);
+      });
 
-    // @gate enableFragmentRefs
-    it('adds an event listener to a newly added child', async () => {
-      const fragmentRef = React.createRef();
-      const childRef = React.createRef();
-      const root = ReactDOMClient.createRoot(container);
-      let showChild;
+      // @gate enableFragmentRefs
+      it('adds an event listener to a newly added child', async () => {
+        const fragmentRef = React.createRef();
+        const childRef = React.createRef();
+        const root = ReactDOMClient.createRoot(container);
+        let showChild;
 
-      function Component() {
-        const [shouldShowChild, setShouldShowChild] = React.useState(false);
-        showChild = () => {
-          setShouldShowChild(true);
-        };
+        function Component() {
+          const [shouldShowChild, setShouldShowChild] = React.useState(false);
+          showChild = () => {
+            setShouldShowChild(true);
+          };
 
-        return (
-          <div>
-            <Fragment ref={fragmentRef}>
-              <div id="a">A</div>
-              {shouldShowChild && (
-                <div ref={childRef} id="b">
-                  B
-                </div>
-              )}
-            </Fragment>
-          </div>
-        );
-      }
+          return (
+            <div>
+              <Fragment ref={fragmentRef}>
+                <div id="a">A</div>
+                {shouldShowChild && (
+                  <div ref={childRef} id="b">
+                    B
+                  </div>
+                )}
+              </Fragment>
+            </div>
+          );
+        }
 
-      await act(() => {
-        root.render(<Component />);
-      });
+        await act(() => {
+          root.render(<Component />);
+        });
 
-      expect(fragmentRef.current).not.toBe(null);
-      expect(childRef.current).toBe(null);
+        expect(fragmentRef.current).not.toBe(null);
+        expect(childRef.current).toBe(null);
 
-      let hasClicked = false;
-      fragmentRef.current.addEventListener('click', () => {
-        hasClicked = true;
-      });
+        let hasClicked = false;
+        fragmentRef.current.addEventListener('click', () => {
+          hasClicked = true;
+        });
 
-      await act(() => {
-        showChild();
-      });
-      expect(childRef.current).not.toBe(null);
+        await act(() => {
+          showChild();
+        });
+        expect(childRef.current).not.toBe(null);
 
-      childRef.current.click();
-      expect(hasClicked).toBe(true);
-    });
+        childRef.current.click();
+        expect(hasClicked).toBe(true);
+      });
 
-    // @gate enableFragmentRefs
-    it('applies event listeners to host children nested within non-host children', async () => {
-      const fragmentRef = React.createRef();
-      const childRef = React.createRef();
-      const nestedChildRef = React.createRef();
-      const root = ReactDOMClient.createRoot(container);
+      // @gate enableFragmentRefs
+      it('applies event listeners to host children nested within non-host children', async () => {
+        const fragmentRef = React.createRef();
+        const childRef = React.createRef();
+        const nestedChildRef = React.createRef();
+        const root = ReactDOMClient.createRoot(container);
 
-      await act(() => {
-        root.render(
-          <div>
-            <Fragment ref={fragmentRef}>
-              <div ref={childRef}>Host A</div>
-              <Wrapper>
+        await act(() => {
+          root.render(
+            <div>
+              <Fragment ref={fragmentRef}>
+                <div ref={childRef}>Host A</div>
                 <Wrapper>
                   <Wrapper>
-                    <div ref={nestedChildRef}>Host B</div>
+                    <Wrapper>
+                      <div ref={nestedChildRef}>Host B</div>
+                    </Wrapper>
                   </Wrapper>
                 </Wrapper>
-              </Wrapper>
-            </Fragment>
-          </div>,
-        );
-      });
-      const logs = [];
-      fragmentRef.current.addEventListener('click', e => {
-        logs.push(e.target.textContent);
-      });
-
-      expect(logs).toEqual([]);
-      childRef.current.click();
-      expect(logs).toEqual(['Host A']);
-      nestedChildRef.current.click();
-      expect(logs).toEqual(['Host A', 'Host B']);
-    });
-
-    // @gate enableFragmentRefs
-    it('allows adding and cleaning up listeners in effects', async () => {
-      const root = ReactDOMClient.createRoot(container);
-
-      let logs = [];
-      function logClick(e) {
-        logs.push(e.currentTarget.id);
-      }
-
-      let rerender;
-      let removeEventListeners;
-
-      function Test() {
-        const fragmentRef = React.useRef(null);
-        // eslint-disable-next-line no-unused-vars
-        const [_, setState] = React.useState(0);
-        rerender = () => {
-          setState(p => p + 1);
-        };
-        removeEventListeners = () => {
-          fragmentRef.current.removeEventListener('click', logClick);
-        };
-        React.useEffect(() => {
-          fragmentRef.current.addEventListener('click', logClick);
-
-          return removeEventListeners;
+              </Fragment>
+            </div>,
+          );
+        });
+        const logs = [];
+        fragmentRef.current.addEventListener('click', e => {
+          logs.push(e.target.textContent);
         });
 
-        return (
-          <Fragment ref={fragmentRef}>
-            <div id="child-a" />
-          </Fragment>
-        );
-      }
-
-      // The event listener was applied
-      await act(() => root.render(<Test />));
-      expect(logs).toEqual([]);
-      document.querySelector('#child-a').click();
-      expect(logs).toEqual(['child-a']);
+        expect(logs).toEqual([]);
+        childRef.current.click();
+        expect(logs).toEqual(['Host A']);
+        nestedChildRef.current.click();
+        expect(logs).toEqual(['Host A', 'Host B']);
+      });
 
-      // The event listener can be removed and re-added
-      logs = [];
-      await act(rerender);
-      document.querySelector('#child-a').click();
-      expect(logs).toEqual(['child-a']);
-    });
+      // @gate enableFragmentRefs
+      it('allows adding and cleaning up listeners in effects', async () => {
+        const root = ReactDOMClient.createRoot(container);
 
-    // @gate enableFragmentRefs
-    it('does not apply removed event listeners to new children', async () => {
-      const root = ReactDOMClient.createRoot(container);
-      const fragmentRef = React.createRef(null);
-      function Test() {
-        return (
-          <Fragment ref={fragmentRef}>
-            <div id="child-a" />
-          </Fragment>
-        );
-      }
+        let logs = [];
+        function logClick(e) {
+          logs.push(e.currentTarget.id);
+        }
 
-      let logs = [];
-      function logClick(e) {
-        logs.push(e.currentTarget.id);
-      }
-      await act(() => {
-        root.render(<Test />);
-      });
-      fragmentRef.current.addEventListener('click', logClick);
-      const childA = document.querySelector('#child-a');
-      childA.click();
-      expect(logs).toEqual(['child-a']);
+        let rerender;
+        let removeEventListeners;
 
-      logs = [];
-      fragmentRef.current.removeEventListener('click', logClick);
-      childA.click();
-      expect(logs).toEqual([]);
-    });
+        function Test() {
+          const fragmentRef = React.useRef(null);
+          // eslint-disable-next-line no-unused-vars
+          const [_, setState] = React.useState(0);
+          rerender = () => {
+            setState(p => p + 1);
+          };
+          removeEventListeners = () => {
+            fragmentRef.current.removeEventListener('click', logClick);
+          };
+          React.useEffect(() => {
+            fragmentRef.current.addEventListener('click', logClick);
 
-    // @gate enableFragmentRefs
-    it('applies event listeners to portaled children', async () => {
-      const fragmentRef = React.createRef();
-      const childARef = React.createRef();
-      const childBRef = React.createRef();
-      const root = ReactDOMClient.createRoot(container);
+            return removeEventListeners;
+          });
 
-      function Test() {
-        return (
-          <Fragment ref={fragmentRef}>
-            <div id="child-a" ref={childARef} />
-            {createPortal(<div id="child-b" ref={childBRef} />, document.body)}
-          </Fragment>
-        );
-      }
+          return (
+            <Fragment ref={fragmentRef}>
+              <div id="child-a" />
+            </Fragment>
+          );
+        }
 
-      await act(() => {
-        root.render(<Test />);
-      });
+        // The event listener was applied
+        await act(() => root.render(<Test />));
+        expect(logs).toEqual([]);
+        document.querySelector('#child-a').click();
+        expect(logs).toEqual(['child-a']);
 
-      const logs = [];
-      fragmentRef.current.addEventListener('click', e => {
-        logs.push(e.target.id);
+        // The event listener can be removed and re-added
+        logs = [];
+        await act(rerender);
+        document.querySelector('#child-a').click();
+        expect(logs).toEqual(['child-a']);
       });
 
-      childARef.current.click();
-      expect(logs).toEqual(['child-a']);
-
-      logs.length = 0;
-      childBRef.current.click();
-      expect(logs).toEqual(['child-b']);
-    });
-
-    describe('with activity', () => {
-      // @gate enableFragmentRefs && enableActivity
-      it('does not apply event listeners to hidden trees', async () => {
-        const parentRef = React.createRef();
-        const fragmentRef = React.createRef();
+      // @gate enableFragmentRefs
+      it('does not apply removed event listeners to new children', async () => {
         const root = ReactDOMClient.createRoot(container);
-
+        const fragmentRef = React.createRef(null);
         function Test() {
           return (
-            <div ref={parentRef}>
-              <Fragment ref={fragmentRef}>
-                <div>Child 1</div>
-                <Activity mode="hidden">
-                  <div>Child 2</div>
-                </Activity>
-                <div>Child 3</div>
-              </Fragment>
-            </div>
+            <Fragment ref={fragmentRef}>
+              <div id="child-a" />
+            </Fragment>
           );
         }
 
+        let logs = [];
+        function logClick(e) {
+          logs.push(e.currentTarget.id);
+        }
         await act(() => {
           root.render(<Test />);
         });
+        fragmentRef.current.addEventListener('click', logClick);
+        const childA = document.querySelector('#child-a');
+        childA.click();
+        expect(logs).toEqual(['child-a']);
 
-        const logs = [];
-        fragmentRef.current.addEventListener('click', e => {
-          logs.push(e.target.textContent);
-        });
-
-        const [child1, child2, child3] = parentRef.current.children;
-        child1.click();
-        child2.click();
-        child3.click();
-        expect(logs).toEqual(['Child 1', 'Child 3']);
+        logs = [];
+        fragmentRef.current.removeEventListener('click', logClick);
+        childA.click();
+        expect(logs).toEqual([]);
       });
 
-      // @gate enableFragmentRefs && enableActivity
-      it('applies event listeners to visible trees', async () => {
-        const parentRef = React.createRef();
+      // @gate enableFragmentRefs
+      it('applies event listeners to portaled children', async () => {
         const fragmentRef = React.createRef();
+        const childARef = React.createRef();
+        const childBRef = React.createRef();
         const root = ReactDOMClient.createRoot(container);
 
         function Test() {
           return (
-            <div ref={parentRef}>
-              <Fragment ref={fragmentRef}>
-                <div>Child 1</div>
-                <Activity mode="visible">
-                  <div>Child 2</div>
-                </Activity>
-                <div>Child 3</div>
-              </Fragment>
-            </div>
+            <Fragment ref={fragmentRef}>
+              <div id="child-a" ref={childARef} />
+              {createPortal(
+                <div id="child-b" ref={childBRef} />,
+                document.body,
+              )}
+            </Fragment>
           );
         }
 
@@ -766,67 +702,242 @@ describe('FragmentRefs', () => {
 
         const logs = [];
         fragmentRef.current.addEventListener('click', e => {
-          logs.push(e.target.textContent);
+          logs.push(e.target.id);
         });
 
-        const [child1, child2, child3] = parentRef.current.children;
-        child1.click();
-        child2.click();
-        child3.click();
-        expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
+        childARef.current.click();
+        expect(logs).toEqual(['child-a']);
+
+        logs.length = 0;
+        childBRef.current.click();
+        expect(logs).toEqual(['child-b']);
+      });
+
+      describe('with activity', () => {
+        // @gate enableFragmentRefs && enableActivity
+        it('does not apply event listeners to hidden trees', async () => {
+          const parentRef = React.createRef();
+          const fragmentRef = React.createRef();
+          const root = ReactDOMClient.createRoot(container);
+
+          function Test() {
+            return (
+              <div ref={parentRef}>
+                <Fragment ref={fragmentRef}>
+                  <div>Child 1</div>
+                  <Activity mode="hidden">
+                    <div>Child 2</div>
+                  </Activity>
+                  <div>Child 3</div>
+                </Fragment>
+              </div>
+            );
+          }
+
+          await act(() => {
+            root.render(<Test />);
+          });
+
+          const logs = [];
+          fragmentRef.current.addEventListener('click', e => {
+            logs.push(e.target.textContent);
+          });
+
+          const [child1, child2, child3] = parentRef.current.children;
+          child1.click();
+          child2.click();
+          child3.click();
+          expect(logs).toEqual(['Child 1', 'Child 3']);
+        });
+
+        // @gate enableFragmentRefs && enableActivity
+        it('applies event listeners to visible trees', async () => {
+          const parentRef = React.createRef();
+          const fragmentRef = React.createRef();
+          const root = ReactDOMClient.createRoot(container);
+
+          function Test() {
+            return (
+              <div ref={parentRef}>
+                <Fragment ref={fragmentRef}>
+                  <div>Child 1</div>
+                  <Activity mode="visible">
+                    <div>Child 2</div>
+                  </Activity>
+                  <div>Child 3</div>
+                </Fragment>
+              </div>
+            );
+          }
+
+          await act(() => {
+            root.render(<Test />);
+          });
+
+          const logs = [];
+          fragmentRef.current.addEventListener('click', e => {
+            logs.push(e.target.textContent);
+          });
+
+          const [child1, child2, child3] = parentRef.current.children;
+          child1.click();
+          child2.click();
+          child3.click();
+          expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
+        });
+
+        // @gate enableFragmentRefs && enableActivity
+        it('handles Activity modes switching', async () => {
+          const fragmentRef = React.createRef();
+          const fragmentRef2 = React.createRef();
+          const parentRef = React.createRef();
+          const root = ReactDOMClient.createRoot(container);
+
+          function Test({mode}) {
+            return (
+              <div id="parent" ref={parentRef}>
+                <Fragment ref={fragmentRef}>
+                  <Activity mode={mode}>
+                    <div id="child1">Child</div>
+                    <Fragment ref={fragmentRef2}>
+                      <div id="child2">Child 2</div>
+                    </Fragment>
+                  </Activity>
+                </Fragment>
+              </div>
+            );
+          }
+
+          await act(() => {
+            root.render(<Test mode="visible" />);
+          });
+
+          let logs = [];
+          fragmentRef.current.addEventListener('click', () => {
+            logs.push('clicked 1');
+          });
+          fragmentRef2.current.addEventListener('click', () => {
+            logs.push('clicked 2');
+          });
+          parentRef.current.lastChild.click();
+          expect(logs).toEqual(['clicked 1', 'clicked 2']);
+
+          logs = [];
+          await act(() => {
+            root.render(<Test mode="hidden" />);
+          });
+          parentRef.current.firstChild.click();
+          parentRef.current.lastChild.click();
+          expect(logs).toEqual([]);
+
+          logs = [];
+          await act(() => {
+            root.render(<Test mode="visible" />);
+          });
+          parentRef.current.lastChild.click();
+          // Event order is flipped here because the nested child re-registers first
+          expect(logs).toEqual(['clicked 2', 'clicked 1']);
+        });
       });
+    });
 
-      // @gate enableFragmentRefs && enableActivity
-      it('handles Activity modes switching', async () => {
+    describe('dispatchEvent()', () => {
+      // @gate enableFragmentRefs
+      it('fires events on the host parent if bubbles=true', async () => {
         const fragmentRef = React.createRef();
-        const fragmentRef2 = React.createRef();
-        const parentRef = React.createRef();
         const root = ReactDOMClient.createRoot(container);
+        let logs = [];
+
+        function handleClick(e) {
+          logs.push([e.type, e.target.id, e.currentTarget.id]);
+        }
 
-        function Test({mode}) {
+        function Test({isMounted}) {
           return (
-            <div id="parent" ref={parentRef}>
-              <Fragment ref={fragmentRef}>
-                <Activity mode={mode}>
-                  <div id="child1">Child</div>
-                  <Fragment ref={fragmentRef2}>
-                    <div id="child2">Child 2</div>
+            <div onClick={handleClick} id="grandparent">
+              <div onClick={handleClick} id="parent">
+                {isMounted && (
+                  <Fragment ref={fragmentRef}>
+                    <div onClick={handleClick} id="child">
+                      Hi
+                    </div>
                   </Fragment>
-                </Activity>
-              </Fragment>
+                )}
+              </div>
             </div>
           );
         }
 
         await act(() => {
-          root.render(<Test mode="visible" />);
+          root.render(<Test isMounted={true} />);
         });
 
-        let logs = [];
-        fragmentRef.current.addEventListener('click', () => {
-          logs.push('clicked 1');
-        });
-        fragmentRef2.current.addEventListener('click', () => {
-          logs.push('clicked 2');
-        });
-        parentRef.current.lastChild.click();
-        expect(logs).toEqual(['clicked 1', 'clicked 2']);
+        let isCancelable = !fragmentRef.current.dispatchEvent(
+          new MouseEvent('click', {bubbles: true}),
+        );
+        expect(logs).toEqual([
+          ['click', 'parent', 'parent'],
+          ['click', 'parent', 'grandparent'],
+        ]);
+        expect(isCancelable).toBe(false);
 
-        logs = [];
+        const fragmentInstanceHandle = fragmentRef.current;
         await act(() => {
-          root.render(<Test mode="hidden" />);
+          root.render(<Test isMounted={false} />);
         });
-        parentRef.current.firstChild.click();
-        parentRef.current.lastChild.click();
+        logs = [];
+        isCancelable = !fragmentInstanceHandle.dispatchEvent(
+          new MouseEvent('click', {bubbles: true}),
+        );
         expect(logs).toEqual([]);
+        expect(isCancelable).toBe(false);
 
         logs = [];
+        isCancelable = !fragmentInstanceHandle.dispatchEvent(
+          new MouseEvent('click', {bubbles: false}),
+        );
+        expect(logs).toEqual([]);
+        expect(isCancelable).toBe(false);
+      });
+
+      // @gate enableFragmentRefs
+      it('fires events on self, and only self if bubbles=false', async () => {
+        const fragmentRef = React.createRef();
+        const root = ReactDOMClient.createRoot(container);
+        let logs = [];
+
+        function handleClick(e) {
+          logs.push([e.type, e.target.id, e.currentTarget.id]);
+        }
+
+        function Test() {
+          return (
+            <div id="parent" onClick={handleClick}>
+              <Fragment ref={fragmentRef} />
+            </div>
+          );
+        }
+
         await act(() => {
-          root.render(<Test mode="visible" />);
+          root.render(<Test />);
         });
-        parentRef.current.lastChild.click();
-        // Event order is flipped here because the nested child re-registers first
-        expect(logs).toEqual(['clicked 2', 'clicked 1']);
+
+        fragmentRef.current.addEventListener('click', handleClick);
+
+        fragmentRef.current.dispatchEvent(
+          new MouseEvent('click', {bubbles: true}),
+        );
+        expect(logs).toEqual([
+          ['click', undefined, undefined],
+          ['click', 'parent', 'parent'],
+        ]);
+
+        logs = [];
+
+        fragmentRef.current.dispatchEvent(
+          new MouseEvent('click', {bubbles: false}),
+        );
+        expect(logs).toEqual([['click', undefined, undefined]]);
       });
     });
   });