LearnNewsExamplesServices
Frontmatter
id17327
titleCells keep stale geometry when a column resize is dropped
stateClosed
labels
bugaitestingregressiongrid
assigneesneo-opus-grace
createdAtAug 18, 2026, 9:49 AM
updatedAtAug 18, 2026, 5:00 PM
githubUrlhttps://github.com/neomjs/neo/issues/17327
authorneo-opus-grace
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 18, 2026, 2:29 PM

Cells keep stale geometry when a column resize is dropped

Closed Backlog/active-chunk-17 bugaitestingregressiongrid
neo-opus-grace
neo-opus-grace commented on Aug 18, 2026, 9:49 AM

Context

Operator-observed on 2026-08-18, against the merged tip of PR #17291 (Resolves #17289, merged 2026-08-17T19:03:00Z, approved by @neo-opus-vega).

PR #17291 closed two defects in the column-resize path. Defect A is confirmed fixed by direct observation — while the resize handle is being dragged, the body cells now track the header. The drop leg is not closed. On drag:end the cells never arrive at the final geometry: the header button keeps the width the operator dragged it to, and the cells stay behind.

Observed (operator screenshot, examples/grid/bigData/-shaped surface): the header button for Number 7 spans roughly three cell columns, while its cells keep the ~100px columnDefaults width. Number 8 and Number 9 sit over the wrong cell spans. A second observation from the same session: during the drag the cells follow with a visible lag.

Inferred: nothing yet. The mechanism is unconfirmed. Everything under Candidate mechanisms below was read from source on the merged tree, not reproduced live — it is a falsifier list, not a diagnosis.

The Problem

Two symptoms, possibly one mechanism:

  • S1 — during drag:move, cells follow the header with a visible delay.
  • S2 — on drag:end, cells never reach the final geometry.

S1 means repaints are still in flight at the moment the drop fires. That coupling is the first thing to test: S2 may simply be S1's last repaint losing a race, in which case one repair closes both.

History, so the next agent does not re-derive it:

  • #9529 introduced updateCellPositions as the lightweight drag:move path.
  • #12883 landed grid/header/Wrapper.mjs, which silently broke the owner.parent.parent.body walk in the resize plugin — the body resolved undefined behind a falsy guard, so the live cell update stopped running entirely.
  • #17289 / PR #17291 replaced that walk with the region-aware grid.header.Toolbar#body getter, and added grid.Body#refreshColumns(force) because mountedColumns is used as a "did the columns change?" proxy in two places and a pure width change satisfies neither.

The residual is inside the code path that PR #17291 introduced. It gets no benefit of the doubt here.

The Architectural Reality

Surface Location Role in this defect
grid.header.plugin.Resizable#onDragEnd src/grid/header/plugin/Resizable.mjs:77-109 Samples the final width, writes owner.width, calls passSizeToBody()
grid.header.Toolbar#passSizeToBody src/grid/header/Toolbar.mjs:344-421 Rebuilds columnPositions + availableWidth, then body.refreshColumns(true)
grid.header.Toolbar#body src/grid/header/Toolbar.mjs:110-121 Region-aware body routing (centre / locked start / locked end)
grid.Body#refreshColumns src/grid/Body.mjs:1454-1461 Suppress-then-render-once; propagates force to both consumers
grid.Body#updateCellPositions src/grid/Body.mjs:1375-1433 The drag:move path; mutates columnPositions in place, updateDepth = 2
grid.Body#createViewData src/grid/Body.mjs:734-775 Defers via registerPreUpdate while isVdomUpdating
manager.VDomUpdate src/manager/VDomUpdate.mjs:228-235, :512-514 preUpdateMap is a single-slot Map keyed by component id
mixin.VdomLifecycle#resolveVdomUpdate src/mixin/VdomLifecycle.mjs:846 Drains the pre-update slot when an in-flight update resolves

Candidate mechanisms (ordered falsifiers)

H1 — the drop-time repaint is deferred and never actually paints. createViewData returns early and registers a pre-update callback when isVdomUpdating is true (Body.mjs:754-760). Given S1, the body is very likely mid-update when the drop fires. Partially exculpated already — do not re-derive this leg: resolveVdomUpdate does drain the slot (VdomLifecycle.mjs:846), and although preUpdateMap is single-slot and last-write-wins (VDomUpdate.mjs:512-514), the deferred closure re-reads columnPositions at call time, so a collapsed stack still carries the newest geometry. "The repaint is silently dropped" is therefore not established. What remains unchecked: whether the deferred createViewData actually terminates in a painted update, or merely mutates vdom while needsVdomUpdate stays false.

H2 — onDragEnd samples the width one tick early. Resizable.mjs:82 reads newWidth from dragProxy.wrapperStyle.width before super.onDragEnd(data) at :84. If the base plugin applies the final pointer delta to the proxy, the sampled width is the penultimate one. Counter-prediction: this would leave header and cells agreeing on a slightly wrong width, which contradicts the observed divergence. Rank below H1 unless the header's rendered width turns out to come from a source other than owner.width.

H3 — passSizeToBody rebuilds geometry from a source that has not settled. On the reported surface every column carries a px width (examples/grid/bigData/GridContainer.mjs:30-35, columnDefaults.width: 100), so hasDynamicWidth is false and the synchronous branch runs off item.width — which onDragEnd set moments earlier at Resizable.mjs:98. H3 is therefore unlikely on this surface. It is live on any grid with flex/auto columns, where the branch awaits getLayoutRect() and can measure the header before its own width write has flushed to the DOM. Test both width modes; do not let a green px-only run clear this.

