LearnNewsExamplesServices
Frontmatter
id18772
titlemenu pointer-rest CONTROL reds on CI: boundingBox returns null
stateClosed
labels
bugaitesting
assigneesneo-opus-ada
createdAtSep 16, 2026, 12:07 PM
updatedAtSep 16, 2026, 12:47 PM
githubUrlhttps://github.com/neomjs/neo/issues/18772
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 16, 2026, 12:47 PM

menu pointer-rest CONTROL reds on CI: boundingBox returns null

neo-opus-ada
neo-opus-ada commented on Sep 16, 2026, 12:07 PM

Context

test/playwright/component/menu/List.spec.mjs:272 — "CONTROL: the same path closes the submenu when a rest counts on enter" — reds intermittently in CI, on the trunk and on PR branches alike, with:

TypeError: Cannot read properties of null (reading 'x')
    at travelDiagonally (test/playwright/component/menu/List.spec.mjs:219:37)

Six occurrences in forty minutes, across three authors' branches and the trunk twice — every failing Engine Tests run in that window names this arm:

run branch when
35083847694 dev 10:14Z
35083275383 grace/18764-grid-native-cell-editing 10:08Z
35082035821 grace/18764-grid-native-cell-editing 09:54Z
35081079204 dev 09:44Z
35080858673 grace/18764-grid-native-cell-editing 09:42Z
35079749239 agent/18760-menu-keynav-cascade 09:35Z

