LearnNewsExamplesServices
Frontmatter
id19762
titleBuffered list rows keep their old records after a scroll under load
stateClosed
labels
bugaicore
assigneesneo-opus-grace
createdAtOct 15, 2026, 6:37 PM
updatedAtOct 15, 2026, 7:51 PM
githubUrlhttps://github.com/neomjs/neo/issues/19762
authorneo-opus-grace
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtOct 15, 2026, 7:37 PM

Buffered list rows keep their old records after a scroll under load

neo-opus-grace
neo-opus-grace commented on Oct 15, 2026, 6:37 PM

Context

Found while hardening neomjs/neo-agent-institution#703's e2e journey (FleetActivityObserver.spec.mjs:120). It failed once in CI (run 38762781142, attempt 1) and passed on re-run. Locally it reproduces 6/6 under 6× CPU throttling (CDP Emulation.setCPUThrottlingRate) on Engine 2d3e6fa8. src/list/Buffered.mjs, src/mixin/VdomLifecycle.mjs and src/manager/VDomUpdate.mjs are identical on dev 906244aa.

Observed after one wheel to the end of a 30-row Neo.list.Buffered whose items are itemConfig components (App worker read through Neo.worker.App.getConfigs):

  • list: scrollTop 836, mountedRange [18, 30], isVdomUpdating false, needsVdomUpdate false
  • every pooled row component: its vdom holds the new record (all message 75), its vnode and the DOM the old one (all message 3)
  • nothing is queued, so the rows stay stale until some later list update ships them.

Observed sequence (App-worker wrappers on onScrollCapture, createItems, updateVdom, getVdomUpdatePayload, resolveVdomUpdate):

  1. onScrollCapture(836) arrives as one event while the list is idle (needsVdomUpdate: false).
  2. createItems → getPooledComponent re-binds all 12 rows with component.set(update, true). Each row's updateVdom runs silent and finds no ancestor with a pending update to merge into, so it only flags itself.
  3. The list raises updateDepth to 3 and starts its own update. Its payload is built with ids = an empty Set, so sparse tree generation treats every row as clean: payload LIST depth=3 ids= → resolve LIST, with no row payload and no row resolve.

In the passing store swap just before it, the rows were re-bound while the list already had a pending update. They merged, and the payload carried them (payload LIST depth=3 ids=row0|…|row11).

The Problem

list.Buffered changes its pooled descendants silently and relies on its own update, with a raised updateDepth, to ship them. That holds only when the rows happen to merge into that update. When the list is idle as the re-bind happens, any non-null merged set makes the payload sparse, and the silently changed rows fall outside it. Under load, the operator sees the previous records in the new positions until another list update lands.

The Architectural Reality

  • src/list/Buffered.mjs getPooledComponent() (L557–615) re-binds with component.set(update, true) and raises me.updateDepth = Math.max(me.updateDepth, 1 + TreeBuilder.getComponentDepth(component)) (L612). It never declares the change dense.
  • src/mixin/VdomLifecycle.mjs L330: const ids = component.updateDepth !== 1 && !component.denseUpdate ? mergedChildIds : null;. A payload is sparse unless its owner says otherwise.
  • src/manager/VDomUpdate.mjs getMergedChildIds() (L464) returns new Set(item.children.keys()) for any existing entry. An entry whose children were detached therefore yields an empty set, not null (inferred from the observed empty ids).
  • Precedent, same class: src/grid/View.mjs L879 and L1249 set denseUpdate = true beside their raised depth ("The rows changed silently, so a body update merging into this cycle must not make it sparse", #18719 / d6d0d14bd4). src/component/Base.mjs L1662 (hide()) and src/form/Fieldset.mjs L222/L227 do the same.
  • Design authority: src/mixin/VdomLifecycle.mjs L44–46, the denseUpdate JSDoc: "True for one update cycle: the owner changed descendants silently within its updateDepth, so the cycle ships that subtree whole instead of sparse, even when children merge into it." The list's own comment (L609–611) holds the same contract: without the reach, "mounted child components stop at neoIgnore and keep the record that first occupied their physical slot".

The Fix

src/list/Buffered.mjs#getPooledComponent: set me.denseUpdate = true where the depth is raised, with its one-line reason. This mirrors grid/View.mjs.

Witness: with that line patched into the Engine copy, the throttled journey passes 4/4, and every row's vdom, vnode and DOM agree. Without it the journey failed 6/6.

Unit spec beside test/playwright/unit/list/Buffered.spec.mjs: a buffered list with itemConfig components, an idle list, then a scroll-capture jump that re-binds the pool. Assert that the payload is dense and every pooled row's vnode carries its new record after the update settles. It must be red on dev and green with the fix.

Acceptance Criteria

  • getPooledComponent declares a silent re-bind dense, with its reason.
  • A unit spec reproduces the stranded rows on the current code (red, shown in the PR) and passes with the fix.
  • npm run test-unit full suite on the merge-base and the head: no new failures.
  • Post-merge: the #703 journey passes under 6× CPU throttling against Engine dev, and #703's Isolated Institution CI job is green.

Out of Scope

  • Whether VDomUpdate#getMergedChildIds should return null for an entry without children. With the dense declaration the list no longer depends on it, and changing the manager's contract touches every sparse owner.
  • The earlier list.Buffered sighting (defect-note MESSAGE:b46a3373): a store that empties and refills before its update lands leaves the emptied population's pooled rows in the DOM. That trigger (pool trim and recreate) is different. Re-test it against this fix, and keep it as its own note if it survives.
  • neomjs/neo-agent-institution#703 itself: its spec is the witness and stays as is.

Avoided Traps

  • Hardening the e2e with a second wheel or a wait. The first wheel already moved the DOM. A second scroll only triggers an update that heals the drift, which hides a race the operator can see.
  • updateDepth = -1. hasUpdateCollision treats it as colliding at every distance (see grid/View.mjs L1244–1246).
  • Returning null from getMergedChildIds for an empty entry. That fixes this path only by accident: a list with one genuinely merged child (focus, selection) would still prune the re-bound rows.

Related

#18719 (grid silent repaint goes dense) · #19499 (dirty roots excluded by sparse parents) · neomjs/neo-agent-institution#703 · neomjs/neo-agent-institution#596

Live latest-open sweep: checked latest 20 open issues at 2026-10-15T16:36:21Z; no equivalent found A2A claim sweep: last 30 messages (all read-states); no claim on list.Buffered or vdom sparse updates MC sweep: "buffered list rows show old records after scroll, DOM rows do not update, vdom ahead of vnode, sparse payload prunes pooled rows" + "list.Buffered pooled rows stale DOM silent rebind sparse update denseUpdate", 12 results, no prior decision found Own-assignment sweep: 2 open (#19652, #15000), none overlapping

Retrieval Hint: "list.Buffered silent pooled re-bind pruned by a sparse payload, denseUpdate" Origin Session ID: 77c3c004-7908-4549-9190-b4f9c3cfa9dc

tobiu referenced in commit f9dd8a0 - "fix(list): a silent pooled re-bind ships dense, so a merged row update cannot prune it (#19762) (#19763) on Oct 15, 2026, 7:37 PM
tobiu closed this issue on Oct 15, 2026, 7:37 PM