Skip to content

Commit 04f1993

Browse files
committed
fix: network plugin cleanup
1 parent c598a87 commit 04f1993

File tree

3 files changed

+224
-2
lines changed

3 files changed

+224
-2
lines changed

.changeset/olive-drinks-itch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': patch
3+
---
4+
5+
fix: properly cleanup in network plugin

packages/browser/src/__tests__/extensions/replay/external/network-plugin.test.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,70 @@ class MockRequest {
1414
// Replace global Request with our mock
1515
global.Request = MockRequest as any
1616

17+
// Mock window with performance API
18+
function createMockWindow() {
19+
const performanceEntries: PerformanceEntry[] = []
20+
const observerCallbacks: Array<(entries: PerformanceObserverEntryList) => void> = []
21+
22+
const mockWindow = {
23+
performance: {
24+
now: () => Date.now(),
25+
getEntries: () => performanceEntries,
26+
getEntriesByName: (name: string) => performanceEntries.filter((e: any) => e.name === name),
27+
clearResourceTimings: () => {
28+
performanceEntries.length = 0
29+
},
30+
},
31+
PerformanceObserver: class {
32+
callback: (entries: PerformanceObserverEntryList) => void
33+
constructor(callback: (entries: PerformanceObserverEntryList) => void) {
34+
this.callback = callback
35+
}
36+
observe() {
37+
observerCallbacks.push(this.callback)
38+
}
39+
disconnect() {
40+
const index = observerCallbacks.indexOf(this.callback)
41+
if (index > -1) observerCallbacks.splice(index, 1)
42+
}
43+
} as any,
44+
XMLHttpRequest: class {
45+
listeners: Map<string, Array<(e: any) => void>> = new Map()
46+
readyState = 0
47+
DONE = 4
48+
status = 200
49+
response = ''
50+
responseText = ''
51+
52+
open() {}
53+
send() {}
54+
setRequestHeader() {}
55+
getAllResponseHeaders() {
56+
return ''
57+
}
58+
addEventListener(event: string, listener: (e: any) => void) {
59+
if (!this.listeners.has(event)) this.listeners.set(event, [])
60+
this.listeners.get(event)!.push(listener)
61+
}
62+
removeEventListener(event: string, listener: (e: any) => void) {
63+
const listeners = this.listeners.get(event)
64+
if (listeners) {
65+
const index = listeners.indexOf(listener)
66+
if (index > -1) listeners.splice(index, 1)
67+
}
68+
}
69+
getListenerCount(event: string) {
70+
return this.listeners.get(event)?.length || 0
71+
}
72+
} as any,
73+
fetch: async () => new Response(),
74+
} as any
75+
76+
mockWindow.PerformanceObserver.supportedEntryTypes = ['navigation', 'resource']
77+
78+
return { mockWindow, performanceEntries, observerCallbacks }
79+
}
80+
1781
const blobUrlTestCases = [
1882
{ url: 'blob:https://example.com/123', expected: false },
1983
{ url: new URL('blob:https://example.com/123'), expected: false },
@@ -180,4 +244,141 @@ describe('network plugin', () => {
180244
})
181245
})
182246
})
247+
248+
describe('network observer lifecycle', () => {
249+
describe('singleton initialization and cleanup', () => {
250+
it('should initialize successfully on first call', () => {
251+
jest.isolateModules(() => {
252+
// eslint-disable-next-line @typescript-eslint/no-require-imports
253+
const { getRecordNetworkPlugin } = require('../../../../extensions/replay/external/network-plugin')
254+
const { mockWindow, observerCallbacks } = createMockWindow()
255+
global.PerformanceObserver = mockWindow.PerformanceObserver
256+
257+
const plugin = getRecordNetworkPlugin()
258+
const cleanup = plugin.observer(() => {}, mockWindow, {})
259+
260+
expect(typeof cleanup).toBe('function')
261+
expect(observerCallbacks.length).toBe(1)
262+
})
263+
})
264+
265+
it('should allow re-initialization after cleanup', () => {
266+
jest.isolateModules(() => {
267+
// eslint-disable-next-line @typescript-eslint/no-require-imports
268+
const { getRecordNetworkPlugin } = require('../../../../extensions/replay/external/network-plugin')
269+
const { mockWindow, observerCallbacks } = createMockWindow()
270+
global.PerformanceObserver = mockWindow.PerformanceObserver
271+
272+
const plugin1 = getRecordNetworkPlugin()
273+
const cleanup1 = plugin1.observer(() => {}, mockWindow, {})
274+
expect(observerCallbacks.length).toBe(1)
275+
276+
cleanup1()
277+
expect(observerCallbacks.length).toBe(0)
278+
279+
const plugin2 = getRecordNetworkPlugin()
280+
const cleanup2 = plugin2.observer(() => {}, mockWindow, {})
281+
expect(observerCallbacks.length).toBe(1)
282+
283+
cleanup2()
284+
})
285+
})
286+
287+
it('should handle multiple cleanup calls safely', () => {
288+
jest.isolateModules(() => {
289+
// eslint-disable-next-line @typescript-eslint/no-require-imports
290+
const { getRecordNetworkPlugin } = require('../../../../extensions/replay/external/network-plugin')
291+
const { mockWindow, observerCallbacks } = createMockWindow()
292+
global.PerformanceObserver = mockWindow.PerformanceObserver
293+
294+
const plugin = getRecordNetworkPlugin()
295+
const cleanup = plugin.observer(() => {}, mockWindow, {})
296+
297+
expect(() => {
298+
cleanup()
299+
cleanup()
300+
cleanup()
301+
}).not.toThrow()
302+
303+
expect(observerCallbacks.length).toBe(0)
304+
})
305+
})
306+
})
307+
308+
describe('XHR listener cleanup', () => {
309+
let mockWindow: any
310+
let xhr: any
311+
312+
beforeEach(() => {
313+
jest.isolateModules(() => {
314+
// eslint-disable-next-line @typescript-eslint/no-require-imports
315+
const { getRecordNetworkPlugin } = require('../../../../extensions/replay/external/network-plugin')
316+
const mock = createMockWindow()
317+
mockWindow = mock.mockWindow
318+
319+
global.PerformanceObserver = mockWindow.PerformanceObserver
320+
321+
const plugin = getRecordNetworkPlugin({ recordBody: true })
322+
plugin.observer(() => {}, mockWindow, { recordBody: true })
323+
324+
xhr = new mockWindow.XMLHttpRequest()
325+
})
326+
})
327+
328+
it('should remove readystatechange listener on successful request', () => {
329+
xhr.open('GET', 'https://example.com')
330+
xhr.send()
331+
332+
expect(xhr.getListenerCount('readystatechange')).toBeGreaterThan(0)
333+
334+
xhr.readyState = xhr.DONE
335+
xhr.status = 200
336+
const listeners = xhr.listeners.get('readystatechange') || []
337+
listeners.forEach((listener: any) => listener())
338+
339+
expect(xhr.getListenerCount('readystatechange')).toBe(0)
340+
})
341+
342+
const failureEvents = [
343+
{ event: 'error', payload: new Error('Network error') },
344+
{ event: 'abort', payload: { type: 'abort' } },
345+
{ event: 'timeout', payload: { type: 'timeout' } },
346+
]
347+
348+
failureEvents.forEach(({ event, payload }) => {
349+
it(`should remove all listeners when XHR ${event}s`, () => {
350+
xhr.open('GET', 'https://example.com')
351+
xhr.send()
352+
353+
const listeners = xhr.listeners.get(event) || []
354+
listeners.forEach((listener: any) => listener(payload))
355+
356+
expect(xhr.getListenerCount('readystatechange')).toBe(0)
357+
expect(xhr.getListenerCount('error')).toBe(0)
358+
expect(xhr.getListenerCount('abort')).toBe(0)
359+
expect(xhr.getListenerCount('timeout')).toBe(0)
360+
})
361+
})
362+
363+
it('should not leak memory with multiple failed requests', () => {
364+
const xhrInstances = Array.from({ length: 10 }, (_, i) => {
365+
const testXhr = new mockWindow.XMLHttpRequest()
366+
testXhr.open('GET', `https://example.com/${i}`)
367+
testXhr.send()
368+
369+
const errorListeners = testXhr.listeners.get('error') || []
370+
errorListeners.forEach((listener: any) => listener(new Error('Network error')))
371+
372+
return testXhr
373+
})
374+
375+
xhrInstances.forEach((testXhr) => {
376+
expect(testXhr.getListenerCount('readystatechange')).toBe(0)
377+
expect(testXhr.getListenerCount('error')).toBe(0)
378+
expect(testXhr.getListenerCount('abort')).toBe(0)
379+
expect(testXhr.getListenerCount('timeout')).toBe(0)
380+
})
381+
})
382+
})
383+
})
183384
})

