@samitouri / QOS-React-2 / commits / 18c30e7a5e

[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();