LearnNewsExamplesServices
Frontmatter
id17554
titlelist.Buffered: fixed-height component row windowing
stateClosed
labels
enhancementaiarchitectureperformancecore
assigneesneo-gpt-emmy
createdAtAug 22, 2026, 6:15 PM
updatedAtAug 22, 2026, 8:20 PM
githubUrlhttps://github.com/neomjs/neo/issues/17554
authorneo-gpt-emmy
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[ ] 17563 list.Buffered: fixed-DOM-order row recycling, [ ] 17550 Activity stream: scrollable buffered list, honest counts, per-row local times
closedAtAug 22, 2026, 8:20 PM

list.Buffered: fixed-height component row windowing

Closed Backlog/active-chunk-18 enhancementaiarchitectureperformancecore
neo-gpt-emmy
neo-gpt-emmy commented on Aug 22, 2026, 6:15 PM

Context

Intake on #17550 (Fleet activity stream) exposed a missing Runtime Engine primitive. The operator wants a real scrollable history with buffered rendering: bounded mounted DOM, not the current dead “N earlier events” fold. Current Neo offers two neighboring shapes, neither reusable for this consumer:

  • Neo.list.Component preserves list/listitem semantics, Store, ListModel, and component rows, but renders one VDOM item/component per store record.
  • Neo.grid.Body owns a fixed row pool and bufferRowRange, but Neo.grid.Container also hard-owns grid/gridcell semantics and grid chrome. A disguised one-column Grid would regress the activity stream’s existing role=log contract.
  • #13045 / PR #13051 proves settled-block windowing for the markdown component. It is the gold-standard geometry pattern, but deliberately domain-specific and variable-height-estimate shaped.

The activity case has the simplifying invariant generic lists can safely require: fixed itemHeight.

The Problem

  1. src/list/Base.mjs#createItems() iterates the full Store and writes one list item per record. src/list/Component.mjs additionally retains component instances by record index. A 500/5,000-row history therefore scales DOM and component ownership with history length.
  2. Reusing Grid would import the wrong semantic surface. GridContainer.role defaults to grid; rows emit gridcell; header/view/scrollbar composition is created unconditionally. Activity/feed/list consumers need ul/li + optional log semantics, not hidden grid chrome.
  3. Windowing cannot key DOM identity to logical records. A pool slot must survive while different records pass through it; selection, focus, and click resolution must still resolve the current logical record.
  4. Prepend-heavy live feeds need a stable scroll anchor. Rebinding the Store while a reader is in history must not steal the viewport.
  5. Implementing these rules inside apps/agentos would make a second virtual renderer outside the engine.

The Architectural Reality

  • Neo.list.Base owns Store integration, ul/li topology, Navigator subscription, ListModel selection, and the protected createItemContent(record, index) render hook.
  • Neo.list.Component owns component-item lifecycle and reuse; specialized consumers such as src/calendar/view/calendars/List.mjs update existing child instances through that hook.
  • Neo.grid.Body supplies the reusable fixed-height math precedent: available rows + symmetric buffer, stable pool slots, mounted/visible ranges, and scroll-index projection.
  • Neo.component.markdown.Component supplies stable spacer IDs, page-quantized range changes, and “do not rebuild inside the buffer” precedent.
  • Main-thread resize observation already has the standard register/unregister path used by Grid and Helix.

The Fix

Add src/list/Buffered.mjs: Neo.list.Buffered extends Neo.list.Component and keeps the existing list consumer contract while replacing record-cardinality rendering with a fixed-height component pool.

  1. Fixed-height contract. A positive inherited itemHeight is mandatory; construction fails loudly when absent/invalid. Variable-height measurement is not smuggled into this leaf.
  2. Mounted range. New reactive bufferRowRange (default 3) plus observed viewport height derive availableRows, mountedRange, and a pool bounded by availableRows + 2 × bufferRowRange (clamped to Store count).
  3. Stable topology. The root keeps list semantics. Stable top/bottom spacer IDs represent unmounted extents; stable slot IDs represent pooled li nodes. Logical record identity rides an explicit data field and the class’s lookup methods—not the slot id.
  4. Component recycling. src/list/Base.mjs passes an optional physical poolIndex through the existing createItem()createItemContent() hook. Buffered adds itemConfig (object or factory) + recordProperty for the generic path: one component is created per physical slot and reconfigured with the current record. Subclasses may still override createItemContent(record, logicalIndex, poolIndex) directly. Excess slots are destroyed when the viewport/pool shrinks.
  5. Scroll + resize. Normal captured scroll events move the mounted range only after the visible range approaches/exits its buffer; ResizeObserver deliveries recompute capacity. Scrolling within the current range is delta-free.
  6. Store mutations. Load/sort/filter/recordChange rebind only affected mounted slots. When records prepend/reorder while the reader is away from the leading edge, preserve the first visible logical record + pixel offset if that record survives; otherwise clamp deterministically.
  7. Selection/focus. Click, selection, selectItem(), and programmatic focus resolve logical records across recycling. Targeting an unmounted record first scrolls/mounts it, then delegates to the normal Navigator/ListModel path.
  8. Documentation. Full JSDoc/Anchor-and-Echo coverage and a src/list/ row in learn/benefits/ArchitectureOverview.md’s Structural Inventory. No ADR: this is a localized Runtime Engine class using existing List/Grid contracts.

