diff --git a/source/event-emitter.ts b/source/event-emitter.ts index 0b753c0..6e4fc26 100644 --- a/source/event-emitter.ts +++ b/source/event-emitter.ts @@ -1,13 +1,17 @@ +import type { DeeplyReadonly } from './types.ts'; + +import { deepFreeze } from './misc.ts'; + export type EventMap = Record; -type Listener = (detail: Readonly) => void; +type Listener = (detail: DeeplyReadonly) => 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(detail: DeeplyReadonly): void; }['bivarianceHack']; /** @@ -79,7 +83,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: Readonly) => { + const wrappedListener: Listener = (detail: DeeplyReadonly) => { this.off(type, listener); listener(detail); }; @@ -151,8 +155,11 @@ export class EventEmitter { const listeners = this.#listeners.get(type); if (!listeners) return false; - // Freeze the payload to make it readonly. - const readonlyPayload = Object.freeze(payload); + // Clone the payload to avoid freezing the original object. + const payloadClone = structuredClone(payload); + + // Freeze the cloned payload to make it readonly. + const readonlyPayload = deepFreeze(payloadClone); // Emit the event to all listeners. listeners.forEach((entry) => { @@ -177,7 +184,7 @@ export class EventEmitter { * @param timeoutMs - The timeout in milliseconds. * @returns The event payload. */ - async waitFor(type: K, predicate: (payload: Readonly) => boolean, timeoutMs?: number): Promise> { + async waitFor(type: K, predicate: (payload: DeeplyReadonly) => boolean, timeoutMs?: number): Promise> { // Create a promise to wait for the event to be emitted. return new Promise((resolve, reject) => { let timeoutId: ReturnType | undefined; @@ -225,7 +232,7 @@ export class EventEmitter { // Create a timeout variable. let timeout: ReturnType; - return (detail: Readonly) => { + return (detail: DeeplyReadonly) => { // If a debounce timer is already pending, clear it before scheduling the next one. if (timeout !== undefined) { clearTimeout(timeout); diff --git a/source/misc.ts b/source/misc.ts index 2b67d1a..59a22c2 100644 --- a/source/misc.ts +++ b/source/misc.ts @@ -1,3 +1,5 @@ +import { DeeplyReadonly } from "./types"; + /** * Validate the value is within the bounds, returning true if it is within the bounds, false otherwise * @@ -30,3 +32,27 @@ export const tryAsync = async (fn: () => unknown, onError?: (error: Error) => vo onError?.(errorInstance); } }; + +/** + * Recursively freezes an object by iterating over all properties and freezing them. + * @param obj - The object to freeze. + * @returns The frozen object. + */ +export const deepFreeze = (value: T): DeeplyReadonly => { + if ( + value !== null && + (typeof value === 'object' || typeof value === 'function') + ) { + for (const key of Reflect.ownKeys(value)) { + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + + if (descriptor && 'value' in descriptor) { + deepFreeze(descriptor.value); + } + } + + Object.freeze(value); + } + + return value; +} diff --git a/source/types.ts b/source/types.ts new file mode 100644 index 0000000..b643cde --- /dev/null +++ b/source/types.ts @@ -0,0 +1,11 @@ +/** + * A deeply readonly type. + * @template T - The type to make deeply readonly. + * @returns The deeply readonly type. + */ +export type DeeplyReadonly = { + readonly [K in keyof T]: + T[K] extends (...args: never[]) => unknown + ? T[K] + : DeeplyReadonly; +}; \ No newline at end of file diff --git a/test/event-emitter.test.ts b/test/event-emitter.test.ts index 7b1eb71..affab82 100644 --- a/test/event-emitter.test.ts +++ b/test/event-emitter.test.ts @@ -89,6 +89,49 @@ const testEventEmitterEmitReturnsTrueWithListeners = (): void => { expect(hasListeners).toBe(true); }; +/** + * Tests that emitted events cannot be mutated. + */ +const testEventEmitterEmittedEventsCannotBeMutated = async (): Promise => { + vi.useFakeTimers(); + + try { + const emitter = new EventEmitter(); + const listener = vi.fn(); + const payload = { nested: { value: 1 } }; + + emitter.on('nested', listener, 100); + + // Arm the debounce timer with the current payload. + emitter.emit('nested', payload); + + // Mutate the original object while the timer is still pending. + payload.nested.value = 999; + + await vi.advanceTimersByTimeAsync(100); + + // The listener must receive a snapshot from emit time, not the mutated value. + expect(listener).toHaveBeenCalledOnce(); + + // Expect the listener to have received the original payload object without any mutations + expect(listener).toHaveBeenCalledWith({ + nested: { + value: 1, + }, + }); + + // Expect the payload object to have been mutated + expect(payload).toStrictEqual({ + nested: { + value: 999, + }, + }); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } +}; + /** * Tests that the off callback returned by on() removes the listener. */ @@ -514,6 +557,7 @@ const runTests = async (): Promise => { test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners); test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners); test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners); + test('EventEmitter: emitted events cannot be mutated', testEventEmitterEmittedEventsCannotBeMutated); 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(