From b741dc080516429a126b41cdfd7f67122591b339 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Sun, 21 Jun 2026 13:05:06 +0530 Subject: [PATCH 1/3] fix(core): properly remove once handlers from Map after emission The emit() method only cleared the once-handler Set but left the Map entry in place. Use delete() to remove the Map entry entirely, and fire handlers from the disconnected Set reference to prevent re-entrancy issues when a once handler triggers another emit for the same event. --- packages/core/src/events/EventEmitter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/events/EventEmitter.ts b/packages/core/src/events/EventEmitter.ts index 4f4fa610..7928ea32 100644 --- a/packages/core/src/events/EventEmitter.ts +++ b/packages/core/src/events/EventEmitter.ts @@ -62,12 +62,12 @@ export class EventEmitter> { // Once handlers — fire and remove const onceHandlers = this._onceHandlers.get(event); if (onceHandlers) { + this._onceHandlers.delete(event); for (const handler of onceHandlers) { try { handler(data); } catch (_err) { // handler errors are silently ignored to prevent crash during rendering } } - onceHandlers.clear(); } } From 96b944fd83bd0ef8cca3185b82b98c054437f1a9 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Sun, 21 Jun 2026 13:07:01 +0530 Subject: [PATCH 2/3] fix(core): prevent re-entrant emit from firing once handlers early Three changes to EventEmitter: 1. emit() now snapshots and removes once handlers from storage _before_ executing any handler. This prevents re-entrant emit() calls from the same event from firing once handlers prematurely. 2. off() removes empty Set entries from the internal Maps so that hasListeners() and memory usage stay clean after all handlers for an event are removed. 3. Added tests for re-entrancy, late-registration, and Map cleanup to lock in the new behavior. Closes #1709 --- packages/core/src/events/EventEmitter.test.ts | 75 +++++++++++++++++++ packages/core/src/events/EventEmitter.ts | 46 +++++++++--- 2 files changed, 109 insertions(+), 12 deletions(-) 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 7928ea32..0499c6b3 100644 --- a/packages/core/src/events/EventEmitter.ts +++ b/packages/core/src/events/EventEmitter.ts @@ -7,8 +7,8 @@ * 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(); /** * Subscribe to an event. @@ -41,14 +41,40 @@ 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 { + // 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); + } + // Regular handlers const handlers = this._handlers.get(event); if (handlers) { @@ -59,14 +85,10 @@ export class EventEmitter> { } } - // Once handlers — fire and remove - const onceHandlers = this._onceHandlers.get(event); - if (onceHandlers) { - this._onceHandlers.delete(event); - for (const handler of onceHandlers) { - try { handler(data); } catch (_err) { - // handler errors are silently ignored to prevent crash during rendering - } + // 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 } } } From bdad89d6998799411dbc88b5bb8792b1a2f4bcf4 Mon Sep 17 00:00:00 2001 From: ionfwsrijan Date: Sun, 21 Jun 2026 14:15:23 +0530 Subject: [PATCH 3/3] fix: prevent re-entrant emit infinite loop for regular handlers --- packages/core/src/events/EventEmitter.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/core/src/events/EventEmitter.ts b/packages/core/src/events/EventEmitter.ts index 0499c6b3..aff6ebe4 100644 --- a/packages/core/src/events/EventEmitter.ts +++ b/packages/core/src/events/EventEmitter.ts @@ -9,6 +9,7 @@ export class EventEmitter> { private _handlers: Map void>> = new Map(); private _onceHandlers: Map void>> = new Map(); + private _emitting: Set = new Set(); /** * Subscribe to an event. @@ -75,14 +76,18 @@ export class EventEmitter> { this._onceHandlers.delete(event); } - // 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 + // 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 + } } } + this._emitting.delete(event); } // Once handlers — fire removed handlers