4 Commits

Author SHA1 Message Date
Renovate Bot a094514a3e chore(deps): update peerdependency eslint to v10
CI / @robonen/crdt (pull_request) Successful in 1m34s
CI / @robonen/docs (pull_request) Successful in 5m20s
CI / @robonen/encoding (pull_request) Successful in 1m16s
CI / @robonen/eslint (pull_request) Successful in 1m4s
CI / @robonen/fetch (pull_request) Successful in 1m14s
CI / @robonen/platform (pull_request) Successful in 1m16s
CI / @robonen/primitives (pull_request) Successful in 4m54s
CI / @robonen/primitives-playground (pull_request) Successful in 1m46s
CI / @robonen/renovate (pull_request) Successful in 51s
CI / @robonen/stdlib (pull_request) Successful in 1m31s
CI / @robonen/stories (pull_request) Successful in 2m9s
CI / @robonen/tsconfig (pull_request) Successful in 51s
CI / @robonen/tsdown (pull_request) Successful in 54s
CI / @robonen/vue (pull_request) Successful in 3m13s
CI / @robonen/writekit (pull_request) Successful in 3m56s
CI / @robonen/writekit-playground (pull_request) Successful in 1m54s
CI / CI (pull_request) Has been cancelled
2026-07-30 20:10:05 +00:00
robonen 53e831894a feat: enhance useVirtualList for dynamic item sizing and improved scrolling behavior
Publish to NPM / Check version changes and publish (push) Failing after 11m20s
2026-07-29 17:19:47 +07:00
robonen a8e5f63415 build: update version to 0.0.2
Publish to NPM / Check version changes and publish (push) Failing after 9m34s
2026-07-17 22:13:04 +07:00
robonen 1cab48ca48 fix(fetch): accept a named interface as request body without a cast
FetchOptions['body'] listed `Record<string, unknown>`, which a named interface
is not assignable to (interfaces carry no implicit index signature), so every
caller passing a typed body had to cast it. Widen the object member to `object`:
named interfaces and arrays now assign directly, `any` is not introduced, and
bare primitives (number/boolean) are still rejected. Runtime is unchanged — the
serializer already narrows the body with its own casts.

Guard it with a type test (src/types.test-d.ts, run under vitest --typecheck):
a named interface must assign to the body type and flow through the method
shortcuts with no cast, while bare primitives stay rejected. Enable typecheck
for the package's vitest project so the assertions are enforced by tsc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:13:04 +07:00
13 changed files with 1450 additions and 419 deletions
+1 -1
View File
@@ -67,7 +67,7 @@
"tsdown": "catalog:"
},
"peerDependencies": {
"eslint": ">=9.39.4"
"eslint": ">=10.8.0"
},
"publishConfig": {
"access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/fetch",
"version": "0.0.1",
"version": "0.0.2",
"license": "Apache-2.0",
"description": "A lightweight, type-safe fetch wrapper with interceptors, retry, and V8-optimized internals",
"keywords": [
+40
View File
@@ -0,0 +1,40 @@
import { assertType, describe, expectTypeOf, it } from 'vitest';
import type { FetchOptions } from './types';
import { createFetch } from './fetch';
type Body = FetchOptions['body'];
describe('FetchOptions body', () => {
it('accepts a named interface without a cast', () => {
// The regression under test: a named `interface` has no implicit index
// signature, so a `Record<string, unknown>` body type would reject it and
// force a cast at every call site. It must assign directly.
interface CreateUser { name: string; age: number }
expectTypeOf<CreateUser>().toExtend<Body>();
assertType<Body>({ name: 'Alice', age: 30 } satisfies CreateUser);
});
it('accepts plain objects, arrays, BodyInit strings and null', () => {
assertType<Body>({ a: 1 });
assertType<Body>([1, 2, 3]);
assertType<Body>('raw string');
assertType<Body>(new FormData());
assertType<Body>(null);
});
it('rejects bare primitives', () => {
// @ts-expect-error a bare number is not a valid request body
assertType<Body>(42);
// @ts-expect-error a bare boolean is not a valid request body
assertType<Body>(true);
});
it('lets a typed interface flow through the method shortcuts', () => {
interface CreateDeal { title: string; amount: number }
const $fetch = createFetch();
const deal: CreateDeal = { title: 'x', amount: 1 };
// Must type-check with no cast on the body.
expectTypeOf($fetch.post).toBeCallableWith('/deals', { body: deal });
});
});
+8 -2
View File
@@ -79,8 +79,14 @@ export interface FetchOptions<R extends ResponseType = 'json', T = unknown>
FetchHooks<T, R> {
/** Base URL prepended to all relative request URLs */
baseURL?: string;
/** Request body — plain objects are automatically JSON-serialized */
body?: RequestInit['body'] | Record<string, unknown> | unknown[] | null;
/**
* Request body. `BodyInit` values (string, Blob, FormData, streams, …) are
* sent as-is; any other object or array is JSON-serialized. Typed as `object`
* rather than `Record<string, unknown>` so a named `interface` assigns
* directly — interfaces carry no implicit index signature and would otherwise
* force every caller to cast the body.
*/
body?: RequestInit['body'] | object | null;
/** Suppress throwing on 4xx/5xx responses */
ignoreResponseError?: boolean;
/** URL query parameters serialized and appended to the request URL */
+6
View File
@@ -3,5 +3,11 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
// Type tests (*.test-d.ts) are statically analyzed with `tsc --noEmit`
// alongside the runtime suite.
typecheck: {
enabled: true,
tsconfig: './tsconfig.src.json',
},
},
});
+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>
<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