@samitouri / QOS-React-1 / commits / 3cba19c977

[DOM] Fix Fragment removeEventListener dropping tracked listeners (#37160)

Removing an unregistered listener used splice(-1), which deleted the last tracked entry and left the real handler stuck on DOM children.

Jack Pope committed Aug 11, 2026 at 17:50 UTC 3cba19c97725975c5a7d0d9aba00d97553158654
2 files changed +57 -17
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+16 -17
@@ -3073,24 +3073,23 @@ FragmentInstance.prototype.removeEventListener = function (
3073 if (listeners === null) {
3074 return;
3075 }
3076 - if (typeof listeners !== 'undefined' && listeners.length > 0) {
3077 - traverseFragmentInstancesAndTextInstances(
3078 - this._fragmentFiber,
3079 - removeEventListenerFromChild,
3080 - type,
3081 - listener,
3082 - optionsOrUseCapture,
3083 - );
3084 - const index = indexOfEventListener(
3085 - listeners,
3086 - type,
3087 - listener,
3088 - optionsOrUseCapture,
3089 - );
3090 - if (this._eventListeners !== null) {
3091 - this._eventListeners.splice(index, 1);
3092 - }
3076 + const index = indexOfEventListener(
3077 + listeners,
3078 + type,
3079 + listener,
3080 + optionsOrUseCapture,
3081 + );
3082 + if (index === -1) {
3083 + return;
3084 }
3085 + traverseFragmentInstancesAndTextInstances(
3086 + this._fragmentFiber,
3087 + removeEventListenerFromChild,
3088 + type,
3089 + listener,
3090 + optionsOrUseCapture,
3091 + );
3092 + listeners.splice(index, 1);
3093 };
3094 function removeEventListenerFromChild(
3095 child: Fiber,
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+41
@@ -606,6 +606,47 @@ describe('FragmentRefs', () => {
606 expect(logs).toEqual(['B']);
607 });
608
609 + // @gate enableFragmentRefs
610 + it('regression: does not detach a registered listener when removing an unregistered one', async () => {
611 + const fragmentRef = React.createRef();
612 + const childRef = React.createRef();
613 + const root = ReactDOMClient.createRoot(container);
614 + let logs = [];
615 +
616 + function registeredListener() {
617 + logs.push('registered');
618 + }
619 +
620 + function unregisteredListener() {
621 + logs.push('unregistered');
622 + }
623 +
624 + await act(() => {
625 + root.render(
626 + <Fragment ref={fragmentRef}>
627 + <div ref={childRef}>child</div>
628 + </Fragment>,
629 + );
630 + });
631 +
632 + fragmentRef.current.addEventListener('click', registeredListener);
633 + childRef.current.click();
634 + expect(logs).toEqual(['registered']);
635 +
636 + // Regression: removing a listener that was never added must be a no-op.
637 + // It must not detach registered listeners from fragmentInstance,
638 + // causing them to stay attached to DOM even after removeEventListener.
639 + fragmentRef.current.removeEventListener('click', unregisteredListener);
640 + logs = [];
641 + childRef.current.click();
642 + expect(logs).toEqual(['registered']);
643 +
644 + fragmentRef.current.removeEventListener('click', registeredListener);
645 + logs = [];
646 + childRef.current.click();
647 + expect(logs).toEqual([]);
648 + });
649 +
650 // @gate enableFragmentRefs
651 it('adds and removes event listeners from children with multiple fragments', async () => {
652 const fragmentRef = React.createRef();