diff --git a/source/event-emitter.ts b/source/event-emitter.ts index ccc0dd5..f99bee4 100644 --- a/source/event-emitter.ts +++ b/source/event-emitter.ts @@ -190,22 +190,29 @@ export class EventEmitter { return new Promise((resolve, reject) => { let timeoutId: ReturnType | undefined; + // Create a cleanup function to remove the listener and clear the timeout if it is still pending. + const cleanup = (listener: Listener): void => { + // Remove the listener from the listeners map. + this.off(type, listener); + + // Clear the timeout if it is still pending. + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }; + // Create a listener function. - const listener = (payload: Readonly): void => { + const listener = (payload: DeeplyReadonly): void => { try { // If the event payload does not match the predicate condition, return. if (!predicate(payload)) { return; } - // Clean up - this.off(type, listener); - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - } - + cleanup(listener); resolve(payload); } catch (error) { + cleanup(listener); reject(error); } }; diff --git a/test/event-emitter.test.ts b/test/event-emitter.test.ts index affab82..d14f6fc 100644 --- a/test/event-emitter.test.ts +++ b/test/event-emitter.test.ts @@ -551,6 +551,66 @@ const testEventEmitterWaitForRejectsOnPredicateError = async (): Promise = expect(listener).not.toHaveBeenCalled(); }; +/** + * Tests that waitFor() removes its listener after the predicate function throws an error. + */ +const testEventEmitterWaitForRemovesListenerAfterPredicateError = async (): Promise => { + const emitter = new EventEmitter(); + + // Create a predicate function that throws an error. + const predicate = vi.fn().mockImplementation(() => { + throw new Error('predicate error'); + }); + + // Wait for the predicate function to throw an error. + const waitPromise = emitter.waitFor('message', predicate); + + // Emit an event, expecting the predicate function to throw an error. + emitter.emit('message', 'hello'); + await expect(waitPromise).rejects.toThrow('predicate error'); + + // Expect the predicate function to have been called once. + expect(predicate).toHaveBeenCalledTimes(1); + + // A later emit must not invoke the failed waitFor predicate again. + emitter.emit('message', 'again'); + + // Expect the predicate function to still have been called once. + expect(predicate).toHaveBeenCalledTimes(1); +}; + +/** + * Tests that waitFor() clears its timeout when the predicate function throws an error. + */ +const testEventEmitterWaitForClearsTimeoutAfterPredicateError = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + + const waitPromise = emitter.waitFor( + 'message', + (): boolean => { + throw new Error('predicate error'); + }, + 100, + ); + + emitter.emit('message', 'hello'); + await expect(waitPromise).rejects.toThrow('predicate error'); + + // The timeout scheduled for waitFor must be cleared on predicate failure. + expect(clearTimeoutSpy).toHaveBeenCalled(); + + // Advancing past the original timeout must not produce a second rejection path. + await vi.advanceTimersByTimeAsync(100); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + const runTests = async (): Promise => { test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners); test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners); @@ -581,6 +641,11 @@ const runTests = async (): Promise => { test('EventEmitter: does not debounce when debounceMilliseconds is zero', testEventEmitterZeroDebounceDoesNotDebounce); test('EventEmitter: debounces once listeners and invokes them only once', testEventEmitterDebouncedOnceListener); test('EventEmitter: rejects waitFor when the predicate function throws', testEventEmitterWaitForRejectsOnPredicateError); + test( + 'EventEmitter: removes the waitFor listener after it rejects due to predicate error', + testEventEmitterWaitForRemovesListenerAfterPredicateError, + ); + test('EventEmitter: clears the timeout when waitFor rejects due to predicate error', testEventEmitterWaitForClearsTimeoutAfterPredicateError); }; await runTests();