LearnNewsExamplesServices
Frontmatter
id18439
titleTwo BigData grid e2e arms fail intermittently on hosted runners: ThumbDragPause blanks frames, TreeBigData''s selection never lands
stateClosed
labels
bugaitesting
assigneesneo-opus-ada
createdAtSep 7, 2026, 4:51 AM
updatedAtSep 15, 2026, 9:31 AM
githubUrlhttps://github.com/neomjs/neo/issues/18439
authorneo-opus-ada
commentsCount37
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 15, 2026, 9:31 AM

Two BigData grid e2e arms fail intermittently on hosted runners: ThumbDragPause blanks frames, TreeBigData's selection never lands

neo-opus-ada
neo-opus-ada commented on Sep 7, 2026, 4:51 AM

Current state, 2026-09-15. This body is the record, and the comment thread below it is history. Add a new sighting to the tables here, not as a comment.

The two modes

The original framing, one "rotating casualty", was wrong. There are two failure modes. Each repeats on its own arm, fails only intermittently and only on hosted runners, and has never been reproduced locally.

mode arm (test(...) title) failure shape
paint ThumbDragPause.spec.mjs › pinning survives a paused thumb drag no frame blanked during or after the pause — rowCount→frames {"42":N} since #18588: N = 8–10 frames in one contiguous 116–145 ms window, with rows present every time
selection TreeBigData.spec.mjs › Selection happy path: initial render and after single expand toHaveClass(/neo-selected/) on the pinned child row, 5 s: element(s) not found, or the row found without neo-selected the pinned "child" is the folder's level-1 sibling (below)

Sightings arrive on first attempts, on PRs whose diffs do not reach the grid. One arm fails per run, the other BigData arms pass, and reruns of the failed job go green. One exception: #18496 was red twice, then green.

Measured

Hosted, using the dispatchable tier from #18507:

run specs paint selection
34287880651 ThumbDragPause + TreeBigData ×20 5/20 0/100
34892729490 ThumbDragPause alone ×20, instrumented 0/20 —
34893510799 ThumbDragPause alone ×20, unmodified dev 0/20 —
34893546359 RowPinning + ThumbDragPause ×10, instrumented 0/10 —
34904255574 whole tier ×3, #18716 gate on 0/3 0/3
34906759612 ThumbDragPause + TreeBigData ×20, paint probe 966b5bf624 0/20 2/20
34908005335 ThumbDragPause + TreeBigData ×40, paint probe + level wait 0419f9cc11 1/40 0/40
34915614433 ThumbDragPause + TreeBigData ×40, fix branch (denseUpdate + level wait) 0/40 0/40

In the ordinary tier, the paint mode fails at position 22, directly after RowPinning (~37 s). ThumbDragPause + TreeBigData reproduced it at 5/20 on 2026-09-08, then at 0/20 and 1/40 on 2026-09-15; alone or after RowPinning only, the arm stayed green in 50 repetitions.

The one failing paint repeat with the probe on released the pin 151 ms before its rows. In 34908005335, repeat 1 blanked 9 frames. The final jump's scroll meta (360000) arrived in a one-delta batch, the body's neo-is-scrolling class, at final+37 ms. The 1048 row deltas followed at final+188 ms, 17 ms after the class was removed again. In each of the 39 passing repeats, the final meta rode the row batch itself, at final+34 to +70 ms. Nine frames at 60 Hz fill that 151 ms gap.

Why a meta without rows can release the pin. grid.View#getVdomUpdateMeta returns the View's scrollTop, and VdomLifecycle#getVdomUpdatePayload attaches it to any flight that collects the View. GridRowScrollPinning#onDeltaUpdate copies it into workerScrollTop for every batch that carries it, whatever else the batch holds, and applyPinning drops the offset once the thumb is up and workerScrollTop is within 5 px of the real scrollTop. That fits the capture: the class batch reported 360000 at final+37, and the rows rendering it arrived at final+188.

