LearnNewsExamplesServices
Frontmatter
id12896
titleMulti-switch column drags desync sliding headers from body cells
stateClosed
labels
bugairegressiongrid
assigneesneo-fable
createdAtJun 11, 2026, 12:25 PM
updatedAtJun 11, 2026, 12:54 PM
githubUrlhttps://github.com/neomjs/neo/issues/12896
authorneo-fable
commentsCount0
parentIssue12883
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtJun 11, 2026, 12:54 PM

Multi-switch column drags desync sliding headers from body cells

Closed v13.0.0/archive-v13-0-0-chunk-17 bugairegressiongrid
neo-fable
neo-fable commented on Jun 11, 2026, 12:25 PM

Context

Sub-issue of #12883, splitting the PR-deliverable render mechanisms from the parent's operator-verified acceptance: the parent's hard-gate AC (operator L4 manual pass on locked-column DnD) remains open on #12883; THIS ticket captures exactly what PR #12894 delivers, so the PR's close-target matches its evidence ladder (per the close-target Request Changes on the PR review).

The operator's post-merge manual pass on PR #12892 caught: dragging a column header across two or more columns leaves the SLIDING neighbors' header cells and body cells out of sync — single-switch drags were unaffected (which is why the #12892 verification round missed it).

Release classification: boardless — parent #12883 carries the v13 board gate; this sub is its PR-deliverable leaf (close-target of PR #12894).

The Problem

Two independent mechanisms, both convicted live on the devindex rig (locked-columns build, 1680×1000) before any code change:

  1. Mid-drag cell-mapping staleness (regression introduced by #12892's region-local mapping): grid.header.toolbar.SortZone#switchItems calls columnPositions.move() on every switch, while pool-slot cell ids keep their render-time column binding until the next createViewData. From the second switch of a drag onward, the index-math reverse map (grid.Body#getDataField) resolves cells one-off — the drag-start hide and the per-switch slide writes land on the wrong columns (live capture: dragging Commits across two columns left Private's body cells parked in Commits' final slot). The pre-#12892 global-index code did not exhibit this staleness only because the global collection never moves mid-drag — it had the off-by-region bug instead.

  2. Chromium Element.moveBefore() stale flex layout (observed Chromium 146 and 149): after the drop's moveNode delta, the toolbar's DOM order is correct while the rendered flex order stays stale — one neighbor keeps painting at its pre-move slot. Every box-moving CSS property neutral (order:0, position:relative zero offsets, no transforms, no containment in the ancestor chain), elementFromPoint confirms the impossible layout, and the state survives minutes plus structural child-list changes (a zero-size box append/remove does NOT heal; per-node display toggles on the moved node do NOT heal; only a parent box rebuild does). Deterministic repro via a moveBefore monkey-patch log: the left-drag drop issues exactly one moveBefore(button-2, before button-3) and breaks; the right-drag drop issues two and renders fine. Synthetic isolations (plain move, absolute-at-move-time, full all-absolute drag sequences, deferred restores) all fail to reproduce — only the real page does.

The Architectural Reality

  • src/grid/Row.mjs createVdom Pass 1 stamps data: {field} into every rendered cell and pools by the region-local index at render time — the materialized binding.
  • src/grid/Body.mjs getCellId / getDataField are the dataField ↔ physical-cell mapping family consumed by the drag pipeline (getColumnCells: drag-start hide, per-switch slides, proxy clone), selection models and column.AnimatedChange.
  • src/main/DeltaUpdates.mjs moveNode uses native Element.moveBefore() (Chromium 133+) for state-preserving DOM moves (focus, iframes, canvas) with an insertBefore fallback for other engines.

The Fix (delivered by PR #12894)

  1. grid.Body#getCellId / #getDataField resolve pooled cells through the rendered data.field binding (first-row scan, exact pool-suffix match) — immune to both the off-by-region and the mid-drag-move failure modes by construction; the region-local index math remains only as the pre-first-render fallback.
  2. DeltaUpdates#moveNode heals the stale flex layout after a native moveBefore via a synchronous parent box rebuild (display toggle + forced reflow + restore — no paint in between), preserving focus and the parent's own scrollLeft/scrollTop (review catch by @neo-opus-grace). Honest boundary documented in-code: scrolled DESCENDANTS inside the parent also reset and are not walked (subtree-scan cost on every move) — tracked with the heal-gating follow-up alongside the upstream Chromium report.

Acceptance Criteria

  • Multi-switch drags (≥2 switches) keep sliding columns' header cells and body cells slot-exact, both directions (live-verified: right drag Private@487↔body 100, hidden dragged@677↔290; left drag post-drop headers [Total@387, Commits@487, Private@577, Impact@667] ↔ body [total@0, commitRatio@100, private@190]).
  • Post-drop header layout matches DOM order after every drop (the moveBefore heal).
  • BodyCellMapping.spec.mjs pins the mid-drag columnPositions.move() regression (binding wins over index math) and exact pool-suffix matching — 8/8.
  • Parent scroll state survives the heal (capture/restore symmetric with the focus guard).
  • Post-merge: covered by the parent #12883's operator L4 manual pass (multi-switch, both directions).

Out of Scope

  • The parent #12883's operator L4 manual pass (the v13 hard-gate AC — closes the parent, not this sub).
  • Heal gating / feature-detection and descendant-scroll capture (follow-up with the upstream Chromium report; note: feature-detect is infeasible — synthetic isolations do not reproduce the bug).
  • Sibling SortZone processDragEnd migration (#12895).

Related

  • Parent: #12883 (operator L4 acceptance lives there)
  • Delivered by: PR #12894
  • Predecessor PR: #12892 (introduced mechanism 1; merged)
  • Sibling follow-up: #12895

Live latest-open sweep: checked latest 20 open issues at 2026-06-11T10:24Z; no equivalent found. A2A in-flight claim sweep: 30-message slice, no overlapping lane-claims in the herd window.

Origin Session ID: e605ce21-3668-445c-bc00-45896aa9a092

Retrieval Hint: "multi-switch drag desync materialized binding moveBefore stale flex layout"

tobiu closed this issue on Jun 11, 2026, 12:54 PM