Canonical unit coverage: test/playwright/unit/list/Buffered.spec.mjs, following the pooling discipline in test/playwright/unit/grid/Pooling.spec.mjs.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback / Edge Case Docs Evidence
Neo.list.Buffered (new) Neo.list.Component + this ticket Store-bound component list with bounded fixed-height row pool and list semantics invalid/missing itemHeight throws before mount class JSDoc + Structural Inventory 500/5,000-record pool-bound unit witnesses
bufferRowRange_ (new) Neo.grid.Body#bufferRowRange non-negative integer rows mounted above/below visible range; default 3 invalid assignment keeps prior valid value config JSDoc edge cases 0/default/runtime change
mountedRange / pool slots (new readonly state) Grid pool precedent stable slot topology; range maps slots to current logical records empty Store → two zero-height spacers, zero rows member/method JSDoc slot identity + component identity across scroll
itemConfig / recordProperty (new) Neo.grid.column.Component#component pooling precedent object/factory config creates once per slot; current record is assigned through the declared property on recycle invalid/absent config falls back to ordinary list text rendering config + class JSDoc pooled component identity and record-change witnesses
createItemContent(record, logicalIndex, poolIndex) (extended protected hook) Neo.list.Base#createItemContent; Neo.list.Component consumers logical index stays Store-relative; pool index identifies the reusable component slot subclass may ignore the optional third argument and keep ordinary behavior method JSDoc Base compatibility + buffered subclass fixture
logical record mapping ListModel/Navigator contracts slot events and APIs resolve the currently bound Store record; unmounted targets scroll before focus/select removed target clears/clamps through current ListModel rules method JSDoc click/select/focus across recycle
prepend/reorder scroll anchor #17550 consumer requirement preserve first visible record + pixel offset while reading history missing anchor record clamps to nearest surviving logical index method JSDoc prepend/sort/filter fixtures

Acceptance Criteria

  • A 5,000-record Store mounts no more than availableRows + 2 × bufferRowRange list rows/components; top + bottom spacers expose the full scroll extent.
  • Scrolling rebinds stable pool slots to the correct records; component and DOM-slot identities survive while logical record ids change explicitly.
  • Scroll inside the mounted buffer emits no structural rebuild; crossing the range boundary changes only edge slots/spacer sizes.
  • Resize grows/shrinks the pool deterministically and destroys excess component instances; unregister/destroy leaves no observer or component leak.
  • Load, recordChange, sort, and filter update the correct mounted rows without record-cardinality rebuilds.
  • A prepend while scrolled into history preserves the same first visible record and pixel offset; leading-edge readers remain at the leading edge.
  • Click, selection, selectItem(), and focus resolve correct logical records before and after recycling, including an initially unmounted target.
  • Root/listitem/log-compatible semantics remain valid; no grid/gridcell roles or hidden grid chrome appear.
  • generate-docs-json for touched classes and the Structural Inventory update land in the same commit/PR.

Out of Scope

Variable-height measurement/reflow · horizontal virtualization · Neo.grid.* changes · markdown windowing changes · animation interoperability with list.plugin.Animate (#17552) · the Fleet activity consumer/count contract (#17550).

Avoided Traps

  • App-local virtualizer: rejected; list windowing is Runtime Engine ownership.
  • One-column hidden Grid: rejected; performance reuse does not justify semantic/chrome regression.
  • Record-keyed DOM nodes: rejected; record identity and physical pool identity are different truths.
  • Generic variable-height framework: rejected for this leaf; fixed itemHeight makes the pool deterministic and one-PR-sized.
  • Plugin interception stack: rejected for the first slice; a dedicated list class avoids order-sensitive method replacement beside list.plugin.Animate.

Related

Consumer: #17550 (will be blocked by this ticket after creation). Gold standard: #13045 / PR #13051. Adjacent/disjoint: #17552. Grid precedent: src/grid/Body.mjs.

Decision Record impact: none.

Structural pre-flight: full path. Chosen src/list/Buffered.mjs over src/grid/ and apps/agentos/; Structural Inventory update required. The repo-wide structure-map command currently fails with a V8 string-length error; bounded --root src/list --files --loc succeeded (6 list classes + 1 plugin, 914 LOC).

Live latest-open sweep: checked the newest 20 open issues at 2026-08-22T16:13Z; no equivalent. Exact live search found only consumer #17550 and disjoint animation #17552. A2A last-30 sweep found no competing claim; claim MESSAGE:dee7535c-a67f-4cf9-9c96-c6707239453a owns this leaf.

Origin Session ID: f47f948b-743b-4c11-84a8-fa60a567a148

Retrieval Hint: “fixed-height buffered component list stable row pool scroll anchor list semantics”

tobiu referenced in commit 762a332 - "feat(list): add fixed-height buffered component rows (#17554) (#17557)" on Aug 22, 2026, 8:20 PM
tobiu closed this issue on Aug 22, 2026, 8:20 PM