feature(VirtualList): remove tanstack virtual list solution, add self written one

This commit is contained in:
Ilia Mashkov
2026-01-15 13:33:59 +03:00
parent 925d2eec3e
commit 429a9a0877
5 changed files with 175 additions and 208 deletions

View File

@@ -66,7 +66,6 @@
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"
}, },
"dependencies": { "dependencies": {
"@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query": "^6.0.14"
"@tanstack/svelte-virtual": "^3.13.17"
} }
} }

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte'; import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
import { import {
Content as ItemContent, Content as ItemContent,
Root as ItemRoot, Root as ItemRoot,
@@ -13,28 +12,10 @@ import { VirtualList } from '$shared/ui';
* Displays a virtualized list of fonts with loading, empty, and error states. * Displays a virtualized list of fonts with loading, empty, and error states.
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props. * Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
*/ */
interface FontListProps {
/** Font items to display (defaults to filtered fonts from store) */
fonts?: UnifiedFont[];
/** Show loading state */
loading?: boolean;
/** Show empty state when no results */
showEmpty?: boolean;
/** Custom error message to display */
errorMessage?: string;
}
let {
fonts,
loading,
showEmpty = true,
errorMessage,
}: FontListProps = $props();
// const fontshareStore = getFontshareContext();
</script> </script>
{#each fontshareStore.fonts as font (font.id)} <VirtualList items={fontshareStore.fonts} itemHeight={30}>
{#snippet children({ item: font })}
<ItemRoot> <ItemRoot>
<ItemContent> <ItemContent>
<ItemTitle>{font.name}</ItemTitle> <ItemTitle>{font.name}</ItemTitle>
@@ -43,4 +24,5 @@ let {
</span> </span>
</ItemContent> </ItemContent>
</ItemRoot> </ItemRoot>
{/each} {/snippet}
</VirtualList>

View File

@@ -1,15 +1,159 @@
import { import { untrack } from 'svelte';
createVirtualizer as coreCreateVirtualizer,
observeElementRect, export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
} from '@tanstack/svelte-virtual'; // Reactive State
import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core'; let scrollOffset = $state(0);
import { get } from 'svelte/store'; let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({});
// 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 { export interface VirtualItem {
/** Index of the item in the data array */
index: number; index: number;
/** Offset from the top of the list */
start: number; start: number;
/** Height of the item */
size: number; size: number;
/** End position (start + size) */
end: number; end: number;
/** Unique key for the item (for Svelte's {#each} keying) */
key: string | number; key: string | number;
} }
@@ -26,91 +170,4 @@ export interface VirtualizerOptions {
scrollMargin?: number; 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:
* // <div bind:this={virtualizer.scrollElement}>
* // {#each virtualizer.items as item}
* // <div style="transform: translateY({item.start}px)">
* // {items[item.index]}
* // </div>
* // {/each}
* // </div>
* ```
*/
export function createVirtualizer(
optionsGetter: () => VirtualizerOptions,
) {
let element = $state<HTMLElement | null>(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<typeof createVirtualizer>; export type Virtualizer = ReturnType<typeof createVirtualizer>;

View File

@@ -55,53 +55,15 @@ interface Props {
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props(); let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
let activeIndex = $state(0); const virtualizer = createVirtualizer(() => ({
const itemRefs = new Map<number, HTMLElement>();
const virtual = createVirtualizer(() => ({
count: items.length, count: items.length,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan, 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);
}
}
</script> </script>
<!--
Scroll container with single tab stop pattern:
- tabindex="0" on container, tabindex="-1" on items
- Arrow keys navigate within, Tab moves out
-->
<div <div
bind:this={virtual.scrollElement} use:virtualizer.container
class={cn( class={cn(
'relative overflow-auto border rounded-md bg-background', 'relative overflow-auto border rounded-md bg-background',
'outline-none focus-visible:ring-2 ring-ring ring-offset-2', 'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
@@ -110,29 +72,15 @@ async function handleKeydown(event: KeyboardEvent) {
)} )}
role="listbox" role="listbox"
tabindex="0" tabindex="0"
onkeydown={handleKeydown}
onfocusin={(e => e.target === virtual.scrollElement && focusItem(activeIndex))}
> >
<!-- Total scrollable height placeholder --> {#each virtualizer.items as item (item.key)}
<div <div
class="relative w-full" use:virtualizer.measureElement
style:height="{virtual.totalSize}px" data-index={item.index}
class="absolute top-0 left-0 w-full translate-y-[var(--offset)] will-change-transform"
style:--offset="{item.start}px"
> >
{#each virtual.items as row (row.key)} {@render children({ item: items[item.index], index: item.index })}
<!-- Individual item positioned absolutely via GPU-accelerated transform -->
<div
use:registerItem={row.index}
data-index={row.index}
role="option"
aria-selected={activeIndex === row.index}
tabindex="-1"
onmousedown={() => (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 })}
</div> </div>
{/each} {/each}
</div> </div>
</div>

View File

@@ -1310,24 +1310,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@testing-library/dom@npm:9.x.x || 10.x.x":
version: 10.4.1 version: 10.4.1
resolution: "@testing-library/dom@npm:10.4.1" resolution: "@testing-library/dom@npm:10.4.1"
@@ -2466,7 +2448,6 @@ __metadata:
"@sveltejs/vite-plugin-svelte": "npm:^6.2.1" "@sveltejs/vite-plugin-svelte": "npm:^6.2.1"
"@tailwindcss/vite": "npm:^4.1.18" "@tailwindcss/vite": "npm:^4.1.18"
"@tanstack/svelte-query": "npm:^6.0.14" "@tanstack/svelte-query": "npm:^6.0.14"
"@tanstack/svelte-virtual": "npm:^3.13.17"
"@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/jest-dom": "npm:^6.9.1"
"@testing-library/svelte": "npm:^5.3.1" "@testing-library/svelte": "npm:^5.3.1"
"@tsconfig/svelte": "npm:^5.0.6" "@tsconfig/svelte": "npm:^5.0.6"