Skip to content

fix: issue 1709 eventemitter once#1712

Open
ionfwsrijan wants to merge 3 commits into
Karanjot786:mainfrom
ionfwsrijan:fix/issue-1709-eventemitter-once
Open

fix: issue 1709 eventemitter once#1712
ionfwsrijan wants to merge 3 commits into
Karanjot786:mainfrom
ionfwsrijan:fix/issue-1709-eventemitter-once

Conversation

@ionfwsrijan

@ionfwsrijan ionfwsrijan commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Description

EventEmitter.once() handlers have two issues:

  1. If a regular handler calls emit() re-entrantly for the same event, the inner emit() fires once handlers prematurely before the outer emit's once phase
  2. off() leaves empty Set entries in the internal Maps, causing hasListeners() to return stale results

Changes

EventEmitter.ts

  • emit(): Snapshots and removes once handlers from the Map before any regular handler executes. Re-entrant emit() calls on the same event won't find the once handlers — they fire at the correct time in the outer call.
  • off(): Deletes empty Set entries from both _handlers and _onceHandlers Maps, keeping internal state clean.

EventEmitter.test.ts

  • Added test: re-entrant emit from regular handler does not fire once handlers early
  • Added test: once handler registered during emit does not fire in current emit
  • Added test: off() removes empty Map entries for regular handlers
  • Added test: off() removes empty Map entries for once handlers
  • Added test: emit() clears OnceHandler Map entry so hasListeners() returns false

Testing

All 19 tests pass, including 5 new edge-case tests for re-entrancy, late registration, and Map cleanup.

Closes #1709

Summary by CodeRabbit

  • Bug Fixes
    • Fixed re-entrant event emissions so “once” handlers don’t fire prematurely during an ongoing emit cycle.
    • Prevented newly registered “once” handlers from running in the current emission.
    • Improved listener lifecycle handling: removing handlers now reliably cleans up internal records, and “once” handlers are properly cleared after being emitted.

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.
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 Karanjot786#1709
@ionfwsrijan ionfwsrijan requested a review from Karanjot786 as a code owner June 21, 2026 07:39
@github-actions github-actions Bot added area:core @termuijs/core type:testing +10 pts. Tests. type:bug +10 pts. Bug fix. labels Jun 21, 2026
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

EventEmitter.emit() is updated to snapshot and delete once handlers from internal storage before executing any handler, preventing re-entrant emissions from re-firing them. A new _emitting set guards regular handler execution to block re-entrant re-execution for the same event. off() now explicitly removes per-event map entries when their handler sets become empty for both regular and once handler maps. Three new test cases verify these re-entrancy, late-registration, and cleanup behaviors.

Changes

EventEmitter once/off correctness

