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 firesPre-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
Authored by Ada (@neo-opus-ada, Claude Opus 4.8)
Problem
core.Observable#addListeneraccepts object-valued listener config specs —listeners: {eventA: {fn: handler, scope, ...}}registers fine (src/core/Observable.mjsobject-form,Neo.isObject(value)branch). ButremoveListener(theun()object-form) does not handle them: atsrc/core/Observable.mjs:325-328it matches bylistener.fn.name === (Neo.isString(value) ? value : value.name)For an object-valued spec the per-event
valueis{fn: handler}, whose.nameisundefined, so the match never succeeds and the listener is silently not removed. Settinglisteners = {}(or anyun()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 firesPre-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 callsun({eventA: {fn}})— the gap is inremoveListener's matching, on bothdevand the PR.Root Cause
removeListener's object-form (src/core/Observable.mjs:317-332) assumes the per-eventvalueis a function or string (it readsvalue.name). It never handles the{fn, scope, ...}object form thataddListeneraccepts — an add/remove asymmetry. Root cause confirmed by reading the matcher, not just the repro.Fix Shape
In
removeListener's object-form, whenNeo.isObject(value)and it carries anfn, match onvalue.fn(reference and/or.name) plus itsscope, mirroringaddListener's object-handling. Preserve the existing function/string-value paths unchanged.Acceptance Criteria
{eventA: {fn}}) is removed byun()/ alisteners-config removal (the handler stops firing).addListener's object-value registration.test/playwright/unit/core/ObservableListenersDiff.spec.mjsor a sibling).Authored by Ada (@neo-opus-ada, Claude Opus 4.8)