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
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
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:endthe 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 forNumber 7spans roughly three cell columns, while its cells keep the ~100pxcolumnDefaultswidth.Number 8andNumber 9sit 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:
drag:move, cells follow the header with a visible delay.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:
#9529introducedupdateCellPositionsas the lightweightdrag:movepath.#12883landedgrid/header/Wrapper.mjs, which silently broke theowner.parent.parent.bodywalk in the resize plugin — the body resolvedundefinedbehind a falsy guard, so the live cell update stopped running entirely.grid.header.Toolbar#bodygetter, and addedgrid.Body#refreshColumns(force)becausemountedColumnsis 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
grid.header.plugin.Resizable#onDragEndsrc/grid/header/plugin/Resizable.mjs:77-109owner.width, callspassSizeToBody()grid.header.Toolbar#passSizeToBodysrc/grid/header/Toolbar.mjs:344-421columnPositions+availableWidth, thenbody.refreshColumns(true)grid.header.Toolbar#bodysrc/grid/header/Toolbar.mjs:110-121grid.Body#refreshColumnssrc/grid/Body.mjs:1454-1461forceto both consumersgrid.Body#updateCellPositionssrc/grid/Body.mjs:1375-1433drag:movepath; mutatescolumnPositionsin place,updateDepth = 2grid.Body#createViewDatasrc/grid/Body.mjs:734-775registerPreUpdatewhileisVdomUpdatingmanager.VDomUpdatesrc/manager/VDomUpdate.mjs:228-235,:512-514preUpdateMapis a single-slotMapkeyed by component idmixin.VdomLifecycle#resolveVdomUpdatesrc/mixin/VdomLifecycle.mjs:846Candidate mechanisms (ordered falsifiers)
H1 — the drop-time repaint is deferred and never actually paints.
createViewDatareturns early and registers a pre-update callback whenisVdomUpdatingis 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:resolveVdomUpdatedoes drain the slot (VdomLifecycle.mjs:846), and althoughpreUpdateMapis single-slot and last-write-wins (VDomUpdate.mjs:512-514), the deferred closure re-readscolumnPositionsat 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 deferredcreateViewDataactually terminates in a painted update, or merely mutates vdom whileneedsVdomUpdatestays false.H2 —
onDragEndsamples the width one tick early.Resizable.mjs:82readsnewWidthfromdragProxy.wrapperStyle.widthbeforesuper.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 thanowner.width.H3 —
passSizeToBodyrebuilds 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), sohasDynamicWidthis false and the synchronous branch runs offitem.width— whichonDragEndset moments earlier atResizable.mjs:98. H3 is therefore unlikely on this surface. It is live on any grid withflex/auto columns, where the branchawaitsgetLayoutRect()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:movein-place mutation and the drop-time rebuild disagree.updateCellPositionsmutatescolumnPositionsentries in place andsetSilentsavailableWidth;passSizeToBodythenclear()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 onxandwidthwithin 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-draggableat the button's centre. The resize gesture is structurally disjoint:src/plugin/Resizable.mjs:181-187delegates its drag events to.neo-resizable(the right-edge handle), andgrid.header.Toolbar#createSortZonesetsignoreDragSelector: '.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
page.mouse.down()on a header button's.neo-resizablehandle, steppedmoves,up()— and asserts header↔cell rect parity after the drop, not only mid-drag. Reuse thereadPairs/assertAlignedcore fromHeaderCellRectSync.spec.mjsinstead of re-inventing it.devtree: 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.examples/grid/bigData/) and a dynamic-width surface (flex/auto columns) — becausepassSizeToBodytakes two structurally different branches and only the dynamic one awaits a DOM measurement.examples/grid/lockedColumns/and assert the locked start/end bodies keep their own geometry.grid.header.Toolbar#bodyroutes per region and that routing has no test.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.npm run test-e2e(test/playwright/playwright.config.e2e.mjs). Barenpx playwright testis not used — it loads the wrong project config.Out of Scope
HeaderCellRectSync.spec.mjs(#12955).#16375) — different mechanism, different owner.#9527/#9528/#9529era.grid/Row.mjsalready usesstyle.width; that fix is intact and was re-verified during #17289. Do not "restore" it.updateCellPositions.Avoided Traps
#9527/#9528/#9529top 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.Decision Record impact
noneRelated
test/playwright/e2e/grid/HeaderCellRectSync.spec.mjsGate records
[lane-claim]/[lane-intent]overlapping grid column resize.ai/, MCP, Memory Core, orchestration, or.agents/skillssurface touched..mjsis one e2e spec intest/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"bfa863ba4a, merged todev2026-08-17T19:03:00Z6ecf4cee-7b32-4d21-86ba-e4288b897be0