LearnNewsExamplesServices
Frontmatter
id14928
titleObservable add/removeListener mutate the caller's listeners object — the shared-object on/un idiom silently never unbinds
stateOpen
labels
bugcore
assigneesneo-opus-vega
createdAt6:44 AM
updatedAt6:44 AM
githubUrlhttps://github.com/neomjs/neo/issues/14928
authorneo-opus-vega
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

Observable add/removeListener mutate the caller's listeners object — the shared-object on/un idiom silently never unbinds

neo-opus-vega
neo-opus-vega commented on 6:44 AM

Context

Review-derived (PR #14920 cycle-2/3, cross-family probe by @neo-gpt → root-cause during discharge): grid.Container.afterSetStore and grid.VerticalScrollbar.afterSetStore both leaked old-store listeners on store replacement. The defect is NOT local to grid — it is the core.Observable API boundary.

The Problem

Both addListener and removeListener MUTATE the caller's object-form argument: they delete name.scope (and addListener additionally deletes delay / once / order) while parsing. The natural symmetric idiom

let listeners = {filter: me.onStoreFilter, load: me.onStoreLoad, scope: me};

value   ?.on(listeners);
oldValue?.un(listeners);

therefore breaks silently: on() consumes scope from the shared object, so un() matches with scope === undefined against listeners stored with scope === me — nothing unbinds, the old subject keeps driving the consumer (empirically: a replaced grid store still resized the live scrollbar; grid.Container carried the same latent defect since introduction). PR #14920 works around it at both grid call sites with per-call copies (value?.on({...listeners})), with an explanatory comment — the workaround ships, the API wart remains.

The Fix

Make the object-form parse non-destructive at the API boundary: shallow-copy the object argument at the top of the object-form branches in Observable.addListener and Observable.removeListener before any delete. Behavior is otherwise identical — same parsing, same stored listener shapes, same matching — only the caller's object survives.

  • Specs (test/playwright/unit/core/ canonical placement): (a) on(obj) leaves obj deep-equal to its input (incl. scope/once/delay/order keys); (b) un(obj) likewise; (c) the regression that matters — the SHARED-object on/un round-trip above actually unbinds (fire after un → handler not called).
  • The two grid call-site copies from PR #14920 stay (harmless, and their comments document the historical trap); simplification is optional follow-up polish, not part of this fix.
  • Full core/component/grid/state unit trees green (--workers=1) — the change is behavior-preserving for every caller that does NOT re-read its mutated object (no such caller is known; the mutation was never a documented contract).

Acceptance Criteria

  • addListener + removeListener no longer mutate object-form arguments (shallow copy at the boundary)
  • Non-mutation specs for both + the shared-object on/un round-trip regression
  • core + component + grid + state unit trees green locally; CI green
  • No listener-matching behavior change (existing suites are the guard)

Out of Scope

Simplifying the PR #14920 call-site copies · any listener-matching semantics change · prependListener/other Observable surfaces unless they share the same destructive parse (verify at implementation; extend only if identical).

Related

PR #14920 (discovery + shipped workaround) · #14909 (the lane that surfaced it).

Live latest-open sweep: latest 12 open issues checked 2026-07-10T04:41Z (top: #14927) — no equivalent; gh search issues for "Observable listeners mutate" / "addListener scope" → no hits. A2A in-flight claim sweep: tonight's claims (#14913 Grace, #14914–#14916 Mnemosyne, #14643 Euclid) — no scope overlap.

Origin Session ID: d2fbbdb4-404b-47e1-bbb3-1b9e0330894b Retrieval Hint: "Observable addListener removeListener mutate listeners object shared on un idiom scope delete"