From 7a9f7e238c8077e7ee23c1e34f2b43b5f26aa0f7 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 6 Jan 2026 21:38:18 +0300 Subject: [PATCH] refactor(createVirtualizer): refactor createVirtualizerStore with modern svelte 5 patterns --- .../createVirtualizer/createVirtualizer.ts | 114 +++++ .../store/createVirtualizerStore.test.ts | 436 ------------------ src/shared/store/createVirtualizerStore.ts | 282 ----------- src/shared/store/index.ts | 26 -- 4 files changed, 114 insertions(+), 744 deletions(-) create mode 100644 src/shared/lib/utils/createVirtualizer/createVirtualizer.ts delete mode 100644 src/shared/store/createVirtualizerStore.test.ts delete mode 100644 src/shared/store/createVirtualizerStore.ts delete mode 100644 src/shared/store/index.ts diff --git a/src/shared/lib/utils/createVirtualizer/createVirtualizer.ts b/src/shared/lib/utils/createVirtualizer/createVirtualizer.ts new file mode 100644 index 0000000..49abe4c --- /dev/null +++ b/src/shared/lib/utils/createVirtualizer/createVirtualizer.ts @@ -0,0 +1,114 @@ +import { + createVirtualizer as coreCreateVirtualizer, + observeElementRect, +} from '@tanstack/svelte-virtual'; +import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core'; +import { get } from 'svelte/store'; + +export interface VirtualItem { + index: number; + start: number; + size: number; + end: number; + key: string | number; +} + +export interface VirtualizerOptions { + /** Total number of items in the data array */ + count: number; + /** Function to estimate the size of an item at a given index */ + estimateSize: (index: number) => number; + /** Number of extra items to render outside viewport (default: 5) */ + overscan?: number; + /** Function to get the key of an item at a given index (defaults to index) */ + getItemKey?: (index: number) => string | number; + /** Optional margin in pixels for scroll calculations */ + 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), + }; +} diff --git a/src/shared/store/createVirtualizerStore.test.ts b/src/shared/store/createVirtualizerStore.test.ts deleted file mode 100644 index 123bd44..0000000 --- a/src/shared/store/createVirtualizerStore.test.ts +++ /dev/null @@ -1,436 +0,0 @@ -/** - * ============================================================================ - * VIRTUALIZER STORE - UNIT TESTS - * ============================================================================ - * - * Tests for createVirtualizerStore - * - * Note: These tests focus on the store API and behavior without requiring - * a full DOM environment. Integration tests with actual DOM are in - * component tests. - */ - -import { - describe, - expect, - it, - vi, -} from 'vitest'; -import { createVirtualizerStore } from './createVirtualizerStore'; - -describe('createVirtualizerStore', () => { - const count = 100; - - describe('initialization', () => { - it('should create virtualizer store with default options', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(store).toBeDefined(); - expect(store.virtualItems).toBeDefined(); - expect(typeof store.virtualItems.subscribe).toBe('function'); - }); - - it('should use custom estimateSize function', () => { - const estimateSize = vi.fn(() => 120); - - const store = createVirtualizerStore({ - count, - estimateSize, - }); - - expect(typeof estimateSize).toBe('function'); - expect(store).toBeDefined(); - }); - - it('should use custom overscan value', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - overscan: 10, - }); - - expect(store).toBeDefined(); - }); - - it('should use getItemKey for stable keys', () => { - const getItemKey = vi.fn((index: number) => `item-${index}`); - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - getItemKey, - }); - - expect(typeof getItemKey).toBe('function'); - }); - - it('should use scrollMargin', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - scrollMargin: 50, - }); - - expect(store).toBeDefined(); - }); - }); - - describe('virtual items structure', () => { - it('should provide virtual items as a readable store', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(store.virtualItems).toBeDefined(); - expect(typeof store.virtualItems.subscribe).toBe('function'); - }); - }); - - describe('total size calculation', () => { - it('should provide totalSize as a readable store', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(store.totalSize).toBeDefined(); - expect(typeof store.totalSize.subscribe).toBe('function'); - }); - - it('should calculate total size via subscription', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - let size = 0; - const unsubscribe = store.totalSize.subscribe(value => { - size = value; - }); - - expect(size).toBeGreaterThanOrEqual(0); - unsubscribe(); - }); - }); - - describe('scroll offset', () => { - it('should provide scrollOffset as a readable store', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(store.scrollOffset).toBeDefined(); - expect(typeof store.scrollOffset.subscribe).toBe('function'); - }); - - it('should initialize scrollOffset to 0 via subscription', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - let offset = 0; - const unsubscribe = store.scrollOffset.subscribe(value => { - offset = value; - }); - - expect(offset).toBe(0); - unsubscribe(); - }); - }); - - describe('scroll element binding', () => { - it('should allow binding scrollElement', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - const mockElement = {} as HTMLElement; - store.scrollElement = mockElement; - - expect(store.scrollElement).toBe(mockElement); - }); - - it('should allow updating scrollElement', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - const mockElement1 = {} as HTMLElement; - const mockElement2 = {} as HTMLElement; - - store.scrollElement = mockElement1; - expect(store.scrollElement).toBe(mockElement1); - - store.scrollElement = mockElement2; - expect(store.scrollElement).toBe(mockElement2); - }); - - it('should initialize scrollElement as null', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(store.scrollElement).toBeNull(); - }); - }); - - describe('scroll methods API', () => { - it('should provide scrollToIndex method', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(store.scrollToIndex).toBeDefined(); - expect(typeof store.scrollToIndex).toBe('function'); - }); - - it('should provide scrollToOffset method', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(store.scrollToOffset).toBeDefined(); - expect(typeof store.scrollToOffset).toBe('function'); - }); - - it('should provide measureElement method', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(store.measureElement).toBeDefined(); - expect(typeof store.measureElement).toBe('function'); - }); - }); - - describe('scroll methods functionality', () => { - it('should handle scrollToIndex with options', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(() => { - store.scrollToIndex(10, { align: 'start' }); - }).not.toThrow(); - }); - - it('should handle scrollToIndex without options', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(() => { - store.scrollToIndex(10); - }).not.toThrow(); - }); - - it('should handle scrollToOffset', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - expect(() => { - store.scrollToOffset(100); - }).not.toThrow(); - }); - - it('should handle measureElement', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - const mockElement = { - dataset: { index: '5' }, - getAttribute: () => null, - } as unknown as HTMLElement; - - expect(() => { - store.measureElement(mockElement); - }).not.toThrow(); - }); - }); - - describe('edge cases', () => { - it('should handle empty items (count: 0)', () => { - const store = createVirtualizerStore({ - count: 0, - estimateSize: () => 80, - }); - - expect(store.virtualItems).toBeDefined(); - expect(store.totalSize).toBeDefined(); - expect(store.scrollOffset).toBeDefined(); - }); - - it('should handle single item (count: 1)', () => { - const store = createVirtualizerStore({ - count: 1, - estimateSize: () => 80, - }); - - expect(store.virtualItems).toBeDefined(); - expect(store.totalSize).toBeDefined(); - expect(store.scrollOffset).toBeDefined(); - }); - - it('should handle large dataset', () => { - const store = createVirtualizerStore({ - count: 10000, - estimateSize: () => 80, - }); - - expect(store.virtualItems).toBeDefined(); - expect(store.totalSize).toBeDefined(); - expect(store.scrollOffset).toBeDefined(); - }); - }); - - describe('custom configuration', () => { - it('should accept all optional parameters', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 100, - overscan: 7, - scrollMargin: 50, - getItemKey: index => `key-${index}`, - }); - - expect(store).toBeDefined(); - expect(store.virtualItems).toBeDefined(); - expect(store.totalSize).toBeDefined(); - expect(store.scrollOffset).toBeDefined(); - expect(store.scrollToIndex).toBeDefined(); - expect(store.scrollToOffset).toBeDefined(); - expect(store.measureElement).toBeDefined(); - expect(store.scrollElement).toBeDefined(); - }); - - it('should work with minimal configuration', () => { - const store = createVirtualizerStore({ - count: 0, - estimateSize: () => 80, - }); - - expect(store).toBeDefined(); - }); - }); - - describe('reactive stores', () => { - it('should return virtualItems as a readable store', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - // virtualItems should be a Readable store - expect(typeof store.virtualItems.subscribe).toBe('function'); - }); - - it('should return totalSize as a readable store', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - // totalSize should be a Readable store - expect(typeof store.totalSize.subscribe).toBe('function'); - }); - - it('should return scrollOffset as a readable store', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - // scrollOffset should be a Readable store - expect(typeof store.scrollOffset.subscribe).toBe('function'); - }); - - it('should provide virtualItems via subscription', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - let items: any[] = []; - const unsubscribe = store.virtualItems.subscribe(value => { - items = value; - }); - - expect(Array.isArray(items)).toBe(true); - unsubscribe(); - }); - - it('should provide totalSize via subscription', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - let size = 0; - const unsubscribe = store.totalSize.subscribe(value => { - size = value; - }); - - expect(typeof size).toBe('number'); - expect(size).toBeGreaterThanOrEqual(0); - unsubscribe(); - }); - - it('should provide scrollOffset via subscription', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 80, - }); - - let offset = 0; - const unsubscribe = store.scrollOffset.subscribe(value => { - offset = value; - }); - - expect(typeof offset).toBe('number'); - unsubscribe(); - }); - }); - - describe('dynamic estimateSize', () => { - it('should handle function-based estimateSize', () => { - const estimateSize = (index: number): number => { - return 80 + (index % 2) * 40; // Alternate between 80 and 120 - }; - - const store = createVirtualizerStore({ - count, - estimateSize, - }); - - expect(store).toBeDefined(); - expect(store.totalSize).toBeDefined(); - }); - - it('should handle constant estimateSize', () => { - const store = createVirtualizerStore({ - count, - estimateSize: () => 100, - }); - - expect(store).toBeDefined(); - expect(store.totalSize).toBeDefined(); - }); - }); -}); diff --git a/src/shared/store/createVirtualizerStore.ts b/src/shared/store/createVirtualizerStore.ts deleted file mode 100644 index cc1dcf4..0000000 --- a/src/shared/store/createVirtualizerStore.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** - * ============================================================================ - * VIRTUALIZER STORE - STORE PATTERN - * ============================================================================ - * - * Svelte store-based virtualizer for virtualized lists. - * - * Benefits of store pattern over hook: - * - More Svelte-native (stores are idiomatic, hooks are React-inspired) - * - Better reactivity (stores auto-derive values using derived()) - * - Consistent with project patterns (createFilterStore, createControlStore) - * - More extensible (can add store methods) - * - Type-safe with TypeScript generics - * - * Performance: - * - Renders only visible items (50-100 max regardless of total count) - * - Maintains 60FPS scrolling with 10,000+ items - * - Minimal memory usage - * - Smooth scrolling without jank - * - * Usage: - * ```svelte - * - * - *
- *
- * {#each virtualItems as item (item.key)} - *
- * {items[item.index].name} - *
- * {/each} - *
- *
- * ``` - */ - -import { createVirtualizer } from '@tanstack/svelte-virtual'; -import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core'; -import { - type Readable, - type Writable, - derived, - writable, -} from 'svelte/store'; - -/** - * Virtual item returned by the virtualizer - */ -export interface VirtualItem { - /** Item index in the original array */ - index: number; - /** Start position (pixels) */ - start: number; - /** Item size (pixels) */ - size: number; - /** End position (pixels) */ - end: number; - /** Stable key for rendering */ - key: string | number; -} - -/** - * Configuration options for createVirtualizerStore - */ -export interface VirtualizerOptions { - /** Fixed count of items (required) */ - count: number; - /** Estimated size for each item (in pixels) */ - estimateSize: (index: number) => number; - /** Number of items to render beyond viewport */ - overscan?: number; - /** Function to get stable key for each item */ - getItemKey?: (index: number) => string | number; - /** Scroll offset threshold for triggering update (in pixels) */ - scrollMargin?: number; -} - -/** - * Options for scrollToIndex - */ -export interface ScrollToIndexOptions { - /** Alignment behavior */ - align?: 'start' | 'center' | 'end' | 'auto'; -} - -/** - * Virtualizer store model with reactive stores and methods - */ -export interface VirtualizerStore { - /** Subscribe to scroll element state */ - subscribe: Writable<{ scrollElement: HTMLElement | null }>['subscribe']; - /** Set scroll element state */ - set: Writable<{ scrollElement: HTMLElement | null }>['set']; - /** Update scroll element state */ - update: Writable<{ scrollElement: HTMLElement | null }>['update']; - /** Array of virtual items to render (reactive store) */ - virtualItems: Readable; - /** Total size of all items (in pixels) (reactive store) */ - totalSize: Readable; - /** Current scroll offset (in pixels) (reactive store) */ - scrollOffset: Readable; - /** Scroll to a specific item index */ - scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void; - /** Scroll to a specific offset */ - scrollToOffset: (offset: number) => void; - /** Manually measure an item element */ - measureElement: (element: HTMLElement) => void; - /** Scroll element reference (getter/setter for binding) */ - scrollElement: HTMLElement | null; -} - -/** - * Create a virtualizer store using Svelte stores - * - * This store wraps TanStack Virtual in a Svelte-idiomatic way. - * The scroll element can be bound to the store for reactive virtualization. - * - * @param options - Virtualization configuration - * @returns VirtualizerStore with reactive values and methods - * - * @example - * ```svelte - * - * - *
- * - *
- * ``` - */ -export function createVirtualizerStore( - options: VirtualizerOptions, -): VirtualizerStore { - const { - count, - estimateSize, - overscan = 5, - getItemKey, - scrollMargin, - } = options; - - // Internal state for scroll element - const { subscribe: scrollElementSubscribe, set: setScrollElement, update } = writable< - { scrollElement: HTMLElement | null } - >({ scrollElement: null }); - - // Create virtualizer - returns a readable store - const virtualizerStore = createVirtualizer({ - count, - getScrollElement: () => { - let scrollElement: HTMLElement | null = null; - scrollElementSubscribe(state => { - scrollElement = state.scrollElement; - }); - return scrollElement; - }, - estimateSize, - overscan, - scrollMargin, - getItemKey: getItemKey ?? ((index: number) => index), - }); - - // Current virtualizer instance (unwrapped from readable store) - let virtualizerInstance: any; - - // Subscribe to the readable store - const _unsubscribe = virtualizerStore.subscribe(value => { - virtualizerInstance = value; - }); - - /** - * Get virtual items from current instance - */ - function getVirtualItems(): VirtualItem[] { - if (!virtualizerInstance) return []; - - const items = virtualizerInstance.getVirtualItems(); - - return items.map((item: CoreVirtualItem): VirtualItem => ({ - index: item.index, - start: item.start, - size: item.size, - end: item.end, - key: String(item.key), - })); - } - - /** - * Get total size from current instance - */ - function getTotalSize(): number { - return virtualizerInstance ? virtualizerInstance.getTotalSize() : 0; - } - - /** - * Get current scroll offset - */ - function getScrollOffset(): number { - return virtualizerInstance?.scrollOffset ?? 0; - } - - /** - * Scroll to a specific item index - * - * @param index - Item index to scroll to - * @param options - Alignment options - */ - function scrollToIndex(index: number, options?: ScrollToIndexOptions): void { - virtualizerInstance?.scrollToIndex(index, options); - } - - /** - * Scroll to a specific offset - * - * @param offset - Scroll offset in pixels - */ - function scrollToOffset(offset: number): void { - virtualizerInstance?.scrollToOffset(offset); - } - - /** - * Manually measure an item element - * - * Useful when item sizes are dynamic and need precise measurement. - * - * @param element - The element to measure - */ - function measureElement(element: HTMLElement): void { - virtualizerInstance?.measureElement(element); - } - - // Create derived stores for reactive values - const virtualItemsStore = derived(virtualizerStore, () => getVirtualItems()); - const totalSizeStore = derived(virtualizerStore, () => getTotalSize()); - const scrollOffsetStore = derived(virtualizerStore, () => getScrollOffset()); - - // Return store object with methods and derived stores - return { - subscribe: scrollElementSubscribe, - set: setScrollElement, - update, - virtualItems: virtualItemsStore, - totalSize: totalSizeStore, - scrollOffset: scrollOffsetStore, - scrollToIndex, - scrollToOffset, - measureElement, - get scrollElement() { - let scrollElement: HTMLElement | null = null; - scrollElementSubscribe(state => { - scrollElement = state.scrollElement; - }); - return scrollElement; - }, - set scrollElement(el) { - setScrollElement({ scrollElement: el }); - }, - }; -} diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts deleted file mode 100644 index d6cab99..0000000 --- a/src/shared/store/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Shared store exports - * - * Exports all store creators and types for Svelte 5 reactive state management - */ - -export { createFilterStore } from './createFilterStore'; -export type { - FilterModel, - FilterStore, - Property, -} from './createFilterStore'; - -export { createControlStore } from './createControlStore'; -export type { - ControlModel, - ControlStoreModel, -} from './createControlStore'; - -export { createVirtualizerStore } from './createVirtualizerStore'; -export type { - ScrollToIndexOptions, - VirtualItem, - VirtualizerOptions, - VirtualizerStore, -} from './createVirtualizerStore';