Replies: 2 comments
|
Phew. After deciphering all that, I would agree that (if the virtual scrollbar could look as similar as possible to the native scrollbar) then only a virtual one one would not be needed. |
Thank you, this is exactly the requirement the virtual scrollbar needs, and it is now part of the plan for it (RFC-014, discussion #127):
Alongside this, the bar gets a real accessibility contract ( |
Uh oh!
There was an error while loading. Please reload this page.
RFC-012: Logical Scroll Model
Status: Approved (with required amendments)
Author: floor
Type: Core Architecture
Created: 2026-06-06
Amended: 2026-06-07 — committee review (GPT-5.5, Gemini 3.1, Opus 4.6, Opus 4.8, Codex)
Summary
Replace the current model where
vlist-contentphysically represents ALL items (potentially millions of pixels tall) with a logical scroll model where scroll position is{ index, offsetPx }— not a physical pixel offset into a giant container.The content container becomes viewport-sized. Items are positioned via transforms relative to the logical scroll position. This eliminates scroll compression, the scale plugin's coordinate mapping, the carousel's 101-cycle virtual window, and the browser's 16.7M pixel limit — all consequences of a single wrong architectural choice.
Motivation
The wrong choice
The current architecture sets
vlist-content.style.heightto the total physical size of all items:This single decision cascades into every part of the codebase:
Browser pixel limit: Chrome/Safari cap element dimensions at 16,777,216px (2²⁴). Content taller than this loses scroll precision. The scale plugin was created to "compress" the coordinate space to fit under this limit.
Compression everywhere: The compression is not contained — it leaks into 24 files and 516 code references:
rendering/scale.tsrendering/viewport.tsplugins/scale/plugin.tsplugins/scrollbar/controller.tsrendering/renderer.tsplugins/table/renderer.tsplugins/grid/plugin.tsplugins/grid/renderer.tsplugins/table/plugin.tsplugins/tree/plugin.tsEvery renderer asks "am I compressed?" Every scroll calculation branches on it. Every plugin that positions items — grid, table, tree, scrollbar, snapshots — must handle compressed coordinates.
calculateCompressedItemPosition,compressionCtx,CompressionContexttouch the entire render pipeline.Carousel virtual inflation: The carousel plugin inflates
totalItemsby 101× to create a virtual scroll window, then rebases when approaching the edges. This is fragile, leaks virtual indices into the public API, and the loop isn't truly circular.Performance cost: Every frame, the render pipeline decompresses coordinates for every visible item. The browser maintains layout state for a multi-million-pixel element. Scroll positions are large floats that lose precision far from the origin.
The insight
We only render ~15-20 items at a time. The content container doesn't need to represent all items physically. It just needs to hold the visible items — viewport-sized.
The scroll position should be a logical value (which item is at the top + how far into that item), not a physical pixel offset into a giant container.
The compression was not a tradeoff — it was technical debt from a wrong root choice. Remove the root choice, and 516 compression references become dead code.
Design
Content model
Items are positioned within the viewport-sized content using
transform: translateY(offset)relative to the logical scroll position, exactly as they are today. The only change is that the content div no longer grows with the number of items.The content element must set
overflow-anchor: noneto prevent the browser's scroll anchoring from fighting DOM recycling and scroll-window rebasing.Logical scroll position
Today,
engineState.scrollPositionis a pixel offset into the content (0 tototalItems × itemSize). In the new model, it becomes a logical value:The internal primitive is
{ index, offsetPx }— a pixel offset within the top visible item. This is stable under variable and autosized items: when an item's measured size changes, the pixel offset remains physically meaningful. Fraction (offsetPx / itemSize) is derived only for scrollbar thumb position or persistence, never used as the source of truth.This shape already exists in the codebase as
ScrollSnapshot.offsetInItem(src/types.ts:275).Scroll input
The browser's native scroll needs physical content to scroll. Three options:
Option A: Hidden scroll proxy
A hidden div with
overflow: autoand a tall-enough content child (e.g.,3 × viewport height) captures native wheel/touch events with browser-provided momentum and inertia. The proxy'sscrollTopis read on each frame and mapped to the logical position. When the proxy approaches its edges, it silently wraps.Pro: native inertia, native touch physics.
Con: proxy management, small rebasing.
Option B: Wheel/touch interception
Intercept
wheelandtouchmoveevents directly. Apply custom inertia/momentum. Map delta to logical position changes.Pro: no proxy, no rebasing, full control.
Con: must reimplement scroll physics. Platform differences.
Option C: Hybrid — small overflow window
Content is
3 × viewport height. Native scroll operates within this window. WhenscrollTopnears the top or bottom, silently rebase to the center.Pro: native inertia, small content, simple.
Con: still has rebasing (but trivial — 1800px window, not millions).
Recommendation: Option C for v1. Smallest change from current architecture, native scroll physics preserved. Option A or B can follow if Phase 0 reveals rebasing issues.
Constraint: rebasing must never occur during active momentum scrolling or overscroll/rubber-banding — only during idle or non-elastic scroll phases. If this constraint cannot be satisfied in Chrome, Safari, and Firefox, Phase 1 falls back to Option A (hidden proxy) or a larger bounded window.
Scrollbar
The native scrollbar is meaningless in this model — the content is always viewport-sized. The custom
scrollbar()plugin already renders a virtual scrollbar for scale mode. In the new model, ALL scrollbars are virtual:logicalPosition / totalItemsvisibleCount / totalItemsThis is simpler than today, where the scrollbar has two modes (native vs. compressed).
Page mode
The page plugin (
src/plugins/page/plugin.ts) installs custom scroll functions and derives scroll position fromgetBoundingClientRect(). In the current model, the list contributes physical height to the document. In the logical model, it does not.A giant document spacer would reintroduce the browser pixel limit this RFC exists to eliminate. Page mode will use a bounded scroll proxy approach: a document-level spacer capped at a safe size (e.g., 3× viewport), with the same rebasing model as the main scroll input. Alternatively, page mode may intercept window scroll events directly. The full design is Phase 1 work, but the constraint is fixed: page mode must not require unbounded document height.
Plugin migration boundary
Plugins currently access scroll state through
PluginContexthooks (onBeforeScroll,onAfterScroll,getScrollPosition()) and direct DOM reads. A handful of plugins have leaked past the hook abstraction to read rawscrollTop/scrollLeftdirectly.Phase 1 will introduce a
ScrollAdapteras the formal boundary between the logical scroll model and plugin code. The exact interface is Phase 1 design work, but its role is defined: plugins that need scroll position go through the adapter, which exposes both logical ({ index, offsetPx }) and pixel-equivalent accessors. Direct DOM scroll reads become an anti-pattern.Cross-axis scroll (e.g., table horizontal overflow) remains native and is not affected by main-axis virtualization.
What changes
Removed (516 references)
compressionCtxcalculateCompressedItemPositionCompressionContexttypeif (compressionCtx)in every rendererStays the same
VListPlugin<T>interface)scrollToIndex,getScrollPosition, events)Simplified
totalItems, truly circularcompressionCtxparameter threadingPerformance impact
Compatibility and semver
This is a breaking change. vlist is pre-1.0, so breaking changes are permitted under semver, but the migration path must be explicit.
Public API continuity
getScrollPosition()and scroll event payloads continue returning pixel equivalents (computed fromindex * itemSize + offsetPx). No API break for consumers reading scroll position.scrollToIndex(i)continues working as today.scrollToPosition(px)translates to the logical model internally.data-index,aria-posinset— unchanged.Deprecation ladder
scale()becomes a no-op that logs a deprecation warning.ScalePluginConfigexport preserved.scale()export removed. Compression helpers removed fromvlist/internals.Known tradeoffs
Viewport-sized content means the browser's native scrollbar, find-in-page, and scroll-to-reveal behaviors no longer work natively. The virtual scrollbar replaces the first; the other two are already limited by virtualization and are not regressions of this RFC.
Migration
The change is internal to the core. The public API translates between logical and pixel positions at the boundary:
scrollToIndex(i)→ setslogicalPosition = { index: i, offsetPx: 0 }getScrollPosition()→ returns a pixel equivalent from logical positionPlugins that read
engineState.scrollPositionswitch to the logical model via theScrollAdapter. Most plugins don't read it directly — they use the sizeCache and render pipeline hooks.Open questions
Variable-size items: prefix sums give O(1) offset lookups today. In the logical model,
indexAtOffsetuses binary search on prefix sums keyed by logical index. Performance should be similar.Accessibility: screen readers use native scroll position for cues. The virtual scrollbar needs correct ARIA signals (
role="scrollbar",aria-valuenow,aria-valuemin,aria-valuemax,aria-controls).RTL:
scrollLeftsemantics differ across browsers in RTL mode. The rebase implementation must normalize RTL scroll values explicitly.Autosize anchoring: when items above the viewport are measured and change size, the logical anchor (
{ index, offsetPx }) must be adjusted to prevent visual jumps. Anchoring rules for measurements above, inside, and below the viewport are Phase 1 design work.Implementation phases
overflow: auto, 3× viewport height, item recycling,scrollToprebasing. Test on Chrome, Safari, Firefox (desktop trackpad/wheel/keyboard), iOS Safari (touch momentum, rubber-banding), Android Chrome (touch momentum). Acceptance: rebasing is not detectable during any input mode. If it fails, evaluate larger window or Option A. Gate for Phase 1.{ index, offsetPx }as internal state. Option C (or fallback) in core.ScrollAdapterintroduced.scale()becomes deprecated no-op. Page mode adapted with bounded scroll proxy.overflow-anchor: noneon content.compressionCtx,CompressionContext,calculateCompressedItemPositionfrom render pipeline. Remove scale plugin.Phase 1 checklist (deferred from RFC)
These are real work items resolved during Phase 1, not RFC gates:
ScrollAdapterinterface designscrollLeftnormalizationCommittee review
Reviewed 2026-06-07 by GPT-5.5, Gemini 3.1, Opus 4.6, Opus 4.8, Codex.
Consensus: architecture approved unanimously. Five amendments adopted (offsetPx, Phase 0, overflow-anchor, semver plan, page-mode commitment). GPT's remaining concerns (plugin migration, ARIA, RTL, autosize) moved to Phase 1 checklist.
References
src/plugins/scale/plugin.ts(650 lines)src/rendering/scale.ts(74 references alone)src/plugins/carousel/plugin.ts— 101-cycle virtual windowScrollSnapshot.offsetInItematsrc/types.ts:275scratchpad/RFC-012-Logical-Scroll-Model/All reactions