Deeply freeze object
This commit is contained in:
+14
-7
@@ -1,13 +1,17 @@
|
|||||||
|
import type { DeeplyReadonly } from './types.ts';
|
||||||
|
|
||||||
|
import { deepFreeze } from './misc.ts';
|
||||||
|
|
||||||
export type EventMap = Record<string, unknown>;
|
export type EventMap = Record<string, unknown>;
|
||||||
|
|
||||||
type Listener<T> = (detail: Readonly<T>) => void;
|
type Listener<T> = (detail: DeeplyReadonly<T>) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internally permits listeners for individual event payloads to be stored
|
* Internally permits listeners for individual event payloads to be stored
|
||||||
* in a collection typed with the union of all event payloads.
|
* in a collection typed with the union of all event payloads.
|
||||||
*/
|
*/
|
||||||
type StoredListener<T> = {
|
type StoredListener<T> = {
|
||||||
bivarianceHack(detail: Readonly<T>): void;
|
bivarianceHack(detail: DeeplyReadonly<T>): void;
|
||||||
}['bivarianceHack'];
|
}['bivarianceHack'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,7 +83,7 @@ export class EventEmitter<T extends EventMap> {
|
|||||||
* @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): OffCallback {
|
||||||
const wrappedListener: Listener<T[K]> = (detail: Readonly<T[K]>) => {
|
const wrappedListener: Listener<T[K]> = (detail: DeeplyReadonly<T[K]>) => {
|
||||||
this.off(type, listener);
|
this.off(type, listener);
|
||||||
listener(detail);
|
listener(detail);
|
||||||
};
|
};
|
||||||
@@ -151,8 +155,11 @@ export class EventEmitter<T extends EventMap> {
|
|||||||
const listeners = this.#listeners.get(type);
|
const listeners = this.#listeners.get(type);
|
||||||
if (!listeners) return false;
|
if (!listeners) return false;
|
||||||
|
|
||||||
// Freeze the payload to make it readonly.
|
// Clone the payload to avoid freezing the original object.
|
||||||
const readonlyPayload = Object.freeze(payload);
|
const payloadClone = structuredClone(payload);
|
||||||
|
|
||||||
|
// Freeze the cloned payload to make it readonly.
|
||||||
|
const readonlyPayload = deepFreeze(payloadClone);
|
||||||
|
|
||||||
// Emit the event to all listeners.
|
// Emit the event to all listeners.
|
||||||
listeners.forEach((entry) => {
|
listeners.forEach((entry) => {
|
||||||
@@ -177,7 +184,7 @@ export class EventEmitter<T extends EventMap> {
|
|||||||
* @param timeoutMs - The timeout in milliseconds.
|
* @param timeoutMs - The timeout in milliseconds.
|
||||||
* @returns The event payload.
|
* @returns The event payload.
|
||||||
*/
|
*/
|
||||||
async waitFor<K extends keyof T>(type: K, predicate: (payload: Readonly<T[K]>) => boolean, timeoutMs?: number): Promise<Readonly<T[K]>> {
|
async waitFor<K extends keyof T>(type: K, predicate: (payload: DeeplyReadonly<T[K]>) => boolean, timeoutMs?: number): Promise<DeeplyReadonly<T[K]>> {
|
||||||
// Create a promise to wait for the event to be emitted.
|
// Create a promise to wait for the event to be emitted.
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
@@ -225,7 +232,7 @@ export class EventEmitter<T extends EventMap> {
|
|||||||
// Create a timeout variable.
|
// Create a timeout variable.
|
||||||
let timeout: ReturnType<typeof setTimeout>;
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
return (detail: Readonly<T[K]>) => {
|
return (detail: DeeplyReadonly<T[K]>) => {
|
||||||
// If a debounce timer is already pending, clear it before scheduling the next one.
|
// If a debounce timer is already pending, clear it before scheduling the next one.
|
||||||
if (timeout !== undefined) {
|
if (timeout !== undefined) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { DeeplyReadonly } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = <T>(value: T): DeeplyReadonly<T> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* A deeply readonly type.
|
||||||
|
* @template T - The type to make deeply readonly.
|
||||||
|
* @returns The deeply readonly type.
|
||||||
|
*/
|
||||||
|
export type DeeplyReadonly<T> = {
|
||||||
|
readonly [K in keyof T]:
|
||||||
|
T[K] extends (...args: never[]) => unknown
|
||||||
|
? T[K]
|
||||||
|
: DeeplyReadonly<T[K]>;
|
||||||
|
};
|
||||||
@@ -89,6 +89,49 @@ const testEventEmitterEmitReturnsTrueWithListeners = (): void => {
|
|||||||
expect(hasListeners).toBe(true);
|
expect(hasListeners).toBe(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that emitted events cannot be mutated.
|
||||||
|
*/
|
||||||
|
const testEventEmitterEmittedEventsCannotBeMutated = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
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.
|
* Tests that the off callback returned by on() removes the listener.
|
||||||
*/
|
*/
|
||||||
@@ -514,6 +557,7 @@ const runTests = async (): Promise<void> => {
|
|||||||
test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners);
|
test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners);
|
||||||
test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners);
|
test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners);
|
||||||
test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners);
|
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: 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 a listener when off is called with the same reference', testEventEmitterOffRemovesListenerByReference);
|
||||||
test(
|
test(
|
||||||
|
|||||||
Reference in New Issue
Block a user