diff --git a/.cspell.json b/.cspell.json index 8290ebb..3aa6a11 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,6 +1,6 @@ { "version": "0.1", "import": ["@generalprotocols/cspell-dictionary/cspell.json"], - "words": ["nonfungible", "lockscript"], + "words": ["nonfungible", "lockscript", "bivariance"], "ignorePaths": ["source/template/xo-template.schema.json"] } diff --git a/package-lock.json b/package-lock.json index 813ede6..74bb9dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1105,9 +1105,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1186,9 +1186,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3503,16 +3503,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/brotli-size": { @@ -5292,9 +5292,9 @@ "peer": true }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "peer": true, @@ -5379,9 +5379,9 @@ "peer": true }, "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "peer": true, @@ -5468,9 +5468,9 @@ "peer": true }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "peer": true, @@ -5542,9 +5542,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -7129,9 +7129,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -7810,9 +7810,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -8288,9 +8288,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -8308,7 +8308,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/source/event-emitter.ts b/source/event-emitter.ts index e455a08..64cdcb0 100644 --- a/source/event-emitter.ts +++ b/source/event-emitter.ts @@ -1,18 +1,29 @@ export type EventMap = Record; -type Listener = (detail: T) => void; +type Listener = (detail: Readonly) => void; + +/** + * Internally permits listeners for individual event payloads to be stored + * in a collection typed with the union of all event payloads. + */ +type StoredListener = { + bivarianceHack(detail: Readonly): void; +}['bivarianceHack']; /** * A listener entry. - * @template T - The event type. + * @template T - The event payload type. */ interface ListenerEntry { - listener: Listener; - wrappedListener: Listener; + listener: StoredListener; + wrappedListener: StoredListener; debounceTime?: number; once?: boolean; } +/** + * Callback returned by {@link on} and {@link once} for removing a listener. + */ export type OffCallback = () => void; /** @@ -24,7 +35,7 @@ export class EventEmitter { * The listeners map. * @private */ - private listeners: Map>> = new Map(); + #listeners: Map>> = new Map(); /** * Add a listener for an event. @@ -38,19 +49,23 @@ export class EventEmitter { const wrappedListener = debounceMilliseconds && debounceMilliseconds > 0 ? this.debounce(listener, debounceMilliseconds) : listener; // If the listeners map does not have the event type, create a new set. - if (!this.listeners.has(type)) { - this.listeners.set(type, new Set()); + if (!this.#listeners.has(type)) { + this.#listeners.set(type, new Set()); } // Create a listener entry. const listenerEntry: ListenerEntry = { listener, wrappedListener, - ...(debounceMilliseconds !== undefined ? { debounceTime: debounceMilliseconds } : {}), }; + // 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 as ListenerEntry); + this.#listeners.get(type)?.add(listenerEntry); // Return an "off" callback that can be called to stop listening for events. return () => this.off(type, listener); @@ -64,7 +79,7 @@ export class EventEmitter { * @returns An off callback that can be called to stop listening for events. */ once(type: K, listener: Listener, debounceMilliseconds?: number): OffCallback { - const wrappedListener: Listener = (detail: T[K]) => { + const wrappedListener: Listener = (detail: Readonly) => { this.off(type, listener); listener(detail); }; @@ -74,8 +89,8 @@ export class EventEmitter { debounceMilliseconds && debounceMilliseconds > 0 ? this.debounce(wrappedListener, debounceMilliseconds) : wrappedListener; // If the listeners map does not have the event type, create a new set. - if (!this.listeners.has(type)) { - this.listeners.set(type, new Set()); + if (!this.#listeners.has(type)) { + this.#listeners.set(type, new Set()); } // Create a listener entry. @@ -83,11 +98,15 @@ export class EventEmitter { listener, wrappedListener: debouncedListener, once: true, - ...(debounceMilliseconds !== undefined ? { debounceTime: debounceMilliseconds } : {}), }; + // 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 as ListenerEntry); + this.#listeners.get(type)?.add(listenerEntry); // Return an "off" callback that can be called to stop listening for events. return () => this.off(type, listener); @@ -98,11 +117,18 @@ export class EventEmitter { * @param type - The event type. * @param listener - The listener function. */ - off(type: K, listener: Listener): void { + off(type: K, listener?: Listener): void { // Get the listeners for the event type. - const listeners = this.listeners.get(type); + const listeners = this.#listeners.get(type); if (!listeners) return; + // If no listener is provided, remove all listeners for the event type. + if (!listener) { + this.#listeners.delete(type); + + return; + } + // Find the listener entry. const listenerEntry = Array.from(listeners).find((entry) => entry.listener === listener || entry.wrappedListener === listener); @@ -120,12 +146,15 @@ export class EventEmitter { */ emit(type: K, payload: T[K]): boolean { // Get the listeners for the event type. - const listeners = this.listeners.get(type); + const listeners = this.#listeners.get(type); if (!listeners) return false; + // Freeze the payload to make it readonly. + const readonlyPayload = Object.freeze(payload); + // Emit the event to all listeners. listeners.forEach((entry) => { - entry.wrappedListener(payload); + entry.wrappedListener(readonlyPayload); }); // Return true if there are listeners for the event, false otherwise. @@ -136,24 +165,29 @@ export class EventEmitter { * Remove all listeners. */ removeAllListeners(): void { - this.listeners.clear(); + this.#listeners.clear(); } /** - * Wait for an event to be emitted. + * Wait for an event to be emitted that matches the provided predicate function's criteria. * @param type - The event type. - * @param predicate - The predicate function. + * @param predicate - Predicate function to filter for whether the event payload matches the criteria. * @param timeoutMs - The timeout in milliseconds. * @returns The event payload. */ - async waitFor(type: K, predicate: (payload: T[K]) => boolean, timeoutMs?: number): Promise { + async waitFor(type: K, predicate: (payload: Readonly) => boolean, timeoutMs?: number): Promise> { // Create a promise to wait for the event to be emitted. return new Promise((resolve, reject) => { let timeoutId: ReturnType | undefined; // Create a listener function. - const listener = (payload: T[K]): void => { - if (predicate(payload)) { + const listener = (payload: Readonly): 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) { @@ -161,6 +195,8 @@ export class EventEmitter { } resolve(payload); + } catch (error) { + reject(error); } }; @@ -187,7 +223,7 @@ export class EventEmitter { // Create a timeout variable. let timeout: ReturnType; - return (detail: T[K]) => { + return (detail: Readonly) => { // If a debounce timer is already pending, clear it before scheduling the next one. if (timeout !== undefined) { clearTimeout(timeout); diff --git a/test/event-emitter.test.ts b/test/event-emitter.test.ts index b0b0e81..7b1eb71 100644 --- a/test/event-emitter.test.ts +++ b/test/event-emitter.test.ts @@ -124,6 +124,21 @@ const testEventEmitterOffRemovesListenerByReference = (): void => { expect(listener).not.toHaveBeenCalled(); }; +/** + * Tests that off() removes all listeners for an event type when no listener is provided. + */ +const testEventEmitterOffRemovesAllListenersForEventType = (): void => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + emitter.on('message', listener); + emitter.off('message'); + + expect(listener).not.toHaveBeenCalled(); + expect(emitter.emit('message', 'hello')).toBe(false); + expect(emitter.emit('count', 42)).toBe(false); +}; + /** * Tests that off() does nothing when given an unknown listener reference. */ @@ -476,6 +491,23 @@ const testEventEmitterDebouncedOnceListener = async (): Promise => { } }; +/** + * Tests that the `waitFor` method rejects if the predicate function throws + */ +const testEventEmitterWaitForRejectsOnPredicateError = async (): Promise => { + const emitter = new EventEmitter(); + const listener = vi.fn(); + + const waitPromise = emitter.waitFor('message', () => { + throw new Error('predicate error'); + }); + + emitter.emit('message', 'hello'); + + await expect(waitPromise).rejects.toThrow('predicate error'); + expect(listener).not.toHaveBeenCalled(); +}; + const runTests = async (): Promise => { test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners); test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners); @@ -484,6 +516,10 @@ const runTests = async (): Promise => { test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners); test('EventEmitter: stops calling a listener after its off callback is invoked', testEventEmitterOffCallbackRemovesListener); test('EventEmitter: removes a listener when off is called with the same reference', testEventEmitterOffRemovesListenerByReference); + test( + 'EventEmitter: removes all listeners for an event type when off is called with no listener', + testEventEmitterOffRemovesAllListenersForEventType, + ); test('EventEmitter: ignores off when the listener reference is unknown', testEventEmitterOffIgnoresUnknownListener); test('EventEmitter: ignores off for an event type with no listeners', testEventEmitterOffIgnoresUnregisteredEventType); test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce); @@ -500,6 +536,7 @@ const runTests = async (): Promise => { test('EventEmitter: resets the debounce timer on repeated emits', testEventEmitterDebouncedTimerResetsOnRepeatedEmits); 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); }; await runTests();