diff --git a/source/event-emitter.ts b/source/event-emitter.ts index f99bee4..caaa66d 100644 --- a/source/event-emitter.ts +++ b/source/event-emitter.ts @@ -22,8 +22,7 @@ type StoredListener = { interface ListenerEntry { listener: StoredListener; wrappedListener: StoredListener; - debounceTime?: number; - once?: boolean; + cancel: () => void; } /** @@ -49,9 +48,11 @@ export class EventEmitter { * @param debounceMilliseconds - The debounce time in milliseconds. * @returns An off callback that can be called to stop listening for events. */ - on(type: K, listener: Listener, debounceMilliseconds?: number): OffCallback { + on(type: K, listener: Listener, debounceMilliseconds: number = 0): OffCallback { + const { cancel, listener: cancellableListener } = this.cancellable(listener); + // Create a wrapped listener so that the debounce can be applied. - const wrappedListener = debounceMilliseconds && debounceMilliseconds > 0 ? this.debounce(listener, debounceMilliseconds) : listener; + const wrappedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener; // If the listeners map does not have the event type, create a new set. if (!this.#listeners.has(type)) { @@ -62,13 +63,9 @@ export class EventEmitter { const listenerEntry: ListenerEntry = { listener, wrappedListener, + cancel, }; - // Set the debounce time if specified. - if (debounceMilliseconds && debounceMilliseconds > 0) { - listenerEntry.debounceTime = debounceMilliseconds; - } - // Add the listener entry to the listeners map. this.#listeners.get(type)?.add(listenerEntry); @@ -83,15 +80,17 @@ export class EventEmitter { * @param debounceMilliseconds - The debounce time in milliseconds. * @returns An off callback that can be called to stop listening for events. */ - once(type: K, listener: Listener, debounceMilliseconds?: number): OffCallback { + once(type: K, listener: Listener, debounceMilliseconds: number = 0): OffCallback { const wrappedListener: Listener = (detail: DeeplyReadonly) => { this.off(type, listener); listener(detail); }; + // Create a cancellable listener. + const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener); + // Create a debounced listener. - const debouncedListener = - debounceMilliseconds && debounceMilliseconds > 0 ? this.debounce(wrappedListener, debounceMilliseconds) : wrappedListener; + const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener; // If the listeners map does not have the event type, create a new set. if (!this.#listeners.has(type)) { @@ -102,14 +101,9 @@ export class EventEmitter { const listenerEntry: ListenerEntry = { listener, wrappedListener: debouncedListener, - once: true, + cancel, }; - // Set the debounce time if specified. - if (debounceMilliseconds && debounceMilliseconds > 0) { - listenerEntry.debounceTime = debounceMilliseconds; - } - // Add the listener entry to the listeners map. this.#listeners.get(type)?.add(listenerEntry); @@ -133,7 +127,7 @@ export class EventEmitter { // Remove the listener entries from the listeners set. listenerEntries.forEach((entry) => { // Set the wrapped listener to a no-op function to prevent it from being called by debounced events after it's been removed. - entry.wrappedListener = (): void => {}; + entry.cancel(); // Remove the listener entry from the listeners set. listeners.delete(entry); @@ -251,4 +245,16 @@ export class EventEmitter { }, wait); }; } + + private cancellable(func: Listener): { cancel: () => void; listener: Listener } { + let cancelled = false; + + return { + cancel: (): boolean => (cancelled = true), + listener: (detail: DeeplyReadonly): void => { + if (cancelled) return; + func(detail); + }, + }; + } } diff --git a/test/event-emitter.test.ts b/test/event-emitter.test.ts index d14f6fc..415ba3f 100644 --- a/test/event-emitter.test.ts +++ b/test/event-emitter.test.ts @@ -261,6 +261,30 @@ const testEventEmitterOnceOffCallbackRemovesListener = (): void => { expect(listener).not.toHaveBeenCalled(); }; +/** + * Tests that debounced listeners do not receive the debounced event if the listener is removed. + */ +const testEventEmitterOffCancelsPendingDebouncedCallback = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + const off = emitter.on('message', listener, 100); + emitter.emit('message', 'first'); + + off(); + emitter.emit('message', 'second'); + await vi.advanceTimersByTimeAsync(100); + + expect(listener).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + /** * Tests that removeAllListeners() clears every registered listener. */ @@ -629,6 +653,10 @@ const runTests = async (): Promise => { test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce); test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners); test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener); + test( + 'EventEmitter: debounced listeners do not receive the debounced event if the listener is removed', + testEventEmitterOffCancelsPendingDebouncedCallback, + ); test('EventEmitter: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners); test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch); test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents);