Skip to content

Snapshot the listeners emit() walks - #2372

Open
enyo wants to merge 1 commit into
mainfrom
emit-snapshot
Open

Snapshot the listeners emit() walks#2372
enyo wants to merge 1 commit into
mainfrom
emit-snapshot

Conversation

@enyo

@enyo enyo commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Fixes #2367 (the emitter part — see the note at the bottom about the bonus findings).

The bug

emit iterates the live array:

for (let callback of callbacks) {
  callback.apply(this, args);
}

and off splices in place:

callbacks.splice(i, 1);

for…of over an array walks by index, so removing the entry at the current index shifts everything after it down one and the loop's next step lands past the neighbour. With [a, b, c] and a removing itself, b never runs.

A listener that unregisters itself is not exotic — it is how you write a one-shot listener, and it is what teardown code does from inside a complete or queuecomplete handler.

The fix

Iterate a copy, which is what Node's EventEmitter does.

Behaviour change worth flagging

The snapshot cuts both ways: a listener registered from inside another listener used to run in that same emit (the live array grew under the loop) and now first runs on the next one. That is the standard semantics and there is no way to keep the removal case correct without it, but it is a real change, so it is called out in the changeset and covered by a test that pins the new behaviour.

Tests

Three, in test/unit-tests/emitter.js. Against main:

  • a self-removing listener: expected [ 'first', 'third' ] to deeply equal [ 'first', 'second', 'third' ] — the skip, exactly as reported
  • a listener added mid-emit: expected [ 'first', 'added' ] to deeply equal [ 'first' ] — the behaviour change above
  • off(event) removing everything mid-emit already passed (it deletes the key rather than mutating the array); it is there as a guard.

The bonus findings in #2367

Both are real — destroy()'s splice(indexOf(this), 1) evicting a live instance on -1, and init() comparing tagName to lower-case "form". They have nothing to do with the emitter, so they are not in this PR. I have them prepared on a branch; the enctype one is a dormant behaviour change (a natively-submitted form would start encoding as multipart/form-data), so it seemed worth your call rather than mine.

`emit` iterated `this._callbacks[event]` directly, so a listener that
removed itself with `off` -- how a one-shot listener is usually written,
and what teardown code does -- spliced the array while the loop was
walking it. Everything after the removed entry shifted down past the
loop's index, and the next listener was silently skipped.

Iterate over a copy instead, the way EventEmitter does. This also means
a listener registered from inside another listener first runs on the
next emit rather than the current one.

Fixes #2367

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 79.42% 880 / 1108
🔵 Statements 79.77% 927 / 1162
🔵 Functions 92.89% 196 / 211
🔵 Branches 76.46% 510 / 667
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/dropzone/src/emitter.ts 100% 100% 100% 100%
Generated in workflow #141 for commit a1a67df by the Vitest Coverage Report Action

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

In-Flight off() Mutates Callback Array During emit(), Silently Skipping Event Handlers

1 participant