H4 — the drag:move in-place mutation and the drop-time rebuild disagree. updateCellPositions mutates columnPositions entries in place and setSilents availableWidth; passSizeToBody then clear()s and re-add()s the whole collection. Look for an ordering in which the rebuild lands before the last in-place mutation's update is flushed.

The Fix

The repair is whichever of H1–H4 survives falsification, so the prescription is deliberately deferred to the probe run rather than guessed here.

What is not deferred: the coverage gap below is a defect in its own right and lands regardless of which hypothesis wins.

The coverage gap — why this shipped twice

test/playwright/e2e/grid/HeaderCellRectSync.spec.mjs (from #12955) already contains exactly the right net: pairwise header-rect ↔ cell-rect assertions on x and width within 1px, evaluated at mid-drag hold points and after every drop, with per-layer scroll context printed on failure. It would have caught both #17289 defects and this residual.

It never fires on them because it is only ever armed by a column reorder gesture — it grabs .neo-draggable at the button's centre. The resize gesture is structurally disjoint: src/plugin/Resizable.mjs:181-187 delegates its drag events to .neo-resizable (the right-edge handle), and grid.header.Toolbar#createSortZone sets ignoreDragSelector: '.neo-resizable' precisely so the two gestures never overlap. A reorder-driven spec therefore cannot arm the resize path, however many passes it runs.

The assertion core is right and reusable. Only the gesture and the surface are missing.

Acceptance Criteria

  • AC-1 A native-gesture e2e spec drives a real column resizepage.mouse.down() on a header button's .neo-resizable handle, stepped moves, up() — and asserts header↔cell rect parity after the drop, not only mid-drag. Reuse the readPairs / assertAligned core from HeaderCellRectSync.spec.mjs instead of re-inventing it.
  • AC-2 The spec is red-proofed against the current dev tree: it MUST fail on the unrepaired tree, naming the offending column and the pixel offset. A spec that is green before the repair does not cover the defect. The failing output is quoted in the PR body.
  • AC-3 Both width modes are covered — a fixed-px surface (examples/grid/bigData/) and a dynamic-width surface (flex/auto columns) — because passSizeToBody takes two structurally different branches and only the dynamic one awaits a DOM measurement.
  • AC-4 Both widen and narrow are asserted. The 9k-era defect was shrink-only; a widen-only spec re-opens that blind spot.
  • AC-5 Locked-column regions are covered: resize a centre-region column on examples/grid/lockedColumns/ and assert the locked start/end bodies keep their own geometry. grid.header.Toolbar#body routes per region and that routing has no test.
  • AC-6 S1 (the visible lag during drag:move) is characterized in the PR body — either it shares the root cause with the drop miss, or it is split out into its own ticket. It is not left unexplained.
  • AC-7 The repair itself: after a resize drop, every visible header's rect matches its first-row cell's rect within 1px, in both width modes.
  • AC-8 The e2e runs via npm run test-e2e (test/playwright/playwright.config.e2e.mjs). Bare npx playwright test is not used — it loads the wrong project config.

Out of Scope

  • Column reorder / SortZone drag behaviour — already netted by HeaderCellRectSync.spec.mjs (#12955).
  • Splitter-driven header/body desync (#16375) — different mechanism, different owner.
  • The min-width-vs-width shrink defect of the #9527 / #9528 / #9529 era. grid/Row.mjs already uses style.width; that fix is intact and was re-verified during #17289. Do not "restore" it.
  • Any performance rework of updateCellPositions.

Avoided Traps

  • Trusting the 9k-range prior art. The Knowledge Base ranks #9527 / #9528 / #9529 top for this exact symptom string and their descriptions match nearly verbatim — but they predate multiple grid bodies for locked columns. The operator flagged this explicitly during #17289; following it would have "fixed" an already-correct line.
  • Asserting the repair with a call-count test. During #17289 a call-count assertion went green on the broken code. This defect class is "work happened, no effect" — only rendered-geometry assertions can falsify it.
  • Treating PR #17291 as trusted ground. The residual sits inside the path that PR authored; H1 and H4 both point at it. It earns no exemption from falsification.

Decision Record impact

none

Related

  • #17289 (closed) — the parent defect; PR #17291 (merged) — the partial fix this ticket completes
  • #12955 — the sibling coverage net, test/playwright/e2e/grid/HeaderCellRectSync.spec.mjs
  • #16375 — adjacent header/cell desync class, different mechanism
  • Historical / superseded framing: #9527, #9528, #9529, #12883

Gate records

  • Live latest-open sweep: checked the latest 20 open issues at 2026-08-18T07:46:30Z; no equivalent found.
  • A2A in-flight claim sweep: latest 30 messages, all read-states; no [lane-claim] / [lane-intent] overlapping grid column resize.
  • Agent OS Structure Map gate: N/A — no ai/, MCP, Memory Core, orchestration, or .agents/skills surface touched.
  • Structural Pre-Flight: the only new .mjs is one e2e spec in test/playwright/e2e/grid/, a sibling-pattern match against the 17 specs already in that folder → Stage 1 fast path.

Origin Session ID: ad99f59b-9d2c-4f82-b6ce-8c8357ef1879

Handoff Retrieval Hints

  • query_raw_memories: "grid column resize drop cells stale geometry header desync"
  • query_raw_memories: "refreshColumns force mountedColumns change proxy two consumers"
  • Commit anchors: PR #17291 head bfa863ba4a, merged to dev 2026-08-17T19:03:00Z
  • Prior session (diagnosis + partial fix): 6ecf4cee-7b32-4d21-86ba-e4288b897be0
tobiu referenced in commit 3085d62 - "fix(grid): a column's width comes from its config unless layout owns it (#17327) (#17332) on Aug 18, 2026, 2:29 PM
tobiu closed this issue on Aug 18, 2026, 2:29 PM