LearnNewsExamplesServices
Frontmatter
id18879
titleMenu arms read a multi-level close off DOM batching, which a loaded runner splits
stateClosed
labels
bugaitesting
assigneesneo-opus-grace
createdAtSep 18, 2026, 2:57 PM
updatedAtSep 18, 2026, 5:33 PM
githubUrlhttps://github.com/neomjs/neo/issues/18879
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 18, 2026, 5:33 PM

Menu arms read a multi-level close off DOM batching, which a loaded runner splits

neo-opus-grace
neo-opus-grace commented on Sep 18, 2026, 2:57 PM

Context

component/menu/List.spec.mjs › a rest on a root leaf takes a two-level branch down in one step went red once in a full local component run (360/361), on a branch that touches no menu code (PR #18876). Its level recorder saw [3, 2, 1] where the arm expects [3, 1]. Run alone, it passes 10/10. With dev's sources under 6× CPU throttling (CDP Emulation.setCPUThrottlingRate), it goes red 1 in 6 and 1 in 3. Every PR's components job runs this spec.

The Problem

recordLevelCounts() (List.spec.mjs:83, from PR #17852) records the menu count after every DOM mutation batch. Four arms assert an exact sequence for a close that takes more than one level down:

Arm Close Expects
:161 a leaf click takes every level down together… click [3, 0]
:779 a rest on a root leaf takes a two-level branch down in one step rest [3, 1]
:796 a click on a leaf inside a hover preview closes every level… click [2, 0]
:808 Enter on a leaf two levels down closes every level together… Enter [3, 0]

The engine settles such a close in one App-worker task. It never promised one DOM batch: every level sends its own removeNode. When a busy main thread applies those messages across frames, a correct close records an intermediate count. That count is the regression's signature ([3, 2, 0] in #17850), so the recorder cannot tell the two apart.

Observed red: :779 only. The other three share the assumption by reading, and none went red in three throttled runs of the file.

The Architectural Reality

  • src/component/Base.mjs unmount() sets me.mounted = false synchronously, then Neo.applyDeltas(me.windowId, {action: 'removeNode', id: me.vdom.id}) — one message per level.
  • src/menu/List.mjs unmount() calls hideSubMenu(false) before super.unmount(), so a level takes its submenu down first, in the same task.
  • The regressions these sequences guard:
    • #17850: a leaf click left the ancestors to a later path.
    • PR #18869's row-4 mutation: menu.List#unmount without hideSubMenu(), after which level 3 outlives its parent.

The Fix

Assert what the engine guarantees, per close:

  • Gesture-driven closes (:161, :796, :808): after the gesture, one round trip to the App worker reads mounted for every level, and every level reads false. The App worker handles the gesture before the round trip, so an ancestor left to a later task (#17850) reads true, however the DOM batches. The DOM end state stays asserted.
  • The rest-driven close (:779): its timer task has no round trip to align with. Record each level's presence per batch, and assert that no batch holds level 3 without level 2 (the row-4 orphan), then the end state.
  • recordLevelCounts() stays for the arms whose steps move one level at a time (:173, :202, :844).

Verify while implementing: the leaf click and Enter reach the cascade with no async hop before the round trip. If one exists, that arm waits for the DOM end state first and then reads mounted.

Acceptance Criteria

  • The four arms pass 10/10 under 6× CPU throttling (--repeat-each=10), and menu/List.spec.mjs passes unthrottled.
  • Each rewritten arm stays red on its regression:
    • the gesture arms, with #17850's order restored in onKeyDownEnter (unmount() before menuFocus = false);
    • :779, with hideSubMenu(false) dropped from menu.List#unmount.
  • No arm asserts a DOM batch shape the engine does not promise.

Out of Scope

  • :503 a rest pending from arrowing onto a parent does not reopen what Right and Left closed also goes red at 6× throttling, 2 in 3: after Left, two levels stay standing. That is a key/rest race on onFocusMove, the seam #18870 reworks, and it is diagnosed there.
  • Batching multi-level unmounts in the engine.

Related

PR #17852 (the recorder) · PR #18869 (:779) · #17850 · #18870 · PR #18876 (the run that surfaced it)

Decision Record impact: none.

Sweeps: latest 20 open issues at 12:44Z plus a recheck at 12:55Z, and all-state searches for levelCounts and menu flakes: only the closed #18848, whose arm this is. A2A, latest 30: no claim. Memory Core on the recorder's nouns: nothing. Own assignments: #18870 shares the spec, not this scope.

Origin Session ID: 0a997d38-ccdb-4352-ac21-566606a6ab2d

Retrieval Hint: "menu List.spec recordLevelCounts DOM batch multi-level unmount round trip mounted"