feat: enhance useVirtualList for dynamic item sizing and improved scrolling behavior
Publish to NPM / Check version changes and publish (push) Failing after 11m20s

This commit is contained in:
2026-07-29 17:19:47 +07:00
parent a8e5f63415
commit 53e831894a
8 changed files with 1394 additions and 415 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/stdlib",
"version": "0.0.10",
"version": "0.0.11",
"license": "Apache-2.0",
"description": "A collection of tools, utilities, and helpers for TypeScript",
"keywords": [
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { FenwickTree } from '.';
/** Deterministic LCG so failures reproduce. */
function lcg(seed: number): () => number {
let state = seed;
return () => {
state = (state * 48271) % 2147483647;
return state / 2147483647;
};
}
describe('FenwickTree', () => {
it('should match naive sums after build', () => {
const rand = lcg(1);
const values = Array.from({ length: 137 }, () => Math.floor(rand() * 100));
const tree = new FenwickTree(values.length);
tree.build(values);
let sum = 0;
for (let i = 0; i <= values.length; i++) {
expect(tree.prefix(i)).toBe(sum);
if (i < values.length)
sum += values[i]!;
}
});
it('should keep prefix sums consistent with a naive array under updates', () => {
const rand = lcg(2);
const length = 64;
const naive = Array.from({ length }, () => Math.floor(rand() * 50));
const tree = new FenwickTree(length);
tree.build(naive);
for (let op = 0; op < 500; op++) {
const index = Math.floor(rand() * length);
const delta = Math.floor(rand() * 40) - 20;
naive[index]! += delta;
tree.update(index, delta);
const probe = Math.floor(rand() * (length + 1));
const expected = naive.slice(0, probe).reduce((a, b) => a + b, 0);
expect(tree.prefix(probe)).toBe(expected);
}
});
it('should match a linear scan in lowerBound, including stride and zero values', () => {
const rand = lcg(3);
for (let round = 0; round < 20; round++) {
const length = 1 + Math.floor(rand() * 40);
const values = Array.from({ length }, () => rand() < 0.2 ? 0 : Math.floor(rand() * 60));
const stride = round % 3 === 0 ? 0 : Math.floor(rand() * 10);
const tree = new FenwickTree(length);
tree.build(values);
const total = values.reduce((a, b) => a + b, 0) + length * stride;
for (const target of [-5, 0, 1, total / 3, total / 2, total - 1, total, total + 100]) {
let expected = 0;
for (let c = 0; c <= length; c++) {
const g = values.slice(0, c).reduce((a, b) => a + b, 0) + c * stride;
if (g <= target)
expected = c;
else
break;
}
if (target < 0)
expected = 0;
expect(tree.lowerBound(target, stride), `length=${length} stride=${stride} target=${target}`).toBe(expected);
}
}
});
it('should handle an empty tree', () => {
const tree = new FenwickTree(0);
expect(tree.prefix(0)).toBe(0);
expect(tree.lowerBound(0)).toBe(0);
expect(tree.lowerBound(100)).toBe(0);
});
it('should rebuild in place via build', () => {
const tree = new FenwickTree(4);
tree.build([1, 2, 3, 4]);
expect(tree.prefix(4)).toBe(10);
tree.build([10, 10, 10, 10]);
expect(tree.prefix(2)).toBe(20);
expect(tree.prefix(4)).toBe(40);
});
});
@@ -0,0 +1,95 @@
/**
* @name FenwickTree
* @category Data Structures
* @description Fenwick (Binary Indexed) tree over an array of non-negative
* numbers: O(log n) prefix sums, point updates, and monotonic lower-bound
* search, plus O(n) bulk rebuild. `lowerBound` assumes all values are
* non-negative (the prefix function must be non-decreasing)
*
* @example
* const tree = new FenwickTree(5);
* tree.build([10, 20, 30, 40, 50]);
* tree.prefix(3); // 60
* tree.update(1, 5); // value at index 1 becomes 25
* tree.lowerBound(65); // 3 — largest c with prefix(c) <= 65
*
* @since 0.0.11
*/
export class FenwickTree {
readonly size: number;
private readonly tree: Float64Array;
private readonly highBit: number;
constructor(size: number) {
this.size = size;
this.tree = new Float64Array(size + 1);
this.highBit = size > 0 ? 1 << (31 - Math.clz32(size)) : 0;
}
/**
* Bulk (re)initialization from raw values, O(n)
*
* @param {ArrayLike<number>} values The values to load, `values.length` must equal `size`
*/
build(values: ArrayLike<number>): void {
const { tree, size } = this;
tree.fill(0);
for (let i = 1; i <= size; i++) {
tree[i]! += values[i - 1]!;
const parent = i + (i & -i);
if (parent <= size)
tree[parent]! += tree[i]!;
}
}
/**
* Add `delta` to the value at `index`, O(log n)
*
* @param {number} index Zero-based index of the value to change
* @param {number} delta Amount to add (may be negative)
*/
update(index: number, delta: number): void {
for (let i = index + 1; i <= this.size; i += i & -i)
this.tree[i]! += delta;
}
/**
* Sum of the first `count` values, O(log n)
*
* @param {number} count How many leading values to sum
* @returns {number} The prefix sum
*/
prefix(count: number): number {
let sum = 0;
for (let i = count; i > 0; i -= i & -i)
sum += this.tree[i]!;
return sum;
}
/**
* Largest `c` in `[0, size]` with `prefix(c) + c * stride <= target`, O(log n).
* `stride` models a constant per-item addition (e.g. a layout gap) without
* storing it in the tree
*
* @param {number} target The offset to search for
* @param {number} stride Constant added per item, defaults to `0`
* @returns {number} The largest count whose strided prefix does not exceed `target`
*/
lowerBound(target: number, stride = 0): number {
if (target < 0)
return 0;
let pos = 0;
let sum = 0;
for (let step = this.highBit; step > 0; step >>= 1) {
const next = pos + step;
if (next <= this.size) {
const candidate = sum + this.tree[next]!;
if (candidate + next * stride <= target) {
pos = next;
sum = candidate;
}
}
}
return pos;
}
}
+1
View File
@@ -1,6 +1,7 @@
export * from './BinaryHeap';
export * from './CircularBuffer';
export * from './Deque';
export * from './FenwickTree';
export * from './LinkedList';
export * from './PriorityQueue';
export * from './Queue';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/vue",
"version": "0.0.14",
"version": "0.1.0",
"license": "Apache-2.0",
"description": "Collection of powerful tools for Vue",
"keywords": [
@@ -2,45 +2,84 @@
import { computed, ref, shallowRef } from 'vue';
import { useVirtualList } from './index';
// 10,000 rows — only the visible window (plus overscan) is ever in the DOM.
const total = 10000;
const items = shallowRef(
Array.from({ length: total }, (_, i) => ({
id: i,
label: `Row #${(i + 1).toString().padStart(5, '0')}`,
hue: (i * 37) % 360,
})),
);
interface Message {
id: number;
author: string;
text: string;
expanded: boolean;
}
const itemHeight = 44;
const WORDS = 'virtual scrolling keeps the DOM small while the list pretends to be infinite and every row is free to size itself'.split(' ');
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(items, {
itemHeight,
let nextId = 0;
function makeMessage(): Message {
const id = nextId++;
const length = 4 + (id * 31) % 60; // deterministic variable length
const text = Array.from({ length }, (_, i) => WORDS[(id + i) % WORDS.length]).join(' ');
return { id, author: `user-${id % 7}`, text, expanded: false };
}
function makeMessages(count: number): Message[] {
return Array.from({ length: count }, makeMessage);
}
const messages = shallowRef<Message[]>(makeMessages(10000));
const CHARS_PER_LINE = 58; // calibrated against the docs demo card width
const { list, containerProps, wrapperProps, scrollTo, isScrolling } = useVirtualList(messages, {
// Rows are genuinely variable (12 clamped lines collapsed, full text
// expanded), so the estimate is data-driven. It only has to be close, not
// exact — measured sizes replace it per row and are cached by key. What
// hurts is *systematic* error: a big overestimate makes every revealed row
// shrink on measure, and anchoring then fights the scroll.
estimateSize: message => 37 + Math.min(2, Math.ceil(message.text.length / CHARS_PER_LINE)) * 20,
getItemKey: message => message.id, // measurements survive prepend/reorder
followOutput: true, // pinned to the newest message when the user is at the end
overscan: 6,
gap: 6,
paddingStart: 8,
paddingEnd: 8,
});
function prepend() {
// Immutable update: getItemKey keeps measurements attached to the right
// messages and scroll anchoring keeps the viewport visually still.
messages.value = [...makeMessages(20), ...messages.value];
}
function append() {
// With followOutput the view stays glued to the end if the user is there.
messages.value = [...messages.value, ...makeMessages(5)];
}
function toggle(message: Message) {
messages.value = messages.value.map(current =>
current === message ? { ...current, expanded: !current.expanded } : current,
);
// No manual remeasure: the row's ResizeObserver sees the new height
// before paint and the layout shifts without flicker.
}
const jumpTo = ref(5000);
function go() {
const index = Math.min(Math.max(jumpTo.value || 0, 0), total - 1);
scrollTo(index, { behavior: 'smooth', block: 'center' });
scrollTo(jumpTo.value || 0, { align: 'center' });
}
const visibleRange = computed(() => {
if (list.value.length === 0)
return '—';
const first = list.value[0]!.index;
const last = list.value[list.value.length - 1]!.index;
return `${first}${last}`;
return `${list.value[0]!.index}${list.value[list.value.length - 1]!.index}`;
});
</script>
<template>
<div class="demo-stack max-w-sm">
<div class="flex items-center justify-between">
<span class="demo-label">Virtual list</span>
<span class="demo-label">Dynamic virtual list</span>
<span class="demo-badge">
{{ total.toLocaleString() }} rows
{{ messages.length.toLocaleString() }} rows
</span>
</div>
@@ -49,25 +88,32 @@ const visibleRange = computed(() => {
class="demo-card h-64"
>
<div v-bind="wrapperProps">
<div
v-for="{ data, index } in list"
:key="index"
class="flex items-center gap-3 border-b border-border px-3"
:style="{ height: `${itemHeight}px` }"
<article
v-for="item in list"
:key="item.key"
v-bind="item.props"
class="cursor-pointer border-b border-border px-3 py-2"
@click="toggle(item.data)"
>
<span
class="size-6 shrink-0 rounded-md border border-border"
:style="{ backgroundColor: `hsl(${data.hue} 65% 55%)` }"
/>
<span class="flex-1 truncate font-mono text-sm text-fg tabular-nums">{{ data.label }}</span>
<span class="text-xs text-fg-subtle">idx {{ index }}</span>
<div class="flex items-baseline justify-between gap-2">
<span class="font-mono text-xs text-fg-subtle">{{ item.data.author }}</span>
<span class="text-xs text-fg-subtle tabular-nums">#{{ item.index }}</span>
</div>
<!-- natural height: collapsed rows clamp to two lines (still variable),
expanded rows grow to the full text -->
<p
class="mt-1 text-sm text-fg"
:class="item.data.expanded ? '' : 'line-clamp-2'"
>
{{ item.data.text }}
</p>
</article>
</div>
</div>
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums flex items-center justify-between">
<span class="text-fg-muted">rendered</span>
<span>{{ list.length }} nodes · idx {{ visibleRange }}</span>
<span>{{ list.length }} nodes · idx {{ visibleRange }}<span v-if="isScrolling"> · scrolling</span></span>
</div>
<div class="flex items-end gap-2">
@@ -77,7 +123,7 @@ const visibleRange = computed(() => {
v-model.number="jumpTo"
type="number"
:min="0"
:max="total - 1"
:max="messages.length - 1"
class="demo-input"
>
</label>
@@ -88,6 +134,20 @@ const visibleRange = computed(() => {
>
Jump
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-bg px-3 py-2 text-sm font-medium text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
@click="prepend"
>
Prepend
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-bg px-3 py-2 text-sm font-medium text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
@click="append"
>
Append
</button>
</div>
</div>
</template>
@@ -1,35 +1,54 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, ref } from 'vue';
import { effectScope, nextTick, shallowRef } from 'vue';
import { useVirtualList } from '.';
type ObserverRecord = InstanceType<typeof StubResizeObserver>;
const observers: ObserverRecord[] = [];
class StubResizeObserver {
callback: ResizeObserverCallback;
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
observers.push(this);
}
}
function makeContainer(overrides: Partial<{
clientWidth: number;
clientHeight: number;
scrollWidth: number;
scrollHeight: number;
}> = {}) {
const el = document.createElement('div');
Object.defineProperties(el, {
clientWidth: { value: overrides.clientWidth ?? 100, configurable: true },
clientHeight: { value: overrides.clientHeight ?? 100, configurable: true },
scrollWidth: { value: overrides.scrollWidth ?? 10000, configurable: true },
scrollHeight: { value: overrides.scrollHeight ?? 10000, configurable: true },
});
el.scrollTop = 0;
el.scrollLeft = 0;
el.scrollTo = vi.fn((opts: ScrollToOptions) => {
if (typeof opts.top === 'number') el.scrollTop = opts.top;
if (typeof opts.left === 'number') el.scrollLeft = opts.left;
if (typeof opts.top === 'number')
el.scrollTop = opts.top;
if (typeof opts.left === 'number')
el.scrollLeft = opts.left;
}) as unknown as typeof el.scrollTo;
return el;
}
function makeRow(index: number): HTMLElement {
const el = document.createElement('div');
el.dataset.index = String(index);
document.body.appendChild(el);
return el;
}
function resizeEntry(target: Element, blockSize: number, inlineSize = 50): ResizeObserverEntry {
return { target, borderBoxSize: [{ blockSize, inlineSize }] } as unknown as ResizeObserverEntry;
}
function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effectScope> } {
const scope = effectScope();
let result!: T;
@@ -41,181 +60,318 @@ function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effect
describe(useVirtualList, () => {
beforeEach(() => {
observers.length = 0;
vi.stubGlobal('ResizeObserver', StubResizeObserver);
});
afterEach(() => vi.unstubAllGlobals());
afterEach(() => {
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
it('renders an empty window before the container mounts (SSR-safe)', () => {
const data = Array.from({ length: 1000 }, (_, i) => i);
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20 }));
const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
it('renders the initial window from estimates and initialContainerSize', () => {
const { result, scope } = withScope(() => useVirtualList(() => items, {
estimateSize: 40,
initialContainerSize: 200,
overscan: 1,
}));
// top=0 → start 0; bottom=200 → lowerBound=5 (5*40 ≤ 200) → end 6 → +overscan
expect(result.range.value).toEqual({ start: 0, end: 7 });
expect(result.list.value).toHaveLength(7);
expect(result.list.value[0]!.start).toBe(0);
expect(result.list.value[3]!.start).toBe(120);
expect(result.totalSize.value).toBe(1000 * 40);
scope.stop();
});
it('applies paddingStart and gap to offsets and totalSize', () => {
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 10), {
estimateSize: 40,
gap: 8,
paddingStart: 12,
paddingEnd: 20,
initialContainerSize: 100,
overscan: 0,
}));
expect(result.list.value[0]!.start).toBe(12);
expect(result.list.value[1]!.start).toBe(12 + 40 + 8);
expect(result.totalSize.value).toBe(12 + 10 * 40 + 9 * 8 + 20);
scope.stop();
});
it('passes item and index to the estimate function', () => {
const estimateSize = vi.fn((item: { id: number }, _index: number) => 10 + item.id);
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 3), {
estimateSize,
initialContainerSize: 100,
}));
expect(estimateSize).toHaveBeenCalledWith(items[0], 0);
expect(result.totalSize.value).toBe(10 + 11 + 12);
scope.stop();
});
it('rebuilds when the source ref is replaced', async () => {
const source = shallowRef(items.slice(0, 10));
const { result, scope } = withScope(() => useVirtualList(source, {
estimateSize: 40,
initialContainerSize: 100,
}));
expect(result.totalSize.value).toBe(400);
source.value = items.slice(0, 3);
await nextTick();
expect(result.totalSize.value).toBe(120);
expect(result.range.value.end).toBeLessThanOrEqual(3);
scope.stop();
});
it('clamps the range when the source shrinks to empty', async () => {
const source = shallowRef(items.slice(0, 10));
const { result, scope } = withScope(() => useVirtualList(source, {
estimateSize: 40,
initialContainerSize: 100,
}));
source.value = [];
await nextTick();
expect(result.range.value).toEqual({ start: 0, end: 0 });
expect(result.list.value).toEqual([]);
expect(result.containerProps.ref.value).toBeNull();
expect(result.containerProps.style).toEqual({ overflowY: 'auto' });
expect(result.totalSize.value).toBe(0);
scope.stop();
});
it('exposes the documented return shape', () => {
const { result, scope } = withScope(() => useVirtualList([1, 2, 3], { itemHeight: 20 }));
it('getOffsetForIndex honors align and clamps to content bounds', () => {
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 100), {
estimateSize: 40,
initialContainerSize: 200,
}));
expect(result).toHaveProperty('list');
expect(result).toHaveProperty('scrollTo');
expect(result).toHaveProperty('containerProps');
expect(result).toHaveProperty('wrapperProps');
expect(typeof result.scrollTo).toBe('function');
expect(typeof result.containerProps.onScroll).toBe('function');
expect(result.getOffsetForIndex(0, 'start')).toBe(0);
expect(result.getOffsetForIndex(50, 'start')).toBe(2000);
expect(result.getOffsetForIndex(50, 'center')).toBe(2000 - (200 - 40) / 2);
expect(result.getOffsetForIndex(50, 'end')).toBe(2000 - 200 + 40);
// clamp: the last item can't be aligned past max scroll
expect(result.getOffsetForIndex(99, 'start')).toBe(100 * 40 - 200);
// 'auto' on a visible item → keep the current offset
expect(result.getOffsetForIndex(1, 'auto')).toBe(0);
scope.stop();
});
it('slices the visible window plus overscan (vertical, fixed height)', async () => {
const data = Array.from({ length: 1000 }, (_, i) => i);
it('resolves auto-align with nearest-edge semantics for oversized items', () => {
// item 50 is taller than the 200px viewport and lies below it
const below = withScope(() => useVirtualList(() => items.slice(0, 100), {
estimateSize: (_item, index) => index === 50 ? 500 : 40,
initialContainerSize: 200,
}));
// nearest: approaching an oversized item from above aligns its start
expect(below.result.getOffsetForIndex(50, 'auto')).toBe(2000);
below.scope.stop();
// item 0 is taller than the viewport and already covers it → no-op
const covering = withScope(() => useVirtualList(() => items.slice(0, 100), {
estimateSize: (_item, index) => index === 0 ? 500 : 40,
initialContainerSize: 200,
}));
expect(covering.result.getOffsetForIndex(0, 'auto')).toBe(0);
covering.scope.stop();
});
it('scrollTo re-syncs a same-tick source replacement synchronously', () => {
const source = shallowRef(items.slice(0, 10));
const { result, scope } = withScope(() => useVirtualList(source, {
estimateSize: 40,
initialContainerSize: 200,
}));
expect(result.totalSize.value).toBe(400);
source.value = items.slice(0, 100);
// no nextTick: the canonical "append then scroll to newest" gesture
result.scrollTo(99);
expect(result.totalSize.value).toBe(4000);
scope.stop();
});
it('scrollTo and remeasure are safe no-ops without a scroll element', () => {
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 10), {
estimateSize: 40,
initialContainerSize: 100,
}));
expect(() => {
result.scrollTo(5);
result.scrollToOffset(100);
result.remeasure();
result.remeasure(2);
result.updateLayout();
}).not.toThrow();
scope.stop();
});
it('slices the window with correct data and indices once the container mounts', async () => {
const el = makeContainer({ clientHeight: 100 });
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 2 }));
const { result, scope } = withScope(() => useVirtualList(() => items, {
estimateSize: 20,
overscan: 2,
}));
// pre-mount: a small fallback window instead of an empty flash
expect(result.list.value.length).toBeGreaterThan(0);
expect(result.list.value.length).toBeLessThanOrEqual(1 + 2);
expect(result.containerProps.style).toMatchObject({ overflowY: 'auto', overflowAnchor: 'none' });
result.containerProps.ref.value = el;
await nextTick();
// offset(0) = 0, capacity = ceil(100/20) = 5, overscan 2 -> start 0, end 7.
expect(result.list.value[0]).toEqual({ data: 0, index: 0 });
expect(result.list.value).toHaveLength(7);
expect(result.list.value.at(-1)).toEqual({ data: 6, index: 6 });
// capacity = 100/20 = 5 → end 6, +overscan 2 → 8 rows
expect(result.list.value[0]).toMatchObject({ data: items[0], index: 0, start: 0, size: 20 });
expect(result.list.value).toHaveLength(8);
expect(result.list.value[0]!.props['data-index']).toBe(0);
expect(result.list.value[0]!.props.style.transform).toBe('translateY(0px)');
scope.stop();
});
it('recomputes the window on scroll with correct original indices', async () => {
const data = Array.from({ length: 1000 }, (_, i) => i);
const el = makeContainer({ clientHeight: 100 });
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 2 }));
const { result, scope } = withScope(() => useVirtualList(() => items, {
estimateSize: 20,
overscan: 2,
}));
result.containerProps.ref.value = el;
await nextTick();
el.scrollTop = 400; // offset = floor(400/20) = 20
el.scrollTop = 400; // first visible = 400/20 = 20
el.dispatchEvent(new Event('scroll'));
await nextTick();
// start = 20 - 2 = 18, end = 20 + 5 + 2 = 27
expect(result.list.value[0]).toEqual({ data: 18, index: 18 });
expect(result.list.value.at(-1)).toEqual({ data: 26, index: 26 });
expect(result.list.value[0]!.index).toBe(18); // 20 - overscan
expect(result.isScrolling.value).toBeTruthy();
scope.stop();
});
it('computes total height and offset spacers via wrapperProps', async () => {
const data = Array.from({ length: 50 }, (_, i) => i);
it('scrollTo writes the scroll offset for the requested alignment', async () => {
const el = makeContainer({ clientHeight: 100 });
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
const { result, scope } = withScope(() => useVirtualList(() => items, {
estimateSize: 20,
overscan: 0,
}));
result.containerProps.ref.value = el;
await nextTick();
// total height = 50 * 20 = 1000; at top, marginTop = 0
expect(result.wrapperProps.value.style.height).toBe('1000px');
expect(result.wrapperProps.value.style.marginTop).toBe('0px');
expect(result.wrapperProps.value.style.width).toBe('100%');
el.scrollTop = 200; // offset = 10, start = 10
el.dispatchEvent(new Event('scroll'));
await nextTick();
// marginTop = distance(10) = 200px; remaining height = 1000 - 200 = 800px
expect(result.wrapperProps.value.style.marginTop).toBe('200px');
expect(result.wrapperProps.value.style.height).toBe('800px');
scope.stop();
});
it('supports horizontal layout with itemWidth', async () => {
const data = Array.from({ length: 1000 }, (_, i) => i);
const el = makeContainer({ clientWidth: 100 });
const { result, scope } = withScope(() => useVirtualList(data, { itemWidth: 25, overscan: 1 }));
expect(result.containerProps.style).toEqual({ overflowX: 'auto' });
result.containerProps.ref.value = el;
await nextTick();
// capacity = ceil(100/25) = 4, overscan 1 -> start 0, end 5
expect(result.list.value).toHaveLength(5);
expect(result.wrapperProps.value.style.display).toBe('flex');
expect(result.wrapperProps.value.style.height).toBe('100%');
expect(result.wrapperProps.value.style.marginLeft).toBe('0px');
scope.stop();
});
it('supports variable item heights via a getter (prefix-sum metrics)', async () => {
const data = Array.from({ length: 100 }, (_, i) => i);
const el = makeContainer({ clientHeight: 100 });
// even indices: 40px, odd: 10px
const itemHeight = (i: number) => (i % 2 === 0 ? 40 : 10);
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight, overscan: 0 }));
result.containerProps.ref.value = el;
await nextTick();
expect(result.list.value[0]).toEqual({ data: 0, index: 0 });
// total = 50 * 40 + 50 * 10 = 2500
expect(result.wrapperProps.value.style.height).toBe('2500px');
// distance to index 4 = sizes[0..3] = 40+10+40+10 = 100
el.scrollTop = 100;
el.dispatchEvent(new Event('scroll'));
await nextTick();
expect(result.list.value[0]).toEqual({ data: 4, index: 4 });
expect(result.wrapperProps.value.style.marginTop).toBe('100px');
scope.stop();
});
it('scrollTo moves the container and re-slices', async () => {
const data = Array.from({ length: 1000 }, (_, i) => i);
const el = makeContainer({ clientHeight: 100 });
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
result.containerProps.ref.value = el;
await nextTick();
result.scrollTo(30);
// distance(30) = 30 * 20 = 600; block 'start' keeps offset 0
result.scrollTo(30, { align: 'start' });
expect(el.scrollTop).toBe(600);
expect(result.list.value[0]).toEqual({ data: 30, index: 30 });
result.scrollTo(30, { align: 'center' });
expect(el.scrollTop).toBe(600 - (100 - 20) / 2);
scope.stop();
});
it('scrollTo is a no-op when the container is not mounted', () => {
const { result, scope } = withScope(() => useVirtualList([1, 2, 3], { itemHeight: 20 }));
expect(() => result.scrollTo(2)).not.toThrow();
scope.stop();
});
it('reacts to a changing source ref', async () => {
const data = ref(Array.from({ length: 10 }, (_, i) => i));
it('applies measured sizes delivered by the ResizeObserver', async () => {
const el = makeContainer({ clientHeight: 100 });
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
const { result, scope } = withScope(() => useVirtualList(() => items, {
estimateSize: 20,
overscan: 0,
}));
result.containerProps.ref.value = el;
await nextTick();
// total = 10 * 20 = 200
expect(result.wrapperProps.value.style.height).toBe('200px');
const observer = observers[0]!;
const row = makeRow(0);
result.measureElement(row);
expect(observer.observe).toHaveBeenCalledWith(row, { box: 'border-box' });
data.value = Array.from({ length: 100 }, (_, i) => i);
observer.callback([resizeEntry(row, 90)], observer as unknown as ResizeObserver);
await nextTick();
// total = 100 * 20 = 2000
expect(result.wrapperProps.value.style.height).toBe('2000px');
// row 0: 20 → 90, total grows by 70
expect(result.totalSize.value).toBe(1000 * 20 + 70);
expect(result.list.value[0]!.size).toBe(90);
expect(result.list.value[1]!.start).toBe(90);
scope.stop();
});
it('clamps the window to the source bounds', async () => {
const data = Array.from({ length: 3 }, (_, i) => i);
const el = makeContainer({ clientHeight: 1000 });
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 5 }));
it('compensates the scroll offset when an item above the viewport grows', async () => {
const el = makeContainer({ clientHeight: 100 });
const { result, scope } = withScope(() => useVirtualList(() => items, {
estimateSize: 20,
overscan: 0,
}));
result.containerProps.ref.value = el;
await nextTick();
expect(result.list.value).toHaveLength(3);
expect(result.list.value.at(-1)).toEqual({ data: 2, index: 2 });
el.scrollTop = 400;
el.dispatchEvent(new Event('scroll'));
await nextTick();
const observer = observers[0]!;
const row = makeRow(0); // starts at 0, above the viewport top (400)
result.measureElement(row);
observer.callback([resizeEntry(row, 100)], observer as unknown as ResizeObserver);
await nextTick(); // deferred compensation write lands post-patch
// growth of 80 above the viewport → scrollTop compensated to 480
expect(el.scrollTop).toBe(480);
scope.stop();
});
it('measures component instances through $el', () => {
const { result, scope } = withScope(() => useVirtualList(() => items, {
estimateSize: 20,
initialContainerSize: 100,
}));
const row = makeRow(3);
result.measureElement({ $el: row });
expect(observers[0]!.observe).toHaveBeenCalledWith(row, { box: 'border-box' });
scope.stop();
});
it('warns once when the ref target cannot be measured', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { result, scope } = withScope(() => useVirtualList(() => items, {
estimateSize: 20,
initialContainerSize: 100,
}));
result.measureElement({ $el: document.createTextNode('fragment anchor') });
result.measureElement({ $el: null });
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
scope.stop();
});
it('supports horizontal layout', async () => {
const el = makeContainer({ clientWidth: 100 });
const { result, scope } = withScope(() => useVirtualList(() => items, {
estimateSize: 25,
axis: 'x',
overscan: 1,
}));
expect(result.containerProps.style).toMatchObject({ overflowX: 'auto' });
result.containerProps.ref.value = el;
await nextTick();
// capacity = 100/25 = 4 → end 5, +overscan 1 → 6 rows
expect(result.list.value).toHaveLength(6);
expect(result.list.value[1]!.props.style.transform).toBe('translateX(25px)');
expect(result.wrapperProps.value.style.width).toBe(`${1000 * 25}px`);
expect(result.wrapperProps.value.style.height).toBe('100%');
scope.stop();
});
});
File diff suppressed because it is too large Load Diff