[DOM] Attach Fragment event listeners to committed text children (#37164)
Commit and delete previously skipped TEXT_NODE, so listeners added before a text child mounted never reached it. Match addEventListener.
Jack Pope committed
Aug 11, 2026 at 18:25 UTC
18c30e7a5e8467fff8c51267dff37c59404c8ce2
2 files changed
+54
-10
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+11
-10
@@ -3720,17 +3720,18 @@ export function commitNewChildToFragmentInstance(
3720
childInstance: InstanceWithFragmentHandles | Text,
3721
fragmentInstance: FragmentInstanceType,
3722
): void {
3723
- if (childInstance.nodeType === TEXT_NODE) {
3724
- return;
3725
- }
3726
- const instance: InstanceWithFragmentHandles = childInstance as any;
3723
const eventListeners = fragmentInstance._eventListeners;
3724
if (eventListeners !== null) {
3725
for (let i = 0; i < eventListeners.length; i++) {
3726
const {type, listener, optionsOrUseCapture} = eventListeners[i];
3731
- instance.addEventListener(type, listener, optionsOrUseCapture);
3727
+ childInstance.addEventListener(type, listener, optionsOrUseCapture);
3728
}
3729
}
3730
+ // Observers and fragment handles only apply to element children.
3731
+ if (childInstance.nodeType === TEXT_NODE) {
3732
+ return;
3733
+ }
3734
+ const instance: InstanceWithFragmentHandles = childInstance as any;
3735
if (fragmentInstance._observers !== null) {
3736
fragmentInstance._observers.forEach(observer => {
3737
observer.observe(instance);
@@ -3745,17 +3746,17 @@ export function deleteChildFromFragmentInstance(
3746
childInstance: InstanceWithFragmentHandles | Text,
3747
fragmentInstance: FragmentInstanceType,
3748
): void {
3748
- if (childInstance.nodeType === TEXT_NODE) {
3749
- return;
3750
- }
3751
- const instance: InstanceWithFragmentHandles = childInstance as any;
3749
const eventListeners = fragmentInstance._eventListeners;
3750
if (eventListeners !== null) {
3751
for (let i = 0; i < eventListeners.length; i++) {
3752
const {type, listener, optionsOrUseCapture} = eventListeners[i];
3756
- instance.removeEventListener(type, listener, optionsOrUseCapture);
3753
+ childInstance.removeEventListener(type, listener, optionsOrUseCapture);
3754
}
3755
}
3756
+ if (childInstance.nodeType === TEXT_NODE) {
3757
+ return;
3758
+ }
3759
+ const instance: InstanceWithFragmentHandles = childInstance as any;
3760
if (enableFragmentRefsInstanceHandles) {
3761
if (instance.reactFragments != null) {
3762
instance.reactFragments.delete(fragmentInstance);
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+43
@@ -809,6 +809,49 @@ describe('FragmentRefs', () => {
809
expect(hasClicked).toBe(true);
810
});
811
812
+ // @gate enableFragmentRefs && enableFragmentRefsTextNodes
813
+ it('adds an event listener to a newly added text child', async () => {
814
+ const fragmentRef = React.createRef();
815
+ const parentRef = React.createRef();
816
+ const root = ReactDOMClient.createRoot(container);
817
+ let showText;
818
+
819
+ function Component() {
820
+ const [shouldShowText, setShouldShowText] = React.useState(false);
821
+ showText = () => {
822
+ setShouldShowText(true);
823
+ };
824
+
825
+ return (
826
+ <div ref={parentRef}>
827
+ <Fragment ref={fragmentRef}>
828
+ {shouldShowText ? 'Hello' : null}
829
+ </Fragment>
830
+ </div>
831
+ );
832
+ }
833
+
834
+ await act(() => {
835
+ root.render(<Component />);
836
+ });
837
+
838
+ const logs = [];
839
+ fragmentRef.current.addEventListener('click', () => {
840
+ logs.push('fragment');
841
+ });
842
+
843
+ await act(() => {
844
+ showText();
845
+ });
846
+
847
+ const textNode = Array.from(parentRef.current.childNodes).find(
848
+ node => node.nodeType === 3,
849
+ );
850
+ expect(textNode).not.toBe(undefined);
851
+ textNode.dispatchEvent(new MouseEvent('click', {bubbles: true}));
852
+ expect(logs).toEqual(['fragment']);
853
+ });
854
+
855
// @gate enableFragmentRefs
856
it('applies event listeners to host children nested within non-host children', async () => {
857
const fragmentRef = React.createRef();