Skip to content

Commit 5f94c8e

Browse files
JacksonGLfacebook-github-bot
authored andcommitted
feat(lens): a prototype for tracking leaked event listener in memory lens
Summary: This diff introduces a new prototype feature in Memory Lens for detecting event listener memory leaks. It achieves this by monkey-patching the `Element.prototype.addEventListener` and `Element.prototype.removeEventListener` methods to intercept event listener registrations and removals. The implementation maintains an internal mapping of DOM elements to their registered callbacks and tracks whether each element remains connected to the DOM. If a DOM element becomes detached but still has active event listeners that were never removed, the detector flags it as a potential memory leak. This helps identify scenarios where event listeners are not properly cleaned up, which can prevent garbage collection and lead to increased memory usage over time. Differential Revision: D74155636 fbshipit-source-id: 12e28d780c577f4f6ffcbb837f2167b160483586
1 parent 702ec10 commit 5f94c8e

File tree

4 files changed

+407
-1
lines changed

4 files changed

+407
-1
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
* @oncall memory_lab
9+
*/
10+
import type {Fiber} from 'react-reconciler';
11+
import {getFiberNodeFromElement} from '../utils/react-fiber-utils';
12+
import {WeakMapPlus} from '../utils/weak-ref-utils';
13+
14+
type EventListenerEntry = {
15+
type: string;
16+
cb: WeakRef<EventListenerOrEventListenerObject>;
17+
options?: boolean | AddEventListenerOptions;
18+
fiber?: WeakRef<Fiber>;
19+
};
20+
21+
type DetachedListenerGroup = {
22+
type: string;
23+
count: number;
24+
entries: WeakRef<EventListenerEntry>[];
25+
};
26+
27+
export class EventListenerTracker {
28+
private static instance: EventListenerTracker | null = null;
29+
#listenerMap: WeakMapPlus<EventTarget, EventListenerEntry[]>;
30+
#detachedListeners: Map<string, DetachedListenerGroup[]>;
31+
#originalAddEventListener: typeof EventTarget.prototype.addEventListener;
32+
#originalRemoveEventListener: typeof EventTarget.prototype.removeEventListener;
33+
34+
private constructor() {
35+
this.#listenerMap = new WeakMapPlus({fallback: 'noop', cleanupMs: 100});
36+
this.#detachedListeners = new Map();
37+
this.#originalAddEventListener = EventTarget.prototype.addEventListener;
38+
this.#originalRemoveEventListener =
39+
EventTarget.prototype.removeEventListener;
40+
this.#patchEventListeners();
41+
}
42+
43+
static getInstance(): EventListenerTracker {
44+
if (!EventListenerTracker.instance) {
45+
EventListenerTracker.instance = new EventListenerTracker();
46+
}
47+
return EventListenerTracker.instance;
48+
}
49+
50+
#patchEventListeners(): void {
51+
// eslint-disable-next-line @typescript-eslint/no-this-alias
52+
const self = this;
53+
EventTarget.prototype.addEventListener = function (
54+
type: string,
55+
listener: EventListenerOrEventListenerObject,
56+
options?: boolean | AddEventListenerOptions,
57+
) {
58+
self.#originalAddEventListener.call(this, type, listener, options);
59+
if (this instanceof Element) {
60+
const fiber = getFiberNodeFromElement(this);
61+
const entry: EventListenerEntry = {
62+
type,
63+
cb: new WeakRef(listener),
64+
options,
65+
fiber: fiber ? new WeakRef(fiber) : undefined,
66+
};
67+
const listeners = self.#listenerMap.get(this) ?? [];
68+
listeners.push(entry);
69+
self.#listenerMap.set(this, listeners);
70+
}
71+
};
72+
73+
EventTarget.prototype.removeEventListener = function (
74+
type: string,
75+
listener: EventListenerOrEventListenerObject,
76+
options?: boolean | EventListenerOptions,
77+
) {
78+
self.#originalRemoveEventListener.call(this, type, listener, options);
79+
if (this instanceof Element) {
80+
const listeners = self.#listenerMap.get(this);
81+
if (listeners) {
82+
const index = listeners.findIndex(
83+
entry =>
84+
entry.type === type &&
85+
entry.cb.deref() === listener &&
86+
entry.options === options,
87+
);
88+
if (index !== -1) {
89+
listeners.splice(index, 1);
90+
}
91+
if (listeners.length === 0) {
92+
self.#listenerMap.delete(this);
93+
} else {
94+
self.#listenerMap.set(this, listeners);
95+
}
96+
}
97+
}
98+
};
99+
}
100+
101+
#unpatchEventListeners(): void {
102+
EventTarget.prototype.addEventListener = this.#originalAddEventListener;
103+
EventTarget.prototype.removeEventListener =
104+
this.#originalRemoveEventListener;
105+
}
106+
107+
addListener(
108+
el: EventTarget,
109+
type: string,
110+
cb: EventListenerOrEventListenerObject,
111+
options?: boolean | AddEventListenerOptions,
112+
): void {
113+
el.addEventListener(type, cb, options);
114+
}
115+
116+
removeListener(
117+
el: EventTarget,
118+
type: string,
119+
cb: EventListenerOrEventListenerObject,
120+
options?: boolean | AddEventListenerOptions,
121+
): void {
122+
el.removeEventListener(type, cb, options);
123+
}
124+
125+
scan(
126+
getComponentName: (elRef: WeakRef<Element>) => string,
127+
): Map<string, DetachedListenerGroup[]> {
128+
const detachedListeners = new Map<string, DetachedListenerGroup[]>();
129+
130+
for (const [el, listeners] of this.#listenerMap.entries()) {
131+
if (el instanceof Element && !el.isConnected) {
132+
for (const listener of listeners) {
133+
// Skip if the callback has been garbage collected
134+
if (!listener.cb.deref()) continue;
135+
136+
const componentName = getComponentName(new WeakRef(el));
137+
if (!detachedListeners.has(componentName)) {
138+
detachedListeners.set(componentName, []);
139+
}
140+
141+
const groups = detachedListeners.get(componentName);
142+
let group = groups?.find(g => g.type === listener.type);
143+
if (!group) {
144+
group = {
145+
type: listener.type,
146+
count: 0,
147+
entries: [],
148+
};
149+
groups?.push(group);
150+
}
151+
group.count++;
152+
group.entries.push(new WeakRef(listener));
153+
}
154+
}
155+
}
156+
157+
this.#detachedListeners = detachedListeners;
158+
return detachedListeners;
159+
}
160+
161+
getDetachedListeners(): Map<string, DetachedListenerGroup[]> {
162+
return this.#detachedListeners;
163+
}
164+
165+
destroy(): void {
166+
this.#unpatchEventListeners();
167+
this.#listenerMap.destroy();
168+
this.#detachedListeners.clear();
169+
EventListenerTracker.instance = null;
170+
}
171+
}

