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
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"
Context
Review-derived (PR #14920 cycle-2/3, cross-family probe by @neo-gpt → root-cause during discharge):
grid.Container.afterSetStoreandgrid.VerticalScrollbar.afterSetStoreboth leaked old-store listeners on store replacement. The defect is NOT local to grid — it is thecore.ObservableAPI boundary.The Problem
Both
addListenerandremoveListenerMUTATE the caller's object-form argument: theydelete name.scope(andaddListeneradditionallydeletesdelay/once/order) while parsing. The natural symmetric idiomlet listeners = {filter: me.onStoreFilter, load: me.onStoreLoad, scope: me}; value ?.on(listeners); oldValue?.un(listeners);therefore breaks silently:
on()consumesscopefrom the shared object, soun()matches withscope === undefinedagainst listeners stored withscope === me— nothing unbinds, the old subject keeps driving the consumer (empirically: a replaced grid store still resized the live scrollbar;grid.Containercarried 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.addListenerandObservable.removeListenerbefore anydelete. Behavior is otherwise identical — same parsing, same stored listener shapes, same matching — only the caller's object survives.test/playwright/unit/core/canonical placement): (a)on(obj)leavesobjdeep-equal to its input (incl.scope/once/delay/orderkeys); (b)un(obj)likewise; (c) the regression that matters — the SHARED-objecton/unround-trip above actually unbinds (fire after un → handler not called).core/component/grid/stateunit 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+removeListenerno longer mutate object-form arguments (shallow copy at the boundary)core+component+grid+stateunit trees green locally; CI greenOut 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 issuesfor "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"