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});
_body.skipCreateViewData = false;
_body.createViewData(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
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.
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 arowIdfield 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#createViewDatamutates 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 passingsilent = trueother thanCellColumnModel's no-op-selection branch.The asymmetry
The two paths do not have the same update reach:
createViewDatame.updateDepth = -1; me.update()me.updateDepth = ROW_DISTANCE + me.maxCellDepth; me.update()(grid/View.mjs)The finite bound is not a defect — it is #17401's fix, and
-1is genuinely unavailable there becausehasUpdateCollisiontreats 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.updateBodymutates the visible range in the same breath as the silent render (visibleRows[0] = newStartIndex, orstartIndexpast 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, identicalrecordId, identicalrecordKey, 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
Bodydirectly: populate a store, scroll so records remap slots, shrink the store, then read the painted rows against worker truthdevbefore the fix and passes after — a green that cannot fail on the defect does not close thisrecordisnull-1is not reintroduced on this path without addressing thehasUpdateCollisionbehaviour that made it unusable (see #17401)The Falsifier
Two branches, and only the first is this ticket:
record === nullin worker truth while still painted ⇒ the clear ran and the DOM did not follow. This mechanism.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
hasUpdateCollisiontreatment of-1itself — that is the constraint this works within, and changing it is its own blast radiusRelated
ROW_DISTANCE + maxCellDepthbound this path now depends oninsertNodeduplicates; a different mechanism (stale vnode baselines), same symptom familyOrigin 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.