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
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"
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. Withdev's sources under 6× CPU throttling (CDPEmulation.setCPUThrottlingRate), it goes red 1 in 6 and 1 in 3. Every PR'scomponentsjob 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::161a leaf click takes every level down together…[3, 0]:779a rest on a root leaf takes a two-level branch down in one step[3, 1]:796a click on a leaf inside a hover preview closes every level…[2, 0]:808Enter on a leaf two levels down closes every level together…[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:
:779only. 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.mjsunmount()setsme.mounted = falsesynchronously, thenNeo.applyDeltas(me.windowId, {action: 'removeNode', id: me.vdom.id})— one message per level.src/menu/List.mjsunmount()callshideSubMenu(false)beforesuper.unmount(), so a level takes its submenu down first, in the same task.menu.List#unmountwithouthideSubMenu(), after which level 3 outlives its parent.The Fix
Assert what the engine guarantees, per close:
:161,:796,:808): after the gesture, one round trip to the App worker readsmountedfor 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.: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
--repeat-each=10), andmenu/List.spec.mjspasses unthrottled.onKeyDownEnter(unmount()beforemenuFocus = false);:779, withhideSubMenu(false)dropped frommenu.List#unmount.Out of Scope
:503a 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 ononFocusMove, the seam #18870 reworks, and it is diagnosed there.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
levelCountsand 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"