LearnNewsExamplesServices
Frontmatter
id18795
titlemenu.List: a pending focus rest reopens a submenu Right and Left closed
stateClosed
labels
bugairegressionaccessibility
assigneesneo-opus-ada
createdAtSep 16, 2026, 2:49 PM
updatedAtSep 16, 2026, 3:41 PM
githubUrlhttps://github.com/neomjs/neo/issues/18795
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 16, 2026, 3:37 PM

menu.List: a pending focus rest reopens a submenu Right and Left closed

Closed v13.2.0/archive-v13-2-0-chunk-21 bugairegressionaccessibility
neo-opus-ada
neo-opus-ada commented on Sep 16, 2026, 2:49 PM

Context

Introduced by my #18780 / PR #18783 (focus rests like a pointer rests), merged today. Surfaced as a components red on PR #18784 at 856e667dae: menu/List.spec.mjs › Right and Left walk two levels down and back up, with focus on the expected item at every step — Expected: 1, Received: 2 after 5.7 s, i.e. a submenu that re-opened and stayed open through the whole poll.

Reproduced deterministically on dev (375c3c5ba8) with a probe arm: subMenuHoverDelay: 1000, focus Open, ArrowDown (onto the parent Inspect), ArrowRight (2 menus), ArrowLeft (1 menu), then wait 1300 ms → 2 menus. The submenu the user just closed with ArrowLeft comes back as a preview.

The Problem

ArrowDown onto a parent is a move between two items of one menu, so menu.List#onFocusMove calls startRest() and a timer is pending for Inspect. ArrowRight then shows and enters the submenu through showSubMenu(nodeId, record), and ArrowLeft in the submenu leaves it through leaveSubMenu() → parentMenu.hideSubMenu(). Neither cancels the root's pending rest. When it fires, onItemRest(Inspect) finds no active submenu and previews it again.

A pointer rest never had this shape: mouseleave is bound to cancelRest, so a rest ends when the pointer leaves its row. A focus rest has no leave event — focus moving into the submenu is not a move within the root menu, which is exactly why onFocusMove never sees it — so nothing ends the rest except its own timer.

The arm is intermittent rather than always red only because the whole Down → Right → Right → Left → Left sequence usually outlasts the 200 ms default, and a rest that fires while its own submenu is still open is a no-op.

The Fix

showSubMenu() calls cancelRest() first. Every way into a submenu — ArrowRight, Enter, a click, and a rest's own preview (whose timer id is already cleared by then) — goes through it, so a rest cannot outlive the submenu being entered or re-entered.

Measured, not assumed: the first draft cancelled in hideSubMenu() as well. Mutation showed that cancel is redundant beside the show-side one and insufficient without it: alone it keeps AC-1 green and leaves AC-1b red (@neo-opus-vega, falsified in the #18796 review). showSubMenu() calls hideSubMenu() itself whenever the submenu is not already active, and a submenu is only ever left after being entered through showSubMenu(). Only the show-side cancel is load-bearing, and one case needs it that a hide-side cancel cannot reach: Right into a submenu a focus rest has already previewed, where showSubMenu() takes its no-hide branch.

Acceptance Criteria

  • AC-1 — with a pending focus rest on a parent, ArrowRight then ArrowLeft leaves one menu after the rest delay has passed. Red-first on dev (the probe above), deterministic via a long subMenuHoverDelay.
  • AC-1b — Right into a submenu a focus rest already previewed wins over a pointer rest still pending on another parent: the entered submenu is still the one showing after the delay.
  • AC-2 — a focus rest that nothing interrupts still previews its submenu (#18780's arms stay green).
  • AC-3 — pointer-rest arms stay green: mouseleave cancellation and the diagonal-path arm are unaffected.
  • AC-4 — the keyboard cascade arm that reddened #18784 survives repeated runs (×15 on the describe).

Out of Scope

  • The rest delay's value and the focus-rest behaviour itself — #18780's decisions stand.

Related

Live latest-open sweep: checked the latest 15 open issues at 2026-09-16T12:48:59Z; no equivalent. A2A sweep: no claim on menu rest timers. Memory Core: my own #18780 lane, no prior decision on rest cancellation. Structure-map gate: N/A.

Decision Record impact: none.

Origin Session ID: 0f5a0db0-9f3a-402c-bb4a-aa369ff1efa3

tobiu referenced in commit 5d375d9 - "fix(menu): showing a submenu ends a rest still pending on its level (#18795) (#18796) on Sep 16, 2026, 3:37 PM
tobiu closed this issue on Sep 16, 2026, 3:37 PM