Why the rows were late: a merged body update makes the scroll repaint sparse. grid.View#syncBodies repaints the rows silently and ships them in one View update deep enough to reach the cells (#17401). While a View update is in the air, a body update collides with it and waits in its post-update queue, and the repaint queues too. At landing the body update merges into the queued repaint, whose payload then carries an allow-list, and sparse tree generation (#8832, #8834) treats every row outside that list as clean and prunes it. ScrollManager#onContainerScroll sets the body's isScrolling before it syncs the bodies, which is that order. unit/grid/ScrollFlightMerge.spec.mjs reproduces it locally. With the body update first, the repaint payload is built at depth 4 with [body] as its allow-list, prunes all 14 rows, and leaves 10 stored row vnodes on their pre-scroll index. With the body update after the repaint, the allow-list is gone by collection and all 14 rows ship.

The selection mode pins the wrong record. After the toggle click, the arm waits for the toggle's is-expanded class, then pins childId from folderRow + .neo-grid-row. Both failures in 34906759612 (repeats 7 and 15) pinned neo-record-2: level 1, the folder's sibling, not a level-2 child. On repeat 15, the row still read aria-level="1" at the assertion. The spec's own header records the same split from local readings: row 1 carried neo-record-2 at level 1 in 2 of 14, and neo-record-79 at level 2 in 12. The toggle's class can land before the rows it reveals, so the arm reads the row that was there before the expand.

34908005335 waited for the folder's level + 1 before the pin, and logged the level first. All 40 repeats logged level 2, so the wait never absorbed a sibling read. 0/40 cannot credit the wait: the log's own getAttribute round trip sits between the class landing and the pin, and may have closed the window by itself.

A PR run repeats the signature on the unprobed spec: NEO_INLINE_CODE_39 attempt 1, #18716 at 43c1aa39d2, pinned neo-record-2 at aria-level="1", aria-posinset="2". That diff reaches the grid's row pool, not expansion or selection.

Local (M5 Max): the paint mode has never gone red, whether alone, in the e2e/grid directory, on the CI tier paths, or with --disable-gpu. The selection mode passed 5/5. The same tier takes about 2.3× longer on the runner.

Ruled out

  • An empty DOM sampled mid-update (paint): every paint instance since #18588 has rows present.
  • The ENGINE launch profile: the tier runs PRESENTING, with the same args as local.
  • Software compositing alone: --disable-gpu locally, 0/5.
  • Spec order on a fast machine: the CI tier paths stay green locally.
  • Rows arriving in a batch without the meta: never seen. The captured failure is the reverse split (Measured).
  • A caught-and-logged worker exception: the #18716 gate saw no worker error and no page error on a failing attempt (job).
  • The positional locator (selection mode): #18440 fixed it, and the failure persists; the remaining position read is the pin itself (above).

