Dont emit to debounced listener if off was called

This commit is contained in:
2026-08-10 02:32:22 +00:00
parent bc28a7e96a
commit 57e809157b
2 changed files with 53 additions and 19 deletions
+25 -19
View File
@@ -22,8 +22,7 @@ type StoredListener<T> = {
interface ListenerEntry<T> { interface ListenerEntry<T> {
listener: StoredListener<T>; listener: StoredListener<T>;
wrappedListener: StoredListener<T>; wrappedListener: StoredListener<T>;
debounceTime?: number; cancel: () => void;
once?: boolean;
} }
/** /**
@@ -49,9 +48,11 @@ export class EventEmitter<T extends EventMap> {
* @param debounceMilliseconds - The debounce time in milliseconds. * @param debounceMilliseconds - The debounce time in milliseconds.
* @returns An off callback that can be called to stop listening for events. * @returns An off callback that can be called to stop listening for events.
*/ */
on<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds?: number): OffCallback { on<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const { cancel, listener: cancellableListener } = this.cancellable(listener);
// Create a wrapped listener so that the debounce can be applied. // 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 the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) { if (!this.#listeners.has(type)) {
@@ -62,13 +63,9 @@ export class EventEmitter<T extends EventMap> {
const listenerEntry: ListenerEntry<T[K]> = { const listenerEntry: ListenerEntry<T[K]> = {
listener, listener,
wrappedListener, wrappedListener,
cancel,
}; };
// Set the debounce time if specified.
if (debounceMilliseconds && debounceMilliseconds > 0) {
listenerEntry.debounceTime = debounceMilliseconds;
}
// Add the listener entry to the listeners map. // Add the listener entry to the listeners map.
this.#listeners.get(type)?.add(listenerEntry); this.#listeners.get(type)?.add(listenerEntry);
@@ -83,15 +80,17 @@ export class EventEmitter<T extends EventMap> {
* @param debounceMilliseconds - The debounce time in milliseconds. * @param debounceMilliseconds - The debounce time in milliseconds.
* @returns An off callback that can be called to stop listening for events. * @returns An off callback that can be called to stop listening for events.
*/ */
once<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds?: number): OffCallback { once<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const wrappedListener: Listener<T[K]> = (detail: DeeplyReadonly<T[K]>) => { const wrappedListener: Listener<T[K]> = (detail: DeeplyReadonly<T[K]>) => {
this.off(type, listener); this.off(type, listener);
listener(detail); listener(detail);
}; };
// Create a cancellable listener.
const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener);
// Create a debounced listener. // Create a debounced listener.
const debouncedListener = const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
debounceMilliseconds && debounceMilliseconds > 0 ? this.debounce(wrappedListener, debounceMilliseconds) : wrappedListener;
// If the listeners map does not have the event type, create a new set. // If the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) { if (!this.#listeners.has(type)) {
@@ -102,14 +101,9 @@ export class EventEmitter<T extends EventMap> {
const listenerEntry: ListenerEntry<T[K]> = { const listenerEntry: ListenerEntry<T[K]> = {
listener, listener,
wrappedListener: debouncedListener, 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. // Add the listener entry to the listeners map.
this.#listeners.get(type)?.add(listenerEntry); this.#listeners.get(type)?.add(listenerEntry);
@@ -133,7 +127,7 @@ export class EventEmitter<T extends EventMap> {
// Remove the listener entries from the listeners set. // Remove the listener entries from the listeners set.
listenerEntries.forEach((entry) => { 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. // 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. // Remove the listener entry from the listeners set.
listeners.delete(entry); listeners.delete(entry);
@@ -251,4 +245,16 @@ export class EventEmitter<T extends EventMap> {
}, wait); }, wait);
}; };
} }
private cancellable<K extends keyof T>(func: Listener<T[K]>): { cancel: () => void; listener: Listener<T[K]> } {
let cancelled = false;
return {
cancel: (): boolean => (cancelled = true),
listener: (detail: DeeplyReadonly<T[K]>): void => {
if (cancelled) return;
func(detail);
},
};
}
} }
+28
View File
@@ -261,6 +261,30 @@ const testEventEmitterOnceOffCallbackRemovesListener = (): void => {
expect(listener).not.toHaveBeenCalled(); expect(listener).not.toHaveBeenCalled();
}; };
/**
* Tests that debounced listeners do not receive the debounced event if the listener is removed.
*/
const testEventEmitterOffCancelsPendingDebouncedCallback = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
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. * Tests that removeAllListeners() clears every registered listener.
*/ */
@@ -629,6 +653,10 @@ const runTests = async (): Promise<void> => {
test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce); test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce);
test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners); test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners);
test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener); 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: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners);
test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch); test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch);
test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents); test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents);