Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/core/src/events/EventEmitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,79 @@ describe('EventEmitter', () => {
expect(onceHandler).toHaveBeenCalled();
expect(regularHandler).toHaveBeenCalled();
});

it('re-entrant emit from regular handler does not fire once handlers early', () => {
const emitter = new EventEmitter<TestEvents>();
const log: string[] = [];

const onceHandler = vi.fn(() => { log.push('once'); });
const reentrant = vi.fn(() => {
log.push('reenter');
emitter.emit('message', 'inner');
});

emitter.on('message', reentrant);
emitter.once('message', onceHandler);

emitter.emit('message', 'outer');

expect(log).toEqual(['reenter', 'once']);
expect(onceHandler).toHaveBeenCalledTimes(1);
});

it('once handler registered during emit does not fire in current emit', () => {
const emitter = new EventEmitter<TestEvents>();
const log: string[] = [];

const innerOnce = vi.fn(() => { log.push('inner-once'); });
const outerHandler = vi.fn(() => {
log.push('outer');
emitter.once('message', innerOnce);
});

emitter.once('message', outerHandler);
emitter.emit('message', 'first');

expect(log).toEqual(['outer']);
expect(innerOnce).not.toHaveBeenCalled();

emitter.emit('message', 'second');
expect(innerOnce).toHaveBeenCalledTimes(1);
});

it('off removes empty Map entries for regular handlers', () => {
const emitter = new EventEmitter<TestEvents>();
const handler1 = vi.fn();
const handler2 = vi.fn();

emitter.on('message', handler1);
emitter.on('message', handler2);
emitter.off('message', handler1);
emitter.off('message', handler2);

expect(emitter.hasListeners('message')).toBe(false);
expect(emitter['_handlers'].has('message' as any)).toBe(false);
});

it('off removes empty Map entries for once handlers', () => {
const emitter = new EventEmitter<TestEvents>();
const handler = vi.fn();

emitter.once('message', handler);
emitter.off('message', handler);

expect(emitter.hasListeners('message')).toBe(false);
expect(emitter['_onceHandlers'].has('message' as any)).toBe(false);
});

it('emit clears OnceHandler Map entry so hasListeners returns false', () => {
const emitter = new EventEmitter<TestEvents>();
const handler = vi.fn();

emitter.once('message', handler);
emitter.emit('message', 'test');

expect(handler).toHaveBeenCalledTimes(1);
expect(emitter.hasListeners('message')).toBe(false);
});
});
63 changes: 45 additions & 18 deletions packages/core/src/events/EventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
* Supports `on`, `off`, `once`, `emit` with type-safe event maps.
*/
export class EventEmitter<TEventMap extends Record<string, any>> {
private _handlers: Map<keyof TEventMap, Set<(data: any) => void>> = new Map(); // any: handler type erased here; callers constrain via generics
private _onceHandlers: Map<keyof TEventMap, Set<(data: any) => void>> = new Map(); // any: handler type erased here; callers constrain via generics
private _handlers: Map<keyof TEventMap, Set<(data: any) => void>> = new Map();
private _onceHandlers: Map<keyof TEventMap, Set<(data: any) => void>> = new Map();
private _emitting: Set<keyof TEventMap> = new Set();

/**
* Subscribe to an event.
Expand Down Expand Up @@ -41,33 +42,59 @@ export class EventEmitter<TEventMap extends Record<string, any>> {
* Unsubscribe from an event.
*/
off<K extends keyof TEventMap>(event: K, handler: (data: TEventMap[K]) => void): void {
this._handlers.get(event)?.delete(handler);
this._onceHandlers.get(event)?.delete(handler);
const reg = this._handlers.get(event);
if (reg) {
reg.delete(handler);
if (reg.size === 0) {
this._handlers.delete(event);
}
}

const once = this._onceHandlers.get(event);
if (once) {
once.delete(handler);
if (once.size === 0) {
this._onceHandlers.delete(event);
}
}
}

/**
* Emit an event to all subscribed handlers.
*
* Once handlers are removed from storage _before_ any handler executes
* so that re-entrant `emit()` calls on the same event cannot re-fire them.
*/
emit<K extends keyof TEventMap>(event: K, data: TEventMap[K]): void {
// Regular handlers
const handlers = this._handlers.get(event);
if (handlers) {
for (const handler of handlers) {
try { handler(data); } catch (_err) {
// handler errors are silently ignored to prevent crash during rendering
}
// Snap-shot and remove once handlers before firing anything
const onceSet = this._onceHandlers.get(event);
const onceSnapshot: ((data: any) => void)[] = [];
if (onceSet) {
for (const handler of onceSet) {
onceSnapshot.push(handler);
}
this._onceHandlers.delete(event);
}

// Once handlers — fire and remove
const onceHandlers = this._onceHandlers.get(event);
if (onceHandlers) {
for (const handler of onceHandlers) {
try { handler(data); } catch (_err) {
// handler errors are silently ignored to prevent crash during rendering
// Regular handlers — skip if re-entrant for the same event
if (!this._emitting.has(event)) {
this._emitting.add(event);
const handlers = this._handlers.get(event);
if (handlers) {
for (const handler of handlers) {
try { handler(data); } catch (_err) {
// handler errors are silently ignored to prevent crash during rendering
}
}
}
onceHandlers.clear();
this._emitting.delete(event);
}

// Once handlers — fire removed handlers
for (const handler of onceSnapshot) {
try { handler(data); } catch (_err) {
// handler errors are silently ignored to prevent crash during rendering
}
}
}

Expand Down
Loading