From 429a9a087775dd76f1e8e537d3ebf063fa7c853b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 15 Jan 2026 13:33:59 +0300 Subject: [PATCH] feature(VirtualList): remove tanstack virtual list solution, add self written one --- package.json | 3 +- src/entities/Font/ui/FontList/FontList.svelte | 42 +-- .../createVirtualizer.svelte.ts | 243 +++++++++++------- src/shared/ui/VirtualList/VirtualList.svelte | 76 +----- yarn.lock | 19 -- 5 files changed, 175 insertions(+), 208 deletions(-) diff --git a/package.json b/package.json index e450e0b..99594e4 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ "vitest-browser-svelte": "^2.0.1" }, "dependencies": { - "@tanstack/svelte-query": "^6.0.14", - "@tanstack/svelte-virtual": "^3.13.17" + "@tanstack/svelte-query": "^6.0.14" } } diff --git a/src/entities/Font/ui/FontList/FontList.svelte b/src/entities/Font/ui/FontList/FontList.svelte index c1f2ac4..528c0fb 100644 --- a/src/entities/Font/ui/FontList/FontList.svelte +++ b/src/entities/Font/ui/FontList/FontList.svelte @@ -1,6 +1,5 @@ -{#each fontshareStore.fonts as font (font.id)} - - - {font.name} - - {font.category} • {font.provider} - - - -{/each} + + {#snippet children({ item: font })} + + + {font.name} + + {font.category} • {font.provider} + + + + {/snippet} + diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 14004c7..18ac8f9 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -1,15 +1,159 @@ -import { - createVirtualizer as coreCreateVirtualizer, - observeElementRect, -} from '@tanstack/svelte-virtual'; -import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core'; -import { get } from 'svelte/store'; +import { untrack } from 'svelte'; + +export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { + // Reactive State + 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 + const options = $derived(optionsGetter()); + + // Optimized Memoization (The Cache Layer) + // Only recalculates when item count or measured sizes change. + const offsets = $derived.by(() => { + const count = options.count; + const result = new Array(count); + let accumulated = 0; + + for (let i = 0; i < count; i++) { + result[i] = accumulated; + accumulated += measuredSizes[i] ?? options.estimateSize(i); + } + return result; + }); + + const totalSize = $derived( + options.count > 0 + ? offsets[options.count - 1] + + (measuredSizes[options.count - 1] ?? options.estimateSize(options.count - 1)) + : 0, + ); + + // Visible Range Calculation + // Svelte tracks dependencies automatically here. + const items = $derived.by((): VirtualItem[] => { + const count = options.count; + if (count === 0 || containerHeight === 0) return []; + + const overscan = options.overscan ?? 5; + const viewportStart = scrollOffset; + const viewportEnd = scrollOffset + containerHeight; + + // Find Start (Linear Scan) + let startIdx = 0; + while (startIdx < count && offsets[startIdx + 1] < viewportStart) { + startIdx++; + } + + // Find End + let endIdx = startIdx; + while (endIdx < count && offsets[endIdx] < viewportEnd) { + endIdx++; + } + + const start = Math.max(0, startIdx - overscan); + const end = Math.min(count, endIdx + overscan); + + 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, + key: options.getItemKey?.(i) ?? i, + }); + } + return result; + }); + + // Svelte Actions (The DOM Interface) + function container(node: HTMLElement) { + elementRef = node; + containerHeight = node.offsetHeight; + + const handleScroll = () => { + scrollOffset = node.scrollTop; + }; + + const resizeObserver = new ResizeObserver(([entry]) => { + if (entry) containerHeight = entry.contentRect.height; + }); + + node.addEventListener('scroll', handleScroll, { passive: true }); + resizeObserver.observe(node); + + return { + destroy() { + node.removeEventListener('scroll', handleScroll); + resizeObserver.disconnect(); + elementRef = null; + }, + }; + } + + 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; + + // Only update if height actually changed to prevent loops + if (!isNaN(index) && measuredSizes[index] !== height) { + measuredSizes[index] = height; + } + } + }); + + resizeObserver.observe(node); + return { + destroy: () => resizeObserver.disconnect(), + }; + } + + // Programmatic Scroll + function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') { + if (!elementRef || index < 0 || index >= options.count) return; + + const itemStart = offsets[index]; + const itemSize = measuredSizes[index] ?? options.estimateSize(index); + let target = itemStart; + + if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; + if (align === 'end') target = itemStart - containerHeight + itemSize; + + elementRef.scrollTo({ top: target, behavior: 'smooth' }); + } + + return { + get items() { + return items; + }, + get totalSize() { + return totalSize; + }, + container, + measureElement, + scrollToIndex, + }; +} export interface VirtualItem { + /** Index of the item in the data array */ index: number; + /** Offset from the top of the list */ start: number; + /** Height of the item */ size: number; + /** End position (start + size) */ end: number; + /** Unique key for the item (for Svelte's {#each} keying) */ key: string | number; } @@ -26,91 +170,4 @@ export interface VirtualizerOptions { scrollMargin?: number; } -/** - * Creates a reactive virtualizer using Svelte 5 runes and TanStack's core library. - * - * @example - * ```ts - * const virtualizer = createVirtualizer(() => ({ - * count: items.length, - * estimateSize: () => 80, - * overscan: 5, - * })); - * - * // In template: - * //
- * // {#each virtualizer.items as item} - * //
- * // {items[item.index]} - * //
- * // {/each} - * //
- * ``` - */ -export function createVirtualizer( - optionsGetter: () => VirtualizerOptions, -) { - let element = $state(null); - - const internalStore = coreCreateVirtualizer({ - get count() { - return optionsGetter().count; - }, - get estimateSize() { - return optionsGetter().estimateSize; - }, - get overscan() { - return optionsGetter().overscan ?? 5; - }, - get scrollMargin() { - return optionsGetter().scrollMargin; - }, - get getItemKey() { - return optionsGetter().getItemKey ?? (i => i); - }, - getScrollElement: () => element, - observeElementRect: observeElementRect, - }); - - const state = $derived(get(internalStore)); - - const virtualItems = $derived( - state.getVirtualItems().map((item: CoreVirtualItem): VirtualItem => ({ - index: item.index, - start: item.start, - size: item.size, - end: item.end, - key: typeof item.key === 'bigint' ? Number(item.key) : item.key, - })), - ); - - return { - get items() { - return virtualItems; - }, - - get totalSize() { - return state.getTotalSize(); - }, - - get scrollOffset() { - return state.scrollOffset ?? 0; - }, - - get scrollElement() { - return element; - }, - set scrollElement(el) { - element = el; - }, - - scrollToIndex: (idx: number, opt?: { align?: 'start' | 'center' | 'end' | 'auto' }) => - state.scrollToIndex(idx, opt), - - scrollToOffset: (off: number) => state.scrollToOffset(off), - - measureElement: (el: HTMLElement) => state.measureElement(el), - }; -} - export type Virtualizer = ReturnType; diff --git a/src/shared/ui/VirtualList/VirtualList.svelte b/src/shared/ui/VirtualList/VirtualList.svelte index 6e11fae..f6471f7 100644 --- a/src/shared/ui/VirtualList/VirtualList.svelte +++ b/src/shared/ui/VirtualList/VirtualList.svelte @@ -55,53 +55,15 @@ interface Props { let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props(); -let activeIndex = $state(0); -const itemRefs = new Map(); - -const virtual = createVirtualizer(() => ({ +const virtualizer = createVirtualizer(() => ({ count: items.length, estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, overscan, })); - -function registerItem(node: HTMLElement, index: number) { - itemRefs.set(index, node); - return { - destroy() { - itemRefs.delete(index); - }, - }; -} - -async function focusItem(index: number) { - activeIndex = index; - virtual.scrollToIndex(index, { align: 'auto' }); - await tick(); - itemRefs.get(index)?.focus(); -} - -async function handleKeydown(event: KeyboardEvent) { - let nextIndex = activeIndex; - if (event.key === 'ArrowDown') nextIndex++; - else if (event.key === 'ArrowUp') nextIndex--; - else if (event.key === 'Home') nextIndex = 0; - else if (event.key === 'End') nextIndex = items.length - 1; - else return; - - if (nextIndex >= 0 && nextIndex < items.length) { - event.preventDefault(); - await focusItem(nextIndex); - } -} -
e.target === virtual.scrollElement && focusItem(activeIndex))} > - -
- {#each virtual.items as row (row.key)} - -
(activeIndex = row.index)} - class="absolute top-0 left-0 w-full outline-none focus:bg-accent focus:text-accent-foreground" - style:height="{row.size}px" - style:transform="translateY({row.start}px)" - > - {@render children({ item: items[row.index], index: row.index })} -
- {/each} -
+ {#each virtualizer.items as item (item.key)} +
+ {@render children({ item: items[item.index], index: item.index })} +
+ {/each}
diff --git a/yarn.lock b/yarn.lock index 23b5b4b..e1f0e72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1310,24 +1310,6 @@ __metadata: languageName: node linkType: hard -"@tanstack/svelte-virtual@npm:^3.13.17": - version: 3.13.17 - resolution: "@tanstack/svelte-virtual@npm:3.13.17" - dependencies: - "@tanstack/virtual-core": "npm:3.13.17" - peerDependencies: - svelte: ^3.48.0 || ^4.0.0 || ^5.0.0 - checksum: 10c0/8139a94d8b913c1a3aef0e7cda4cfd8451c3e46455a5bd5bae1df26ab7583bfde785ab93cacefba4f0f45f2e2cd13f43fa8cf672c45cb31d52b3232ffb37e69e - languageName: node - linkType: hard - -"@tanstack/virtual-core@npm:3.13.17": - version: 3.13.17 - resolution: "@tanstack/virtual-core@npm:3.13.17" - checksum: 10c0/a021795b88856eff8518137ecb85b72f875399bc234ad10bea440ecb6ab48e5e72a74c9a712649a7765f0c37bc41b88263f5104d18df8256b3d50f6a97b32c48 - languageName: node - linkType: hard - "@testing-library/dom@npm:9.x.x || 10.x.x": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" @@ -2466,7 +2448,6 @@ __metadata: "@sveltejs/vite-plugin-svelte": "npm:^6.2.1" "@tailwindcss/vite": "npm:^4.1.18" "@tanstack/svelte-query": "npm:^6.0.14" - "@tanstack/svelte-virtual": "npm:^3.13.17" "@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/svelte": "npm:^5.3.1" "@tsconfig/svelte": "npm:^5.0.6"