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
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
Context
Introduced by my #18780 / PR #18783 (focus rests like a pointer rests), merged today. Surfaced as a
componentsred on PR #18784 at856e667dae: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: 2after 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, focusOpen,ArrowDown(onto the parentInspect),ArrowRight(2 menus),ArrowLeft(1 menu), then wait 1300 ms → 2 menus. The submenu the user just closed withArrowLeftcomes back as a preview.The Problem
ArrowDownonto a parent is a move between two items of one menu, somenu.List#onFocusMovecallsstartRest()and a timer is pending forInspect.ArrowRightthen shows and enters the submenu throughshowSubMenu(nodeId, record), andArrowLeftin the submenu leaves it throughleaveSubMenu()→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:
mouseleaveis bound tocancelRest, 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 whyonFocusMovenever 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()callscancelRest()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()callshideSubMenu()itself whenever the submenu is not already active, and a submenu is only ever left after being entered throughshowSubMenu(). 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, whereshowSubMenu()takes its no-hide branch.Acceptance Criteria
ArrowRightthenArrowLeftleaves one menu after the rest delay has passed. Red-first ondev(the probe above), deterministic via a longsubMenuHoverDelay.mouseleavecancellation and the diagonal-path arm are unaffected.Out of Scope
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