packages/browser/src/extensions/replay/external/network-plugin.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,13 +292,21 @@ function initXhrObserver(cb: networkCallback, win: IWindow, options: Required<Ne
292292
return originalSend(body)
293293
}
294294

295+
// Cleanup function to remove all event listeners and prevent memory leaks
296+
const cleanup = () => {
297+
xhr.removeEventListener('readystatechange', readyStateListener)
298+
xhr.removeEventListener('error', cleanup)
299+
xhr.removeEventListener('abort', cleanup)
300+
xhr.removeEventListener('timeout', cleanup)
301+
}
302+
295303
const readyStateListener = () => {
296304
if (xhr.readyState !== xhr.DONE) {
297305
return
298306
}
299307

300-
// Clean up the listener immediately when done to prevent memory leaks
301-
xhr.removeEventListener('readystatechange', readyStateListener)
308+
// Clean up all listeners immediately when done to prevent memory leaks
309+
cleanup()
302310

303311
end = win.performance.now()
304312
const responseHeaders: Headers = {}
@@ -348,6 +356,13 @@ function initXhrObserver(cb: networkCallback, win: IWindow, options: Required<Ne
348356
// so let's ignore the rule here.
349357
// eslint-disable-next-line posthog-js/no-add-event-listener
350358
xhr.addEventListener('readystatechange', readyStateListener)
359+
// Also clean up on error, abort, and timeout to prevent memory leaks
360+
// eslint-disable-next-line posthog-js/no-add-event-listener
361+
xhr.addEventListener('error', cleanup)
362+
// eslint-disable-next-line posthog-js/no-add-event-listener
363+
xhr.addEventListener('abort', cleanup)
364+
// eslint-disable-next-line posthog-js/no-add-event-listener
365+
xhr.addEventListener('timeout', cleanup)
351366

352367
originalOpen.call(xhr, method, url.toString(), async, username, password)
353368
}
@@ -672,6 +687,7 @@ function initNetworkObserver(
672687
performanceObserver()
673688
xhrObserver()
674689
fetchObserver()
690+
initialisedHandler = null
675691
}
676692
return initialisedHandler
677693
}

0 commit comments

Comments
 (0)