Skip to content

Commit 420f63a

Browse files
committed
fix: network plugin cleanup
1 parent c598a87 commit 420f63a

File tree

3 files changed

+274
-3
lines changed

3 files changed

+274
-3
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: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/// <reference lib="dom" />
22

33
import { expect } from '@jest/globals'
4-
import { shouldRecordBody } from '../../../../extensions/replay/external/network-plugin'
4+
import {
5+
shouldRecordBody,
6+
getRecordNetworkPlugin,
7+
_resetNetworkObserverSingleton,
8+
} from '../../../../extensions/replay/external/network-plugin'
59

610
// Mock Request class since jsdom might not provide it
711
class MockRequest {
@@ -14,6 +18,70 @@ class MockRequest {
1418
// Replace global Request with our mock
1519
global.Request = MockRequest as any
1620

21+
// Mock window with performance API
22+
function createMockWindow() {
23+
const performanceEntries: PerformanceEntry[] = []
24+
const observerCallbacks: Array<(entries: PerformanceObserverEntryList) => void> = []
25+
26+
const mockWindow = {
27+
performance: {
28+
now: () => Date.now(),
29+
getEntries: () => performanceEntries,
30+
getEntriesByName: (name: string) => performanceEntries.filter((e: any) => e.name === name),
31+
clearResourceTimings: () => {
32+
performanceEntries.length = 0
33+
},
34+
},
35+
PerformanceObserver: class {
36+
callback: (entries: PerformanceObserverEntryList) => void
37+
constructor(callback: (entries: PerformanceObserverEntryList) => void) {
38+
this.callback = callback
39+
}
40+
observe() {
41+
observerCallbacks.push(this.callback)
42+
}
43+
disconnect() {
44+
const index = observerCallbacks.indexOf(this.callback)
45+
if (index > -1) observerCallbacks.splice(index, 1)
46+
}
47+
} as any,
48+
XMLHttpRequest: class {
49+
listeners: Map<string, Array<(e: any) => void>> = new Map()
50+
readyState = 0
51+
DONE = 4
52+
status = 200
53+
response = ''
54+
responseText = ''
55+
56+
open() {}
57+
send() {}
58+
setRequestHeader() {}
59+
getAllResponseHeaders() {
60+
return ''
61+
}
62+
addEventListener(event: string, listener: (e: any) => void) {
63+
if (!this.listeners.has(event)) this.listeners.set(event, [])
64+
this.listeners.get(event)!.push(listener)
65+
}
66+
removeEventListener(event: string, listener: (e: any) => void) {
67+
const listeners = this.listeners.get(event)
68+
if (listeners) {
69+
const index = listeners.indexOf(listener)
70+
if (index > -1) listeners.splice(index, 1)
71+
}
72+
}
73+
getListenerCount(event: string) {
74+
return this.listeners.get(event)?.length || 0
75+
}
76+
} as any,
77+
fetch: async () => new Response(),
78+
} as any
79+
80+
mockWindow.PerformanceObserver.supportedEntryTypes = ['navigation', 'resource']
81+
82+
return { mockWindow, performanceEntries, observerCallbacks }
83+
}
84+
1785
const blobUrlTestCases = [
1886
{ url: 'blob:https://example.com/123', expected: false },
1987
{ url: new URL('blob:https://example.com/123'), expected: false },
@@ -180,4 +248,179 @@ describe('network plugin', () => {
180248
})
181249
})
182250
})
251+
252+
describe('network observer lifecycle', () => {
253+
describe('singleton initialization and cleanup', () => {
254+
let mockWindow: any
255+
let observerCallbacks: Array<(entries: PerformanceObserverEntryList) => void>
256+
let originalPerformanceObserver: any
257+
258+
beforeEach(() => {
259+
// Reset singleton state between tests
260+
_resetNetworkObserverSingleton()
261+
262+
const mock = createMockWindow()
263+
mockWindow = mock.mockWindow
264+
observerCallbacks = mock.observerCallbacks
265+
266+
// Mock global PerformanceObserver
267+
originalPerformanceObserver = global.PerformanceObserver
268+
global.PerformanceObserver = mockWindow.PerformanceObserver
269+
})
270+
271+
afterEach(() => {
272+
// Restore original
273+
global.PerformanceObserver = originalPerformanceObserver
274+
_resetNetworkObserverSingleton()
275+
})
276+
277+
it('should initialize successfully on first call', () => {
278+
const plugin = getRecordNetworkPlugin()
279+
const cleanup = plugin.observer(() => {}, mockWindow, {})
280+
281+
expect(cleanup).toBeDefined()
282+
expect(typeof cleanup).toBe('function')
283+
expect(observerCallbacks.length).toBeGreaterThan(0)
284+
})
285+
286+
it('should allow re-initialization after cleanup', () => {
287+
const plugin1 = getRecordNetworkPlugin()
288+
const cleanup1 = plugin1.observer(() => {}, mockWindow, {})
289+
290+
// Cleanup first initialization
291+
cleanup1()
292+
expect(observerCallbacks.length).toBe(0)
293+
294+
// Should be able to initialize again
295+
const plugin2 = getRecordNetworkPlugin()
296+
const cleanup2 = plugin2.observer(() => {}, mockWindow, {})
297+
298+
expect(cleanup2).toBeDefined()
299+
expect(typeof cleanup2).toBe('function')
300+
expect(observerCallbacks.length).toBeGreaterThan(0)
301+
302+
cleanup2()
303+
})
304+
305+
it('should handle multiple cleanup calls safely', () => {
306+
const plugin = getRecordNetworkPlugin()
307+
const cleanup = plugin.observer(() => {}, mockWindow, {})
308+
309+
// Multiple cleanup calls should not throw
310+
expect(() => {
311+
cleanup()
312+
cleanup()
313+
cleanup()
314+
}).not.toThrow()
315+
316+
expect(observerCallbacks.length).toBe(0)
317+
})
318+
319+
it('should create new observers after cleanup and re-init', () => {
320+
const callbacks: any[] = []
321+
322+
const plugin1 = getRecordNetworkPlugin()
323+
const cleanup1 = plugin1.observer((data) => callbacks.push({ cycle: 1, data }), mockWindow, {})
324+
const observerCount1 = observerCallbacks.length
325+
326+
cleanup1()
327+
328+
const plugin2 = getRecordNetworkPlugin()
329+
const cleanup2 = plugin2.observer((data) => callbacks.push({ cycle: 2, data }), mockWindow, {})
330+
const observerCount2 = observerCallbacks.length
331+
332+
// Should have created new observer
333+
expect(observerCount2).toBeGreaterThan(0)
334+
expect(observerCount2).toBe(observerCount1)
335+
336+
cleanup2()
337+
})
338+
})
339+
340+
describe('XHR listener cleanup', () => {
341+
let mockWindow: any
342+
let xhr: any
343+
let originalPerformanceObserver: any
344+
345+
beforeEach(() => {
346+
// Reset singleton state between tests
347+
_resetNetworkObserverSingleton()
348+
349+
const mock = createMockWindow()
350+
mockWindow = mock.mockWindow
351+
352+
// Save original and mock global PerformanceObserver
353+
originalPerformanceObserver = global.PerformanceObserver
354+
global.PerformanceObserver = mockWindow.PerformanceObserver
355+
356+
// Initialize plugin with recordBody enabled to trigger XHR wrapping
357+
const plugin = getRecordNetworkPlugin({ recordBody: true })
358+
plugin.observer(() => {}, mockWindow, { recordBody: true })
359+
360+
// Create XHR instance (will be wrapped)
361+
xhr = new mockWindow.XMLHttpRequest()
362+
})
363+
364+
afterEach(() => {
365+
// Restore original
366+
global.PerformanceObserver = originalPerformanceObserver
367+
_resetNetworkObserverSingleton()
368+
})
369+
370+
it('should remove readystatechange listener on successful request', () => {
371+
xhr.open('GET', 'https://example.com')
372+
xhr.send()
373+
374+
expect(xhr.getListenerCount('readystatechange')).toBeGreaterThan(0)
375+
376+
xhr.readyState = xhr.DONE
377+
xhr.status = 200
378+
const listeners = xhr.listeners.get('readystatechange') || []
379+
listeners.forEach((listener: any) => listener())
380+
381+
expect(xhr.getListenerCount('readystatechange')).toBe(0)
382+
})
383+
384+
const failureEvents = [
385+
{ event: 'error', payload: new Error('Network error') },
386+
{ event: 'abort', payload: { type: 'abort' } },
387+
{ event: 'timeout', payload: { type: 'timeout' } },
388+
]
389+
390+
failureEvents.forEach(({ event, payload }) => {
391+
it(`should remove all listeners when XHR ${event}s`, () => {
392+
xhr.open('GET', 'https://example.com')
393+
xhr.send()
394+
395+
const listeners = xhr.listeners.get(event) || []
396+
listeners.forEach((listener: any) => listener(payload))
397+
398+
expect(xhr.getListenerCount('readystatechange')).toBe(0)
399+
expect(xhr.getListenerCount('error')).toBe(0)
400+
expect(xhr.getListenerCount('abort')).toBe(0)
401+
expect(xhr.getListenerCount('timeout')).toBe(0)
402+
})
403+
})
404+
405+
it('should not leak memory with multiple failed requests', () => {
406+
const xhrInstances = Array.from({ length: 10 }, (_, i) => {
407+
const testXhr = new mockWindow.XMLHttpRequest()
408+
testXhr.open('GET', `https://example.com/${i}`)
409+
testXhr.send()
410+
411+
const errorListeners = testXhr.listeners.get('error') || []
412+
errorListeners.forEach((listener: any) => listener(new Error('Network error')))
413+
414+
return testXhr
415+
})
416+
417+
xhrInstances.forEach((testXhr) => {
418+
expect(testXhr.getListenerCount('readystatechange')).toBe(0)
419+
expect(testXhr.getListenerCount('error')).toBe(0)
420+
expect(testXhr.getListenerCount('abort')).toBe(0)
421+
expect(testXhr.getListenerCount('timeout')).toBe(0)
422+
})
423+
})
424+
})
425+
})
183426
})

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

Lines changed: 25 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
}
@@ -691,4 +707,11 @@ export const getRecordNetworkPlugin: (options?: NetworkRecordOptions) => RecordP
691707
}
692708
}
693709

710+
// Test-only helper to reset the singleton state
711+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
712+
// @ts-ignore
713+
export const _resetNetworkObserverSingleton = () => {
714+
initialisedHandler = null
715+
}
716+
694717
// rrweb/networ@1 ends

0 commit comments

Comments
 (0)