Context
Clicking a leaf item inside a submenu runs its handler and unmounts that submenu, but every menu above it stays mounted. The user is left with an orphaned menu hanging over the app after the action they chose has already run.
Reported from live use, then reproduced and root-caused. This is a regression: the behaviour changed in 99fa693845 — feat(menu): complete Menu and Picker outside dismissal (#17825) (#17828), merged 2026-08-28.
Live latest-open sweep: checked the latest 20 open issues at 2026-08-29T12:55:08Z; no equivalent found. A2A in-flight claim sweep over the 30 most recent messages (all read-states): active claims are DevIndex #11, Engine #17820, Skills PR #17, Brain #233, Institution #48 — none on menu focus or dismissal.
The Problem
Reproducer, entirely in-repo — examples/menu/context/:
- Right-click the workspace to open the context menu.
- Hover Inspect, then Details.
- Click the Copy name leaf.
Expected: the action runs and the whole menu tree closes.
Observed: the action runs, the Details submenu unmounts, and Inspect's menu plus the root menu stay open.
Measured on a consumer app carrying the same engine version — after clicking a leaf two levels down, one menu remained mounted with its items still in the DOM, while the leaf's handler had already executed.
The Architectural Reality
Three pieces, and the interaction between them is the defect.
1. The close-the-whole-tree path is menuFocus bubbling. src/menu/List.mjs#afterSetMenuFocus:
if (me.isRoot) {
if (!value) {
me[me.floating ? 'unmount' : 'hideSubMenu']()
}
} else {
me.parentMenu.menuFocus = value
}A non-root menu losing focus sets its parent's menuFocus, which recurses until the root unmounts. That is the only mechanism that closes ancestors.
2. A leaf click unmounts only its own menu. src/menu/List.mjs#onKeyDownEnter:
me.hideOnLeafItemClick && !record.items && me.unmount();
me is the submenu holding the leaf. Nothing here touches the parents — by design, because the bubble above was supposed to do it.
3. unmount() now writes menuFocus silently, which breaks the bubble. #17825 added one line:
unmount() { unmount() {
this.selectionModel?... this._menuFocus = false;
this.hideSubMenu(); this.selectionModel?...
super.unmount() this.hideSubMenu();
} super.unmount()
}The underscore is the whole defect. _menuFocus bypasses the config setter, so afterSetMenuFocus never runs and the parent is never told.
It also poisons the later write. When focus subsequently leaves, onFocusLeave sets this.menuFocus = false — but the silent write already made it false, the setter sees no change, and the hook stays silent. So the recovery path that would otherwise have closed the tree a moment later is disarmed too.
Verified by reading both revisions: git show 99fa693845^:src/menu/List.mjs has no _menuFocus in unmount(); git show 99fa693845:src/menu/List.mjs has it.
The Fix
The silent write is the right instinct in the wrong place: unmount() should not re-enter the bubble it is being called from, but it must not swallow the case where nothing has bubbled yet.
Two candidate shapes, and the choice belongs to whoever owns #17825:
- A — propagate before unmounting. In
onKeyDownEnter, set me.menuFocus = false through the setter before me.unmount(), so the bubble runs while the chain is intact. Leaves unmount()'s silent write in place for the paths that legitimately need it (outside-pointer dismissal already unmounts the root directly).
- B — make the silent write conditional. Keep
unmount() silent only when the caller is already the bubble (afterSetMenuFocus → unmount), and let other callers go through the setter. Needs a guard flag, so A is likely simpler.
Whichever is chosen, the regression test is the thing that matters: a leaf click at depth ≥ 2 must leave zero mounted menus.
Acceptance Criteria
Out of Scope
- The separator and
menu.Model field gaps — those are their own ticket.
- Any change to
isInteractionPath or the outside-pointer listener beyond not regressing them.
- Keyboard dismissal semantics (Escape), which are not part of this path.
Avoided Traps
Do not fix this by unmounting the parent chain directly from onKeyDownEnter. Walking parentMenu and unmounting each one would produce the right visible result and bypass menuFocus entirely, leaving two mechanisms that both close menus and can disagree. The bubble is the design; the silent write is what disabled it.
Related
99fa693845 — feat(menu): complete Menu and Picker outside dismissal (#17825) (#17828), the commit that introduced the silent write
- #17825 — the dismissal work this regresses out of
- #17839 / #17840 — adjacent
menu/* and data.TreeStore work; no overlap with this focus path
Origin Session ID: 35fdecfd-9cae-44be-a727-de1bac27397f
Retrieval Hint: query_raw_memories("menu List unmount silent _menuFocus parent menu stays open submenu leaf click") · commit anchor 99fa693845 · symbols afterSetMenuFocus, onKeyDownEnter, hideOnLeafItemClick
Context
Clicking a leaf item inside a submenu runs its handler and unmounts that submenu, but every menu above it stays mounted. The user is left with an orphaned menu hanging over the app after the action they chose has already run.
Reported from live use, then reproduced and root-caused. This is a regression: the behaviour changed in
99fa693845—feat(menu): complete Menu and Picker outside dismissal (#17825) (#17828), merged 2026-08-28.Live latest-open sweep: checked the latest 20 open issues at 2026-08-29T12:55:08Z; no equivalent found. A2A in-flight claim sweep over the 30 most recent messages (all read-states): active claims are DevIndex #11, Engine #17820, Skills PR #17, Brain #233, Institution #48 — none on menu focus or dismissal.
The Problem
Reproducer, entirely in-repo —
examples/menu/context/:Expected: the action runs and the whole menu tree closes. Observed: the action runs, the
Detailssubmenu unmounts, andInspect's menu plus the root menu stay open.Measured on a consumer app carrying the same engine version — after clicking a leaf two levels down, one menu remained mounted with its items still in the DOM, while the leaf's handler had already executed.
The Architectural Reality
Three pieces, and the interaction between them is the defect.
1. The close-the-whole-tree path is
menuFocusbubbling.src/menu/List.mjs#afterSetMenuFocus:if (me.isRoot) { if (!value) { me[me.floating ? 'unmount' : 'hideSubMenu']() } } else { // bubble the focus change upwards me.parentMenu.menuFocus = value }A non-root menu losing focus sets its parent's
menuFocus, which recurses until the root unmounts. That is the only mechanism that closes ancestors.2. A leaf click unmounts only its own menu.
src/menu/List.mjs#onKeyDownEnter:me.hideOnLeafItemClick && !record.items && me.unmount();meis the submenu holding the leaf. Nothing here touches the parents — by design, because the bubble above was supposed to do it.3.
unmount()now writesmenuFocussilently, which breaks the bubble.#17825added one line:// before // after unmount() { unmount() { this.selectionModel?... this._menuFocus = false; // <- added this.hideSubMenu(); this.selectionModel?... super.unmount() this.hideSubMenu(); } super.unmount() }The underscore is the whole defect.
_menuFocusbypasses the config setter, soafterSetMenuFocusnever runs and the parent is never told.It also poisons the later write. When focus subsequently leaves,
onFocusLeavesetsthis.menuFocus = false— but the silent write already made itfalse, the setter sees no change, and the hook stays silent. So the recovery path that would otherwise have closed the tree a moment later is disarmed too.Verified by reading both revisions:
git show 99fa693845^:src/menu/List.mjshas no_menuFocusinunmount();git show 99fa693845:src/menu/List.mjshas it.The Fix
The silent write is the right instinct in the wrong place:
unmount()should not re-enter the bubble it is being called from, but it must not swallow the case where nothing has bubbled yet.Two candidate shapes, and the choice belongs to whoever owns
#17825:onKeyDownEnter, setme.menuFocus = falsethrough the setter beforeme.unmount(), so the bubble runs while the chain is intact. Leavesunmount()'s silent write in place for the paths that legitimately need it (outside-pointer dismissal already unmounts the root directly).unmount()silent only when the caller is already the bubble (afterSetMenuFocus→unmount), and let other callers go through the setter. Needs a guard flag, so A is likely simpler.Whichever is chosen, the regression test is the thing that matters: a leaf click at depth ≥ 2 must leave zero mounted menus.
Acceptance Criteria
#17825still works — a pointer-down outside the interaction island still unmounts the root, andisInteractionPathstill exempts the menu's own tree.test/playwright/unit/menu/List.spec.mjscase asserts the mounted-menu count is 0 after a depth-2 leaf click, and fails against the currentdev.hideOnLeafItemClick: falsestill suppresses the close entirely.Out of Scope
menu.Modelfield gaps — those are their own ticket.isInteractionPathor the outside-pointer listener beyond not regressing them.Avoided Traps
Do not fix this by unmounting the parent chain directly from
onKeyDownEnter. WalkingparentMenuand unmounting each one would produce the right visible result and bypassmenuFocusentirely, leaving two mechanisms that both close menus and can disagree. The bubble is the design; the silent write is what disabled it.Related
99fa693845—feat(menu): complete Menu and Picker outside dismissal (#17825) (#17828), the commit that introduced the silent writemenu/*anddata.TreeStorework; no overlap with this focus pathOrigin Session ID: 35fdecfd-9cae-44be-a727-de1bac27397f
Retrieval Hint:
query_raw_memories("menu List unmount silent _menuFocus parent menu stays open submenu leaf click")· commit anchor99fa693845· symbolsafterSetMenuFocus,onKeyDownEnter,hideOnLeafItemClick