From dbd959561556874c387b7bcd46f65b7b2f776581 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 17 May 2026 09:37:31 -0400 Subject: [PATCH] feat(virtual): derive visibleCount and overscan from container size 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 --- docs/primitives/virtual.md | 40 ++++++--- src/primitives/Virtual.tsx | 178 ++++++++++++++++++++++++------------- 2 files changed, 141 insertions(+), 77 deletions(-) diff --git a/docs/primitives/virtual.md b/docs/primitives/virtual.md index 52886ca..3694e04 100644 --- a/docs/primitives/virtual.md +++ b/docs/primitives/virtual.md @@ -5,8 +5,9 @@ ### Behavior - Renders a 1D list of items. -- Uses `displaySize` to define the number of visible items. -- Uses `bufferSize` to pre-render additional items to the left and right of the visible window for smoother scrolling. +- Visible count and overscan buffer are derived automatically from the container size and the first child's measured size — no need to pass `displaySize` or `bufferSize`. +- On mount, a single probe item is rendered so the layout pass can measure it; the full slice is rendered as soon as measurement completes. +- Item size is assumed uniform; the first child is the reference. If your dataset swaps to differently-sized items, remount the component. - Only a subset of the total items is rendered, improving performance. - Triggers `onEndReached` when the user approaches the end of the list, allowing for infinite scrolling or fetching more data. - Focus is updated and maintained internally, with optional control via `scrollToIndex`. @@ -20,8 +21,7 @@ import { VirtualRow, VirtualColumn } from './primitives/Virtual'; ; ``` +The container must have a measurable `width` (for `VirtualRow`) or `height` (for `VirtualColumn`) on first layout — either set explicitly or sized by a flex parent that has finished laying out. + ### Props - **each** (`readonly T[] | undefined | null | false`): The full list of items to be rendered. -- **displaySize** (`number`): Number of items per row (required). -- **bufferSize** (`number`): Number of items to pre-render above and below the visible area (default: `2`). - **wrap** (`boolean`): If `true`, navigation wraps around the ends of the list and scrolling loops infinitely. - **scrollIndex** (`number`): Specifies the index within the visible window where the focus should be anchored during scrolling. -- **onEndReached** (`() => void`): Callback triggered when selection moves near the end of the list Requires `onEndReachedThreshold` to be set. +- **onEndReached** (`() => void`): Callback triggered when selection moves near the end of the list. Requires `onEndReachedThreshold` to be set. - **onEndReachedThreshold** (`number`): Number from end of items when `onEndReached` will be called (default: `undefined`). -- **debugInfo** (`boolean`): Logs internal slice recalculations and bounds shifts to console. -- **factorScale** (`boolean`): If `true`, scrolling calculations will take item focus scale into account. -- **uniformSize** (`boolean`): If `true` (default), assumes all items are uniform size to calculate scrolling, improving performance. +- **debugInfo** (`boolean`): Logs internal slice recalculations, derived dimensions, and bounds shifts to the console. - **children** (`(item: Accessor, index: Accessor) => JSX.Element`): Function that renders each item. - **selected** (`number`): Initial selected index. - **autofocus** (`boolean`): If `true`, the component will auto-focus the first item on mount. @@ -54,15 +52,29 @@ Use `cursor` property on the node to get the absolute index in the list of items ### Performance Optimization - Renders only a subset of the full list (`slice`) for improved memory and render-time performance. +- Visible count and buffer are computed once from a single child measurement, then cached. - Automatically re-calculates the slice on selection or data change. - Reuses Children components - Merges styles with internal layout defaults: - - `display: flex`, `flexWrap: wrap`, `gap: 30` - - `transition: { y: 250ms ease-out }` + - `display: flex`, `gap: 30` + - Column variant adds `flexDirection: column` ### Focus & Navigation - Focus is managed via `selected` and handled automatically when navigating. -- Navigation jumps vertically by row (`onUp`, `onDown`) and horizontally by item (`onLeft`, `onRight`). -- When the selected index crosses a row boundary, the slice is updated and the grid scrolls accordingly. +- Navigation moves by item (`onLeft`/`onRight` for `VirtualRow`, `onUp`/`onDown` for `VirtualColumn`). +- When selection crosses the window edge, the slice is updated and the row/column scrolls accordingly. - Internal `onSelectedChanged` adjusts for slice-relative index offset to maintain correct selection. + +### Migration from previous versions + +The following props were removed and are now derived automatically: + +| Removed prop | Replacement | +| ------------- | ------------------------------------------------ | +| `displaySize` | derived from `container size / first child size` | +| `bufferSize` | derived as `max(2, ceil(visibleCount * 0.25))` | +| `uniformSize` | always treated as uniform (was the default) | +| `factorScale` | dropped; layout uses unscaled item size | + +If you previously passed `displaySize` to constrain how many items render, set `width`/`height` on the container instead — the visible count will follow. diff --git a/src/primitives/Virtual.tsx b/src/primitives/Virtual.tsx index 5e4698e..9274645 100644 --- a/src/primitives/Virtual.tsx +++ b/src/primitives/Virtual.tsx @@ -12,18 +12,20 @@ import { export type VirtualProps = lng.NewOmit & { each: readonly T[] | undefined | null | false; - displaySize: number; - bufferSize?: number; wrap?: boolean; scrollIndex?: number; onEndReached?: () => void; onEndReachedThreshold?: number; debugInfo?: boolean; - factorScale?: boolean; - uniformSize?: boolean; children: (item: s.Accessor, index: s.Accessor) => s.JSX.Element; }; +type DerivedDims = { + visibleCount: number; + bufferSize: number; + itemSize: number; +}; + function createVirtual( component: typeof lngp.Row | typeof lngp.Column, props: VirtualProps, @@ -31,29 +33,36 @@ function createVirtual( ) { const isRow = component === lngp.Row; const axis = isRow ? 'x' : 'y'; + const sizeDim = isRow ? 'width' : 'height'; const [cursor, setCursor] = s.createSignal(props.selected ?? 0); - const bufferSize = s.createMemo(() => props.bufferSize || 2); const scrollIndex = s.createMemo(() => props.scrollIndex || 0); const items = s.createMemo(() => props.each || []); const itemCount = s.createMemo(() => items().length); const scrollType = s.createMemo(() => props.scroll || 'auto'); + // Derived from a single measurement of the container + first child. + // `undefined` means we haven't measured yet — slice rendering stays in probe mode. + const [derivedDims, setDerivedDims] = s.createSignal(); + const visibleCount = () => derivedDims()?.visibleCount ?? 0; + const bufferSize = () => derivedDims()?.bufferSize ?? 2; + const selected = () => { - if (itemCount() <= props.displaySize) { - return utils.clamp(props.selected || 0, 0, Math.max(0, itemCount() - 1)); + const vc = visibleCount(); + const total = itemCount(); + if (!vc) { + return utils.clamp(props.selected || 0, 0, Math.max(0, total - 1)); + } + if (total <= vc) { + return utils.clamp(props.selected || 0, 0, Math.max(0, total - 1)); } if (props.wrap) { return Math.max(bufferSize(), scrollIndex()); } - return utils.clamp(props.selected || 0, 0, Math.max(0, itemCount() - 1)); + return utils.clamp(props.selected || 0, 0, Math.max(0, total - 1)); }; - let cachedScaledSize: number | undefined; let targetPosition: number | undefined; let cachedAnimationController: lng.IAnimationController | undefined; - const uniformSize = s.createMemo(() => { - return props.uniformSize !== false; - }); type SliceState = { start: number; @@ -82,24 +91,8 @@ function createVirtual( return delta; } - function computeSize(selected: number = 0) { - if (uniformSize() && cachedScaledSize) { - return cachedScaledSize; - } else if (viewRef) { - const gap = viewRef.gap || 0; - const dimension = isRow ? 'width' : 'height'; // This can't be moved up as it depends on viewRef - const prevSelectedChild = viewRef.children[selected]; - - if (prevSelectedChild instanceof lng.ElementNode) { - const itemSize = prevSelectedChild[dimension] || 0; - const focusStyle = prevSelectedChild.style?.focus as lng.NodeStyles; - const scale = focusStyle?.scale ?? prevSelectedChild.scale ?? 1; - const scaledSize = itemSize * (props.factorScale ? scale : 1) + gap; - cachedScaledSize = scaledSize; - return scaledSize; - } - } - return 0; + function computeSize() { + return derivedDims()?.itemSize ?? 0; } function computeSlice( @@ -108,7 +101,8 @@ function createVirtual( prev: SliceState, ): SliceState { const total = itemCount(); - if (total === 0) + const vc = visibleCount(); + if (total === 0 || vc === 0) return { start: 0, slice: [], @@ -119,7 +113,7 @@ function createVirtual( cursor: 0, }; - if (total <= props.displaySize) { + if (total <= vc) { return { start: 0, slice: items() as T[], @@ -131,7 +125,8 @@ function createVirtual( }; } - const length = props.displaySize + bufferSize(); + const buf = bufferSize(); + const length = vc + buf; let start = prev.start; let selected = prev.selected; let atStart = prev.atStart; @@ -144,20 +139,20 @@ function createVirtual( selected = 1; } else { start = utils.clamp( - c - bufferSize(), + c - buf, 0, - Math.max(0, total - props.displaySize - bufferSize()), + Math.max(0, total - vc - buf), ); if (delta === 0 && c > 3) { shiftBy = c < 3 ? -c : -2; selected = 2; } else { selected = - c < bufferSize() + c < buf ? c - : c >= total - props.displaySize - ? c - (total - props.displaySize) + bufferSize() - : bufferSize(); + : c >= total - vc + ? c - (total - vc) + buf + : buf; } } break; @@ -173,21 +168,17 @@ function createVirtual( } else { if (delta < 0) { // Moving left - if (prev.start > 0 && prev.selected >= props.displaySize) { - // Move selection left inside slice + if (prev.start > 0 && prev.selected >= vc) { start = prev.start; selected = prev.selected - 1; } else if (prev.start > 0) { - // Move selection left inside slice start = prev.start - 1; selected = prev.selected; - // shiftBy = 0; } else if (prev.start === 0 && !prev.atStart) { start = 0; selected = prev.selected - 1; atStart = true; - } else if (selected >= props.displaySize - 1) { - // Shift window left, keep selection pinned + } else if (selected >= vc - 1) { start = 0; selected = prev.selected - 1; } else { @@ -198,7 +189,6 @@ function createVirtual( } else if (delta > 0) { // Moving right if (prev.selected < scrollIndex()) { - // Move selection right inside slice start = prev.start; selected = prev.selected + 1; shiftBy = 0; @@ -210,13 +200,11 @@ function createVirtual( start = 0; selected = 1; atStart = false; - } else if (prev.start >= total - props.displaySize) { - // At end: clamp slice, selection drifts right + } else if (prev.start >= total - vc) { start = prev.start; selected = c - start; shiftBy = 0; } else { - // Shift window right, keep selection pinned start = prev.start + 1; selected = Math.max(prev.selected, scrollIndex() + 1); } @@ -225,7 +213,7 @@ function createVirtual( if (c > 0) { start = Math.min( c - (scrollIndex() || 1), - total - props.displaySize - bufferSize(), + total - vc - buf, ); selected = Math.max(scrollIndex() || 1, c - start); shiftBy = total - c < 3 ? c - total : -1; @@ -250,7 +238,7 @@ function createVirtual( case 'edge': { const startScrolling = Math.max( 1, - props.displaySize + (atStart ? -1 : 0), + vc + (atStart ? -1 : 0), ); if (props.wrap) { if (delta > 0) { @@ -280,7 +268,6 @@ function createVirtual( } } else { if (delta === 0 && c > 0) { - //initial setup selected = c > startScrolling ? startScrolling : c; start = Math.max(0, c - startScrolling + 1); shiftBy = c > startScrolling ? -1 : 0; @@ -363,7 +350,7 @@ function createVirtual( function scrollToIndex(this: lng.ElementNode, index: number) { s.untrack(() => { - if (itemCount() === 0) return; + if (itemCount() === 0 || !derivedDims()) return; lastNavTime = performance.now(); if (originalPosition !== undefined) { @@ -407,10 +394,11 @@ function createVirtual( props.onSelectedChanged.call(this, idx, this, active, lastIdx); } - if (noChange) return; + if (noChange || !derivedDims()) return; const rawDelta = idx - (lastIdx ?? 0); - const windowLen = elm?.children?.length ?? props.displaySize + bufferSize(); + const windowLen = + elm?.children?.length ?? visibleCount() + bufferSize(); const delta = props.wrap ? normalizeDeltaForWindow(rawDelta, windowLen) : rawDelta; @@ -439,7 +427,7 @@ function createVirtual( queueMicrotask(() => { elm.updateLayout(); - const childSize = computeSize(slice().selected); + const childSize = computeSize(); if ( cachedAnimationController && @@ -465,7 +453,8 @@ function createVirtual( }; const updateSelected = ([sel, _items]: [number?, any?]) => { - if (!viewRef || sel === undefined || itemCount() === 0) return; + if (!viewRef || sel === undefined || itemCount() === 0 || !derivedDims()) + return; const safeSel = utils.clamp(sel, 0, itemCount() - 1); const item = items()[safeSel]; setCursor(safeSel); @@ -483,7 +472,7 @@ function createVirtual( if (newState.shiftBy === 0) return; - const childSize = computeSize(slice().selected); + const childSize = computeSize(); // Original Position is offset to support scrollToIndex originalPosition = originalPosition ?? viewRef.lng[axis]; targetPosition = targetPosition ?? viewRef.lng[axis]; @@ -492,12 +481,64 @@ function createVirtual( }); }; + // Measure container + first probe child, derive visibleCount/bufferSize. + function measureAndInit() { + if (!viewRef || derivedDims() || itemCount() === 0) return; + + viewRef.updateLayout(); + const containerSize = viewRef[sizeDim] || 0; + if (!containerSize) return; + + const firstChild = viewRef.children[0]; + if (!(firstChild instanceof lng.ElementNode)) return; + + const childSize = firstChild[sizeDim] || 0; + if (!childSize) return; + + const gap = viewRef.gap || 0; + const itemSize = childSize + gap; + const vc = Math.max(1, Math.floor(containerSize / itemSize)); + const buf = Math.max(2, Math.ceil(vc * 0.25)); + + if (props.debugInfo) { + console.log('[Virtual] measured', { + containerSize, + childSize, + gap, + visibleCount: vc, + bufferSize: buf, + }); + } + + setDerivedDims({ visibleCount: vc, bufferSize: buf, itemSize }); + + const sel = utils.clamp(props.selected ?? 0, 0, itemCount() - 1); + setCursor(sel); + const initialState = computeSlice(sel, 0, slice()); + setSlice(initialState); + viewRef.selected = initialState.selected; + } + + // Re-attempt measurement when items first populate (covers async load). + s.createEffect(() => { + items(); + if (!viewRef || derivedDims() || itemCount() === 0) return; + queueMicrotask(measureAndInit); + }); + let doOnce = false; s.createEffect( s.on([() => props.wrap, items], () => { - if (!viewRef || itemCount() === 0 || !props.wrap || doOnce) return; + if ( + !viewRef || + itemCount() === 0 || + !props.wrap || + doOnce || + !derivedDims() + ) + return; doOnce = true; - if (itemCount() <= props.displaySize) { + if (itemCount() <= visibleCount()) { queueMicrotask(() => { originalPosition = viewRef.lng[axis]; targetPosition = viewRef.lng[axis]; @@ -506,7 +547,7 @@ function createVirtual( } // offset just for wrap so we keep one item before queueMicrotask(() => { - const childSize = computeSize(slice().selected); + const childSize = computeSize(); viewRef.lng[axis] = (viewRef.lng[axis] || 0) + childSize * -1; // Original Position is offset to support scrollToIndex originalPosition = viewRef.lng[axis]; @@ -519,7 +560,7 @@ function createVirtual( s.createEffect( s.on(items, () => { - if (!viewRef) return; + if (!viewRef || !derivedDims()) return; let c = cursor(); if (c >= itemCount()) { c = Math.max(0, itemCount() - 1); @@ -531,6 +572,16 @@ function createVirtual( }), ); + // Before measurement completes, render just the first item so we have + // something to measure. Once derivedDims is set, render the real slice. + const renderedSlice = s.createMemo(() => { + if (!derivedDims()) { + const list = items(); + return list.length > 0 ? ([list[0]] as T[]) : []; + } + return slice().slice; + }); + return ( ( {...keyHandlers} ref={lngp.chainRefs((el) => { viewRef = el as lngp.NavigableElement; + queueMicrotask(measureAndInit); }, props.ref)} selected={selected()} cursor={cursor()} @@ -563,7 +615,7 @@ function createVirtual( ) } > - {props.children} + {props.children} ); }