Context
Operator-reported live regression (2026-08-17): dragging a grid column's right edge resizes the header but the cells keep their old widths and left offsets. The grid visibly desyncs — header columns and body columns no longer line up. No JS errors are logged.
Verified against HEAD e4b8d2be9d, re-validated on 7aed27077f after dev moved, by reading each link in the chain from the current tree (not from prior-art recall). The operator explicitly flagged that the #9527/#9529-era prior art predates the multi-body/locked-column split — that warning is load-bearing and turned out to be the key: the 9k-era code is the stale thing here, not a fix that needs restoring.
src/grid/Row.mjs:262 still uses cellConfig.style.width — the #9527 minWidth fix is intact and is not this defect.
Correction (post-implementation). This body originally described TWO failing links. Implementation found a third short-circuit on the same stale signal — createViewData's own change auto-detection — which is documented in §Defect B below and reflected in The Fix and the ACs. The first prescription would have left the defect standing.
The Problem
Defects compound. All fail silently, which is why there is no error.
Defect A — drag:move never reaches the body (dead since the header.Wrapper split)
src/grid/header/plugin/Resizable.mjs:59-66 resolves the body by walking parents:
let toolbar = owner.parent,
body = toolbar?.parent?.body;
...
if (body) { body.updateCellPositions(owner.dataField, newWidth) } owner.width = newWidth still runs, so the header button resizes live. The cell update sits behind a falsy if (body) guard.
Answers the operator's open question: during drag:move only the header toolbar updates. Cells are never touched.
Provenance:
toolbar?.parent?.body was introduced in 5b58f5e194 (#9529) and never updated since; git log -S returns exactly that one commit.
src/grid/header/plugin/Resizable.mjs was last touched at 5a539f4389 (#9531) — the 9k range.
src/grid/header/Wrapper.mjs landed later in 9ef7b81ba8 (#12883) and src/grid/Container.mjs:275-281 now nests items: [me.headerToolbar] inside it, inserting exactly one container level.
header.Wrapper exposes gridContainer, headerStart, headerEnd — no body member.
The resize plugin is frozen at the pre-multi-body parent topology.
Defect B — the drop repaint is incidental, not guaranteed (latent all along)
onDragEnd → toolbar?.passSizeToBody() resolves the body correctly (via me.gridContainer), rebuilds columnPositions, sets availableWidth, then calls body.updateMountedAndVisibleColumns().
mountedColumns is used as the "did the columns change?" proxy in two places, and a pure width change satisfies neither — the mounted range [startIndex, endIndex] stays equal, src/core/Config.mjs:162 gates notify on !me.isEqual(newValue, oldValue), and src/core/Compare.mjs:25-37 compareArrays deep-compares element-wise:
src/grid/Body.mjs:521 — afterSetMountedColumns(value, oldValue) { oldValue && this.createViewData() }. Equal array → no notify → no render at all.
src/grid/Body.mjs:767 — if (!force && !Neo.isEqual(me.mountedColumns, me.#lastMountedColumns)) { force = true }. Equal array → force stays false → recycle stays true → Row#updateContent recycles the existing cell nodes and keeps the geometry that was just replaced.
Link 2 is why closing link 1 alone is insufficient: the render fires but recycles past the new columnPositions. This was found by the behaviour test (asserting actual cell width/left); a call-count test passes against it.
afterSetAvailableWidth still repaints the body's own width, which is why the grid widens while the cells stay put.
The codebase documents the link-1 trap at src/grid/Body.mjs:472-476 and works around it in afterSetContainerWidth and afterSetBufferColumnRange. passSizeToBody never got that treatment.
Why it surfaced now
Defect B has been latent since #9529. It was masked: the live drag:move path kept cells correct, so by drop time they already matched and nobody noticed the drop path never repaints. When the header.Wrapper split broke the live path (Defect A), the latent drop defect became the visible regression.
Fixing only A would re-mask B rather than fix it. Both must land.
The Architectural Reality
| Surface |
Body resolution |
Status |
grid/header/Toolbar.mjs:318-322 passSizeToBody |
me.gridContainer + me.layoutLock ternary |
✅ multi-body aware |
grid/header/plugin/Resizable.mjs:59-60 onDragMove |
owner.parent.parent.body |
❌ pre-multi-body |
The correct resolution already existed but was inlined as a one-off ternary in passSizeToBody, so the second consumer could not reuse it and hand-rolled a parent walk that later rotted. There was no SSOT for "which body does this toolbar drive".
All three toolbars (headerStart, headerToolbar, headerEnd) receive gridContainer at construction (grid/header/Wrapper.mjs:171,193), so a getter on grid.header.Toolbar covers the locked regions too — resizing a locked column is repaired by the same change.
The Fix
1. src/grid/header/Toolbar.mjs — the body-resolution SSOT.
A body getter returning the region-correct body from gridContainer + layoutLock, consumed inside passSizeToBody (replacing the inlined ternary).
2. src/grid/header/plugin/Resizable.mjs — consume the SSOT.
toolbar?.parent?.body → owner.parent?.body. Deletes the parent walk that rotted.
3. src/grid/Body.mjs — make the repaint deterministic, and force it past recycling.
refreshColumns(force = false) {
let me = this;
me.skipCreateViewData = true;
me.updateMountedAndVisibleColumns(force);
me.skipCreateViewData = false;
me.createViewData(false, force)
}force propagates to both consumers of the stale proxy: it recomputes the range, and it disables cell recycling so the new columnPositions actually reach the cells. passSizeToBody calls refreshColumns(true) (geometry changed).
Correction (2026-08-17, raised by @neo-opus-vega reviewing PR #17291, verified against the diff before amending). This paragraph originally claimed both existing duplicate call sites "delegate to it with their original semantics preserved". That is true of afterSetContainerWidth and false of afterSetBufferColumnRange:
So that call site moved from recycling to not recycling. Kept deliberately, not repaired: bufferColumnRange has no runtime mutation site in src/ — reads at :1452, :1480-1481, forwarded at :1562, no assignment anywhere — so it changes on an application config change rather than per frame, and forcing a non-recycled repaint on a range change is the safer behaviour. The defect was in this ledger, which answered "was the refactor behaviour-preserving?" with an unqualified yes where the truth is "yes for one call site, deliberately no for the other".
Cost profile is the #9529 design intent restored: updateCellPositions stays the cheap per-frame path; the forced repaint fires once on drop, not per tick.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
Neo.grid.header.Toolbar#body (new getter) |
grid/header/Toolbar.mjs:29-46 (gridContainer, layoutLock configs, verified present) |
Returns bodyStart/bodyEnd/body per layoutLock |
null when gridContainer unset |
JSDoc @summary |
Ternary already proven at Toolbar.mjs:322; 4 unit tests |
Neo.grid.Body#refreshColumns (new method) |
grid/Body.mjs:472-476 documented idiom |
Suppress incidental render, recompute, one explicit createViewData(false, force) |
afterSetContainerWidth unchanged; afterSetBufferColumnRange intentionally moves recycle true→false (config-time only, no runtime mutation site) — see the correction above |
JSDoc @summary |
Existing duplicates at Body.mjs:434-437, :477-480; mutation-verified |
Neo.grid.header.Toolbar#passSizeToBody |
Toolbar.mjs:318 |
Unchanged signature; repaint becomes guaranteed rather than incidental |
silent=true still skips repaint (unchanged) |
existing @summary |
Toolbar.mjs:391 |
Decision Record impact
none — no ADR governs grid header topology or the body render pipeline.
Acceptance Criteria
Out of Scope
- Column drag-reorder desync (
#12883 / #12930 family, cell-id generations) — a different pipeline.
- Dock/pane splitter resize (
#16375, CLOSED) — a different gesture.
- The
SortZone/Resizable event-isolation work (#9531) — intact, untouched.
- Any change to
updateCellPositions' internal algorithm; only its reachability was at fault.
- Observed, not fixed: header buttons carry
flex: 'none' (truthy), so passSizeToBody always takes its getLayoutRect() measurement branch even for a fully fixed-width grid, where the pure-math branch would serve. Possible avoidable main-thread round-trip; needs its own measurement before anyone calls it a defect.
Avoided Traps
- Re-applying the
#9527 minWidth→width fix. The KB surfaces #9527 first for this symptom and its description matches almost verbatim. It is a false lead: Row.mjs:262 already uses style.width.
- Fixing only Defect A. Would make the symptom disappear while leaving the drop path unable to repaint — re-masking a latent defect.
- Stopping at the first short-circuit. Closing
afterSetMountedColumns alone still leaves createViewData's own mountedColumns auto-detect recycling past the new geometry. A call-count test passes there; only a cell-geometry assertion catches it.
- Hardcoding
gridContainer.body in Resizable. Works for the centre region and silently breaks locked start/end resizing — the same narrow-fix class that caused this bug.
- Deep-comparison "fix" at the config layer. Making
mountedColumns always notify would repaint on every horizontal scroll tick; the equality short-circuit is correct and load-bearing for scroll performance (Body.mjs:1485-1489).
Related
#9529 — introduced updateCellPositions and the now-rotted parent walk.
#9527 — the minWidth→width cell fix (intact; not this defect).
#12883 — introduced grid/header/Wrapper.mjs, the topology change that broke the lookup.
#9531 — SortZone/Resizable isolation (unaffected).
Handoff Retrieval Hints
query_raw_memories: "grid column resize header cell desync multi-body header.Wrapper parent walk"
- Commit anchors:
5b58f5e194 (parent walk introduced) → 9ef7b81ba8 (Wrapper landed, lookup broken) → 5a539f4389 (last touch of Resizable.mjs).
- Verification trees:
e4b8d2be9d (diagnosis), 7aed27077f (re-validated), f8fd37c226 (fix head).
Live latest-open sweep: checked latest 20 open issues at 2026-08-17T09:12:43Z; A2A claim sweep over 30 newest messages (all read-states); KB semantic sweep (type: ticket); local resources/content/issues/ grep. No equivalent found.
Origin Session ID: 6ecf4cee-7b32-4d21-86ba-e4288b897be0
Context
Operator-reported live regression (2026-08-17): dragging a grid column's right edge resizes the header but the cells keep their old widths and
leftoffsets. The grid visibly desyncs — header columns and body columns no longer line up. No JS errors are logged.Verified against
HEAD e4b8d2be9d, re-validated on7aed27077fafter dev moved, by reading each link in the chain from the current tree (not from prior-art recall). The operator explicitly flagged that the#9527/#9529-era prior art predates the multi-body/locked-column split — that warning is load-bearing and turned out to be the key: the 9k-era code is the stale thing here, not a fix that needs restoring.src/grid/Row.mjs:262still usescellConfig.style.width— the#9527minWidthfix is intact and is not this defect.The Problem
Defects compound. All fail silently, which is why there is no error.
Defect A —
drag:movenever reaches the body (dead since the header.Wrapper split)src/grid/header/plugin/Resizable.mjs:59-66resolves the body by walking parents:let toolbar = owner.parent, // grid.header.Toolbar ✅ body = toolbar?.parent?.body; // → header.Wrapper.body → undefined ❌ ... if (body) { body.updateCellPositions(owner.dataField, newWidth) } // silently skippedowner.width = newWidthstill runs, so the header button resizes live. The cell update sits behind a falsyif (body)guard.Answers the operator's open question: during
drag:moveonly the header toolbar updates. Cells are never touched.Provenance:
toolbar?.parent?.bodywas introduced in5b58f5e194(#9529) and never updated since;git log -Sreturns exactly that one commit.src/grid/header/plugin/Resizable.mjswas last touched at5a539f4389(#9531) — the 9k range.src/grid/header/Wrapper.mjslanded later in9ef7b81ba8(#12883) andsrc/grid/Container.mjs:275-281now nestsitems: [me.headerToolbar]inside it, inserting exactly one container level.header.WrapperexposesgridContainer,headerStart,headerEnd— nobodymember.The resize plugin is frozen at the pre-multi-body parent topology.
Defect B — the drop repaint is incidental, not guaranteed (latent all along)
onDragEnd→toolbar?.passSizeToBody()resolves the body correctly (viame.gridContainer), rebuildscolumnPositions, setsavailableWidth, then callsbody.updateMountedAndVisibleColumns().mountedColumnsis used as the "did the columns change?" proxy in two places, and a pure width change satisfies neither — the mounted range[startIndex, endIndex]stays equal,src/core/Config.mjs:162gates notify on!me.isEqual(newValue, oldValue), andsrc/core/Compare.mjs:25-37compareArraysdeep-compares element-wise:src/grid/Body.mjs:521—afterSetMountedColumns(value, oldValue) { oldValue && this.createViewData() }. Equal array → no notify → no render at all.src/grid/Body.mjs:767—if (!force && !Neo.isEqual(me.mountedColumns, me.#lastMountedColumns)) { force = true }. Equal array →forcestays false →recyclestays true →Row#updateContentrecycles the existing cell nodes and keeps the geometry that was just replaced.Link 2 is why closing link 1 alone is insufficient: the render fires but recycles past the new
columnPositions. This was found by the behaviour test (asserting actual cellwidth/left); a call-count test passes against it.afterSetAvailableWidthstill repaints the body's own width, which is why the grid widens while the cells stay put.The codebase documents the link-1 trap at
src/grid/Body.mjs:472-476and works around it inafterSetContainerWidthandafterSetBufferColumnRange.passSizeToBodynever got that treatment.Why it surfaced now
Defect B has been latent since
#9529. It was masked: the livedrag:movepath kept cells correct, so by drop time they already matched and nobody noticed the drop path never repaints. When theheader.Wrappersplit broke the live path (Defect A), the latent drop defect became the visible regression.Fixing only A would re-mask B rather than fix it. Both must land.
The Architectural Reality
grid/header/Toolbar.mjs:318-322passSizeToBodyme.gridContainer+me.layoutLockternarygrid/header/plugin/Resizable.mjs:59-60onDragMoveowner.parent.parent.bodyThe correct resolution already existed but was inlined as a one-off ternary in
passSizeToBody, so the second consumer could not reuse it and hand-rolled a parent walk that later rotted. There was no SSOT for "which body does this toolbar drive".All three toolbars (
headerStart,headerToolbar,headerEnd) receivegridContainerat construction (grid/header/Wrapper.mjs:171,193), so a getter ongrid.header.Toolbarcovers the locked regions too — resizing a locked column is repaired by the same change.The Fix
1.
src/grid/header/Toolbar.mjs— the body-resolution SSOT.A
bodygetter returning the region-correct body fromgridContainer+layoutLock, consumed insidepassSizeToBody(replacing the inlined ternary).2.
src/grid/header/plugin/Resizable.mjs— consume the SSOT.toolbar?.parent?.body→owner.parent?.body. Deletes the parent walk that rotted.3.
src/grid/Body.mjs— make the repaint deterministic, and force it past recycling.refreshColumns(force = false) { let me = this; me.skipCreateViewData = true; me.updateMountedAndVisibleColumns(force); me.skipCreateViewData = false; me.createViewData(false, force) }forcepropagates to both consumers of the stale proxy: it recomputes the range, and it disables cell recycling so the newcolumnPositionsactually reach the cells.passSizeToBodycallsrefreshColumns(true)(geometry changed).Correction (2026-08-17, raised by @neo-opus-vega reviewing PR #17291, verified against the diff before amending). This paragraph originally claimed both existing duplicate call sites "delegate to it with their original semantics preserved". That is true of
afterSetContainerWidthand false ofafterSetBufferColumnRange:// before (dev): updateMountedAndVisibleColumns(true) + createViewData() // → second arg defaults false → recycle stays TRUE // after: refreshColumns(true) // → createViewData(false, TRUE) → recycle FALSESo that call site moved from recycling to not recycling. Kept deliberately, not repaired:
bufferColumnRangehas no runtime mutation site insrc/— reads at:1452,:1480-1481, forwarded at:1562, no assignment anywhere — so it changes on an application config change rather than per frame, and forcing a non-recycled repaint on a range change is the safer behaviour. The defect was in this ledger, which answered "was the refactor behaviour-preserving?" with an unqualified yes where the truth is "yes for one call site, deliberately no for the other".Cost profile is the
#9529design intent restored:updateCellPositionsstays the cheap per-frame path; the forced repaint fires once on drop, not per tick.Contract Ledger Matrix
Neo.grid.header.Toolbar#body(new getter)grid/header/Toolbar.mjs:29-46(gridContainer,layoutLockconfigs, verified present)bodyStart/bodyEnd/bodyperlayoutLocknullwhengridContainerunset@summaryToolbar.mjs:322; 4 unit testsNeo.grid.Body#refreshColumns(new method)grid/Body.mjs:472-476documented idiomcreateViewData(false, force)afterSetContainerWidthunchanged;afterSetBufferColumnRangeintentionally moves recycletrue→false(config-time only, no runtime mutation site) — see the correction above@summaryBody.mjs:434-437,:477-480; mutation-verifiedNeo.grid.header.Toolbar#passSizeToBodyToolbar.mjs:318silent=truestill skips repaint (unchanged)@summaryToolbar.mjs:391Decision Record impact
none— no ADR governs grid header topology or the body render pipeline.Acceptance Criteria
Neo.grid.header.Toolbarexposes abodygetter resolving the region-correct body viagridContainer+layoutLock;passSizeToBodyconsumes it instead of the inlined ternary.grid/header/plugin/Resizable.mjs#onDragMoveresolves the body via the toolbar getter; noparent.parentwalk remains in the file.Neo.grid.Body#refreshColumns(force)exists with JSDoc, propagatesforceto bothupdateMountedAndVisibleColumnsandcreateViewData, andafterSetBufferColumnRange+afterSetContainerWidthboth delegate to it.passSizeToBodyrepaints viarefreshColumns(true)whensilentis falsy;silent=truestill performs no repaint.layoutLock'start'/'end'/nullresolvesbodyStart/bodyEnd/bodyrespectively.passSizeToBody, mounted rows carry the new cellwidth/leftwhile the mounted column range is asserted unchanged. Mutation-verified against three separate reverts.header.Wrappertopology — the oldowner.parent.parent.bodyexpression is assertedundefinedon the real tree.updateMountedAndVisibleColumns()with an unchanged range performs zero renders, proving the guard is not vacuous. Not an AC of this ticket — stated so nobody mistakes it for covered: no automated test drives a real pointer drag throughResizable. The unit suite covers the drop path end-to-end and thedrag:movepath at its routing seam, which is where both defects lived; a synthetic pointer driver against a live app is a separate coverage lane (prior art: the driver built for#16375). The operator who reported this reproduces it live in one gesture, and that retest is the confirmation signal — not engineering work this PR owes.Out of Scope
#12883/#12930family, cell-id generations) — a different pipeline.#16375, CLOSED) — a different gesture.SortZone/Resizableevent-isolation work (#9531) — intact, untouched.updateCellPositions' internal algorithm; only its reachability was at fault.flex: 'none'(truthy), sopassSizeToBodyalways takes itsgetLayoutRect()measurement branch even for a fully fixed-width grid, where the pure-math branch would serve. Possible avoidable main-thread round-trip; needs its own measurement before anyone calls it a defect.Avoided Traps
#9527minWidth→widthfix. The KB surfaces#9527first for this symptom and its description matches almost verbatim. It is a false lead:Row.mjs:262already usesstyle.width.afterSetMountedColumnsalone still leavescreateViewData's ownmountedColumnsauto-detect recycling past the new geometry. A call-count test passes there; only a cell-geometry assertion catches it.gridContainer.bodyinResizable. Works for the centre region and silently breaks locked start/end resizing — the same narrow-fix class that caused this bug.mountedColumnsalways notify would repaint on every horizontal scroll tick; the equality short-circuit is correct and load-bearing for scroll performance (Body.mjs:1485-1489).Related
#9529— introducedupdateCellPositionsand the now-rotted parent walk.#9527— theminWidth→widthcell fix (intact; not this defect).#12883— introducedgrid/header/Wrapper.mjs, the topology change that broke the lookup.#9531—SortZone/Resizableisolation (unaffected).Handoff Retrieval Hints
query_raw_memories:"grid column resize header cell desync multi-body header.Wrapper parent walk"5b58f5e194(parent walk introduced) →9ef7b81ba8(Wrapper landed, lookup broken) →5a539f4389(last touch ofResizable.mjs).e4b8d2be9d(diagnosis),7aed27077f(re-validated),f8fd37c226(fix head).Live latest-open sweep: checked latest 20 open issues at 2026-08-17T09:12:43Z; A2A claim sweep over 30 newest messages (all read-states); KB semantic sweep (
type: ticket); localresources/content/issues/grep. No equivalent found.Origin Session ID: 6ecf4cee-7b32-4d21-86ba-e4288b897be0