feat: enhance useVirtualList for dynamic item sizing and improved scrolling behavior
Publish to NPM / Check version changes and publish (push) Failing after 11m20s
Publish to NPM / Check version changes and publish (push) Failing after 11m20s
This commit is contained in:
@@ -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,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,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 (1–2 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
Reference in New Issue
Block a user