Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/browser-utils/src/instrumentation/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type InstrumentedElement = Element & {
__sentry_instrumentation_handlers__?: {
[key in 'click' | 'keypress']?: {
handler?: unknown;
capture?: boolean;
/** The number of custom listeners attached to this element */
refCount: number;
};
Expand Down Expand Up @@ -82,7 +83,12 @@ export function instrumentDOM(): void {
if (!handlerForType.handler) {
const handler = makeDOMEventHandler(triggerDOMHandler);
handlerForType.handler = handler;
originalAddEventListener.call(this, type, handler, options);
// Track the user-set `capture` option because it changes the identity of the registration of the
// event listener callback function (addEL(fn, true) vs addEL(fn, false) are two different registrations).
// Our listener needs to have the same capture setting, so that subsequent calls or removaleEventListener
// calls correspond to the correct handler function.
handlerForType.capture = typeof options === 'boolean' ? options : !!options?.capture;
originalAddEventListener.call(this, type, handler, handlerForType.capture);
}

handlerForType.refCount++;
Expand Down Expand Up @@ -110,7 +116,7 @@ export function instrumentDOM(): void {
handlerForType.refCount--;
// If there are no longer any custom handlers of the current type on this element, we can remove ours, too.
if (handlerForType.refCount <= 0) {
originalRemoveEventListener.call(this, type, handlerForType.handler, options);
originalRemoveEventListener.call(this, type, handlerForType.handler, handlerForType.capture);
handlerForType.handler = undefined;
delete handlers[type]; // eslint-disable-line @typescript-eslint/no-dynamic-delete
}
Expand Down
62 changes: 59 additions & 3 deletions packages/browser-utils/test/instrumentation/dom.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,68 @@
import { describe, expect, it } from 'vitest';
/**
* @vitest-environment jsdom
*/
import { afterEach, describe, expect, it } from 'vitest';
import { instrumentDOM } from '../../src/instrumentation/dom';
import { WINDOW } from '../../src/types';

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

describe('instrumentXHR', () => {
it('it does not throw if XMLHttpRequest is a key on window but not defined', () => {
describe('instrumentDOM', () => {
const { addEventListener: nativeAdd, removeEventListener: nativeRemove } = EventTarget.prototype;

// `instrumentDOM` patches `EventTarget.prototype` and isn't idempotent, so restore the native methods after every test.
afterEach(() => {
EventTarget.prototype.addEventListener = nativeAdd;
EventTarget.prototype.removeEventListener = nativeRemove;
});

it('does not throw if XMLHttpRequest is a key on window but not defined', () => {
expect(instrumentDOM).not.toThrow();
});

it('does not leak document click listeners when removeEventListener uses mismatched capture options', () => {
const documentClickListeners = { capture: new Set<unknown>(), bubble: new Set<unknown>() };

const phase = (options?: boolean | EventListenerOptions): Set<unknown> =>
(typeof options === 'boolean' ? options : !!options?.capture)
? documentClickListeners.capture
: documentClickListeners.bubble;

// Installed before `instrumentDOM` so these sit underneath the SDK and also see the listeners it attaches itself.
EventTarget.prototype.addEventListener = function (type, listener, options) {
if (this === document && type === 'click') {
phase(options).add(listener);
}
return nativeAdd.call(this, type, listener, options);
};

EventTarget.prototype.removeEventListener = function (type, listener, options) {
if (this === document && type === 'click') {
phase(options).delete(listener);
}
return nativeRemove.call(this, type, listener, options);
};

instrumentDOM();

// baseline listenercount is 1 which comes from the SDK's global click handler registered
// in instrumentDOM().
const baseline = documentClickListeners.capture.size + documentClickListeners.bubble.size;

const never = (): void => {};
const onCapture = (): void => {};
const onBubble = (): void => {};

for (let i = 0; i < 20; i++) {
document.addEventListener('click', onCapture, true);
document.addEventListener('click', onBubble);
document.removeEventListener('click', never);
document.removeEventListener('click', never);
document.removeEventListener('click', onCapture, true);
document.removeEventListener('click', onBubble);
}

expect(documentClickListeners.capture.size + documentClickListeners.bubble.size - baseline).toBe(0);
});
});
Loading