From 9fda63fb9b82ad52de12e42dff0995d52294f5d6 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Fri, 21 Mar 2025 11:06:59 -0400 Subject: [PATCH 1/3] Add dispatchEvent to fragment instances --- .../fragment-refs/EventDispatchCase.js | 96 +++ .../fixtures/fragment-refs/index.js | 2 + .../src/client/ReactFiberConfigDOM.js | 18 + .../__tests__/ReactDOMFragmentRefs-test.js | 809 ++++++++++-------- 4 files changed, 556 insertions(+), 369 deletions(-) create mode 100644 fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js new file mode 100644 index 00000000000..6a7c18a891f --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js @@ -0,0 +1,96 @@ +import TestCase from '../../TestCase'; +import Fixture from '../../Fixture'; + +const React = window.React; +const {Fragment, useRef, useState} = React; + +function WrapperComponent(props) { + return props.children; +} + +function handler(e) { + const text = e.currentTarget.innerText; + alert('You clicked: ' + text); +} + +const initialState = { + child: false, + parent: false, + grandparent: false, +}; + +export default function EventListenerCase() { + const fragmentRef = useRef(null); + const [clickedState, setClickedState] = useState({...initialState}); + + return ( + + +
  • + Each div has regular click handlers, you can click each one to observe + the status changing +
  • +
  • Clear the clicked state
  • +
  • + Click the "Dispatch click event" button to dispatch a click event on + the Fragment +
  • +
    + + + Dispatching an event on a Fragment will forward the dispatch to its + parent. 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. + + + + + + + +
    { + setTimeout(() => { + setClickedState(prev => ({...prev, grandparent: true})); + }, 200); + }} + className="card"> + Fragment grandparent - clicked:{' '} + {clickedState.grandparent ? 'true' : 'false'} +
    { + setTimeout(() => { + setClickedState(prev => ({...prev, parent: true})); + }, 100); + }} + className="card"> + Fragment parent - clicked: {clickedState.parent ? 'true' : 'false'} + +
    { + setClickedState(prev => ({...prev, child: true})); + }}> + Fragment child - clicked:{' '} + {clickedState.child ? 'true' : 'false'} +
    +
    +
    +
    +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/index.js b/fixtures/dom/src/components/fixtures/fragment-refs/index.js index b84f273177d..23b440938cf 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/index.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/index.js @@ -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 ( + diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 8584b644eff..9efe556befa 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2597,6 +2597,7 @@ export type FragmentInstanceType = { listener: EventListener, optionsOrUseCapture?: EventListenerOptionsOrUseCapture, ): void, + dispatchEvent(event: Event): boolean, focus(focusOptions?: FocusOptions): void, focusLast(focusOptions?: FocusOptions): void, blur(): void, @@ -2694,6 +2695,23 @@ function removeEventListenerFromChild( return false; } // $FlowFixMe[prop-missing] +FragmentInstance.prototype.dispatchEvent = function ( + this: FragmentInstanceType, + event: Event, +): boolean { + const parentHostInstance = getFragmentParentHostInstance(this._fragmentFiber); + if (parentHostInstance === null) { + if (__DEV__) { + console.error( + 'You are attempting to dispatch an event on a disconnected ' + + 'FragmentInstance. No event was dispatched.', + ); + } + return true; + } + return parentHostInstance.dispatchEvent(event); +}; +// $FlowFixMe[prop-missing] FragmentInstance.prototype.focus = function ( this: FragmentInstanceType, focusOptions?: FocusOptions, diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 50447e1eac6..af974f5b259 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -299,410 +299,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 ( -
    - - <>Text -
    A
    - <> -
    B
    - -
    -
    - ); - } - await act(() => { - root.render(); - }); + return () => { + fragmentRef.current.removeEventListener( + 'click', + handleFragmentRefClicks, + ); + }; + }, []); + return ( +
    + + <>Text +
    A
    + <> +
    B
    + +
    +
    + ); + } - childARef.current.addEventListener('click', () => { - logs.push('A'); - }); + await act(() => { + root.render(); + }); - 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( -
    - -
    A
    -
    - -
    B
    + // @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( +
    + +
    A
    +
    + +
    B
    +
    +
    + +
    C
    -
    - -
    C
    -
    -
    , - ); - }); +
    , + ); + }); - 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 ( -
    - -
    A
    - {shouldShowChild && ( -
    - B -
    - )} -
    -
    - ); - } + return ( +
    + +
    A
    + {shouldShowChild && ( +
    + B +
    + )} +
    +
    + ); + } - await act(() => { - root.render(); - }); + await act(() => { + root.render(); + }); - 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( -
    - -
    Host A
    - + await act(() => { + root.render( +
    + +
    Host A
    -
    Host B
    + +
    Host B
    +
    - -
    -
    , - ); - }); - 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; +
    +
    , + ); + }); + const logs = []; + fragmentRef.current.addEventListener('click', e => { + logs.push(e.target.textContent); }); - return ( - -
    - - ); - } - - // The event listener was applied - await act(() => root.render()); - 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 ( - -
    - - ); - } + let logs = []; + function logClick(e) { + logs.push(e.currentTarget.id); + } - let logs = []; - function logClick(e) { - logs.push(e.currentTarget.id); - } - await act(() => { - root.render(); - }); - 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 ( - -
    - {createPortal(
    , document.body)} - - ); - } + return ( + +
    + + ); + } - await act(() => { - root.render(); - }); + // The event listener was applied + await act(() => root.render()); + 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 ( -
    - -
    Child 1
    - -
    Child 2
    -
    -
    Child 3
    -
    -
    + +
    + ); } + let logs = []; + function logClick(e) { + logs.push(e.currentTarget.id); + } await act(() => { root.render(); }); + 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 ( -
    - -
    Child 1
    - -
    Child 2
    -
    -
    Child 3
    -
    -
    + +
    + {createPortal( +
    , + document.body, + )} + ); } @@ -712,67 +648,202 @@ 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']); }); - // @gate enableFragmentRefs && enableActivity - it('handles Activity modes switching', async () => { + 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 ( +
    + +
    Child 1
    + +
    Child 2
    +
    +
    Child 3
    +
    +
    + ); + } + + await act(() => { + root.render(); + }); + + 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 ( +
    + +
    Child 1
    + +
    Child 2
    +
    +
    Child 3
    +
    +
    + ); + } + + await act(() => { + root.render(); + }); + + 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 ( +
    + + +
    Child
    + +
    Child 2
    +
    +
    +
    +
    + ); + } + + await act(() => { + root.render(); + }); + + 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(); + }); + parentRef.current.firstChild.click(); + parentRef.current.lastChild.click(); + expect(logs).toEqual([]); + + logs = []; + await act(() => { + root.render(); + }); + parentRef.current.lastChild.click(); + // Event order is flipped here because the nested child re-registers first + expect(logs).toEqual(['clicked 2', 'clicked 1']); + }); + }); + }); + + describe('dispatchEvent()', () => { + // @gate enableFragmentRefs + it('fires events on the host parent', async () => { const fragmentRef = React.createRef(); - const fragmentRef2 = React.createRef(); - const parentRef = React.createRef(); const root = ReactDOMClient.createRoot(container); + let logs = []; - function Test({mode}) { + function handleClick(e) { + logs.push([e.type, e.target.id, e.currentTarget.id]); + } + + function Test({isMounted}) { return ( -
    - - -
    Child
    - -
    Child 2
    +
    +
    + {isMounted && ( + +
    + Hi +
    - - + )} +
    ); } await act(() => { - root.render(); + root.render(); }); - 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(); + root.render(); }); - parentRef.current.firstChild.click(); - parentRef.current.lastChild.click(); - expect(logs).toEqual([]); - logs = []; - await act(() => { - root.render(); - }); - parentRef.current.lastChild.click(); - // Event order is flipped here because the nested child re-registers first - expect(logs).toEqual(['clicked 2', 'clicked 1']); + isCancelable = !fragmentInstanceHandle.dispatchEvent( + new MouseEvent('click', {bubbles: true}), + ); + assertConsoleErrorDev( + [ + 'You are attempting to dispatch an event on a disconnected ' + + 'FragmentInstance. No event was dispatched.', + ], + {withoutStack: true}, + ); + expect(logs).toEqual([]); + expect(isCancelable).toBe(false); }); }); }); From fc9cdaba7a9ebb313b356b3e6cf0a20e3a812b15 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Tue, 15 Apr 2025 17:14:30 -0400 Subject: [PATCH 2/3] Trigger a fragment instances own listeners with dispatchEvent --- .../fragment-refs/EventDispatchCase.js | 99 +++++++++++++++---- .../src/client/ReactFiberConfigDOM.js | 37 +++++-- .../__tests__/ReactDOMFragmentRefs-test.js | 54 ++++++++-- 3 files changed, 155 insertions(+), 35 deletions(-) diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js index 6a7c18a891f..ef5a9590213 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js @@ -8,11 +8,6 @@ function WrapperComponent(props) { return props.children; } -function handler(e) { - const text = e.currentTarget.innerText; - alert('You clicked: ' + text); -} - const initialState = { child: false, parent: false, @@ -22,34 +17,75 @@ const initialState = { 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 (
  • - Each div has regular click handlers, you can click each one to observe - the status changing + Each box has regular click handlers, you can click each one to observe + the status changing through standard bubbling.
  • Clear the clicked state
  • Click the "Dispatch click event" button to dispatch a click event on - the Fragment + the Fragment. The event will be dispatched on the Fragment's parent, + so the child will not change state. +
  • +
  • + 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. +
  • +
  • + 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. +
  • +
  • + If you turn off bubbling, only the Fragment's event handler will be + called.
  • - Dispatching an event on a Fragment will forward the dispatch to its - parent. 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. +

    + 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.{' '} +

    +

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

    + + +
    { + id="grandparent" + onClick={e => { setTimeout(() => { - setClickedState(prev => ({...prev, grandparent: true})); + setClick('grandparent'); }, 200); }} className="card"> Fragment grandparent - clicked:{' '} {clickedState.grandparent ? 'true' : 'false'}
    { + id="parent" + onClick={e => { setTimeout(() => { - setClickedState(prev => ({...prev, parent: true})); + setClick('parent'); }, 100); }} className="card"> Fragment parent - clicked: {clickedState.parent ? 'true' : 'false'}
    { - setClickedState(prev => ({...prev, child: true})); + onClick={e => { + setClick('child'); }}> Fragment child - clicked:{' '} {clickedState.child ? 'true' : 'false'} diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 9efe556befa..469a760322f 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2699,17 +2699,36 @@ FragmentInstance.prototype.dispatchEvent = function ( this: FragmentInstanceType, event: Event, ): boolean { - const parentHostInstance = getFragmentParentHostInstance(this._fragmentFiber); - if (parentHostInstance === null) { - if (__DEV__) { - console.error( - 'You are attempting to dispatch an event on a disconnected ' + - 'FragmentInstance. No event was dispatched.', - ); - } + const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber); + if (parentHostFiber === null) { return true; } - return parentHostInstance.dispatchEvent(event); + const parentHostInstance = getInstanceFromHostFiber(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 ( diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index af974f5b259..fb802ed2d41 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -789,7 +789,7 @@ describe('FragmentRefs', () => { describe('dispatchEvent()', () => { // @gate enableFragmentRefs - it('fires events on the host parent', async () => { + it('fires events on the host parent if bubbles=true', async () => { const fragmentRef = React.createRef(); const root = ReactDOMClient.createRoot(container); let logs = []; @@ -835,16 +835,56 @@ describe('FragmentRefs', () => { isCancelable = !fragmentInstanceHandle.dispatchEvent( new MouseEvent('click', {bubbles: true}), ); - assertConsoleErrorDev( - [ - 'You are attempting to dispatch an event on a disconnected ' + - 'FragmentInstance. No event was dispatched.', - ], - {withoutStack: 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 ( +
    + +
    + ); + } + + await act(() => { + root.render(); + }); + + 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]]); + }); }); }); From 30f04c247757be646781badf6d0099cf7a5444de Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Tue, 6 May 2025 13:10:36 -0400 Subject: [PATCH 3/3] flow --- packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 469a760322f..84eeacc9cc7 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2703,7 +2703,8 @@ FragmentInstance.prototype.dispatchEvent = function ( if (parentHostFiber === null) { return true; } - const parentHostInstance = getInstanceFromHostFiber(parentHostFiber); + const parentHostInstance = + getInstanceFromHostFiber(parentHostFiber); const eventListeners = this._eventListeners; if ( (eventListeners !== null && eventListeners.length > 0) ||