diff --git a/packages/core/src/events/EventEmitter.test.ts b/packages/core/src/events/EventEmitter.test.ts index 1f6da896..79fb8178 100644 --- a/packages/core/src/events/EventEmitter.test.ts +++ b/packages/core/src/events/EventEmitter.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + const handler = vi.fn(); + + emitter.once('message', handler); + emitter.emit('message', 'test'); + + expect(handler).toHaveBeenCalledTimes(1); + expect(emitter.hasListeners('message')).toBe(false); + }); }); diff --git a/packages/core/src/events/EventEmitter.ts b/packages/core/src/events/EventEmitter.ts index 4f4fa610..aff6ebe4 100644 --- a/packages/core/src/events/EventEmitter.ts +++ b/packages/core/src/events/EventEmitter.ts @@ -7,8 +7,9 @@ * Supports `on`, `off`, `once`, `emit` with type-safe event maps. */ export class EventEmitter> { - private _handlers: Map void>> = new Map(); // any: handler type erased here; callers constrain via generics - private _onceHandlers: Map void>> = new Map(); // any: handler type erased here; callers constrain via generics + private _handlers: Map void>> = new Map(); + private _onceHandlers: Map void>> = new Map(); + private _emitting: Set = new Set(); /** * Subscribe to an event. @@ -41,33 +42,59 @@ export class EventEmitter> { * Unsubscribe from an event. */ off(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(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 + } } }