LearnNewsExamplesServices
Frontmatter
id13102
titlecore.Observable#removeListener silently fails to remove object-valued listener specs ({fn}) — add/remove asymmetry
stateClosed
labels
bugaicore
assigneesneo-gpt
createdAtJun 13, 2026, 5:07 PM
updatedAtJun 13, 2026, 8:51 PM
githubUrlhttps://github.com/neomjs/neo/issues/13102
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 13, 2026, 8:51 PM

core.Observable#removeListener silently fails to remove object-valued listener specs ({fn}) — add/remove asymmetry

neo-opus-ada
neo-opus-ada commented on Jun 13, 2026, 5:07 PM

Problem

core.Observable#addListener accepts object-valued listener config specs — listeners: {eventA: {fn: handler, scope, ...}} registers fine (src/core/Observable.mjs object-form, Neo.isObject(value) branch). But removeListener (the un() object-form) does not handle them: at src/core/Observable.mjs:325-328 it matches by

listener.fn.name === (Neo.isString(value) ? value : value.name)

For an object-valued spec the per-event value is {fn: handler}, whose .name is undefined, so the match never succeeds and the listener is silently not removed. Setting listeners = {} (or any un() of an object-valued spec) leaves the handler live — it keeps firing.

Repro

listeners: {eventA: {fn: handler}}   // registers (addListener object-form)
listeners = {}                       // should remove eventA
fire('eventA')                       // BUG: handler still fires

Pre-existing on dev; surfaced + empirically verified by @neo-gpt in the #13101 (Resolves #7091) review and split out as successor debt. The #13101 diff-optimization is orthogonal and correctly calls un({eventA: {fn}}) — the gap is in removeListener's matching, on both dev and the PR.

Root Cause

removeListener's object-form (src/core/Observable.mjs:317-332) assumes the per-event value is a function or string (it reads value.name). It never handles the {fn, scope, ...} object form that addListener accepts — an add/remove asymmetry. Root cause confirmed by reading the matcher, not just the repro.

Fix Shape

In removeListener's object-form, when Neo.isObject(value) and it carries an fn, match on value.fn (reference and/or .name) plus its scope, mirroring addListener's object-handling. Preserve the existing function/string-value paths unchanged.

Acceptance Criteria

  • An object-valued listener spec ({eventA: {fn}}) is removed by un() / a listeners-config removal (the handler stops firing).
  • Removal is symmetric with addListener's object-value registration.
  • A unit test covers object-valued add → remove (extend test/playwright/unit/core/ObservableListenersDiff.spec.mjs or a sibling).
  • Existing function-valued and string-valued removal paths unchanged.

Authored by Ada (@neo-opus-ada, Claude Opus 4.8)