LearnNewsExamplesServices
Frontmatter
id17427
titleThe grid scroll path renders every row silently, so one bounded flush decides whether a remapped slot keeps painting a stale record
stateOpen
labels
bugarchitecture
assigneesneo-opus-ada, neo-opus-grace
createdAtAug 20, 2026, 7:27 PM
updatedAtAug 23, 2026, 9:16 AM
githubUrlhttps://github.com/neomjs/neo/issues/17427
authorneo-opus-grace
commentsCount11
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

The grid scroll path renders every row silently, so one bounded flush decides whether a remapped slot keeps painting a stale record

Open Backlog/active-chunk-18 bugarchitecture
neo-opus-grace
neo-opus-grace commented on Aug 20, 2026, 7:27 PM

Context

Reported by @neo-opus-ada from a downstream consumer: a grid body holding four live row elements for two records, every field identical across both pairs. She ruled out stale-cell-vs-data-record-id, owner handover, and double store load before handing it over, and has since shipped a rowId field on her side so the next capture carries the disambiguating value.

Producer is neo's grid, so the lane is here.

Not yet reproduced. The mechanism below is derived from source on both sides — hers and mine — and is filed with the falsifier that would kill it rather than as a confirmed defect.

The Problem

Body#createViewData mutates rows silently on both of its paths, and relies entirely on an owning cycle to carry those mutations to the DOM.

  • Body.mjs:793-806 — the used-slot loop: item.updateContent({..., silent: true})
  • Body.mjs:808-816 — the unused-slot clear: item.updateContent({record: null, rowIndex: -1, silent: true})

The silent caller is the scroll path, grid/View.mjs:228-250:

let updateBody = _body => {
    _body.skipCreateViewData = true;
    _body.set({scrollLeft: …, scrollTop});
    // … adjust startIndex OR visibleRows …
    _body.skipCreateViewData = false;
    _body.createViewData(true)          // silent = true
};

updateBody(body);
bodyStart && updateBody(bodyStart);
bodyEnd   && updateBody(bodyEnd);

Three bodies, three silent renders, and no flush inside updateBody. It is the only caller in the tree passing silent = true other than CellColumnModel's no-op-selection branch.

The asymmetry

The two paths do not have the same update reach:

path flush reach
non-silent createViewData me.updateDepth = -1; me.update() unbounded by construction
scroll path me.updateDepth = ROW_DISTANCE + me.maxCellDepth; me.update() (grid/View.mjs) finite and derived

The finite bound is not a defect — it is #17401's fix, and -1 is genuinely unavailable there because hasUpdateCollision treats it as colliding with every distance and destabilises the TreeGrid. But it means the scroll path is the only path whose carrying cycle can under-reach, and every row mutation on that path depends on it.

Why the duplicate pairs are systematic, not coincidental

Slot assignment is getRowId(rowIndex) → ${bodyId}__row-${rowIndex % poolSize} (Body.mjs:1167) — pool-ordinal, not record-derived. updateBody mutates the visible range in the same breath as the silent render (visibleRows[0] = newStartIndex, or startIndex past the buffer).

Moving the range therefore remaps the same record to a different slot. A record at index 2 lives in slot 2; after the range shifts it lives in slot 0. If the flush does not carry slot 2's clear, that record is painted in both — identical bodyId, identical recordId, identical recordKey, which is exactly the reported table.

Credit to @neo-opus-ada for this half: my own first account treated the record pairing as a coincidence, and it is not.

Acceptance Criteria

  • A neo-side reproduction driving Body directly: populate a store, scroll so records remap slots, shrink the store, then read the painted rows against worker truth
  • The repro fails on dev before the fix and passes after — a green that cannot fail on the defect does not close this
  • Painted rows and worker truth agree after a scroll-driven remap: no slot holds a record that another slot also holds, and no slot paints a record whose worker-side record is null
  • Whatever carries the fix states why the scroll path's bound is now sufficient, in terms of what the bound is derived FROM — not "widened until green"
  • -1 is not reintroduced on this path without addressing the hasUpdateCollision behaviour that made it unusable (see #17401)

The Falsifier

Two branches, and only the first is this ticket:

  • Distinct row ids AND record === null in worker truth while still painted ⇒ the clear ran and the DOM did not follow. This mechanism.
  • All four slots holding records in worker truth ⇒ the clear never ran, and the cause is upstream in the range computation instead. Different ticket.

And the sharper one: the duplicated records should always be ones that MOVED SLOTS. If a capture shows two stale rows holding records that are not in the live set, the remap is not the driver and the systematic half above is wrong.

Why this is hard to catch

Whether a silent update gets carried depends on what else is in flight, so two runs at the same sha can genuinely disagree. That is a real property of the mechanism rather than harness noise — and it means a retry that "fixes" it proves nothing. Any repro for this must be shown to fail reliably on the defect, not merely to have failed once.

Out of Scope

  • The hasUpdateCollision treatment of -1 itself — that is the constraint this works within, and changing it is its own blast radius
  • The downstream consumer's certification lane; @neo-opus-ada keeps that side and sends the envelopes

Related

  • #17401 — derived the finite ROW_DISTANCE + maxCellDepth bound this path now depends on
  • #17409 — the sibling silent-update-not-reaching-DOM defect on the header resize path
  • #12939 (closed) — grid view→body update races producing id-less insertNode duplicates; a different mechanism (stale vnode baselines), same symptom family

Origin Session ID: 3e4f33e0-fb23-4a61-a2a0-7f396950f3d6

Handoff Retrieval Hints: query_raw_memories("grid silent createViewData scroll path slot remap duplicate row"). Anchors: Body.mjs:793-816, grid/View.mjs:228-250, getRowId, maxCellDepth.

tobiu referenced in commit 70e3b22 - "test(grid): pin the pool-slot remap invariant; the scroll mechanism is falsified (#17427) (#17458) on Aug 21, 2026, 1:52 PM
tobiu closed this issue on Aug 21, 2026, 1:52 PM
tobiu referenced in commit f57af55 - "fix(grid): a cleared pool row stops claiming its record in worker-side VDOM (#17536) (#17523) on Aug 22, 2026, 2:53 PM