Layer / File(s) Summary
Private state, emit() snapshot-and-delete + off() cleanup
packages/core/src/events/EventEmitter.ts
Private fields: inline type-erasure comments removed; new _emitting set tracks in-progress regular handler execution per event. off() explicitly deletes handlers from both _handlers and _onceHandlers and removes the per-event map entry when that set becomes empty. emit() snapshots once handlers into a local array and deletes the map entry before executing any handler; regular handlers execute only if the event is not in _emitting; once handlers iterate the snapshot.
Re-entrancy, late-registration, and cleanup tests
packages/core/src/events/EventEmitter.test.ts
Three test groups: (1) a regular handler re-entering via emit does not cause the once handler to fire early; (2) a once handler registered during an active emission is not called until the next emission; (3) off() and emit() leave no residual map entries, confirmed via hasListeners.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐇 Once is once, not twice or thrice,
The snapshot's clipped, the logic's nice,
Re-entry won't trick our trusty set,
Empty maps—no lingering debt!
A tidy emitter, clean and precise! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately identifies the issue (#1709) and describes the main change in EventEmitter's once handler functionality.
Description check ✅ Passed The description covers all required template sections including problem statement, changes, testing, and linked issue.
Linked Issues check ✅ Passed The PR implements all required fixes: snapshots and removes once handlers before execution to fix re-entrancy [#1709], cleans empty Map entries in off() [#1709], and adds 5 new tests for lifecycle/re-entrancy/cleanup [#1709].
Out of Scope Changes check ✅ Passed All changes directly address the two issues outlined in #1709: re-entrant emit behavior and stale Map entries, with no unrelated modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/events/EventEmitter.ts (1)

35-37: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Unsubscribe closure bypasses off() cleanup logic.

The once() return value directly deletes from the Set without removing empty Map entries, while on() returns () => this.off(event, handler) which triggers cleanup. If users call the unsubscribe function before the event fires, an empty Set remains in _onceHandlers.

For consistency with the cleanup fix in off():

Proposed fix
         return () => {
-            this._onceHandlers.get(event)?.delete(handler);
+            this.off(event, handler);
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/events/EventEmitter.ts` around lines 35 - 37, The
unsubscribe closure in the once() method directly deletes from the _onceHandlers
Set without removing empty Map entries, causing orphaned empty Sets to remain.
To maintain consistency with the cleanup logic in off() and match the pattern
used in on(), replace the direct Set deletion with a call to this.off(event,
handler) instead. This ensures that when a handler is removed, the corresponding
Set is deleted from the Map if it becomes empty.
🧹 Nitpick comments (1)
packages/core/src/events/EventEmitter.test.ts (1)

214-226: 💤 Low value

Remove unnecessary type assertion.

'message' is already a valid keyof TestEvents, so the as any cast is unnecessary. If it's needed to satisfy TypeScript with private property access, add a comment explaining why per coding guidelines.

-        expect(emitter['_handlers'].has('message' as any)).toBe(false);
+        expect(emitter['_handlers'].has('message')).toBe(false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/events/EventEmitter.test.ts` around lines 214 - 226, In the
test 'off removes empty Map entries for regular handlers', remove the
unnecessary type assertion `as any` from the `'message'` key when accessing the
private `_handlers` property with `emitter['_handlers'].has('message' as any)`.
The string 'message' is already a valid keyof TestEvents, so the cast is
redundant. If TypeScript still requires a type assertion to access the private
property, add a comment explaining the necessity per coding guidelines rather
than using an implicit `as any` cast.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/core/src/events/EventEmitter.ts`:
- Around line 35-37: The unsubscribe closure in the once() method directly
deletes from the _onceHandlers Set without removing empty Map entries, causing
orphaned empty Sets to remain. To maintain consistency with the cleanup logic in
off() and match the pattern used in on(), replace the direct Set deletion with a
call to this.off(event, handler) instead. This ensures that when a handler is
removed, the corresponding Set is deleted from the Map if it becomes empty.

---

Nitpick comments:
In `@packages/core/src/events/EventEmitter.test.ts`:
- Around line 214-226: In the test 'off removes empty Map entries for regular
handlers', remove the unnecessary type assertion `as any` from the `'message'`
key when accessing the private `_handlers` property with
`emitter['_handlers'].has('message' as any)`. The string 'message' is already a
valid keyof TestEvents, so the cast is redundant. If TypeScript still requires a
type assertion to access the private property, add a comment explaining the
necessity per coding guidelines rather than using an implicit `as any` cast.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d6907745-acae-4fb5-90b7-f56d51e966f1

📥 Commits

Reviewing files that changed from the base of the PR and between 35c2213 and 96b944f.

📒 Files selected for processing (2)
  • packages/core/src/events/EventEmitter.test.ts
  • packages/core/src/events/EventEmitter.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/events/EventEmitter.ts (1)

36-38: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Unsubscribe function from once() doesn't clean up empty Map entries.

The off() method now properly deletes empty Set entries from _onceHandlers, but the unsubscribe function returned by once() still leaves stale entries. This means hasListeners() will return incorrect results when users unsubscribe via the returned function rather than calling off().

Consider delegating to off() or applying the same cleanup:

Proposed fix
         return () => {
-            this._onceHandlers.get(event)?.delete(handler);
+            const set = this._onceHandlers.get(event);
+            if (set) {
+                set.delete(handler);
+                if (set.size === 0) {
+                    this._onceHandlers.delete(event);
+                }
+            }
         };

Or simply delegate:

         return () => {
-            this._onceHandlers.get(event)?.delete(handler);
+            this.off(event, handler);
         };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/events/EventEmitter.ts` around lines 36 - 38, The
unsubscribe function returned by the once() method removes the handler from the
_onceHandlers Set but does not clean up the Map entry when the Set becomes
empty, leaving stale entries that cause hasListeners() to return incorrect
results. Fix this by either delegating to the existing off() method (passing the
event and handler) instead of directly deleting from the Set, or by adding the
same cleanup logic that off() uses to check if the Set is empty and delete the
Map entry if so.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/core/src/events/EventEmitter.ts`:
- Around line 36-38: The unsubscribe function returned by the once() method
removes the handler from the _onceHandlers Set but does not clean up the Map
entry when the Set becomes empty, leaving stale entries that cause
hasListeners() to return incorrect results. Fix this by either delegating to the
existing off() method (passing the event and handler) instead of directly
deleting from the Set, or by adding the same cleanup logic that off() uses to
check if the Set is empty and delete the Map entry if so.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a99c723-d6a8-4218-a730-62afbc38630e

📥 Commits

Reviewing files that changed from the base of the PR and between 96b944f and bdad89d.

📒 Files selected for processing (1)
  • packages/core/src/events/EventEmitter.ts

@ionfwsrijan

Copy link
Copy Markdown
Contributor Author

@Karanjot786 Please review this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core @termuijs/core type:bug +10 pts. Bug fix. type:testing +10 pts. Tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] EventEmitter once() Listeners Not Auto-Removed After Emission — Confirmed Memory Leak

1 participant