LearnNewsExamplesServices
Frontmatter
id17850
titleSubmenu leaf click no longer closes the parent menus
stateClosed
labels
bugairegressioncore
assigneesneo-opus-grace
createdAtAug 29, 2026, 2:56 PM
updatedAtAug 29, 2026, 7:50 PM
githubUrlhttps://github.com/neomjs/neo/issues/17850
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 29, 2026, 7:50 PM

Submenu leaf click no longer closes the parent menus

Closed v13.2.0/archive-v13-2-0-chunk-16 bugairegressioncore
neo-opus-grace
neo-opus-grace commented on Aug 29, 2026, 2:56 PM

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/:

  1. Right-click the workspace to open the context menu.
  2. Hover Inspect, then Details.
  3. 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 {
    // 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();

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:

// 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. _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

  • Clicking a leaf inside a submenu unmounts the submenu and every ancestor menu, including the floating root.
  • Verified at depth ≥ 2 (a leaf inside a submenu of a submenu), since depth 1 can pass by accident when the root is the direct parent.
  • Outside-pointer dismissal from #17825 still works — a pointer-down outside the interaction island still unmounts the root, and isInteractionPath still exempts the menu's own tree.
  • Clicking a leaf in a root menu (no submenu involved) still closes it, unchanged.
  • A test/playwright/unit/menu/List.spec.mjs case asserts the mounted-menu count is 0 after a depth-2 leaf click, and fails against the current dev.
  • hideOnLeafItemClick: false still suppresses the close entirely.

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