Open

  • Paint: which boundary owns the fix. The pruning is pinned design (SparseUpdates.spec, AsymmetricMerging.spec), and -1 is ruled out for the repaint (#17401). The addon is not the site: adopting the meta only from batches with rows would strand the pin on a sub-row scroll, which ships no rows. Options: (1) the repaint registers every row it repainted into the View's update, so the allow-list names them, at one payload collection per row per scroll step; (2) the repaint marks its own cycle dense, a one-cycle VdomLifecycle flag that skips the allow-list for that owner only and leaves every other merge sparse; (3) the scroll handler toggles isScrolling after syncing the bodies, which fixes this order while any other deferred body update can still re-merge. Implemented (2) on agent/18439-scroll-pin-meta: VdomLifecycle#denseUpdate, set by View#syncBodies. ScrollFlightMerge.spec is red on dev and green with it, and the full unit suite passes (4068). Fork sent to @neo-opus-grace as the #17401 owner.
  • Selection: whether the stale pin is the whole failure. 0/40 with the wait does not separate the wait from the probe's own read.
  • #14853 / #14854: ruled distinct. Both closed on 2026-07-05, with #14870 and #14872. #14853 was RowPinning's mass blanking (185 frames); the paint mechanism here needs the finite repaint depth that #17401 introduced on 2026-08-20. #14854 covered TreeBigData's bulk-expand and structural-selection arms, not the happy path.

Next

  1. Paint: the fix branch ran 0/40 hosted. That alone cannot certify it against dev's 1/40, so ScrollFlightMerge.spec stays the deterministic evidence.
  2. Selection: the arm now waits for the revealed row to reach the folder's level + 1 before pinning it, so it cannot pin a level-1 sibling however the class and the rows interleave. The fix branch ran 0/40 hosted.
  3. Open the PR that resolves this ticket from agent/18439-scroll-pin-meta.

Contract Ledger

Two consumed surfaces ship with the fix: the denseUpdate cycle flag on every component, and the collected marking of a flight's merge registrations. Verified against PR #18719 at c30b10cd3d.

Target Surface Source of Authority Behavior Fallback / Edge Case Docs Evidence
Neo.mixin.VdomLifecycle config denseUpdate=false (Boolean, @protected) this ticket True for one cycle: while set, executeVdomUpdate passes ids = null, so the owner ships its subtree whole within its own updateDepth even when children merge into the flight Default false leaves the sparse path (ids = mergedChildIds) exactly as it was; updateDepth === 1 teleports with ids = null either way; getVdomUpdatePayload resets it to false at collection, beside the updateDepth reset, so it never spans two cycles JSDoc on the config unit/grid/ScrollFlightMerge.spec.mjs › rows repainted while a body update waits behind the View still ship when it merges into the repaint; the unchanged sparse default is pinned by unit/vdom/AsymmetricMerging.spec.mjs › Selective Merging and › Leapfrog Merging
Neo.grid.View#syncBodies (writer) this ticket Sets denseUpdate = true beside the finite updateDepth = ROW_DISTANCE + maxCellDepth before update(): the scroll repainted the rows silently, so nothing within that depth is clean Owner-scoped — it marks the View's own cycle only, and the depth stays finite, since -1 makes hasUpdateCollision collide with every distance and pulls unrelated pending child updates into the scroll cycle Inline rationale at the writer the ScrollFlightMerge arm above, and e2e/grid/TreeBigData.spec.mjs
Neo.manager.VDomUpdate#markMergedCollected(ownerId, childIds) (new) this ticket Marks collected: true on exactly the merge registrations a flight collected; called from executeVdomUpdate where the flight records componentMergedChildren A child that merges again while the flight is in the air replaces its entry with an unmarked one, which the next cycle collects JSDoc unit/vdom/AsymmetricMerging.spec.mjs › Re-merge in flight: a child merging again while the parent flies still ships its newer change
Neo.manager.VDomUpdate#executeCallbacks(ownerId, data, processedChildIds) (changed predicate) this ticket Settles and deletes only children whose entry carries collected; previously any entry present under the owner Unchanged on the failure side: a failed flight still carries no processedChildIds and goes through rejectCallbacks, which rejects every registered child JSDoc the Re-merge in flight arm — the newer entry survives the landing instead of being cleared unshipped

Acceptance Criteria

  • Each mode has a hosted reproduction condition with a rate. For paint, confirm or refute 5/20 with TreeBigData against 0/50 without.
  • Each arm's wait observes the state it waits on (#18215 is the precedent), or the product defect behind the miss is fixed.
  • The outcome is a repair, a quarantine with an owner and a stated condition, or a recorded rate shown to be acceptable. Not a raised timeout or tolerance with no rate behind it.
  • #14853 / #14854 are reopened, superseded, or ruled distinct with evidence.

Same tier, not this ticket

  • HeaderCellRectSync.spec.mjs header/cell alignment misses: hosted, green on rerun, unclaimed.
  • DemoBCrossWindowDragNL failures, traced to #17578.

Reading a red

  • gh run list and gh run view --job show the latest attempt, so a rerun launders a red. Count attempts via gh api .../attempts/<n>/jobs. Uploaded artifacts survive the rerun.
  • Record an arm by its test(...) title; line numbers move.
  • A rerun without the tier ahead of it, or on other hardware, has not tested the failing condition. Say which one you changed.

Related

#18408 (the tier) · #18441 / #18440 (selection locator, fixed) · #18587 / #18588 (blank-frame split) · #18507 (dispatchable probe) · #18215 (a wait that cannot see its condition)

tobiu referenced in commit 2f8339f - "test(grid): the selection arm binds rows by record, not by position (#18439) (#18440) on Sep 7, 2026, 8:25 AM
tobiu referenced in commit e4ea42e - "ci(test): make the e2e tier dispatchable as a flake probe (#18505) (#18507) on Sep 9, 2026, 10:32 AM
tobiu referenced in commit d6d0d14 - "fix(vdom): a silent scroll repaint keeps its rows when a body update merges into it (#18439) (#18719) on Sep 15, 2026, 9:31 AM
tobiu closed this issue on Sep 15, 2026, 9:31 AM