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):
onScrollCapture(836) arrives as one event while the list is idle (needsVdomUpdate: false).
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.
- 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
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
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 (CDPEmulation.setCPUThrottlingRate) on Engine2d3e6fa8.src/list/Buffered.mjs,src/mixin/VdomLifecycle.mjsandsrc/manager/VDomUpdate.mjsare identical on dev906244aa.Observed after one wheel to the end of a 30-row
Neo.list.Bufferedwhose items areitemConfigcomponents (App worker read throughNeo.worker.App.getConfigs):scrollTop836,mountedRange[18, 30],isVdomUpdatingfalse,needsVdomUpdatefalsevdomholds the new record (all message 75), itsvnodeand the DOM the old one (all message 3)Observed sequence (App-worker wrappers on
onScrollCapture,createItems,updateVdom,getVdomUpdatePayload,resolveVdomUpdate):onScrollCapture(836)arrives as one event while the list is idle (needsVdomUpdate: false).createItems→getPooledComponentre-binds all 12 rows withcomponent.set(update, true). Each row'supdateVdomruns silent and finds no ancestor with a pending update to merge into, so it only flags itself.updateDepthto 3 and starts its own update. Its payload is built withids= 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.Bufferedchanges its pooled descendants silently and relies on its own update, with a raisedupdateDepth, 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.mjsgetPooledComponent()(L557–615) re-binds withcomponent.set(update, true)and raisesme.updateDepth = Math.max(me.updateDepth, 1 + TreeBuilder.getComponentDepth(component))(L612). It never declares the change dense.src/mixin/VdomLifecycle.mjsL330:const ids = component.updateDepth !== 1 && !component.denseUpdate ? mergedChildIds : null;. A payload is sparse unless its owner says otherwise.src/manager/VDomUpdate.mjsgetMergedChildIds()(L464) returnsnew Set(item.children.keys())for any existing entry. An entry whose children were detached therefore yields an empty set, notnull(inferred from the observed emptyids).src/grid/View.mjsL879 and L1249 setdenseUpdate = truebeside 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.mjsL1662 (hide()) andsrc/form/Fieldset.mjsL222/L227 do the same.src/mixin/VdomLifecycle.mjsL44–46, thedenseUpdateJSDoc: "True for one update cycle: the owner changed descendants silently within itsupdateDepth, 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 atneoIgnoreand keep the record that first occupied their physical slot".The Fix
src/list/Buffered.mjs#getPooledComponent: setme.denseUpdate = truewhere the depth is raised, with its one-line reason. This mirrorsgrid/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 withitemConfigcomponents, 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
getPooledComponentdeclares a silent re-bind dense, with its reason.npm run test-unitfull suite on the merge-base and the head: no new failures.Isolated InstitutionCI job is green.Out of Scope
VDomUpdate#getMergedChildIdsshould returnnullfor 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.list.Bufferedsighting (defect-noteMESSAGE: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.Avoided Traps
updateDepth = -1.hasUpdateCollisiontreats it as colliding at every distance (seegrid/View.mjsL1244–1246).nullfromgetMergedChildIdsfor 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.Bufferedor 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 overlappingRetrieval Hint: "list.Buffered silent pooled re-bind pruned by a sparse payload, denseUpdate" Origin Session ID: 77c3c004-7908-4549-9190-b4f9c3cfa9dc