@neo-opus-grace reports it reddening her PRs four times independently (#18769 ×2, #18773), which is where the count above stopped being two.

The 09:35Z run's components job was re-run at the same SHA and passed (09:50Z), which establishes intermittency. The arm landed on dev with #18765 (Resolves #18759); #18766 does not touch the hover path, and dev's own red rules it out as a cause.

Locally it does not reproduce — 117 passes, no red (see AC-1 below for what was tried).

The standing cost is the usual one — a flaky arm on the trunk trains every author to re-run reds without reading them.

The Problem

async function travelDiagonally(page) {
    const
        share = await page.getByText('Share', {exact: true}).boundingBox(),
        print = await page.getByText('Print', {exact: true}).boundingBox();

print is null at line 205, and line 219 is where it is first read. A null here is not "no such element": locator.boundingBox() resolves the selector with state: 'attached' first, so a selector matching nothing times out rather than returning null (@neo-opus-vega measured exactly this while filing #18693, and the note is why this ticket does not start from "the submenu never opened"). null means the element was resolved and then had no box when it was measured — it left the document, or its subtree stopped generating one, inside the window between the two calls.

So the arm reads two rectangles across two round-trips, after asserting toHaveCount(2) in a third. Anything that removes or replaces the submenu in between nulls the second read. That is also a correctness problem independent of the flake: the two rectangles can come from two different layout states, so the "diagonal" the arm then walks is only as coherent as the interval between the reads — a submenu still settling yields a path neither rectangle describes.

What removes it is not established, and this ticket does not claim it. AC-1 is a measurement, not a reading.

The Architectural Reality

  • test/playwright/component/menu/List.spec.mjs:202 travelDiagonally — the helper, shared by the passing diagonal arm (:256) and this CONTROL (:272). The CONTROL differs only in subMenuHoverDelay: 0, which makes every mouseenter a rest immediately rather than after 200 ms.
  • src/menu/List.mjs#showSubMenu reuses a cached submenu per record key and only re-mounts when activeSubMenu !== subMenu, so a same-item rest is a no-op; the removal, if it is one, comes from somewhere else.
  • src/main/DomAccess.mjs:272 — align() sets the subject to hidden: true when the target rect cannot be resolved; :1336 — syncAligns() does the same when the target element has left the document, and onDocumentMutation (:893) calls syncAligns on any childList mutation that is not purely align subjects. A submenu aligned to an item node is therefore one stale node reference away from being hidden by the main thread. Candidate, not conclusion.
  • A floating component sits at left: -10000px until aligned, which still yields a box — so "not yet aligned" does not explain a null.

The Fix

  1. Reproduce with instrumentation before changing anything — an in-page MutationObserver trail plus mouseover / setConfigs capture over the arm, run against CI-like conditions (the full components suite, not the file alone), the way #18721's overflow flake was taken from reproduction to mechanism. If the trail shows the submenu leaving, the defect is in src/menu/ or src/main/DomAccess.mjs and gets its own leaf.
  2. Measure both rows in one page task — replace the two boundingBox() round-trips with a single page.evaluate that reads both rectangles from one layout state and names which row it could not find. This removes the window by construction and makes a genuinely closed submenu fail as an assertion with a readable message instead of as a null dereference two lines later. The arms' product assertions (toHaveCount(1) here, toHaveCount(2) in the diagonal arm) stay exactly as they are.

Acceptance Criteria

  • AC-1 — not reproduced locally, in 117 arm runs across three conditions, all on macOS against 99ebfde7d8:

    condition result
    the pointer rest describe ×15 (the arm in its file) 75/75
    an instrumented copy of the arm's pre-travel reads ×40 at 6× CDP CPU throttling — in-page MutationObserver over document.body, capture listeners for mouseover / mouseout / focusin / focusout / scroll, and a wrapper over the main thread's Neo.worker.App.setConfigs to catch a hidden: true from DomAccess 40/40, no trail printed
    the full components suite ×2, so the arm runs in its CI position (#241 of 291) rather than alone 2/2 green on this arm

    So the trail cannot be taken on this machine, and this ticket's fix is therefore a fix to the measurement only — stated here as the AC requires. The reproduction that remains is CI-side, and the fix carries it: the helper now reports the missing row and the live menu count, so the next CI red diagnoses itself instead of dying on a null property read. If that message ever names Print with a menu count of 2, the submenu was showing and the row was replaced; with a count of 1, the submenu closed — AC-5 then applies.

  • AC-2 — travelDiagonally reads both rectangles from a single page task, and a missing row fails with a message naming the row.

  • AC-3 — the changed helper is exercised red-first: with the submenu forced closed, the arm fails on the new assertion rather than on a null property read.

  • AC-4 — the full components suite runs clean locally, and the pointer rest describe survives ×15 with the change in place.

  • AC-5 — if AC-1 finds a product defect, it is filed as its own leaf and linked here; this ticket does not fix engine behaviour under a testing label.

Out of Scope

  • The hover and keyboard behaviour themselves (#18759, #18760) — the arms assert them correctly; only the measurement is at issue.
  • PR #18766, which is green at 99ebfde7d8 and does not touch the hover path.
  • Any other component-suite flake; this ticket names one arm.

Avoided Traps

  • A blind waitFor before the read. It would convert a transient close into a silent pass and leave the arm sampling an arbitrary instant — the same trap @neo-opus-vega refused in #18693.
  • Re-running until green. The same-SHA rerun here classifies the failure; it does not discharge it.
  • Asserting the mechanism from source. DomAccess's hide-on-missing-target is the plausible candidate and the cached-submenu guard argues against a remount. AC-1 requires the trail.
  • Deleting the CONTROL. It is what makes subMenuHoverDelay load-bearing in the diagonal arm rather than incidental.

Related

  • #18759 / #18765 — the arm's origin. #18760 / #18766 — the stacked PR that surfaced it first.
  • #18721 — the same shape one lane earlier: a CI-only component flake taken to its mechanism by an instrumented, throttled run.
  • #18693 — where the boundingBox() instrument note (a stale handle times out; a null means resolved-then-boxless) was measured.

Live latest-open sweep: checked the latest 20 open issues at 2026-09-16T10:04:51Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages (all read-states): @neo-opus-grace's 09:43Z FYI reports the same arm as a data point and claims nothing; no competing [lane-claim]. Memory Core rationale sweep on the symptom nouns: surfaced #18721 and the #18693 instrument note, both cited above, no prior decision on this arm. Own-assignment sweep: no same-surface hit. Structure-map gate: N/A — no .mjs created or relocated, nothing under ai/, Agent OS, MCP, Memory Core, orchestration or .agents/skills touched.

Decision Record impact: none.

Origin Session ID: cd67869d-cec2-4c36-99a6-aa521a73fddb

Retrieval Hint: "menu pointer rest CONTROL boundingBox null CI flake two roundtrips" · query_raw_memories on "a null boundingBox means resolved then boxless, not absent"

tobiu referenced in commit 4b241b7 - "test(menu): the pointer-rest arms measure their rows in one page task (#18772) (#18778) on Sep 16, 2026, 12:47 PM
tobiu closed this issue on Sep 16, 2026, 12:47 PM