Skip to content

Commit 71201ff

Browse files
dobladovLms24
andauthored
fix(browser-utils): Remove readystatechange listener to prevent memory leaks (#22216)
Remove the `readystatechange` event listener once an XHR request reaches `readyState === 4` (done). Previously, the listener added in the instrumented `open()` was never removed, so every XHR kept its listener This is problematic on low-end TV devices, during video playback the player continuously triggers network requests (often several per second), so on long-lived, memory-constrained devices these leaked listeners and pinned objects accumulate and steadily grow memory usage --------- Co-authored-by: Lukas Stracke <lukas.stracke@sentry.io>
1 parent 2d3fc43 commit 71201ff

2 files changed

Lines changed: 134 additions & 5 deletions

File tree

packages/browser-utils/src/instrument/xhr.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ export function instrumentXHR(): void {
9090
virtualError,
9191
};
9292
triggerHandlers('xhr', handlerData);
93+
94+
// In the `addEventListener` branch below, this handler is the only
95+
// `readystatechange` listener we add, so detach it once the request is
96+
// done to avoid pinning the XMLHttpRequest and its captured
97+
// `virtualError` per HTTP call on long-lived pages. In the
98+
// `onreadystatechange` proxy branch the handler isn't registered via
99+
// `addEventListener`, so this is a harmless no-op there.
100+
xhrOpenThisArg.removeEventListener('readystatechange', onreadystatechangeHandler);
93101
}
94102
};
95103

Lines changed: 126 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,133 @@
1-
import { describe, expect, it } from 'vitest';
2-
import { instrumentXHR } from '../../src/instrument/xhr';
1+
import type { HandlerDataXhr } from '@sentry/core';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
import { addXhrInstrumentationHandler, instrumentXHR } from '../../src/instrument/xhr';
34
import { WINDOW } from '../../src/types';
45

5-
// @ts-expect-error - idk
6-
WINDOW.XMLHttpRequest = undefined;
6+
const win = WINDOW as typeof WINDOW & { XMLHttpRequest?: typeof XMLHttpRequest };
7+
const originalXMLHttpRequest = win.XMLHttpRequest;
78

89
describe('instrumentXHR', () => {
9-
it('it does not throw if XMLHttpRequest is a key on window but not defined', () => {
10+
afterEach(() => {
11+
win.XMLHttpRequest = originalXMLHttpRequest;
12+
});
13+
14+
it('does not throw if XMLHttpRequest is a key on window but not defined', () => {
15+
win.XMLHttpRequest = undefined;
1016
expect(instrumentXHR).not.toThrow();
1117
});
18+
19+
it('removes readystatechange event listener on readyState 4 when registered with addEventListener', () => {
20+
const addEventListenerSpy = vi.fn();
21+
const removeEventListenerSpy = vi.fn();
22+
23+
class MockXMLHttpRequest {
24+
public readyState: number = 0;
25+
public status: number = 200;
26+
public addEventListener = addEventListenerSpy;
27+
public removeEventListener = removeEventListenerSpy;
28+
public open(_method: string, _url: string): void {}
29+
public send(): void {}
30+
public setRequestHeader(_header: string, _value: string): void {}
31+
}
32+
33+
win.XMLHttpRequest = MockXMLHttpRequest as unknown as typeof XMLHttpRequest;
34+
35+
instrumentXHR();
36+
37+
const xhr = new MockXMLHttpRequest();
38+
xhr.open('GET', 'http://example.com');
39+
40+
expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
41+
const handler = addEventListenerSpy.mock.calls[0]?.[1] as (this: MockXMLHttpRequest) => void;
42+
43+
xhr.readyState = 2;
44+
handler.call(xhr);
45+
expect(removeEventListenerSpy).not.toHaveBeenCalled();
46+
47+
xhr.readyState = 4;
48+
handler.call(xhr);
49+
expect(removeEventListenerSpy).toHaveBeenCalledWith('readystatechange', handler);
50+
});
51+
52+
it('still reports the xhr completion (with endTimestamp) after removing the listener', () => {
53+
const addEventListenerSpy = vi.fn();
54+
const removeEventListenerSpy = vi.fn();
55+
56+
class MockXMLHttpRequest {
57+
public readyState: number = 0;
58+
public status: number = 200;
59+
public addEventListener = addEventListenerSpy;
60+
public removeEventListener = removeEventListenerSpy;
61+
public open(_method: string, _url: string): void {}
62+
public send(): void {}
63+
public setRequestHeader(_header: string, _value: string): void {}
64+
}
65+
66+
win.XMLHttpRequest = MockXMLHttpRequest as unknown as typeof XMLHttpRequest;
67+
68+
const instrumentationHandler = vi.fn();
69+
addXhrInstrumentationHandler(instrumentationHandler);
70+
instrumentXHR();
71+
72+
const xhr = new MockXMLHttpRequest();
73+
xhr.open('GET', 'http://example.com');
74+
75+
const handler = addEventListenerSpy.mock.calls[0]?.[1] as (this: MockXMLHttpRequest) => void;
76+
77+
xhr.readyState = 4;
78+
handler.call(xhr);
79+
80+
const completionCall = instrumentationHandler.mock.calls.find(
81+
([data]: [HandlerDataXhr]) => data.endTimestamp !== undefined,
82+
);
83+
expect(completionCall).toBeDefined();
84+
expect(completionCall?.[0].startTimestamp).toBeDefined();
85+
86+
// completion must be reported before we detach the listener, so a throwing
87+
// `removeEventListener` could never silently drop the completion event
88+
const completionOrder = Math.min(...instrumentationHandler.mock.invocationCallOrder);
89+
expect(removeEventListenerSpy.mock.invocationCallOrder[0]).toBeGreaterThan(completionOrder);
90+
});
91+
92+
it('does not remove a listener via addEventListener when onreadystatechange is used', () => {
93+
const addEventListenerSpy = vi.fn();
94+
const removeEventListenerSpy = vi.fn();
95+
96+
class MockXMLHttpRequest {
97+
public readyState: number = 0;
98+
public status: number = 200;
99+
public onreadystatechange: (() => void) | null = null;
100+
public addEventListener = addEventListenerSpy;
101+
public removeEventListener = removeEventListenerSpy;
102+
public open(_method: string, _url: string): void {}
103+
public send(): void {}
104+
public setRequestHeader(_header: string, _value: string): void {}
105+
}
106+
107+
win.XMLHttpRequest = MockXMLHttpRequest as unknown as typeof XMLHttpRequest;
108+
109+
const handlerData: HandlerDataXhr[] = [];
110+
addXhrInstrumentationHandler(data => {
111+
handlerData.push(data);
112+
});
113+
instrumentXHR();
114+
115+
const xhr = new MockXMLHttpRequest();
116+
const originalOnReadyStateChange = vi.fn();
117+
xhr.onreadystatechange = originalOnReadyStateChange;
118+
xhr.open('GET', 'http://example.com');
119+
120+
// onreadystatechange path must not register a separate readystatechange listener
121+
expect(addEventListenerSpy).not.toHaveBeenCalled();
122+
123+
// the SDK wraps the user's handler rather than replacing it
124+
expect(xhr.onreadystatechange).not.toBe(originalOnReadyStateChange);
125+
126+
xhr.readyState = 4;
127+
xhr.onreadystatechange?.();
128+
129+
// the user's original handler still runs and completion is still reported
130+
expect(originalOnReadyStateChange).toHaveBeenCalledTimes(1);
131+
expect(handlerData.some(data => data.endTimestamp !== undefined)).toBe(true);
132+
});
12133
});

0 commit comments

Comments
 (0)