Repository: sindresorhus/emittery (js)

Issue:
Possible memory-leak in bindMethods
First, I'm not sure if this issue is limited to bindMethods, but that's what shows up in my profile, so I'm reporting it that way.

I observed that a huge number of sets were being created by emittery and never being cleaned up. Upon investigating, it looks like these sets are being created to store the various event-handler functions for event names but these sets are never destroyed and the map keys never cleared out when all of the handlers are removed.

For example, here's a snapshot of my memory profile:
![image](https://user-images.githubusercontent.com/2933207/184464319-c0486bf3-d988-40b9-8618-054db10389d8.png)


Pull request description:
Fixes memory-leaks
Should fix #102 

Patch:
diff --git a/index.js b/index.js
index 1612c7d..b7ed7b6 100644
--- a/index.js
+++ b/index.js
@@ -1,8 +1,7 @@
 'use strict';
 
-const anyMap = new WeakMap();
-const eventsMap = new WeakMap();
-const producersMap = new WeakMap();
+const {anyMap, producersMap, eventsMap} = require('./maps.js');
+
 const anyProducer = Symbol('anyProducer');
 const resolvedPromise = Promise.resolve();
 
@@ -28,7 +27,7 @@ function assertListener(listener) {
 function getListeners(instance, eventName) {
 	const events = eventsMap.get(instance);
 	if (!events.has(eventName)) {
-		events.set(eventName, new Set());
+		return;
 	}
 
 	return events.get(eventName);
@@ -38,7 +37,7 @@ function getEventProducers(instance, eventName) {
 	const key = typeof eventName === 'string' || typeof eventName === 'symbol' || typeof eventName === 'number' ? eventName : anyProducer;
 	const producers = producersMap.get(instance);
 	if (!producers.has(key)) {
-		producers.set(key, new Set());
+		return;
 	}
 
 	return producers.get(key);
@@ -79,7 +78,14 @@ function iterator(instance, eventNames) {
 	};
 
 	for (const eventName of eventNames) {
-		getEventProducers(instance, eventName).add(producer);
+		let set = getEventProducers(instance, eventName);
+		if (!set) {
+			set = new Set();
+			const producers = producersMap.get(instance);
+			producers.set(eventName, set);
+		}
+
+		set.add(producer);
 	}
 
 	return {
@@ -111,7 +117,14 @@ function iterator(instance, eventNames) {
 			queue = undefined;
 
 			for (const eventName of eventNames) {
-				getEventProducers(instance, eventName).delete(producer);
+				const set = getEventProducers(instance, eventName);
+				if (set) {
+					set.delete(producer);
+					if (set.size === 0) {
+						const producers = producersMap.get(instance);
+						producers.delete(eventName);
+					}
+				}
 			}
 
 			flush();
@@ -221,6 +234,9 @@ class Emittery {
 		anyMap.set(this, new Set());
 		eventsMap.set(this, new Map());
 		producersMap.set(this, new Map());
+
+		producersMap.get(this).set(anyProducer, new Set());
+
 		this.debug = options.debug || {};
 
 		if (this.debug.enabled === undefined) {
@@ -259,7 +275,14 @@ class Emittery {
 		eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
 		for (const eventName of eventNames) {
 			assertEventName(eventName);
-			getListeners(this, eventName).add(listener);
+			let set = getListeners(this, eventName);
+			if (!set) {
+				set = new Set();
+				const events = eventsMap.get(this);
+				events.set(eventName, set);
+			}
+
+			set.add(listener);
 
 			this.logIfDebugEnabled('subscribe', eventName, undefined);
 
@@ -277,7 +300,14 @@ class Emittery {
 		eventNames = Array.isArray(eventNames) ? eventNames : [eventNames];
 		for (const eventName of eventNames) {
 			assertEventName(eventName);
-			getListeners(this, eventName).delete(listener);
+			const set = getListeners(this, eventName);
+			if (set) {
+				set.delete(listener);
+				if (set.size === 0) {
+					const events = eventsMap.get(this);
+					events.delete(eventName);
+				}
+			}
 
 			this.logIfDebugEnabled('unsubscribe', eventName, undefined);
 
@@ -321,7 +351,7 @@ class Emittery {
 
 		enqueueProducers(this, eventName, eventData);
 
-		const listeners = getListeners(this, eventName);
+		const listeners = getListeners(this, eventName) || new Set();
 		const anyListeners = anyMap.get(this);
 		const staticListeners = [...listeners];
 		const staticAnyListeners = isMetaEvent(eventName) ? [] : [...anyListeners];
@@ -350,7 +380,7 @@ class Emittery {
 
 		this.logIfDebugEnabled('emitSerial', eventName, eventData);
 
-		const listeners = getListeners(this, eventName);
+		const listeners = getListeners(this, eventName) || new Set();
 		const anyListeners = anyMap.get(this);
 		const staticListeners = [...listeners];
 		const staticAnyListeners = [...anyListeners];
@@ -401,28 +431,34 @@ class Emittery {
 			this.logIfDebugEnabled('clear', eventName, undefined);
 
 			if (typeof eventName === 'string' || typeof eventName === 'symbol' || typeof eventName === 'number') {
-				getListeners(this, eventName).clear();
+				const set = getListeners(this, eventName);
+				if (set) {
+					set.clear();
+				}
 
 				const producers = getEventProducers(this, eventName);
+				if (producers) {
+					for (const producer of producers) {
+						producer.finish();
+					}
 
-				for (const producer of producers) {
-					producer.finish();
+					producers.clear();
 				}
-
-				producers.clear();
 			} else {
 				anyMap.get(this).clear();
 
-				for (const listeners of eventsMap.get(this).values()) {
+				for (const [eventName, listeners] of eventsMap.get(this).entries()) {
 					listeners.clear();
+					eventsMap.get(this).delete(eventName);
 				}
 
-				for (const producers of producersMap.get(this).values()) {
+				for (const [eventName, producers] of producersMap.get(this).entries()) {
 					for (const producer of producers) {
 						producer.finish();
 					}
 
 					producers.clear();
+					producersMap.get(this).delete(eventName);
 				}
 			}
 		}
@@ -434,8 +470,8 @@ class Emittery {
 
 		for (const eventName of eventNames) {
 			if (typeof eventName === 'string') {
-				count += anyMap.get(this).size + getListeners(this, eventName).size +
-					getEventProducers(this, eventName).size + getEventProducers(this).size;
+				count += anyMap.get(this).size + (getListeners(this, eventName) || new Set()).size +
+					(getEventProducers(this, eventName) || new Set()).size + (getEventProducers(this) || new Set()).size;
 				continue;
 			}
 
diff --git a/maps.js b/maps.js
new file mode 100644
index 0000000..7bf3421
--- /dev/null
+++ b/maps.js
@@ -0,0 +1,9 @@
+const anyMap = new WeakMap();
+const eventsMap = new WeakMap();
+const producersMap = new WeakMap();
+
+module.exports = {
+	anyMap,
+	eventsMap,
+	producersMap
+};


Test patch:
diff --git a/test/index.js b/test/index.js
index 7de7788..03bae4a 100644
--- a/test/index.js
+++ b/test/index.js
@@ -2,6 +2,7 @@ import test from 'ava';
 import delay from 'delay';
 import pEvent from 'p-event';
 import Emittery from '../index.js';
+import {eventsMap} from '../maps.js';
 
 test('on()', async t => {
 	const emitter = new Emittery();
@@ -348,6 +349,19 @@ test('off() - no listener', t => {
 	}, TypeError);
 });
 
+test('off() - clears global maps when all listeners are removed', t => {
+	const emitter = new Emittery();
+
+	const event = 'string';
+	const callback = () => {};
+
+	emitter.on(event, callback);
+	t.is(eventsMap.get(emitter).get(event).size, 1);
+
+	emitter.off(event, callback);
+	t.is(eventsMap.get(emitter).get(event), undefined);
+});
+
 test('once()', async t => {
 	const fixture = '🌈';
 	const emitter = new Emittery();