From dbd959561556874c387b7bcd46f65b7b2f776581 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 17 May 2026 09:37:31 -0400 Subject: [PATCH 1/4] 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} ); } From 35f5e1641db0d20ce84bc1eb35d965ed8ca4b318 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Mon, 18 May 2026 10:38:17 -0400 Subject: [PATCH 2/4] tweaks to virtual --- src/primitives/Virtual.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/primitives/Virtual.tsx b/src/primitives/Virtual.tsx index 9274645..91fd326 100644 --- a/src/primitives/Virtual.tsx +++ b/src/primitives/Virtual.tsx @@ -485,7 +485,7 @@ function createVirtual( function measureAndInit() { if (!viewRef || derivedDims() || itemCount() === 0) return; - viewRef.updateLayout(); + // viewRef.updateLayout(); const containerSize = viewRef[sizeDim] || 0; if (!containerSize) return; @@ -498,7 +498,7 @@ function createVirtual( 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)); + const buf = Math.max(2, Math.ceil(vc * 0.3)); if (props.debugInfo) { console.log('[Virtual] measured', { @@ -599,6 +599,7 @@ function createVirtual( forwardFocus={/* @once */ lngp.navigableForwardFocus} scrollToIndex={/* @once */ scrollToIndex} onSelectedChanged={/* @once */ onSelectedChanged} + flexBoundary='fixed' style={ /* @once */ lng.combineStyles( props.style, From 5ebaff3d2bab7eeea8fcea815057303a374c1bfc Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Fri, 29 May 2026 08:28:58 -0400 Subject: [PATCH 3/4] add tests, more refactor for virtual --- src/primitives/Virtual.tsx | 60 +++-- tests/virtual.test.tsx | 443 +++++++++++++++++++++++++++++++++++++ 2 files changed, 482 insertions(+), 21 deletions(-) create mode 100644 tests/virtual.test.tsx diff --git a/src/primitives/Virtual.tsx b/src/primitives/Virtual.tsx index 91fd326..c41e0ef 100644 --- a/src/primitives/Virtual.tsx +++ b/src/primitives/Virtual.tsx @@ -100,8 +100,17 @@ function createVirtual( delta: number, prev: SliceState, ): SliceState { + // Hoist every memo read used inside this function — they're constants for + // the duration of one slice computation, and pulling them up keeps the + // switch branches focused on the actual math. const total = itemCount(); const vc = visibleCount(); + const buf = bufferSize(); + const si = scrollIndex(); + const allItems = items(); + const wrap = !!props.wrap; + const mode = scrollType(); + if (total === 0 || vc === 0) return { start: 0, @@ -116,7 +125,7 @@ function createVirtual( if (total <= vc) { return { start: 0, - slice: items() as T[], + slice: allItems as T[], selected: utils.clamp(c, 0, total - 1), delta, shiftBy: 0, @@ -125,16 +134,15 @@ function createVirtual( }; } - const buf = bufferSize(); const length = vc + buf; let start = prev.start; let selected = prev.selected; let atStart = prev.atStart; let shiftBy = -delta; - switch (scrollType()) { + switch (mode) { case 'always': - if (props.wrap) { + if (wrap) { start = utils.mod(c - 1, total); selected = 1; } else { @@ -158,10 +166,10 @@ function createVirtual( break; case 'auto': - if (props.wrap) { + if (wrap) { if (delta === 0) { - selected = scrollIndex() || 1; - start = utils.mod(c - (scrollIndex() || 1), total); + selected = si || 1; + start = utils.mod(c - (si || 1), total); } else { start = utils.mod(c - (prev.selected || 1), total); } @@ -188,11 +196,11 @@ function createVirtual( } } else if (delta > 0) { // Moving right - if (prev.selected < scrollIndex()) { + if (prev.selected < si) { start = prev.start; selected = prev.selected + 1; shiftBy = 0; - } else if (prev.selected === scrollIndex() || atStart) { + } else if (prev.selected === si || atStart) { start = prev.start; selected = prev.selected + 1; atStart = false; @@ -206,16 +214,16 @@ function createVirtual( shiftBy = 0; } else { start = prev.start + 1; - selected = Math.max(prev.selected, scrollIndex() + 1); + selected = Math.max(prev.selected, si + 1); } } else { // Initial setup if (c > 0) { start = Math.min( - c - (scrollIndex() || 1), + c - (si || 1), total - vc - buf, ); - selected = Math.max(scrollIndex() || 1, c - start); + selected = Math.max(si || 1, c - start); shiftBy = total - c < 3 ? c - total : -1; atStart = false; } else { @@ -240,7 +248,7 @@ function createVirtual( 1, vc + (atStart ? -1 : 0), ); - if (props.wrap) { + if (wrap) { if (delta > 0) { if (prev.selected < startScrolling) { selected = prev.selected + 1; @@ -314,12 +322,12 @@ function createVirtual( let newSlice = prev.slice; if (start !== prev.start || newSlice.length === 0) { - newSlice = props.wrap + newSlice = wrap ? (Array.from( { length }, - (_, i) => items()[utils.mod(start + i, total)], + (_, i) => allItems[utils.mod(start + i, total)], ) as T[]) - : items().slice(start, start + length); + : allItems.slice(start, start + length); } const state: SliceState = { @@ -377,6 +385,10 @@ function createVirtual( } let originalPosition: number | undefined; + // Tracks whether we've already fired onEndReached for the current "in-zone" + // state. Resets when the cursor moves back out of the threshold, so callers + // get exactly one call per crossing instead of one per keypress. + let endReachedFired = false; const onSelectedChanged: lngp.OnSelectedChanged = function ( _idx, elm, @@ -414,11 +426,15 @@ function createVirtual( setSlice(newState); elm.selected = newState.selected; - if ( - props.onEndReachedThreshold !== undefined && - cursor() >= itemCount() - props.onEndReachedThreshold - ) { - props.onEndReached?.(); + if (props.onEndReachedThreshold !== undefined) { + const crossed = + cursor() >= itemCount() - props.onEndReachedThreshold; + if (crossed && !endReachedFired) { + endReachedFired = true; + props.onEndReached?.(); + } else if (!crossed && endReachedFired) { + endReachedFired = false; + } } if (newState.shiftBy === 0) return; @@ -561,6 +577,8 @@ function createVirtual( s.createEffect( s.on(items, () => { if (!viewRef || !derivedDims()) return; + // "End" moves when data changes; allow onEndReached to fire again. + endReachedFired = false; let c = cursor(); if (c >= itemCount()) { c = Math.max(0, itemCount() - 1); diff --git a/tests/virtual.test.tsx b/tests/virtual.test.tsx new file mode 100644 index 0000000..da10aa2 --- /dev/null +++ b/tests/virtual.test.tsx @@ -0,0 +1,443 @@ +import * as v from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { VirtualRow } from '../src/primitives/Virtual.jsx'; +import { moveSelection } from '../src/primitives/utils/handleNavigation.js'; +import type { NavigableElement } from '../src/primitives/types.js'; + +import { renderer, waitForUpdate } from './setup.js'; + +const ITEM_W = 300; +const ITEM_H = 400; +const GAP = 30; // Virtual hardcodes this in its style +const STRIDE = ITEM_W + GAP; +const CONTAINER_W = 1820; + +const Poster = (props: { item: number; index: number }) => ( + +); + +/** + * Returns the underlying ElementNode that VirtualRow renders as its container. + * That container's children are the rendered slice items. + */ +function getRailContainer(ref: lng.ElementNode | undefined): lng.ElementNode { + if (!ref) throw new Error('ref not assigned'); + return ref; +} + +v.describe('VirtualRow', () => { + v.test('renders nothing when each is undefined', async () => { + let virtualRef!: lng.ElementNode; + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + v.assert.equal(virtualRef.children.length, 0); + dispose(); + }); + + v.test('renders nothing when each is an empty array', async () => { + let virtualRef!: lng.ElementNode; + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + v.assert.equal(virtualRef.children.length, 0); + dispose(); + }); + + v.test('probe phase renders a single item until measurement completes', async () => { + let virtualRef!: lng.ElementNode; + const items = Array.from({ length: 30 }, (_, i) => i); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + // Before microtasks run, the probe-render path is active. + v.assert.isAtLeast(virtualRef.children.length, 1); + + await waitForUpdate(); + + // After measurement, the slice expands to visibleCount + bufferSize. + // visibleCount = floor(1820 / 330) = 5 + // bufferSize = max(2, ceil(5 * 0.3)) = 2 + // slice length = visibleCount + bufferSize = 7 + v.assert.equal(virtualRef.children.length, 7); + + dispose(); + }); + + v.test('initial cursor reflects the `selected` prop', async () => { + let virtualRef!: NavigableElement; + const items = Array.from({ length: 30 }, (_, i) => i); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + v.assert.equal(virtualRef.cursor, 5); + dispose(); + }); + + v.test('updating `each` from undefined to populated expands the slice', async () => { + let virtualRef!: lng.ElementNode; + const [data, setData] = s.createSignal(undefined); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + v.assert.equal(virtualRef.children.length, 0); + + setData(Array.from({ length: 30 }, (_, i) => i)); + await waitForUpdate(); + + v.assert.equal(virtualRef.children.length, 7); + dispose(); + }); + + v.test('updating `each` to a longer array does not exceed the window', async () => { + let virtualRef!: lng.ElementNode; + const [data, setData] = s.createSignal( + Array.from({ length: 30 }, (_, i) => i), + ); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + v.assert.equal(virtualRef.children.length, 7); + + setData(Array.from({ length: 200 }, (_, i) => i)); + await waitForUpdate(); + + // Still a windowed slice — not the full 200. + v.assert.equal(virtualRef.children.length, 7); + dispose(); + }); + + v.test('updating `each` to fewer items than the window renders them all', async () => { + let virtualRef!: lng.ElementNode; + const [data, setData] = s.createSignal( + Array.from({ length: 30 }, (_, i) => i), + ); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + v.assert.equal(virtualRef.children.length, 7); + + setData([0, 1, 2]); + await waitForUpdate(); + + v.assert.equal(virtualRef.children.length, 3); + dispose(); + }); + + v.test('onSelectedChanged fires when moveSelection advances the cursor', async () => { + let virtualRef!: NavigableElement; + const items = Array.from({ length: 30 }, (_, i) => i); + const onSelectedChanged = v.vi.fn(); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + onSelectedChanged.mockClear(); + + // Simulate a right arrow keypress by directly invoking the navigation helper. + moveSelection(virtualRef, 1); + await waitForUpdate(); + + v.assert.equal(onSelectedChanged.mock.calls.length >= 1, true); + v.assert.equal(virtualRef.cursor, 1); + + dispose(); + }); + + v.test('cursor advances repeatedly under successive moveSelection calls', async () => { + let virtualRef!: NavigableElement; + const items = Array.from({ length: 30 }, (_, i) => i); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + for (let step = 0; step < 8; step++) { + moveSelection(virtualRef, 1); + await waitForUpdate(); + } + + v.assert.equal(virtualRef.cursor, 8); + // Slice should still be windowed, not the whole list. + v.assert.isAtMost(virtualRef.children.length, 7); + dispose(); + }); + + v.test('onEndReached fires when cursor crosses the threshold', async () => { + let virtualRef!: NavigableElement; + const items = Array.from({ length: 10 }, (_, i) => i); + const onEndReached = v.vi.fn(); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + onEndReached.mockClear(); + + // total=10, threshold=2 → fires when cursor >= 8 + for (let step = 0; step < 8; step++) { + moveSelection(virtualRef, 1); + await waitForUpdate(); + } + + // Latched: should have fired exactly once when the cursor first crossed + // the threshold, then stayed silent for the remaining in-zone keypresses. + v.assert.equal(onEndReached.mock.calls.length, 1); + + // Moving back out of the zone and back in should re-arm and fire again. + onEndReached.mockClear(); + moveSelection(virtualRef, -1); + await waitForUpdate(); + moveSelection(virtualRef, 1); + await waitForUpdate(); + v.assert.equal(onEndReached.mock.calls.length, 1); + + dispose(); + }); + + v.test('scrollToIndex jumps the cursor to the target', async () => { + let virtualRef!: NavigableElement; + const items = Array.from({ length: 30 }, (_, i) => i); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + virtualRef.scrollToIndex(15); + await waitForUpdate(); + + v.assert.equal(virtualRef.cursor, 15); + dispose(); + }); + + v.test('scrollToIndex clamps out-of-range targets', async () => { + let virtualRef!: NavigableElement; + const items = Array.from({ length: 10 }, (_, i) => i); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + virtualRef.scrollToIndex(999); + await waitForUpdate(); + + v.assert.equal(virtualRef.cursor, 9); + + virtualRef.scrollToIndex(-5); + await waitForUpdate(); + + v.assert.equal(virtualRef.cursor, 0); + dispose(); + }); + + v.test('updating `selected` prop externally moves the cursor', async () => { + let virtualRef!: NavigableElement; + const items = Array.from({ length: 30 }, (_, i) => i); + const [sel, setSel] = s.createSignal(0); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + setSel(7); + await waitForUpdate(); + + v.assert.equal(virtualRef.cursor, 7); + dispose(); + }); + + v.test('wrap mode produces a window when scrolling past the end', async () => { + let virtualRef!: NavigableElement; + const items = Array.from({ length: 10 }, (_, i) => i); + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + // Advance past the last item — cursor should wrap around to the start. + for (let step = 0; step < 12; step++) { + moveSelection(virtualRef, 1); + await waitForUpdate(); + } + + // 12 forward steps in a 10-item wrapped list → cursor = (0 + 12) % 10 = 2 + v.assert.equal(virtualRef.cursor, 2); + dispose(); + }); + + v.test('total <= window size renders every item with no slicing', async () => { + let virtualRef!: lng.ElementNode; + const items = [0, 1, 2, 3]; + + const dispose = renderer.render(() => ( + + {(item, i) => } + + )); + + await waitForUpdate(); + + v.assert.equal(virtualRef.children.length, 4); + dispose(); + }); +}); From 970b2616a9646760ef485aa6a66ba997dbefe2b0 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Fri, 29 May 2026 08:29:29 -0400 Subject: [PATCH 4/4] fix export order --- src/primitives/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/primitives/index.ts b/src/primitives/index.ts index 7bc9f5b..6ee1698 100644 --- a/src/primitives/index.ts +++ b/src/primitives/index.ts @@ -18,16 +18,20 @@ export * from './Suspense.jsx'; export * from './Marquee.jsx'; export * from './createFocusStack.jsx'; export * from './useHold.js'; +// withScrolling/handleNavigation are re-exported BEFORE VirtualGrid/Virtual/Rail +// because those modules evaluate `lngp.withScrolling(...)` (etc.) at module-load +// time, and would otherwise see a partial namespace via the primitives barrel. +export * from './utils/withScrolling.js'; +export * from './utils/handleNavigation.js'; export * from './VirtualGrid.jsx'; export * from './Virtual.jsx'; -export * from './utils/withScrolling.js'; +export * from './Rail.jsx'; export * from './createTag.jsx'; export { type AnyFunction, chainFunctions, chainRefs, } from './utils/chainFunctions.js'; -export * from './utils/handleNavigation.js'; export { createSpriteMap, type SpriteDef } from './utils/createSpriteMap.js'; export { createBlurredImage } from './utils/createBlurredImage.js';