Skip to content

feat(virtual): derive visibleCount and overscan from container size#13

Open
chiefcll wants to merge 5 commits into
mainfrom
virtual-derived-sizing
Open

feat(virtual): derive visibleCount and overscan from container size#13
chiefcll wants to merge 5 commits into
mainfrom
virtual-derived-sizing

Conversation

@chiefcll

@chiefcll chiefcll commented May 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces VirtualRow / VirtualColumn with a version that derives window size from the rendered layout instead of asking the caller for it. The outer API is otherwise unchanged.

Removed props (all auto-derived now):

Removed Replacement
displaySize floor(container size / (first child size + gap))
bufferSize max(2, ceil(visibleCount * 0.3))
uniformSize always treated as uniform (was the default)
factorScale dropped; layout uses unscaled item size

All other props (each, wrap, scrollIndex, onEndReached, onEndReachedThreshold, debugInfo, children, plus inherited scroll/selected/onSelectedChanged) are unchanged. VirtualGrid is untouched.

How it works

  • On mount, a single probe item is rendered so the layout pass has something to measure.
  • After the first updateLayout() the container's width/height and the first child's size are read; visibleCount, bufferSize, and itemSize are cached in a single signal.
  • Once measurement completes, the full slice is rendered and the existing computeSlice state machine takes over — all the scroll / wrap / edge logic from before is preserved verbatim.
  • Item size is assumed uniform for the lifetime of the component. Callers swapping to a differently-sized dataset should remount.

Behavior changes (beyond the removed props)

  • Callers must give the Virtual element a measurable width (Row) or height (Column) on first layout — either explicit, or sized by a flex parent. If the container has no size on the first measurement attempt, the effect re-runs when each updates.
  • First paint shows one probe item; the full window appears on the next microtask.
  • onEndReached is now latched: it fires once when the cursor crosses the threshold, re-arms when the cursor leaves the zone or each changes. Previously it fired on every keypress inside the zone, so callers doing infinite scroll got duplicate fetches.
  • flexBoundary='fixed' is now set on the container, and the default styles no longer include flexWrap: wrap or the y transition.

Context: what users are hitting with Virtual today

Two open community PRs (both against the wrap logic this PR carries over verbatim) are worth reading alongside this one — teams are patching these locally in their own libs while they wait:

  • #24 — with wrap and Config.animationsEnabled === false, every keypress overshoots the scroll position. Root cause: the non-animated path in onSelectedChanged applies shiftBy on top of a stale container position instead of first normalizing via prevChildPos - active[axis] like the animated path does. This PR preserves that bug because it keeps onSelectedChanged unchanged; it should either rebase on fix: position calculation in createVirtual function with animations disabled #24 or absorb the one-line fix.
  • #25wrap applies its left-buffer offset immediately on mount, which looks wrong for a "first time on the page" state and makes an initial left press jump to the last item. The proposed skipInitialWrap defers wrap until the user first navigates. This PR keeps the same doOnce mount-offset effect, so it neither fixes nor conflicts with the desire — but it will merge-conflict with feat(Virtual): add skipInitialWrap to defer wrap behavior until first navigation #25's implementation.

The common thread: the incremental, mutation-based scroll bookkeeping (shiftBy, originalPosition, targetPosition, doOnce, atStart) is where all the user-reported wrap bugs live. Deriving visibleCount automatically is nice, but it doesn't touch the part of Virtual that is actually costing users time.

Known issues in this PR (to fix before merge)

  • Build break: src/primitives/index.ts adds export * from './Rail.jsx', but Rail.jsx does not exist on this branch — leftover from another branch. Remove it (keep the withScrolling/handleNavigation reorder, which is legitimate).
  • Stuck in probe mode: measurement only retries when each changes. If the container is sized by a flex parent that hasn't laid out yet and the data never changes again, the component renders one item forever. Needs a retry on layout (e.g. onLayout) rather than only on data.
  • Pre-measurement calls are dropped: scrollToIndex, updateSelected, and onSelectedChanged all silently no-op while derivedDims() is undefined. Apps that restore scroll position right after mount will lose the call — queue it and replay after measurement instead.
  • Docs drift: docs/primitives/virtual.md says the buffer is ceil(visibleCount * 0.25); the code uses 0.3.
  • Partial-item peek: floor(containerSize / itemSize) undercounts when the design intentionally shows a partial item at the edge (standard TV rail pattern). The buffer usually hides this, but visibleCount also drives the total <= vc "render everything" branch and scroll anchoring — worth a ceil (or +1) and a test.
  • Dead code: commented-out // viewRef.updateLayout(); in measureAndInit.

Ideas to simplify further

  1. Make container position derived, not accumulated. Replace the incremental shiftBy mutations with one absolute formula: position = base - windowOffset * itemSize. The animated path animates to that target; the non-animated path assigns it. One code path, and the entire class of stale-position bugs from fix: position calculation in createVirtual function with animations disabled #24 becomes impossible — no more prevChildPos - active[axis] normalization, originalPosition, or targetPosition bookkeeping.
  2. Make computeSlice a pure function of (cursor, total, vc, buf, scrollIndex, mode, wrap). Today it's a state machine over prev with delta, atStart, and shiftBy threading through a 3-mode × wrap switch. Start position is derivable directly: anchor the cursor at scrollIndex (or the edge), clamp or mod, and selected = cursor - start. That deletes most of the switch and makes the slice trivially testable.
  3. Keep displaySize as an optional override. When provided: no probe phase, no measurement, works with unmeasurable containers, and existing callers migrate with zero changes. When omitted: derive as this PR does. This turns a breaking change into an additive one and doubles as the escape hatch for the probe-mode edge cases above.
  4. Drop the single-item probe. Item size is knowable from the first real layout pass regardless of how many items render — render min(each.length, smallDefault) items immediately and correct the window after measuring. Kills the one-frame single-item flash on slow TV hardware.
  5. Absorb the wrap fixes while we're here. Rebase on fix: position calculation in createVirtual function with animations disabled #24 (one-line fix in code this PR already reformats), and consider making feat(Virtual): add skipInitialWrap to defer wrap behavior until first navigation #25's ask the default: don't apply the wrap offset on mount, unlock wrap on first navigation. That deletes the doOnce effect entirely instead of adding a prop to gate it.

Test plan

  • npm run tsc — currently fails on the missing Rail.jsx export; re-verify after removing it
  • npm run lint — 0 errors (warnings are pre-existing)
  • npm test — new tests/virtual.test.tsx covers probe phase, slice windowing, navigation, latched onEndReached, scrollToIndex clamping, wrap traversal
  • Smoke-test a real app with VirtualRow and VirtualColumn to confirm one-frame measurement is imperceptible and scroll behavior matches v1
  • Verify wrap, scrollIndex, and scroll: 'edge' | 'always' | 'auto' modes still behave as before — specifically with Config.animationsEnabled = false (the fix: position calculation in createVirtual function with animations disabled #24 scenario)
  • Verify a Virtual sized by a flex parent (no explicit width/height) measures correctly on first layout

🤖 Generated with Claude Code

Removes displaySize, bufferSize, uniformSize, and factorScale props
from VirtualRow / VirtualColumn. Visible count and buffer are now
computed from container width/height and the first child's measured
size on a single layout pass; the result is cached for the lifetime
of the component (item size assumed uniform).

A single probe item is rendered until measurement completes, then the
full slice expands in. Container must have a measurable width (Row)
or height (Column) on first layout.

VirtualGrid is untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chiefcll chiefcll force-pushed the virtual-derived-sizing branch from 494bf14 to dbd9595 Compare May 17, 2026 23:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant