@samitouri / QOS-React / commits / beef6d60f4

[DOM] Scope Fragment once listeners to the fragment, not each child (#37169)

`{once: true}` events are supposed to fire once. Since the `addEventListener` implementation adds a listener to each host child, `once` was not respected if you trigger event on multiple children. Here we wrap the event so we can remove is after the first call

Jack Pope committed Aug 13, 2026 at 07:11 UTC beef6d60f46a97f5c20471df81760fcf365d63ef
3 files changed +169 -14
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+75 -13
@@ -2988,6 +2988,9 @@ type StoredEventListener = {
2988 type: string,
2989 listener: EventListener,
2990 optionsOrUseCapture: void | EventListenerOptionsOrUseCapture,
2991 + // When once:true, a wrapper that removes the fragment listener after the
2992 + // first fire. Otherwise the same as listener.
2993 + attachedListener: EventListener,
2994 };
2995
2996 export type FragmentInstanceType = {
@@ -3042,13 +3045,37 @@ FragmentInstance.prototype.addEventListener = function (
3045 const isNewEventListener =
3046 indexOfEventListener(listeners, type, listener, optionsOrUseCapture) === -1;
3047 if (isNewEventListener) {
3045 - listeners.push({type, listener, optionsOrUseCapture});
3048 + const fragmentInstance = this;
3049 + let attachedListener = listener;
3050 + if (isOnceOption(optionsOrUseCapture)) {
3051 + // once is fragment-scoped: the first fire on any child removes this
3052 + // listener from the fragment and every host child.
3053 + attachedListener = function (this: EventTarget, event: Event) {
3054 + fragmentInstance.removeEventListener(
3055 + type,
3056 + listener,
3057 + optionsOrUseCapture,
3058 + );
3059 + if (typeof listener === 'function') {
3060 + listener.call(this, event);
3061 + } else {
3062 + listener.handleEvent(event);
3063 + }
3064 + };
3065 + }
3066 + const attachOptions = getAttachOptions(optionsOrUseCapture);
3067 + listeners.push({
3068 + type,
3069 + listener,
3070 + optionsOrUseCapture,
3071 + attachedListener,
3072 + });
3073 traverseFragmentInstancesAndTextInstances(
3074 this._fragmentFiber,
3075 addEventListenerToChild,
3076 type,
3050 - listener,
3051 - optionsOrUseCapture,
3077 + attachedListener,
3078 + attachOptions,
3079 );
3080 }
3081 this._eventListeners = listeners;
@@ -3083,12 +3110,15 @@ FragmentInstance.prototype.removeEventListener = function (
3110 if (index === -1) {
3111 return;
3112 }
3113 + const {attachedListener, optionsOrUseCapture: storedOptions} =
3114 + listeners[index];
3115 + const attachOptions = getAttachOptions(storedOptions);
3116 traverseFragmentInstancesAndTextInstances(
3117 this._fragmentFiber,
3118 removeEventListenerFromChild,
3119 type,
3090 - listener,
3091 - optionsOrUseCapture,
3120 + attachedListener,
3121 + attachOptions,
3122 );
3123 listeners.splice(index, 1);
3124 };
@@ -3102,6 +3132,22 @@ function removeEventListenerFromChild(
3132 instance.removeEventListener(type, listener, optionsOrUseCapture);
3133 return false;
3134 }
3135 +function isOnceOption(opts: ?EventListenerOptionsOrUseCapture): boolean {
3136 + return opts != null && typeof opts !== 'boolean' && opts.once === true;
3137 +}
3138 +function getAttachOptions(
3139 + opts: void | EventListenerOptionsOrUseCapture,
3140 +): void | EventListenerOptionsOrUseCapture {
3141 + // Strip once when attaching to host children; Fragment owns once semantics.
3142 + if (opts == null || typeof opts === 'boolean' || opts.once !== true) {
3143 + return opts;
3144 + }
3145 + return {
3146 + capture: opts.capture,
3147 + passive: opts.passive,
3148 + signal: opts.signal,
3149 + };
3150 +}
3151 function normalizeListenerOptions(
3152 opts: ?EventListenerOptionsOrUseCapture,
3153 ): string {
@@ -3166,16 +3212,24 @@ FragmentInstance.prototype.dispatchEvent = function (
3212 : document.createTextNode('');
3213 if (eventListeners) {
3214 for (let i = 0; i < eventListeners.length; i++) {
3169 - const {type, listener, optionsOrUseCapture} = eventListeners[i];
3170 - temp.addEventListener(type, listener, optionsOrUseCapture);
3215 + const {type, attachedListener, optionsOrUseCapture} = eventListeners[i];
3216 + temp.addEventListener(
3217 + type,
3218 + attachedListener,
3219 + getAttachOptions(optionsOrUseCapture),
3220 + );
3221 }
3222 }
3223 parentHostInstance.appendChild(temp);
3224 const cancelable = temp.dispatchEvent(event);
3225 if (eventListeners) {
3226 for (let i = 0; i < eventListeners.length; i++) {
3177 - const {type, listener, optionsOrUseCapture} = eventListeners[i];
3178 - temp.removeEventListener(type, listener, optionsOrUseCapture);
3227 + const {type, attachedListener, optionsOrUseCapture} = eventListeners[i];
3228 + temp.removeEventListener(
3229 + type,
3230 + attachedListener,
3231 + getAttachOptions(optionsOrUseCapture),
3232 + );
3233 }
3234 }
3235 parentHostInstance.removeChild(temp);
@@ -3730,8 +3784,12 @@ export function commitNewChildToFragmentInstance(
3784 const eventListeners = fragmentInstance._eventListeners;
3785 if (eventListeners !== null) {
3786 for (let i = 0; i < eventListeners.length; i++) {
3733 - const {type, listener, optionsOrUseCapture} = eventListeners[i];
3734 - childInstance.addEventListener(type, listener, optionsOrUseCapture);
3787 + const {type, attachedListener, optionsOrUseCapture} = eventListeners[i];
3788 + childInstance.addEventListener(
3789 + type,
3790 + attachedListener,
3791 + getAttachOptions(optionsOrUseCapture),
3792 + );
3793 }
3794 }
3795 // Observers and fragment handles only apply to element children.
@@ -3756,8 +3814,12 @@ export function deleteChildFromFragmentInstance(
3814 const eventListeners = fragmentInstance._eventListeners;
3815 if (eventListeners !== null) {
3816 for (let i = 0; i < eventListeners.length; i++) {
3759 - const {type, listener, optionsOrUseCapture} = eventListeners[i];
3760 - childInstance.removeEventListener(type, listener, optionsOrUseCapture);
3817 + const {type, attachedListener, optionsOrUseCapture} = eventListeners[i];
3818 + childInstance.removeEventListener(
3819 + type,
3820 + attachedListener,
3821 + getAttachOptions(optionsOrUseCapture),
3822 + );
3823 }
3824 }
3825 if (childInstance.nodeType === TEXT_NODE) {
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+94
@@ -809,6 +809,100 @@ describe('FragmentRefs', () => {
809 expect(hasClicked).toBe(true);
810 });
811
812 + // @gate enableFragmentRefs
813 + it('fires a once listener only once across existing children', async () => {
814 + const fragmentRef = React.createRef();
815 + const childARef = React.createRef();
816 + const childBRef = React.createRef();
817 + const root = ReactDOMClient.createRoot(container);
818 +
819 + await act(() => {
820 + root.render(
821 + <div>
822 + <Fragment ref={fragmentRef}>
823 + <div ref={childARef} id="a">
824 + A
825 + </div>
826 + <div ref={childBRef} id="b">
827 + B
828 + </div>
829 + </Fragment>
830 + </div>,
831 + );
832 + });
833 +
834 + const logs = [];
835 + fragmentRef.current.addEventListener(
836 + 'click',
837 + () => {
838 + logs.push('once');
839 + },
840 + {once: true},
841 + );
842 +
843 + childARef.current.click();
844 + expect(logs).toEqual(['once']);
845 +
846 + logs.length = 0;
847 + childBRef.current.click();
848 + expect(logs).toEqual([]);
849 + });
850 +
851 + // @gate enableFragmentRefs
852 + it('does not re-arm a once listener when a new child is inserted', async () => {
853 + const fragmentRef = React.createRef();
854 + const childARef = React.createRef();
855 + const childBRef = React.createRef();
856 + const root = ReactDOMClient.createRoot(container);
857 + let showChildB;
858 +
859 + function Component() {
860 + const [shouldShowChildB, setShouldShowChildB] = React.useState(false);
861 + showChildB = () => {
862 + setShouldShowChildB(true);
863 + };
864 +
865 + return (
866 + <div>
867 + <Fragment ref={fragmentRef}>
868 + <div ref={childARef} id="a">
869 + A
870 + </div>
871 + {shouldShowChildB && (
872 + <div ref={childBRef} id="b">
873 + B
874 + </div>
875 + )}
876 + </Fragment>
877 + </div>
878 + );
879 + }
880 +
881 + await act(() => {
882 + root.render(<Component />);
883 + });
884 +
885 + const logs = [];
886 + fragmentRef.current.addEventListener(
887 + 'click',
888 + () => {
889 + logs.push('once');
890 + },
891 + {once: true},
892 + );
893 +
894 + childARef.current.click();
895 + expect(logs).toEqual(['once']);
896 +
897 + await act(() => {
898 + showChildB();
899 + });
900 +
901 + logs.length = 0;
902 + childBRef.current.click();
903 + expect(logs).toEqual([]);
904 + });
905 +
906 // @gate enableFragmentRefs && enableFragmentRefsTextNodes
907 it('adds an event listener to a newly added text child', async () => {
908 const fragmentRef = React.createRef();
packages/react-reconciler/src/ReactFiberCommitWork.js
-1
@@ -3374,7 +3374,6 @@ function reappearLayoutEffects(
3374 // Fallthrough
3375 }
3376 case HostComponent: {
3377 - // TODO: Enable HostText for RN
3377 if (
3378 enableFragmentRefs &&
3379 (finishedWork.tag === HostComponent ||