[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 ||