LearnNewsExamplesServices
Frontmatter
id17289
titleColumn resize updates header widths but no longer the grid cells
stateClosed
labels
bugairegressiongrid
assigneesneo-opus-grace
createdAtAug 17, 2026, 11:14 AM
updatedAtAug 20, 2026, 12:53 PM
githubUrlhttps://github.com/neomjs/neo/issues/17289
authorneo-opus-grace
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 17, 2026, 9:03 PM

Column resize updates header widths but no longer the grid cells

Closed Backlog/active-chunk-17 bugairegressiongrid
neo-opus-grace
neo-opus-grace commented on Aug 17, 2026, 11:14 AM

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,          // grid.header.Toolbar        ✅
    body    = toolbar?.parent?.body; // → header.Wrapper.body → undefined ❌
...
if (body) { body.updateCellPositions(owner.dataField, newWidth) }  // silently skipped

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, headerEndno 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)

onDragEndtoolbar?.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:

  1. src/grid/Body.mjs:521afterSetMountedColumns(value, oldValue) { oldValue && this.createViewData() }. Equal array → no notify → no render at all.
  2. src/grid/Body.mjs:767if (!force && !Neo.isEqual(me.mountedColumns, me.#lastMountedColumns)) { force = true }. Equal array → force stays false → recycle stays trueRow#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?.bodyowner.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:

// before (dev):  updateMountedAndVisibleColumns(true) + createViewData()
//                → second arg defaults false → recycle stays TRUE
// after:         refreshColumns(true)
//                → createViewData(false, TRUE) → recycle FALSE

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 truefalse (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

  • Neo.grid.header.Toolbar exposes a body getter resolving the region-correct body via gridContainer + layoutLock; passSizeToBody consumes it instead of the inlined ternary.
  • grid/header/plugin/Resizable.mjs#onDragMove resolves the body via the toolbar getter; no parent.parent walk remains in the file.
  • Neo.grid.Body#refreshColumns(force) exists with JSDoc, propagates force to both updateMountedAndVisibleColumns and createViewData, and afterSetBufferColumnRange + afterSetContainerWidth both delegate to it.
  • passSizeToBody repaints via refreshColumns(true) when silent is falsy; silent=true still performs no repaint.
  • Unit test: a toolbar with layoutLock 'start'/'end'/null resolves bodyStart/bodyEnd/body respectively.
  • Unit test (fails on the unfixed tree): after a width change routed through passSizeToBody, mounted rows carry the new cell width/left while the mounted column range is asserted unchanged. Mutation-verified against three separate reverts.
  • Unit test (fails on the unfixed tree): the header button reaches the body through the header.Wrapper topology — the old owner.parent.parent.body expression is asserted undefined on the real tree.
  • Non-vacuity control: a bare 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 through Resizable. The unit suite covers the drop path end-to-end and the drag:move path 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

  • 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 minWidthwidth 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 minWidthwidth cell fix (intact; not this defect).
  • #12883 — introduced grid/header/Wrapper.mjs, the topology change that broke the lookup.
  • #9531SortZone/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

tobiu referenced in commit d41f685 - "fix(grid): restore header→cell width sync on column resize (#17289) (#17291) on Aug 17, 2026, 9:03 PM
tobiu closed this issue on Aug 17, 2026, 9:03 PM