From 53e831894a6392673048c99940674a359d5d295e Mon Sep 17 00:00:00 2001 From: robonen Date: Wed, 29 Jul 2026 17:19:47 +0700 Subject: [PATCH] feat: enhance useVirtualList for dynamic item sizing and improved scrolling behavior --- core/stdlib/package.json | 2 +- .../src/structs/FenwickTree/index.test.ts | 91 ++ core/stdlib/src/structs/FenwickTree/index.ts | 95 ++ core/stdlib/src/structs/index.ts | 1 + vue/toolkit/package.json | 2 +- .../component/useVirtualList/demo.vue | 126 +- .../component/useVirtualList/index.test.ts | 410 +++++-- .../component/useVirtualList/index.ts | 1082 +++++++++++++---- 8 files changed, 1394 insertions(+), 415 deletions(-) create mode 100644 core/stdlib/src/structs/FenwickTree/index.test.ts create mode 100644 core/stdlib/src/structs/FenwickTree/index.ts diff --git a/core/stdlib/package.json b/core/stdlib/package.json index f29a4e5..c2c1782 100644 --- a/core/stdlib/package.json +++ b/core/stdlib/package.json @@ -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": [ diff --git a/core/stdlib/src/structs/FenwickTree/index.test.ts b/core/stdlib/src/structs/FenwickTree/index.test.ts new file mode 100644 index 0000000..40713b6 --- /dev/null +++ b/core/stdlib/src/structs/FenwickTree/index.test.ts @@ -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); + }); +}); diff --git a/core/stdlib/src/structs/FenwickTree/index.ts b/core/stdlib/src/structs/FenwickTree/index.ts new file mode 100644 index 0000000..8af78d4 --- /dev/null +++ b/core/stdlib/src/structs/FenwickTree/index.ts @@ -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} values The values to load, `values.length` must equal `size` + */ + build(values: ArrayLike): 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; + } +} diff --git a/core/stdlib/src/structs/index.ts b/core/stdlib/src/structs/index.ts index 7110086..f990bee 100644 --- a/core/stdlib/src/structs/index.ts +++ b/core/stdlib/src/structs/index.ts @@ -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'; diff --git a/vue/toolkit/package.json b/vue/toolkit/package.json index ca3a176..66ac6e5 100644 --- a/vue/toolkit/package.json +++ b/vue/toolkit/package.json @@ -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": [ diff --git a/vue/toolkit/src/composables/component/useVirtualList/demo.vue b/vue/toolkit/src/composables/component/useVirtualList/demo.vue index a84793d..944e90b 100644 --- a/vue/toolkit/src/composables/component/useVirtualList/demo.vue +++ b/vue/toolkit/src/composables/component/useVirtualList/demo.vue @@ -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(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}`; }); diff --git a/vue/toolkit/src/composables/component/useVirtualList/index.test.ts b/vue/toolkit/src/composables/component/useVirtualList/index.test.ts index d76ed11..fbaf449 100644 --- a/vue/toolkit/src/composables/component/useVirtualList/index.test.ts +++ b/vue/toolkit/src/composables/component/useVirtualList/index.test.ts @@ -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; + +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(fn: () => T): { result: T; scope: ReturnType } { const scope = effectScope(); let result!: T; @@ -41,181 +60,318 @@ function withScope(fn: () => T): { result: T; scope: ReturnType { 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(); }); }); diff --git a/vue/toolkit/src/composables/component/useVirtualList/index.ts b/vue/toolkit/src/composables/component/useVirtualList/index.ts index 11bf13b..a913c7c 100644 --- a/vue/toolkit/src/composables/component/useVirtualList/index.ts +++ b/vue/toolkit/src/composables/component/useVirtualList/index.ts @@ -1,15 +1,86 @@ -import type { ComputedRef, MaybeRefOrGetter, Ref, ShallowRef, StyleValue } from 'vue'; -import { computed, shallowRef, toValue, watch } from 'vue'; -import { clamp, isNumber } from '@robonen/stdlib'; -import { useElementSize } from '@/composables/elements/useElementSize'; +import type { CSSProperties, ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue'; +import { computed, nextTick, shallowRef, toValue, watch } from 'vue'; +import { FenwickTree, clamp, isNumber } from '@robonen/stdlib'; import { useEventListener } from '@/composables/browser/useEventListener'; +import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose'; -/** - * Fixed pixel size or a per-index getter. - */ -export type UseVirtualListItemSize = number | ((index: number) => number); +// ─── Types ─────────────────────────────────────────────────────────────────── -export interface UseVirtualListOptionsBase { +export type UseVirtualListAlign = 'start' | 'center' | 'end' | 'auto'; + +export interface UseVirtualListScrollToOptions { + /** + * Alignment of the target item inside the viewport. `'auto'` uses + * nearest-edge semantics: scroll only if the item is not fully visible. + * + * @default 'auto' + */ + align?: UseVirtualListAlign; + /** + * Native scroll behavior. With dynamic sizes `'smooth'` is best-effort: + * offsets shift as newly revealed items get measured mid-animation. + */ + behavior?: ScrollBehavior; +} + +export interface UseVirtualListItemProps { + /** + * Measures the row: bind the whole object with `v-bind="item.props"`. + * Works on plain elements and on components with a single root element + * (resolved through the instance's `$el`); fragment-rooted components + * cannot be measured and trigger a dev warning. + */ + ref: (el: unknown) => void; + /** Lets the ResizeObserver map an element back to its index. */ + 'data-index': number; + style: CSSProperties; +} + +export interface UseVirtualListItem { + data: T; + index: number; + /** Result of `getItemKey` — use as `:key`. */ + key: PropertyKey; + /** Offset of the item start from the wrapper start, px. */ + start: number; + /** Current size: measured if the row was ever rendered, estimate otherwise, px. */ + size: number; + end: number; + /** + * Spread onto the row root: `v-bind="item.props"`. + * + * A `v-bind` spread puts the element on Vue's FULL_PROPS diff path; with + * three props that is cheap, but perf-critical consumers can opt into the + * faster PROPS path by binding explicitly: + * `:ref="item.props.ref" :data-index="item.index" :style="item.props.style"`. + * Avoid spreading onto a row *component*: there FULL_PROPS also means a full + * props diff plus an attrs-fallthrough `cloneVNode` of its root on every + * render — keep the row root a plain element, or set `inheritAttrs: false` + * and bind explicitly. + */ + props: UseVirtualListItemProps; +} + +export interface UseVirtualListRange { + start: number; + end: number; +} + +export interface UseVirtualListOptions { + /** + * Size (px) assumed for an item until it is measured, or a getter + * `(item, index) => number`. A data-driven estimate keeps the first paint + * and the scrollbar close to the truth and minimizes anchoring corrections. + * + * @default 48 + */ + estimateSize?: number | ((item: T, index: number) => number); + /** + * Scroll axis. Horizontal mode assumes LTR writing direction. + * + * @default 'y' + */ + axis?: 'x' | 'y'; /** * Number of extra items rendered above and below the visible window to * reduce blank flashes while scrolling. @@ -17,309 +88,814 @@ export interface UseVirtualListOptionsBase { * @default 5 */ overscan?: number; -} - -export interface UseHorizontalVirtualListOptions extends UseVirtualListOptionsBase { /** - * Horizontal item size in pixels, or a getter `(index) => number`. + * Stable identity for an item. Measured sizes are cached by this key, so + * they survive prepends, removals and reorders — and the scroll position + * is re-anchored to the same item when the list shifts. Defaults to the + * index, which is only correct for append-only lists. */ - itemWidth: UseVirtualListItemSize; -} - -export interface UseVerticalVirtualListOptions extends UseVirtualListOptionsBase { + getItemKey?: (item: T, index: number) => PropertyKey; /** - * Vertical item size in pixels, or a getter `(index) => number`. + * Virtual gap between items (px) — pure layout, never measured. + * + * @default 0 */ - itemHeight: UseVirtualListItemSize; -} - -export type UseVirtualListOptions - = | UseHorizontalVirtualListOptions - | UseVerticalVirtualListOptions; - -export interface UseVirtualListItem { - data: T; - index: number; -} - -export interface UseVirtualListScrollToOptions { - behavior?: ScrollBehavior; - block?: ScrollLogicalPosition; - inline?: ScrollLogicalPosition; + gap?: number; + /** + * Leading padding inside the wrapper, px. + * + * @default 0 + */ + paddingStart?: number; + /** + * Trailing padding inside the wrapper, px. + * + * @default 0 + */ + paddingEnd?: number; + /** + * External scroll container. When omitted, bind `containerProps` + * (or `containerRef`) to your own element. Whatever the wiring path, + * `overflow-anchor: none` is applied to the element on attach; the + * `overflow` itself is only set by `containerProps`. + */ + scrollElement?: MaybeRefOrGetter; + /** + * Offset (px) of the wrapper start from the scroll container's content + * start — for scrollers that render other content before the list. + * + * @default 0 + */ + scrollMargin?: number; + /** + * Viewport size (px) assumed before the container is measured. + * Lets SSR / the first client render emit the initial window of items + * instead of an empty list. + * + * @default 0 + */ + initialContainerSize?: number; + /** + * Index to scroll to (aligned to `'start'`) once both the scroll element + * and a non-empty source are available — safe with async-loaded data. + */ + initialScrollIndex?: number; + /** + * Keep the visual position stable when items above the viewport are + * measured to a different size, and when the list is prepended to. + * + * @default true + */ + anchorScroll?: boolean; + /** + * Chat mode: when the viewport is at the very end and items are appended, + * keep it pinned to the end instead of anchoring to the first visible row. + * + * @default false + */ + followOutput?: boolean; + /** + * ms of scroll silence before `isScrolling` resets. `0` disables. + * + * @default 150 + */ + scrollingDelay?: number; } export interface UseVirtualListContainerProps { ref: ShallowRef; - onScroll: () => void; - style: StyleValue; -} - -export interface UseVirtualListWrapperStyle { - width: string; - height: string; - marginTop?: string; - marginLeft?: string; - display?: string; + style: CSSProperties; } export interface UseVirtualListReturn { /** - * The currently visible slice (with original indices) to render. + * Items in the current window, with layout offsets and spreadable props. */ - list: Ref>>; + list: ComputedRef>>; /** - * Scroll the container so the item at `index` becomes visible. + * Full content size along the scroll axis (paddings and gaps included), px. */ - scrollTo: (index: number, options?: UseVirtualListScrollToOptions) => void; + totalSize: ComputedRef; + /** + * Current rendered index window `[start, end)`, overscan included. + */ + range: Readonly>; + isScrolling: Readonly>; + /** + * Scroll container element — bind via `containerProps` or use directly. + */ + containerRef: ShallowRef; /** * Props to bind on the scrolling container element. */ containerProps: UseVirtualListContainerProps; /** - * Reactive props to bind on the inner wrapper element (spacer offsets). + * Reactive props to bind on the inner wrapper (sizer) element. */ - wrapperProps: ComputedRef<{ style: UseVirtualListWrapperStyle }>; + wrapperProps: ComputedRef<{ style: CSSProperties }>; + /** + * Row measurer — already wired into `item.props.ref`; exposed for custom layouts. + */ + measureElement: (el: unknown) => void; + /** + * Scroll the container so the item at `index` satisfies the alignment. + */ + scrollTo: (index: number, options?: UseVirtualListScrollToOptions) => void; + scrollToOffset: (offset: number, options?: { behavior?: ScrollBehavior }) => void; + /** + * Scroll offset that would satisfy `align` for `index`. + * Reflects the layout as of the last flush (does not force a re-sync). + */ + getOffsetForIndex: (index: number, align?: UseVirtualListAlign) => number; + /** + * Re-read the source and rebuild layout. Only needed after *in-place* + * mutation of the source array (`watch` cannot observe those) — replacing + * the array triggers this automatically, and `scrollTo` re-syncs a + * same-tick replacement on its own. + */ + updateLayout: () => void; + /** + * Drop cached measurements (all, or one index) and re-measure live rows. + * Useful when row content changes without a resize the observer would see. + */ + remeasure: (index?: number) => void; } -interface UseVirtualListState { - start: number; - end: number; -} +// ─── Composable ────────────────────────────────────────────────────────────── -type Axis = 'horizontal' | 'vertical'; - -const scrollKey = { - horizontal: 'scrollLeft', - vertical: 'scrollTop', -} as const; - -const scrollToKey = { - horizontal: 'left', - vertical: 'top', -} as const; - -const defaultScrollToOptions: UseVirtualListScrollToOptions = { - behavior: 'auto', - block: 'start', - inline: 'nearest', -}; - -interface UseVirtualListMetrics { - /** Cumulative offset before the item at `index` (i.e. distance from the start). */ - distance: (index: number) => number; - /** Total size of every item. */ - total: () => number; - /** How many items fit, starting at `start`, inside `containerSize`. */ - viewCapacity: (containerSize: number, start: number) => number; - /** Index of the first item whose cumulative span reaches `scrollPos`. */ - offset: (scrollPos: number) => number; - /** Size of a single item. */ - size: (index: number) => number; -} - -/** - * Build size metrics for the current source. - * - * For a fixed numeric `itemSize` everything is O(1) arithmetic. For a getter - * we precompute a prefix-sum table once per (source, itemSize) change so that - * `distance`, `total`, `offset`, and `viewCapacity` are O(1)/O(log n) lookups - * instead of re-reducing the whole array on every scroll frame. - */ -function createMetrics(length: number, itemSize: UseVirtualListItemSize): UseVirtualListMetrics { - if (isNumber(itemSize)) { - const fixed = itemSize; - return { - size: () => fixed, - distance: index => clamp(index, 0, length) * fixed, - total: () => length * fixed, - viewCapacity: containerSize => Math.ceil(containerSize / fixed), - offset: scrollPos => Math.floor(scrollPos / fixed), - }; - } - - // prefix[i] = sum of sizes of items [0, i); prefix[length] = total size. - const prefix = new Float64Array(length + 1); - for (let i = 0; i < length; i++) - prefix[i + 1] = prefix[i]! + itemSize(i); - - const total = prefix[length]!; - - // Largest index whose cumulative offset is <= target, via binary search. - const lowerBound = (target: number): number => { - let lo = 0; - let hi = length; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (prefix[mid]! <= target) - lo = mid + 1; - else - hi = mid; - } - return lo - 1; - }; - - return { - size: index => itemSize(index), - distance: index => prefix[clamp(index, 0, length)]!, - total: () => total, - viewCapacity: (containerSize, start) => { - const target = prefix[clamp(start, 0, length)]! + containerSize; - const end = lowerBound(target); - return Math.max(0, end - start + 1); - }, - offset: scrollPos => Math.max(0, lowerBound(scrollPos)), - }; -} +/** Ignore sub-0.01px deltas: fractional-zoom noise, not real resizes. */ +const SIZE_EPSILON = 0.01; /** * @name useVirtualList * @category Component - * @description Virtualize a large list so only the items inside (and slightly - * around) the viewport are rendered. Supports vertical (`itemHeight`) and - * horizontal (`itemWidth`) layouts, fixed or per-index sizes, and an `overscan` - * buffer. Backed by `useElementSize` (reactive container size) and - * `useEventListener` (passive, auto-cleaned scroll handling). SSR-safe: renders - * an empty window until the container mounts. + * @description Virtualize a large list with dynamically measured item sizes. + * Rows render at their natural size: layout starts from `estimateSize` and is + * corrected by a shared ResizeObserver, which fires after layout but before + * paint, so corrections are not visible as flicker. Offsets come from a + * Fenwick tree (O(log n) hot paths). When an item above the viewport changes + * size — or the list is prepended to — the scroll position is compensated so + * content does not jump; the compensation write is deferred until after the + * DOM patch (still pre-paint) so it is never clamped by a stale wrapper + * height. Supports vertical and horizontal (LTR) layouts, `gap`/paddings, + * an external `scrollElement`, `followOutput` chat pinning, `scrollTo` with + * nearest-edge `'auto'` alignment, and SSR via `initialContainerSize`. * - * @param {MaybeRefOrGetter} list The full source array (may be reactive) - * @param {UseVirtualListOptions} options Layout options — supply `itemHeight` (vertical) or `itemWidth` (horizontal), plus optional `overscan` - * @returns {UseVirtualListReturn} `{ list, containerProps, wrapperProps, scrollTo }` + * Non-goals (by design): window as scroller (element scrollers only), + * RTL horizontal mode, reactive options (only the source and `scrollElement` + * are reactive), pixel-perfect `behavior: 'smooth'` landings. + * + * @param {MaybeRefOrGetter} source The full source array (may be reactive) + * @param {UseVirtualListOptions} options Layout and behavior options + * @returns {UseVirtualListReturn} `{ list, totalSize, range, isScrolling, containerRef, containerProps, wrapperProps, measureElement, scrollTo, scrollToOffset, getOffsetForIndex, updateLayout, remeasure }` * * @example - * const all = ref(Array.from({ length: 99999 }, (_, i) => i)); - * const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(all, { itemHeight: 22 }); + * const messages = shallowRef([]); + * const { list, containerProps, wrapperProps } = useVirtualList(messages, { + * estimateSize: m => 52 + Math.ceil(m.text.length / 80) * 20, + * getItemKey: m => m.id, // measurements survive prepend/reorder + * followOutput: true, // stay pinned to the newest message + * }); * //
* //
- * //
{{ data }}
+ * //
+ * // {{ item.data.text }} + * //
* //
* //
* * @example - * // Variable heights and a wider overscan buffer. - * const { list } = useVirtualList(items, { itemHeight: i => (i % 2 ? 40 : 80), overscan: 10 }); + * // Fixed-size grid rows: the estimate is exact, measurement never corrects. + * const { list } = useVirtualList(items, { estimateSize: 44, overscan: 10 }); * * @since 0.0.14 */ export function useVirtualList( - list: MaybeRefOrGetter, - options: UseVirtualListOptions, + source: MaybeRefOrGetter, + options: UseVirtualListOptions = {}, ): UseVirtualListReturn { - const isVertical = 'itemHeight' in options; - const axis: Axis = isVertical ? 'vertical' : 'horizontal'; - const itemSize = isVertical - ? (options as UseVerticalVirtualListOptions).itemHeight - : (options as UseHorizontalVirtualListOptions).itemWidth; - const overscan = options.overscan ?? 5; + const { + estimateSize = 48, + axis = 'y', + overscan = 5, + getItemKey = (_item, index) => index, + gap = 0, + paddingStart = 0, + paddingEnd = 0, + scrollElement, + scrollMargin = 0, + initialContainerSize = 0, + initialScrollIndex, + anchorScroll = true, + followOutput = false, + scrollingDelay = 150, + } = options; + + const horizontal = axis === 'x'; + const scrollProp = horizontal ? 'scrollLeft' as const : 'scrollTop' as const; + const clientProp = horizontal ? 'clientWidth' as const : 'clientHeight' as const; + + const estimate: (item: T, index: number) => number + = isNumber(estimateSize) ? () => estimateSize : estimateSize; const containerRef = shallowRef(null); - const size = useElementSize(containerRef); - const source = computed(() => toValue(list)); + const getScrollEl = (): HTMLElement | null => + scrollElement !== undefined ? toValue(scrollElement) ?? null : containerRef.value; - const currentList = shallowRef>>([]); - const state = shallowRef({ start: 0, end: overscan }); + // ─── State ───────────────────────────────────────────────────────────────── + // Hot data lives outside the reactivity system: the scroll handler and the + // ResizeObserver touch it at pixel/frame frequency. Renders are driven by + // exactly two coarse signals: `range` (window moved), `version` (layout changed). - // Recompute metrics only when the source length or item-size strategy changes. - const metrics = computed(() => createMetrics(source.value.length, itemSize)); + let items: readonly T[] = []; + let count = 0; + let sizes = new Float64Array(0); + let keys: PropertyKey[] = []; + let keyToIndex = new Map(); + let tree = new FenwickTree(0); + const measured = new Map(); - const calculateRange = (): void => { - const element = containerRef.value; - if (!element) + let scrollOffset = 0; + let viewport = initialContainerSize; + + const version = shallowRef(0); + const range = shallowRef({ start: 0, end: 0 }); + const isScrolling = shallowRef(false); + + let pendingChanged = false; // some size/offset changed since the last flush + let viewportChanged = false; // container resized — range-only recompute + let anchorSuppressed = false; // smooth programmatic scroll in flight + let programmaticOffset = -1; // echo marker for our own compensation writes + let chaseTarget = -1; // last offset written by the scrollTo chase + let pendingScrollTarget = -1; // deferred compensation target, -1 = none + let scrollToRaf = 0; + let disposed = false; + let didInitialScroll = initialScrollIndex === undefined; + let warnedNonElement = false; + let scrollingTimer: ReturnType | undefined; + let smoothTimer: ReturnType | undefined; + + // ─── Geometry ────────────────────────────────────────────────────────────── + + /** Item start in wrapper coordinates (paddingStart and gaps included). */ + function offsetOf(index: number): number { + return paddingStart + tree.prefix(index) + index * gap; + } + + function contentSize(): number { + if (count === 0) + return paddingStart + paddingEnd; + return paddingStart + tree.prefix(count) + (count - 1) * gap + paddingEnd; + } + + function updateRange(): void { + if (count === 0) { + if (range.value.start !== 0 || range.value.end !== 0) + range.value = { start: 0, end: 0 }; return; - - const m = metrics.value; - const len = source.value.length; - const scrollPos = axis === 'vertical' ? element.scrollTop : element.scrollLeft; - const containerSize = axis === 'vertical' ? element.clientHeight : element.clientWidth; - - const offset = m.offset(scrollPos); - const viewCapacity = m.viewCapacity(containerSize, offset); - - const start = clamp(offset - overscan, 0, len); - const end = clamp(offset + viewCapacity + overscan, 0, len); - - state.value = { start, end }; - - const view: Array> = []; - for (let i = start; i < end; i++) - view.push({ data: source.value[i]!, index: i }); - currentList.value = view; - }; - - // Re-slice when the viewport size, the source, or the mounted element changes. - watch( - [size.width, size.height, source, containerRef], - calculateRange, - { flush: 'post' }, - ); - - // Passive scroll listener with automatic cleanup and reactive re-binding. - useEventListener(containerRef, 'scroll', calculateRange, { passive: true }); - - const offsetStart = computed(() => metrics.value.distance(state.value.start)); - const totalSize = computed(() => metrics.value.total()); - - const wrapperProps = computed((): { style: UseVirtualListWrapperStyle } => { - if (axis === 'vertical') { - return { - style: { - width: '100%', - height: `${totalSize.value - offsetStart.value}px`, - marginTop: `${offsetStart.value}px`, - }, - }; } - return { - style: { - height: '100%', - width: `${totalSize.value - offsetStart.value}px`, - marginLeft: `${offsetStart.value}px`, - display: 'flex', - }, - }; + const top = scrollOffset - scrollMargin - paddingStart; + const first = tree.lowerBound(top, gap); + const last = tree.lowerBound(top + viewport, gap) + 1; + const start = clamp(first - overscan, 0, count - 1); + const end = clamp(last + overscan, start + 1, count); + const current = range.value; + if (current.start !== start || current.end !== end) + range.value = { start, end }; + } + + // ─── Render output ───────────────────────────────────────────────────────── + // Declared before the watches below: their `immediate` callbacks can reach + // `scrollTo` → `totalSize` while `const` computeds are still in TDZ otherwise. + + const totalSize = computed(() => { + void version.value; + return contentSize(); }); - const containerStyle: StyleValue = isVertical - ? { overflowY: 'auto' } - : { overflowX: 'auto' }; - - const scrollTo = (index: number, scrollOptions?: UseVirtualListScrollToOptions): void => { - const element = containerRef.value; - if (!element) - return; - - const resolved = { ...defaultScrollToOptions, ...scrollOptions }; - const m = metrics.value; - - let offset = 0; - const align = axis === 'horizontal' ? resolved.inline : resolved.block; - if (align) { - const containerSize = axis === 'vertical' ? element.clientHeight : element.clientWidth; - const fullItemSize = m.size(index); - if (align === 'center') - offset = containerSize / 2 - fullItemSize / 2; - else if (align === 'end') - offset = containerSize - fullItemSize; - else if (align === 'nearest' && m.distance(index) > element[scrollKey[axis]] + containerSize / 2) - offset = containerSize - fullItemSize; + const list = computed>>(() => { + void version.value; + const { start, end } = range.value; + const result: Array> = []; + if (start >= end) + return result; + // one O(log n) prefix, then O(1) accumulation per row + let offset = offsetOf(start); + for (let i = start; i < end; i++) { + const size = sizes[i]!; + result.push({ + data: items[i]!, + index: i, + key: keys[i]!, + start: offset, + size, + end: offset + size, + props: { + ref: measureElement, + 'data-index': i, + style: horizontal + ? { position: 'absolute', top: 0, left: 0, height: '100%', transform: `translateX(${offset}px)` } + : { position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${offset}px)` }, + }, + }); + offset += size + gap; } + return result; + }); - element.scrollTo({ - [scrollToKey[axis]]: m.distance(index) - offset, - behavior: resolved.behavior, - }); - calculateRange(); - }; - + // `overflow-anchor: none` — native scroll anchoring would double-correct on + // top of our own compensation. On platforms with classic scrollbars consider + // also adding `scrollbar-gutter: stable` to avoid threshold reflow loops. const containerProps: UseVirtualListContainerProps = { ref: containerRef, - onScroll: calculateRange, - style: containerStyle, + style: { + overflowAnchor: 'none', + ...(horizontal ? { overflowX: 'auto' } : { overflowY: 'auto' }), + } as CSSProperties, }; + const wrapperProps = computed(() => ({ + style: (horizontal + ? { position: 'relative', width: `${totalSize.value}px`, height: '100%' } + : { position: 'relative', height: `${totalSize.value}px`, width: '100%' }) as CSSProperties, + })); + + // ─── Measurement ─────────────────────────────────────────────────────────── + + let observer: ResizeObserver | undefined; + + // Live row elements by item key: a remount under the same key evicts the + // stale node, KeepAlive-detached rows stay observed while cached, and + // out-of-window disconnected entries are swept after each patch. + const elements = new Map(); + + function ensureObserver(): ResizeObserver | undefined { + if (!observer && typeof ResizeObserver !== 'undefined') + observer = new ResizeObserver(onResizeEntries); + return observer; + } + + function readSize(entry: ResizeObserverEntry): number { + const box = entry.borderBoxSize?.[0]; + if (box) + return horizontal ? box.inlineSize : box.blockSize; + const rect = entry.target.getBoundingClientRect(); + return horizontal ? rect.width : rect.height; + } + + /** Record a new size for `index`; returns the applied delta (0 if below epsilon). */ + function commitSize(index: number, size: number, cache = true): number { + const delta = size - sizes[index]!; + if (Math.abs(delta) < SIZE_EPSILON) + return 0; + sizes[index] = size; + if (cache) + measured.set(keys[index]!, size); + tree.update(index, delta); + pendingChanged = true; + return delta; + } + + // Only items starting above the viewport top shift visible content; `adjust` + // folds in compensation from earlier entries of the same batch so late ones + // are classified against the final geometry. + function anchorContribution(index: number, delta: number, adjust: number): number { + if (delta === 0 || !anchorScroll || anchorSuppressed) + return 0; + return offsetOf(index) + scrollMargin < scrollOffset + adjust ? delta : 0; + } + + function onResizeEntries(entries: ResizeObserverEntry[]): void { + const scroller = getScrollEl(); + let adjust = 0; + for (const entry of entries) { + const target = entry.target as HTMLElement; + if (target === scroller) { + const next = scroller[clientProp]; + if (next !== viewport) { + viewport = next; + viewportChanged = true; + } + continue; + } + if (!target.isConnected) + continue; // lifecycle is owned by the keyed registry + sweep + const index = Number(target.dataset.index); + if (!Number.isInteger(index) || index < 0 || index >= count) + continue; + const delta = commitSize(index, readSize(entry)); + adjust += anchorContribution(index, delta, adjust); + } + flushSizeChanges(adjust); + } + + function measureElement(el: unknown): void { + if (!el || typeof el !== 'object') + return; + // component instance (or its expose proxy) resolves through $el + const node = ('nodeType' in el ? el : (el as { $el?: unknown }).$el) as HTMLElement | null | undefined; + if (!node || node.nodeType !== 1) { + if (!warnedNonElement) { + warnedNonElement = true; + console.warn( + '[useVirtualList] item.props.ref did not resolve to a DOM element — the row will keep its ' + + 'estimated size. Bind item.props to a plain element or a component with a single root element.', + ); + } + return; + } + const ro = ensureObserver(); + if (!ro) + return; + const index = Number(node.dataset.index); + if (!Number.isInteger(index) || index < 0 || index >= count) + return; + const key = keys[index]!; + const previous = elements.get(key); + if (previous === node) + return; + if (previous) + ro.unobserve(previous); + elements.set(key, node); + ro.observe(node, { box: 'border-box' }); + } + + // RO only fires when a size *changes* — after dropping a cached measurement + // the estimate would stick for already-rendered rows, so re-read them by hand. + function syncLiveElements(only?: number): void { + let adjust = 0; + const commitNode = (node: HTMLElement): void => { + if (!node.isConnected) + return; + const index = Number(node.dataset.index); + if (!Number.isInteger(index) || index < 0 || index >= count) + return; + const rect = node.getBoundingClientRect(); + const delta = commitSize(index, horizontal ? rect.width : rect.height); + adjust += anchorContribution(index, delta, adjust); + }; + if (only !== undefined) { + const node = elements.get(keys[only]!); + if (node) + commitNode(node); + } + else { + for (const node of elements.values()) + commitNode(node); + } + flushSizeChanges(adjust); + } + + // Sweep after each DOM patch: disconnected entries still inside the window + // are kept (KeepAlive cache / v-if — a remount replaces them by key). + watch([range, version], () => { + if (elements.size === 0) + return; + const { start, end } = range.value; + for (const [key, node] of elements) { + if (node.isConnected) + continue; + const index = keyToIndex.get(key); + if (index === undefined || index < start || index >= end) { + observer?.unobserve(node); + elements.delete(key); + } + } + }, { flush: 'post' }); + + // ─── Scroll compensation ─────────────────────────────────────────────────── + + // Compensation writes must land AFTER the DOM patch: the wrapper still has + // its old size here, so an immediate write could be clamped by the stale + // scrollHeight. nextTick runs after the render flush but before paint. + // `behavior: 'instant'` bypasses any `scroll-behavior: smooth` CSS on the + // scroller, which would animate the correction and desync `scrollOffset`. + function requestScrollWrite(target: number): void { + const schedule = pendingScrollTarget < 0; + pendingScrollTarget = target; + scrollOffset = target; // optimistic: keep layout math consistent pre-flush + if (!schedule) + return; + nextTick(() => { + const value = pendingScrollTarget; + pendingScrollTarget = -1; + if (disposed || value < 0) + return; + const element = getScrollEl(); + if (!element) + return; + element.scrollTo(horizontal ? { left: value, behavior: 'instant' } : { top: value, behavior: 'instant' }); + scrollOffset = element[scrollProp]; // re-read: the browser clamp is authoritative + programmaticOffset = scrollOffset; + updateRange(); + }); + } + + function flushSizeChanges(adjust: number): void { + if (adjust !== 0) + requestScrollWrite(scrollOffset + adjust); + if (pendingChanged) { + pendingChanged = false; + version.value++; + updateRange(); + } + else if (viewportChanged) { + // a pure viewport change moves no offsets — skip the `list` re-render + updateRange(); + } + viewportChanged = false; + } + + // ─── Source sync ─────────────────────────────────────────────────────────── + + function rebuild(): void { + const element = getScrollEl(); + const oldCount = count; + + // Viewport stability across the rebuild: pin to the end (followOutput) or + // anchor to the first visible item's key (jump-free prepend). Skipped when + // the viewport is still above the list (scrollOffset < scrollMargin). + let anchorKey: PropertyKey | undefined; + let anchorShift = 0; + let pinToEnd = false; + if (element && count > 0 && !anchorSuppressed) { + const oldTotal = contentSize(); + if (followOutput && scrollOffset >= oldTotal + scrollMargin - viewport - 1) { + pinToEnd = true; + } + else if (anchorScroll && scrollOffset >= scrollMargin) { + const first = Math.min( + tree.lowerBound(scrollOffset - scrollMargin - paddingStart, gap), + count - 1, + ); + anchorKey = keys[first]; + anchorShift = scrollOffset - (offsetOf(first) + scrollMargin); + } + } + + items = toValue(source); + count = items.length; + sizes = new Float64Array(count); + // push keeps numeric keys PACKED_SMI; prefilling with undefined + // (Array.from({ length })) would pin the array to PACKED_ELEMENTS + keys = []; + keyToIndex = new Map(); + for (let i = 0; i < count; i++) { + const key = getItemKey(items[i]!, i); + keys.push(key); + keyToIndex.set(key, i); + sizes[i] = measured.get(key) ?? estimate(items[i]!, i); + } + // drop cache/registry entries for keys that left the list + for (const key of measured.keys()) { + if (!keyToIndex.has(key)) + measured.delete(key); + } + for (const [key, node] of elements) { + if (!keyToIndex.has(key)) { + observer?.unobserve(node); + elements.delete(key); + } + } + tree = new FenwickTree(count); + tree.build(sizes); + + if (pinToEnd && count > oldCount) { + requestScrollWrite(Math.max(0, contentSize() + scrollMargin - viewport)); + } + else if (anchorKey !== undefined) { + const index = keyToIndex.get(anchorKey); + if (index !== undefined) { + const target = offsetOf(index) + scrollMargin + anchorShift; + if (Math.abs(target - scrollOffset) > SIZE_EPSILON) + requestScrollWrite(target); + } + } + + version.value++; + updateRange(); + maybeInitialScroll(); + } + + function maybeInitialScroll(): void { + if (didInitialScroll || count === 0 || !getScrollEl()) + return; + didInitialScroll = true; + scrollTo(initialScrollIndex!, { align: 'start' }); + } + + watch(() => toValue(source), (next) => { + // scrollTo may have re-synced this replacement already + if (next !== items) + rebuild(); + }, { immediate: true }); + + // ─── Scroll events ───────────────────────────────────────────────────────── + + // debounce callbacks are hoisted — inline arrows would allocate per scroll event + function onSmoothIdle(): void { + anchorSuppressed = false; + } + + function onScrollIdle(): void { + isScrolling.value = false; + } + + /** Re-arm on every scroll event: "idle" = no events for 200ms. */ + function armSmoothIdleTimer(): void { + clearTimeout(smoothTimer); + smoothTimer = setTimeout(onSmoothIdle, 200); + } + + function onScroll(event: Event): void { + const offset = (event.currentTarget as HTMLElement)[scrollProp]; + if (offset === programmaticOffset) { + // echo of our own compensation write — don't flash isScrolling + programmaticOffset = -1; + scrollOffset = offset; + updateRange(); + return; + } + scrollOffset = offset; + if (scrollToRaf && Math.abs(offset - chaseTarget) > 1) { + // the user took over mid-chase — stop fighting their input + cancelAnimationFrame(scrollToRaf); + scrollToRaf = 0; + } + updateRange(); + if (anchorSuppressed) + armSmoothIdleTimer(); + if (scrollingDelay > 0) { + if (!isScrolling.value) + isScrolling.value = true; + clearTimeout(scrollingTimer); + scrollingTimer = setTimeout(onScrollIdle, scrollingDelay); + } + } + + useEventListener(getScrollEl, 'scroll', onScroll, { passive: true }); + + watch(getScrollEl, (element, _previous, onCleanup) => { + if (!element) + return; + ensureObserver()?.observe(element); + // native scroll anchoring would double-correct on top of ours — + // applied here so every wiring path gets it, not just containerProps + const previousAnchor = element.style.overflowAnchor; + element.style.overflowAnchor = 'none'; + scrollOffset = element[scrollProp]; + viewport = element[clientProp]; + updateRange(); + maybeInitialScroll(); + onCleanup(() => { + observer?.unobserve(element); + element.style.overflowAnchor = previousAnchor; + }); + }, { immediate: true, flush: 'post' }); + + // ─── Programmatic scrolling ──────────────────────────────────────────────── + + /** `'auto'` resolves with nearest-edge semantics (CSS `block: 'nearest'`). */ + function resolveAlign(index: number, align: UseVirtualListAlign): Exclude | null { + if (align !== 'auto') + return align; + const start = offsetOf(index) + scrollMargin; + const end = start + sizes[index]!; + const viewStart = scrollOffset; + const viewEnd = scrollOffset + viewport; + if (start >= viewStart && end <= viewEnd) + return null; // fully visible + if (start <= viewStart && end >= viewEnd) + return null; // taller than the viewport and already covering it + if (start < viewStart) + return sizes[index]! > viewport ? 'end' : 'start'; + return sizes[index]! > viewport ? 'start' : 'end'; + } + + function getOffsetForIndex(index: number, align: UseVirtualListAlign = 'auto'): number { + if (count === 0) + return 0; + const i = clamp(index, 0, count - 1); + const resolved = resolveAlign(i, align); + if (resolved === null) + return scrollOffset; + const start = offsetOf(i) + scrollMargin; + const size = sizes[i]!; + let target: number; + if (resolved === 'start') + target = start; + else if (resolved === 'center') + target = start - (viewport - size) / 2; + else + target = start - viewport + size; + return clamp(target, 0, Math.max(0, totalSize.value + scrollMargin - viewport)); + } + + function scrollToOffset(offset: number, scrollOptions: { behavior?: ScrollBehavior } = {}): void { + const element = getScrollEl(); + if (!element) + return; + if (scrollToRaf) { + // an explicit scroll request takes precedence over a pending chase + cancelAnimationFrame(scrollToRaf); + scrollToRaf = 0; + } + if (scrollOptions.behavior === 'smooth') { + anchorSuppressed = true; + armSmoothIdleTimer(); + } + element.scrollTo(horizontal + ? { left: offset, behavior: scrollOptions.behavior } + : { top: offset, behavior: scrollOptions.behavior }); + } + + function scrollTo(index: number, scrollOptions: UseVirtualListScrollToOptions = {}): void { + // "append then scroll to newest" replaces the source and calls this in the + // same tick — re-sync instead of silently using the stale layout + if (toValue(source) !== items) + rebuild(); + const element = getScrollEl(); + if (!element || count === 0) + return; + if (scrollToRaf) { + cancelAnimationFrame(scrollToRaf); + scrollToRaf = 0; + } + const i = clamp(index, 0, count - 1); + const align = resolveAlign(i, scrollOptions.align ?? 'auto'); + if (align === null) + return; + const behavior = scrollOptions.behavior; + if (behavior === 'smooth') { + anchorSuppressed = true; + armSmoothIdleTimer(); + } + // Jumping into unmeasured territory lands on estimates; newly revealed rows + // are measured before the next paint, shifting the target — chase it for a + // few frames until the position is stable. + const attempt = (triesLeft: number): void => { + const target = getOffsetForIndex(i, align); + chaseTarget = target; + element.scrollTo(horizontal ? { left: target, behavior } : { top: target, behavior }); + if (behavior === 'smooth' || triesLeft <= 0) + return; + scrollToRaf = requestAnimationFrame(() => { + scrollToRaf = 0; + if (disposed || !element.isConnected) + return; + if (Math.abs(getOffsetForIndex(i, align) - element[scrollProp]) > 1) + attempt(triesLeft - 1); + }); + }; + attempt(8); + } + + // ─── Public helpers ──────────────────────────────────────────────────────── + + function updateLayout(): void { + rebuild(); + } + + function remeasure(index?: number): void { + if (index === undefined) { + measured.clear(); + rebuild(); + syncLiveElements(); + } + else if (index >= 0 && index < count) { + measured.delete(keys[index]!); + const delta = commitSize(index, estimate(items[index]!, index), false); + flushSizeChanges(anchorContribution(index, delta, 0)); + syncLiveElements(index); + } + } + + tryOnScopeDispose(() => { + disposed = true; + observer?.disconnect(); + elements.clear(); + measured.clear(); + if (scrollingTimer) + clearTimeout(scrollingTimer); + if (smoothTimer) + clearTimeout(smoothTimer); + if (scrollToRaf) + cancelAnimationFrame(scrollToRaf); + }); + return { - list: currentList, - scrollTo, + list, + totalSize, + range, + isScrolling, + containerRef, containerProps, wrapperProps, + measureElement, + scrollTo, + scrollToOffset, + getOffsetForIndex, + updateLayout, + remeasure, }; }