From 8c0c91deb7bb6795b00da55207f60dadeda5dbf2 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 16 Jan 2026 17:48:33 +0300 Subject: [PATCH] feat(createVirtualizer): enhance logic with binary search and requestAnimationFrame --- .../createVirtualizer.svelte.ts | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 3482292..ea96535 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -79,27 +79,23 @@ export interface VirtualizerOptions { * * ``` */ -export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { - // Reactive State +export function createVirtualizer(optionsGetter: () => VirtualizerOptions & { data: T[] }) { let scrollOffset = $state(0); let containerHeight = $state(0); let measuredSizes = $state>({}); - - // Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking) let elementRef: HTMLElement | null = null; - // Reactive Options + // By wrapping the getter in $derived, we track everything inside it const options = $derived(optionsGetter()); - // Optimized Memoization (The Cache Layer) - // Only recalculates when item count or measured sizes change. + // This derivation now tracks: count, measuredSizes, AND the data array itself const offsets = $derived.by(() => { const count = options.count; - const result = Array.from({ length: count }); + const result = new Float64Array(count); let accumulated = 0; - for (let i = 0; i < count; i++) { result[i] = accumulated; + // Accessing measuredSizes here creates the subscription accumulated += measuredSizes[i] ?? options.estimateSize(i); } return result; @@ -112,24 +108,30 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { : 0, ); - // Visible Range Calculation - // Svelte tracks dependencies automatically here. const items = $derived.by((): VirtualItem[] => { - const count = options.count; - if (count === 0 || containerHeight === 0) return []; + // We MUST read options.data here so Svelte knows to re-run + // this derivation when the items array is replaced! + const { count, data } = options; + if (count === 0 || containerHeight === 0 || !data) return []; const overscan = options.overscan ?? 5; - const viewportStart = scrollOffset; - const viewportEnd = scrollOffset + containerHeight; - // Find Start (Linear Scan) + // Binary search for efficiency + let low = 0; + let high = count - 1; let startIdx = 0; - while (startIdx < count && offsets[startIdx + 1] < viewportStart) { - startIdx++; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + if (offsets[mid] <= scrollOffset) { + startIdx = mid; + low = mid + 1; + } else { + high = mid - 1; + } } - // Find End let endIdx = startIdx; + const viewportEnd = scrollOffset + containerHeight; while (endIdx < count && offsets[endIdx] < viewportEnd) { endIdx++; } @@ -139,18 +141,16 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { const result: VirtualItem[] = []; for (let i = start; i < end; i++) { - const size = measuredSizes[i] ?? options.estimateSize(i); result.push({ index: i, start: offsets[i], - size, - end: offsets[i] + size, + size: measuredSizes[i] ?? options.estimateSize(i), + end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)), key: options.getItemKey?.(i) ?? i, }); } return result; }); - // Svelte Actions (The DOM Interface) /** @@ -185,6 +185,8 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { }; } + let measurementBuffer: Record = {}; + let frameId: number | null = null; /** * Svelte action to measure individual item elements for dynamic height support. * @@ -195,23 +197,32 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { * @returns Object with destroy method for cleanup */ function measureElement(node: HTMLElement) { - // Use a ResizeObserver on individual items for dynamic height support const resizeObserver = new ResizeObserver(([entry]) => { - if (entry) { - const index = parseInt(node.dataset.index || '', 10); - const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; + if (!entry) return; + const index = parseInt(node.dataset.index || '', 10); + const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; - // Only update if height actually changed to prevent loops - if (!isNaN(index) && measuredSizes[index] !== height) { - measuredSizes[index] = height; + if (!isNaN(index) && measuredSizes[index] !== height) { + // 1. Stuff the measurement into a temporary buffer + measurementBuffer[index] = height; + + // 2. Schedule a single update for the next animation frame + if (frameId === null) { + frameId = requestAnimationFrame(() => { + // 3. Update the state once for all collected measurements + // We use spread to trigger a single fine-grained update + measuredSizes = { ...measuredSizes, ...measurementBuffer }; + + // 4. Reset the buffer + measurementBuffer = {}; + frameId = null; + }); } } }); resizeObserver.observe(node); - return { - destroy: () => resizeObserver.disconnect(), - }; + return { destroy: () => resizeObserver.disconnect() }; } // Programmatic Scroll