Skip to content

Commit 60891cf

Browse files
mydeaclaude
andcommitted
fix(core): Reuse one wrapper per listener in bindScopeToEmitter
Minting a fresh wrapper on every registration broke DOM EventTarget semantics: addEventListener dedupes by (type, callback, capture), so distinct wrapper refs defeated that idempotency (the listener fired once per call) and capture-aware removal could drop the wrong wrapper. Reuse a single stable wrapper per listener and forward the caller's options unchanged: the DOM dedupes correctly and Node's EventEmitter still counts duplicate registrations (removable one-per-call). This also subsumes the earlier per-event wrapper stack. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5f01351 commit 60891cf

2 files changed

Lines changed: 49 additions & 17 deletions

File tree

packages/core/src/tracing/bindScopeToEmitter.ts

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import type { Scope } from '../scope';
44
type BoundListener = (...args: unknown[]) => unknown;
55

66
/**
7-
* Per-event map from a user-provided listener to its scope-bound wrappers. A listener may be
8-
* registered for the same event more than once (each registration is independent in Node's
9-
* `EventEmitter`), so we keep a stack of wrappers rather than a single one — otherwise only the
10-
* most recent registration would be removable via the original listener reference.
7+
* Per-event map from a user-provided listener to its single scope-bound wrapper. We reuse one stable
8+
* wrapper per listener (rather than minting a new one per registration) and let the underlying
9+
* emitter/target handle repeat registrations:
10+
* - Node's `EventEmitter` allows duplicates and counts them, so registering the same wrapper N times
11+
* fires N times and `removeListener` removes one instance per call — no orphaned wrappers.
12+
* - the DOM `EventTarget` dedupes by `(type, callback, capture)`, so reusing the wrapper preserves
13+
* that idempotency; a fresh wrapper per call would defeat it and fire the listener repeatedly.
1114
*/
12-
type ListenerPatchMap = Record<string, WeakMap<BoundListener, BoundListener[]> | undefined>;
15+
type ListenerPatchMap = Record<string, WeakMap<BoundListener, BoundListener> | undefined>;
1316

1417
// We patch both Node.js `EventEmitter` registration methods (`on`, `addListener`, ...) and the DOM
1518
// `EventTarget.addEventListener`, so this works for Node emitters and browser-native event targets.
@@ -119,12 +122,12 @@ function patchAddListener(ee: EventEmitterLike, original: BoundListener, scope:
119122
map[event] = listeners;
120123
}
121124

122-
const boundListener = bindListenerToScope(listener, scope);
123-
const wrappers = listeners.get(listener);
124-
if (wrappers) {
125-
wrappers.push(boundListener);
126-
} else {
127-
listeners.set(listener, [boundListener]);
125+
// Reuse one stable wrapper per listener so repeat registrations are handled correctly by the
126+
// underlying emitter/target (Node counts duplicates; the DOM dedupes by `(callback, capture)`).
127+
let boundListener = listeners.get(listener);
128+
if (!boundListener) {
129+
boundListener = bindListenerToScope(listener, scope);
130+
listeners.set(listener, boundListener);
128131
}
129132

130133
isAddingBoundListener = true;
@@ -142,14 +145,12 @@ function patchRemoveListener(ee: EventEmitterLike, original: BoundListener): Bou
142145
const listener = args[1];
143146
const rest = args.slice(2);
144147

145-
const wrappers = isBoundListener(listener) ? getPatchMap(ee)?.[event]?.get(listener) : undefined;
146-
if (!wrappers?.length) {
148+
const boundListener = isBoundListener(listener) ? getPatchMap(ee)?.[event]?.get(listener) : undefined;
149+
if (!boundListener) {
147150
return original.apply(this, args);
148151
}
149-
// Remove the most recently registered wrapper, mirroring Node's `removeListener` (which removes
150-
// the last-added matching instance). All wrappers for a given listener+scope are equivalent, so
151-
// which one we drop is immaterial — what matters is that each call removes a distinct wrapper.
152-
const boundListener = wrappers.pop();
152+
// Pass the same stable wrapper and forward the caller's extra args (e.g. the `capture` option of
153+
// `removeEventListener`) unchanged, so the emitter/target matches the right registration itself.
153154
return original.call(this, event, boundListener, ...rest);
154155
};
155156
}

packages/core/test/lib/tracing/bindScopeToEmitter.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,37 @@ describe('bindScopeToEmitter', () => {
313313
expect(listener).toHaveBeenCalledTimes(1);
314314
});
315315

316+
it('preserves addEventListener idempotency for identical (type, listener) registrations', () => {
317+
const target = new EventTarget();
318+
bindScopeToEmitter(target);
319+
320+
const listener = vi.fn();
321+
// The DOM dedupes identical registrations -> the listener must only fire once.
322+
target.addEventListener('data', listener);
323+
target.addEventListener('data', listener);
324+
325+
target.dispatchEvent(new Event('data'));
326+
327+
expect(listener).toHaveBeenCalledTimes(1);
328+
});
329+
330+
it('removes the correct registration when only the capture phase differs', () => {
331+
const target = new EventTarget();
332+
bindScopeToEmitter(target);
333+
334+
const listener = vi.fn();
335+
// Capture is part of a registration's identity, so these are two distinct registrations.
336+
target.addEventListener('data', listener, { capture: true });
337+
target.addEventListener('data', listener, { capture: false });
338+
339+
// Remove only the capture-phase one; the bubble-phase registration must survive.
340+
target.removeEventListener('data', listener, { capture: true });
341+
342+
target.dispatchEvent(new Event('data'));
343+
344+
expect(listener).toHaveBeenCalledTimes(1);
345+
});
346+
316347
it('passes through non-function (EventListener object) listeners without throwing', () => {
317348
const target = new EventTarget();
318349
bindScopeToEmitter(target);

0 commit comments

Comments
 (0)