Skip to content

Commit c39c75f

Browse files
DorianChncodex
andcommitted
fix(browser-utils): prevent DOM handler leaks
Co-Authored-By: OpenAI GPT-5 <codex@openai.com>
1 parent daacc2b commit c39c75f

2 files changed

Lines changed: 100 additions & 18 deletions

File tree

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

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ type RemoveEventListener = (
1818
type InstrumentedElement = Element & {
1919
__sentry_instrumentation_handlers__?: {
2020
[key in 'click' | 'keypress']?: {
21-
handler?: unknown;
22-
/** The number of custom listeners attached to this element */
23-
refCount: number;
21+
handler?: EventListenerOrEventListenerObject;
22+
capture?: boolean;
23+
listeners: Map<EventListenerOrEventListenerObject, Set<boolean>>;
2424
};
2525
};
2626
};
@@ -31,6 +31,48 @@ let debounceTimerID: number | undefined;
3131
let lastCapturedEventType: string | undefined;
3232
let lastCapturedEventTargetId: string | undefined;
3333

34+
function getCapture(options: boolean | EventListenerOptions | AddEventListenerOptions | undefined): boolean {
35+
return typeof options === 'boolean' ? options : !!options?.capture;
36+
}
37+
38+
function hasListener(
39+
handler: { listeners: Map<EventListenerOrEventListenerObject, Set<boolean>> },
40+
listener: EventListenerOrEventListenerObject,
41+
capture: boolean,
42+
): boolean {
43+
return handler.listeners.get(listener)?.has(capture) ?? false;
44+
}
45+
46+
function trackListener(
47+
handler: { listeners: Map<EventListenerOrEventListenerObject, Set<boolean>> },
48+
listener: EventListenerOrEventListenerObject,
49+
capture: boolean,
50+
): void {
51+
const captures = handler.listeners.get(listener);
52+
if (captures) {
53+
captures.add(capture);
54+
} else {
55+
handler.listeners.set(listener, new Set([capture]));
56+
}
57+
}
58+
59+
function untrackListener(
60+
handler: { listeners: Map<EventListenerOrEventListenerObject, Set<boolean>> },
61+
listener: EventListenerOrEventListenerObject,
62+
capture: boolean,
63+
): boolean {
64+
const captures = handler.listeners.get(listener);
65+
if (!captures?.delete(capture)) {
66+
return false;
67+
}
68+
69+
if (captures.size === 0) {
70+
handler.listeners.delete(listener);
71+
}
72+
73+
return true;
74+
}
75+
3476
/**
3577
* Add an instrumentation handler for when a click or a keypress happens.
3678
*
@@ -77,15 +119,21 @@ export function instrumentDOM(): void {
77119
try {
78120
const handlers = (this.__sentry_instrumentation_handlers__ =
79121
this.__sentry_instrumentation_handlers__ || {});
80-
const handlerForType = (handlers[type] = handlers[type] || { refCount: 0 });
122+
const handlerForType = (handlers[type] = handlers[type] || {
123+
listeners: new Map<EventListenerOrEventListenerObject, Set<boolean>>(),
124+
});
125+
const capture = getCapture(options);
126+
127+
if (!hasListener(handlerForType, listener, capture)) {
128+
if (!handlerForType.handler) {
129+
const handler = makeDOMEventHandler(triggerDOMHandler);
130+
originalAddEventListener.call(this, type, handler, capture);
131+
handlerForType.handler = handler;
132+
handlerForType.capture = capture;
133+
}
81134

82-
if (!handlerForType.handler) {
83-
const handler = makeDOMEventHandler(triggerDOMHandler);
84-
handlerForType.handler = handler;
85-
originalAddEventListener.call(this, type, handler, options);
135+
trackListener(handlerForType, listener, capture);
86136
}
87-
88-
handlerForType.refCount++;
89137
} catch {
90138
// Accessing dom properties is always fragile.
91139
// Also allows us to skip `addEventListeners` calls with no proper `this` context.
@@ -106,11 +154,10 @@ export function instrumentDOM(): void {
106154
const handlers = this.__sentry_instrumentation_handlers__ || {};
107155
const handlerForType = handlers[type];
108156

109-
if (handlerForType) {
110-
handlerForType.refCount--;
157+
if (handlerForType && untrackListener(handlerForType, listener, getCapture(options))) {
111158
// 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);
159+
if (handlerForType.listeners.size === 0) {
160+
originalRemoveEventListener.call(this, type, handlerForType.handler, handlerForType.capture);
114161
handlerForType.handler = undefined;
115162
delete handlers[type]; // eslint-disable-line @typescript-eslint/no-dynamic-delete
116163
}
Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,47 @@
1-
import { describe, expect, it } from 'vitest';
1+
/** @vitest-environment jsdom */
2+
3+
import { describe, expect, it, vi } from 'vitest';
24
import { instrumentDOM } from '../../src/instrumentation/dom';
35
import { WINDOW } from '../../src/types';
46

57
// @ts-expect-error - idk
68
WINDOW.XMLHttpRequest = undefined;
79

8-
describe('instrumentXHR', () => {
9-
it('it does not throw if XMLHttpRequest is a key on window but not defined', () => {
10-
expect(instrumentDOM).not.toThrow();
10+
describe('instrumentDOM', () => {
11+
it('does not leak its handler when removing unregistered listeners', () => {
12+
const addEventListener = vi.spyOn(EventTarget.prototype, 'addEventListener');
13+
const removeEventListener = vi.spyOn(EventTarget.prototype, 'removeEventListener');
14+
15+
try {
16+
expect(instrumentDOM).not.toThrow();
17+
18+
const target = new EventTarget();
19+
const onCapture = (): void => {};
20+
const onBubble = (): void => {};
21+
const neverRegistered = (): void => {};
22+
const addCallsBeforeTest = addEventListener.mock.calls.length;
23+
24+
for (let i = 0; i < 20; i++) {
25+
target.addEventListener('click', onCapture, true);
26+
target.addEventListener('click', onBubble);
27+
target.removeEventListener('click', neverRegistered);
28+
target.removeEventListener('click', neverRegistered);
29+
target.removeEventListener('click', onCapture, true);
30+
target.removeEventListener('click', onBubble);
31+
}
32+
33+
const instrumentedHandlers = addEventListener.mock.calls
34+
.slice(addCallsBeforeTest)
35+
.map(([, listener]) => listener)
36+
.filter(listener => listener !== onCapture && listener !== onBubble);
37+
38+
expect(instrumentedHandlers).toHaveLength(20);
39+
for (const handler of instrumentedHandlers) {
40+
expect(removeEventListener).toHaveBeenCalledWith('click', handler, true);
41+
}
42+
} finally {
43+
addEventListener.mockRestore();
44+
removeEventListener.mockRestore();
45+
}
1146
});
1247
});

0 commit comments

Comments
 (0)