Skip to content

Commit 0a39acf

Browse files
authored
fix(browser-utils): Stop leaking DOM instrumentation listeners on mismatched removals (#24727)
This PR fixes two (related) problems in our `instrumentDOM` event listener instrumentation: TIL about event listener [capture](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture) modes. 1. We didn't differentiate between `addEventListener(fn, {capture: true})` and `addEventListener(fn, {capture: false})` calls, causing leakage of our event listeners when event listeners were removed with different options. => Fixed by checking the `capture` option, registering our own listeners in the same capture config and keeping the capture option on the meta object of the event target so that we can then remove it in the correct capture config 2. More generally fixes an issue with `refCount` where e.g. calling `removeEventListener` with a callback that was never added via `addEventListener`: Browsers just ignore this call but our refCount was decremented anyway. => Fixed by replacing the general ref count with two sets of callbacks (for both capture modes) and only removing our listener if all user-set listeners were removed Fixes #24702 supersedes #24725 supersedes #24723
1 parent bd211bb commit 0a39acf

2 files changed

Lines changed: 209 additions & 14 deletions

File tree

‎packages/browser-utils/src/instrumentation/dom.ts‎

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,19 @@ type InstrumentedElement = Element & {
1919
__sentry_instrumentation_handlers__?: {
2020
[key in 'click' | 'keypress']?: {
2121
handler?: unknown;
22-
/** The number of custom listeners attached to this element */
23-
refCount: number;
22+
capture?: boolean;
23+
// listeners added with `capture: true`, meaning the listener is invoked before other
24+
// listeners inside the element's hierarchy.
25+
captureListeners: WeakSet<EventListenerOrEventListenerObject>;
26+
// listeners added with `capture: false` (default), meaning the listener is invoked
27+
// after other listeners inside the element's hierarchy (i.e. the event bubbles up)
28+
bubbleListeners: WeakSet<EventListenerOrEventListenerObject>;
29+
// Total number of listeners in `captureListeners` and `bubbleListeners`. WeakSets have no `size`, but we use them
30+
// so listeners removed without going through our `removeEventListener` patch aren't retained by us.
31+
listenerCount: number;
32+
// Set once a `once` or `signal` listener was added. The browser removes those without going
33+
// through `removeEventListener`, so we can't tell when they're gone and must keep our handler attached.
34+
sticky?: boolean;
2435
};
2536
};
2637
};
@@ -31,6 +42,10 @@ let debounceTimerID: number | undefined;
3142
let lastCapturedEventType: string | undefined;
3243
let lastCapturedEventTargetId: string | undefined;
3344

45+
function getCapture(options: boolean | EventListenerOptions | undefined): boolean {
46+
return typeof options === 'boolean' ? options : !!options?.capture;
47+
}
48+
3449
/**
3550
* Add an instrumentation handler for when a click or a keypress happens.
3651
*
@@ -73,19 +88,42 @@ export function instrumentDOM(): void {
7388

7489
fill(proto, 'addEventListener', function (originalAddEventListener: AddEventListener): AddEventListener {
7590
return function (this: InstrumentedElement, type, listener, options): AddEventListener {
76-
if (type === 'click' || type == 'keypress') {
91+
// The browser ignores `null` listeners, so there's nothing for our handler to accompany.
92+
if ((type === 'click' || type == 'keypress') && listener) {
7793
try {
7894
const handlers = (this.__sentry_instrumentation_handlers__ =
7995
this.__sentry_instrumentation_handlers__ || {});
80-
const handlerForType = (handlers[type] = handlers[type] || { refCount: 0 });
96+
97+
const handlerForType = (handlers[type] = handlers[type] || {
98+
captureListeners: new WeakSet(),
99+
bubbleListeners: new WeakSet(),
100+
listenerCount: 0,
101+
});
102+
103+
const capture = getCapture(options);
81104

82105
if (!handlerForType.handler) {
83106
const handler = makeDOMEventHandler(triggerDOMHandler);
84107
handlerForType.handler = handler;
85-
originalAddEventListener.call(this, type, handler, options);
108+
// Track the user-set `capture` option because it changes the identity of the registration of the
109+
// event listener callback function (addEL(fn, true) vs addEL(fn, false) are two different registrations).
110+
// Our listener needs to have the same capture setting, so that subsequent calls or removeEventListener
111+
// calls correspond to the correct handler function.
112+
handlerForType.capture = capture;
113+
originalAddEventListener.call(this, type, handler, handlerForType.capture);
86114
}
87115

88-
handlerForType.refCount++;
116+
const listeners = handlerForType[capture ? 'captureListeners' : 'bubbleListeners'];
117+
// Adding the same listener twice in the same phase is a no-op in the browser.
118+
if (!listeners.has(listener)) {
119+
if (typeof options === 'object' && (options?.once || options?.signal)) {
120+
// Not tracked to avoid retaining listeners the browser auto-removes.
121+
handlerForType.sticky = true;
122+
} else {
123+
listeners.add(listener);
124+
handlerForType.listenerCount++;
125+
}
126+
}
89127
} catch {
90128
// Accessing dom properties is always fragile.
91129
// Also allows us to skip `addEventListeners` calls with no proper `this` context.
@@ -101,16 +139,16 @@ export function instrumentDOM(): void {
101139
'removeEventListener',
102140
function (originalRemoveEventListener: RemoveEventListener): RemoveEventListener {
103141
return function (this: InstrumentedElement, type, listener, options): () => void {
104-
if (type === 'click' || type == 'keypress') {
142+
if ((type === 'click' || type == 'keypress') && listener) {
105143
try {
106144
const handlers = this.__sentry_instrumentation_handlers__ || {};
107145
const handlerForType = handlers[type];
108146

109-
if (handlerForType) {
110-
handlerForType.refCount--;
147+
// Removing a listener that was never added is a no-op in the browser, so it mustn't count for ours either.
148+
if (handlerForType?.[getCapture(options) ? 'captureListeners' : 'bubbleListeners'].delete(listener)) {
111149
// If there are no longer any custom handlers of the current type on this element, we can remove ours, too.
112-
if (handlerForType.refCount <= 0) {
113-
originalRemoveEventListener.call(this, type, handlerForType.handler, options);
150+
if (!--handlerForType.listenerCount && !handlerForType.sticky) {
151+
originalRemoveEventListener.call(this, type, handlerForType.handler, handlerForType.capture);
114152
handlerForType.handler = undefined;
115153
delete handlers[type]; // eslint-disable-line @typescript-eslint/no-dynamic-delete
116154
}
Lines changed: 160 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,169 @@
1-
import { describe, expect, it } from 'vitest';
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { afterEach, describe, expect, it } from 'vitest';
25
import { instrumentDOM } from '../../src/instrumentation/dom';
36
import { WINDOW } from '../../src/types';
47

58
// @ts-expect-error - idk
69
WINDOW.XMLHttpRequest = undefined;
710

8-
describe('instrumentXHR', () => {
9-
it('it does not throw if XMLHttpRequest is a key on window but not defined', () => {
11+
type InstrumentedDocument = Document & { __sentry_instrumentation_handlers__?: Record<string, any> };
12+
13+
describe('instrumentDOM', () => {
14+
const { addEventListener: nativeAdd, removeEventListener: nativeRemove } = EventTarget.prototype;
15+
16+
// `instrumentDOM` patches `EventTarget.prototype` and isn't idempotent, so restore the native methods after every test.
17+
afterEach(() => {
18+
EventTarget.prototype.addEventListener = nativeAdd;
19+
EventTarget.prototype.removeEventListener = nativeRemove;
20+
delete (document as InstrumentedDocument).__sentry_instrumentation_handlers__;
21+
});
22+
23+
/** Runs `instrumentDOM` and returns a function counting the click listeners actually attached to `document`. */
24+
function instrumentAndTrackDocumentClickListeners(): () => number {
25+
const documentClickListeners = { capture: new Set<unknown>(), bubble: new Set<unknown>() };
26+
27+
const phase = (options?: boolean | EventListenerOptions): Set<unknown> =>
28+
(typeof options === 'boolean' ? options : !!options?.capture)
29+
? documentClickListeners.capture
30+
: documentClickListeners.bubble;
31+
32+
// Installed before `instrumentDOM` so these sit underneath the SDK and also see the listeners it attaches itself.
33+
EventTarget.prototype.addEventListener = function (type, listener, options) {
34+
if (this === document && type === 'click') {
35+
phase(options).add(listener);
36+
}
37+
return nativeAdd.call(this, type, listener, options);
38+
};
39+
40+
EventTarget.prototype.removeEventListener = function (type, listener, options) {
41+
if (this === document && type === 'click') {
42+
phase(options).delete(listener);
43+
}
44+
return nativeRemove.call(this, type, listener, options);
45+
};
46+
47+
instrumentDOM();
48+
49+
return () => documentClickListeners.capture.size + documentClickListeners.bubble.size;
50+
}
51+
52+
it('does not throw if XMLHttpRequest is a key on window but not defined', () => {
1053
expect(instrumentDOM).not.toThrow();
1154
});
55+
56+
it('does not leak document click listeners when removeEventListener uses mismatched capture options', () => {
57+
const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners();
58+
59+
// baseline listenercount is 1 which comes from the SDK's global click handler registered
60+
// in instrumentDOM().
61+
const baseline = countDocumentClickListeners();
62+
63+
const never = (): void => {};
64+
const onCapture = (): void => {};
65+
const onBubble = (): void => {};
66+
67+
for (let i = 0; i < 20; i++) {
68+
document.addEventListener('click', onCapture, true);
69+
document.addEventListener('click', onBubble);
70+
document.removeEventListener('click', never);
71+
document.removeEventListener('click', never);
72+
document.removeEventListener('click', onCapture, true);
73+
document.removeEventListener('click', onBubble);
74+
}
75+
76+
expect(countDocumentClickListeners() - baseline).toBe(0);
77+
});
78+
79+
it('keeps its handler attached while listeners remain, even after removing listeners that were never added', () => {
80+
const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners();
81+
const baseline = countDocumentClickListeners();
82+
83+
const never = (): void => {};
84+
const onClick = (): void => {};
85+
86+
document.addEventListener('click', onClick);
87+
document.removeEventListener('click', never);
88+
document.removeEventListener('click', onClick, true);
89+
90+
// `onClick` plus the SDK's handler for it
91+
expect(countDocumentClickListeners() - baseline).toBe(2);
92+
93+
document.removeEventListener('click', onClick);
94+
95+
expect(countDocumentClickListeners() - baseline).toBe(0);
96+
});
97+
98+
it('removes its handler when listeners are added and removed with `null` options', () => {
99+
const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners();
100+
const baseline = countDocumentClickListeners();
101+
102+
const onClick = (): void => {};
103+
104+
// @ts-expect-error - `null` is valid at runtime and treated like default options
105+
document.addEventListener('click', onClick, null);
106+
// @ts-expect-error - see above
107+
document.removeEventListener('click', onClick, null);
108+
109+
expect(countDocumentClickListeners() - baseline).toBe(0);
110+
});
111+
112+
it('does not retain listeners added with `once` or `signal`, which the browser removes on its own', () => {
113+
instrumentDOM();
114+
115+
for (let i = 0; i < 20; i++) {
116+
const controller = new AbortController();
117+
document.addEventListener('click', () => {}, { signal: controller.signal });
118+
document.addEventListener('click', () => {}, { once: true, capture: true });
119+
controller.abort();
120+
}
121+
document.dispatchEvent(new MouseEvent('click'));
122+
123+
const clickHandlers = (document as InstrumentedDocument).__sentry_instrumentation_handlers__?.click;
124+
expect(clickHandlers.listenerCount).toBe(0);
125+
expect(clickHandlers.handler).toBeDefined();
126+
});
127+
128+
it('keeps its handler attached after removing tracked listeners if a `once` or `signal` listener was added', () => {
129+
const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners();
130+
const baseline = countDocumentClickListeners();
131+
132+
const onClick = (): void => {};
133+
const onceClick = (): void => {};
134+
135+
document.addEventListener('click', onClick);
136+
document.addEventListener('click', onceClick, { once: true });
137+
document.removeEventListener('click', onClick);
138+
139+
// `onceClick` plus the SDK's handler, which must still see the pending `once` listener's event
140+
expect(countDocumentClickListeners() - baseline).toBe(2);
141+
});
142+
143+
it('counts a listener added twice in the same phase only once', () => {
144+
const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners();
145+
const baseline = countDocumentClickListeners();
146+
147+
const onClick = (): void => {};
148+
149+
document.addEventListener('click', onClick);
150+
document.addEventListener('click', onClick);
151+
document.removeEventListener('click', onClick);
152+
153+
expect(countDocumentClickListeners() - baseline).toBe(0);
154+
});
155+
156+
it('removes its handler if a listener is re-added with `once` while already registered', () => {
157+
const countDocumentClickListeners = instrumentAndTrackDocumentClickListeners();
158+
const baseline = countDocumentClickListeners();
159+
160+
const onClick = (): void => {};
161+
162+
document.addEventListener('click', onClick);
163+
// no-op in the browser, since `onClick` is already registered for the bubble phase
164+
document.addEventListener('click', onClick, { once: true });
165+
document.removeEventListener('click', onClick);
166+
167+
expect(countDocumentClickListeners() - baseline).toBe(0);
168+
});
12169
});

0 commit comments

Comments
 (0)