packages/lens/src/core/react-memory-scan.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import type {
1717
Optional,
1818
ScanResult,
1919
AnyValue,
20+
EventListenerLeak,
21+
Nullable,
2022
} from './types';
2123
import type {BasicExtension} from '../extensions/basic-extension';
2224

@@ -28,6 +30,7 @@ import {
2830
} from '../utils/react-fiber-utils';
2931
import {DOMObserver} from './dom-observer';
3032
import {config} from '../config/config';
33+
import {EventListenerTracker} from './event-listener-tracker';
3134

3235
export default class ReactMemoryScan {
3336
static nextElementId = 0;
@@ -43,13 +46,17 @@ export default class ReactMemoryScan {
4346
#extensions: Array<BasicExtension>;
4447
#scanIntervalMs: number;
4548
#domObserver: Optional<DOMObserver>;
49+
#eventListenerTracker: Nullable<EventListenerTracker>;
4650

4751
constructor(options: CreateOptions = {}) {
4852
this.#elementWeakRefs = [];
4953
this.#isActivated = false;
5054
this.#elementToBoundingRects = new WeakMap();
5155
this.#elementToComponentStack = new WeakMap();
5256
this.#knownFiberNodes = [];
57+
this.#eventListenerTracker = options.trackEventListenerLeaks
58+
? EventListenerTracker.getInstance()
59+
: null;
5360

5461
this.#fiberAnalyzer = new ReactFiberAnalyzer();
5562
this.#intervalId = 0 as unknown as NodeJS.Timeout;
@@ -215,6 +222,19 @@ export default class ReactMemoryScan {
215222
}
216223
}
217224

225+
getCachedComponentName(elementRef: WeakRef<Element>): string {
226+
const FALLBACK_NAME = '<Unknown>';
227+
const element = elementRef.deref();
228+
if (element == null) {
229+
return FALLBACK_NAME;
230+
}
231+
const componentStack = this.#elementToComponentStack.get(element);
232+
if (componentStack == null) {
233+
return FALLBACK_NAME;
234+
}
235+
return componentStack[0] ?? FALLBACK_NAME;
236+
}
237+
218238
updateFiberNodes(fiberNodes: Array<WeakRef<Fiber>>): Array<WeakRef<Fiber>> {
219239
const knownFiberSet = new WeakSet<Fiber>();
220240
for (const fiberNode of this.#knownFiberNodes) {
@@ -284,6 +304,32 @@ export default class ReactMemoryScan {
284304
}
285305
}
286306

307+
#scanEventListenerLeaks(): EventListenerLeak[] {
308+
if (this.#eventListenerTracker == null) {
309+
return [];
310+
}
311+
// Scan for event listener leaks
312+
const detachedListeners = this.#eventListenerTracker.scan(
313+
this.getCachedComponentName.bind(this),
314+
);
315+
const eventListenerLeaks: EventListenerLeak[] = [];
316+
for (const [componentName, listeners] of detachedListeners.entries()) {
317+
const typeCount = new Map<string, number>();
318+
for (const listener of listeners) {
319+
const count = typeCount.get(listener.type) ?? 0;
320+
typeCount.set(listener.type, count + 1);
321+
}
322+
for (const [type, count] of typeCount.entries()) {
323+
eventListenerLeaks.push({
324+
type,
325+
componentName,
326+
count,
327+
});
328+
}
329+
}
330+
return eventListenerLeaks;
331+
}
332+
287333
scan(): ScanResult {
288334
const start = Date.now();
289335
this.#runGC();
@@ -299,6 +345,11 @@ export default class ReactMemoryScan {
299345
);
300346
const leakedFibers = this.updateFiberNodes(scanResult.fiberNodes);
301347
scanResult.leakedFibers = leakedFibers;
348+
349+
// scan for event listener leaks
350+
// TODO: show the results in the UI widget
351+
scanResult.eventListenerLeaks = this.#scanEventListenerLeaks();
352+
302353
(window as AnyValue).leakedFibers = this.packLeakedFibers(leakedFibers);
303354
const end = Date.now();
304355
this.#log(`scan took ${end - start}ms`);

packages/lens/src/core/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export type CreateOptions = {
5858
subscribers?: Array<AnalysisResultCallback>;
5959
extensions?: Array<BasicExtension>;
6060
scanIntervalMs?: number;
61+
trackEventListenerLeaks?: boolean;
6162
};
6263

6364
/**
@@ -68,6 +69,12 @@ export type CreateOptions = {
6869
* @property {number} totalDetachedElements - Number of detached DOM elements found
6970
* @property {Map<string, number>} detachedComponentToFiberNodeCount - Map of component names to their detached instance counts
7071
*/
72+
export type EventListenerLeak = {
73+
type: string;
74+
componentName: string;
75+
count: number;
76+
};
77+
7178
export type ScanResult = {
7279
components: Set<string>;
7380
componentToFiberNodeCount: Map<string, number>;
@@ -76,6 +83,7 @@ export type ScanResult = {
7683
detachedComponentToFiberNodeCount: Map<string, number>;
7784
fiberNodes: Array<WeakRef<Fiber>>;
7885
leakedFibers: Array<WeakRef<Fiber>>;
86+
eventListenerLeaks?: EventListenerLeak[];
7987
};
8088

8189
/**
@@ -158,3 +166,15 @@ export type Config = {
158166
performance: PerformanceConfig;
159167
features: FeatureFlags;
160168
};
169+
170+
export type Entry<K extends object, V> = {
171+
ref: WeakRef<K>;
172+
value: V;
173+
};
174+
175+
export type FallbackMode = 'strong' | 'noop';
176+
177+
export type WeakMapPlusOptions = {
178+
fallback?: FallbackMode;
179+
cleanupMs?: number;
180+
};

0 commit comments

Comments
 (0)