From bee529dff8874e5b6a05304a00bd0c6d88900c5f Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 16 Feb 2026 14:14:06 +0300 Subject: [PATCH] fix(createVirtualizer): fix scroll issues that make scroll position jump when new page of fonts loads. Add some optimizations e.g. common ResizeObserver --- .../createVirtualizer.svelte.ts | 142 +++++++++++------- 1 file changed, 89 insertions(+), 53 deletions(-) diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 9fabb92..9b8ea6a 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -3,6 +3,7 @@ * * Used to render visible items with absolute positioning based on computed offsets. */ + export interface VirtualItem { /** * Index of the item in the data array @@ -132,6 +133,7 @@ export function createVirtualizer( // Accessing measuredSizes here creates the subscription accumulated += measuredSizes[i] ?? options.estimateSize(i); } + return result; }); @@ -189,10 +191,13 @@ export function createVirtualizer( const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd; // Proximity calculation: 1.0 at center, 0.0 at edges + // Guard against division by zero (containerHeight can be 0 on initial render) const itemCenter = itemStart + (itemSize / 2); const distanceToCenter = Math.abs(viewportCenter - itemCenter); const maxDistance = containerHeight / 2; - const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance)); + const proximity = maxDistance > 0 + ? Math.max(0, 1 - (distanceToCenter / maxDistance)) + : 0; result.push({ index: i, @@ -206,16 +211,6 @@ export function createVirtualizer( }); } - // console.log('🎯 Virtual Items Calculation:', { - // scrollOffset, - // containerHeight, - // viewportEnd, - // startIdx, - // endIdx, - // withOverscan: { start, end }, - // itemCount: end - start, - // }); - return result; }); // Svelte Actions (The DOM Interface) @@ -256,25 +251,19 @@ export function createVirtualizer( scrollOffset = scrolledPastTop; rafId = null; }); - - // 🔍 DIAGNOSTIC - // console.log('📜 Scroll Event:', { - // windowScrollY: window.scrollY, - // elementRectTop: rect.top, - // scrolledPastTop, - // containerHeight - // }); }; const handleResize = () => { containerHeight = window.innerHeight; - cachedOffsetTop = getElementOffset(); + elementOffsetTop = getElementOffset(); + cachedOffsetTop = elementOffsetTop; handleScroll(); }; // Initial setup requestAnimationFrame(() => { - cachedOffsetTop = getElementOffset(); + elementOffsetTop = getElementOffset(); + cachedOffsetTop = elementOffsetTop; handleScroll(); }); @@ -293,6 +282,11 @@ export function createVirtualizer( cancelAnimationFrame(rafId); rafId = null; } + // Disconnect shared ResizeObserver + if (sharedResizeObserver) { + sharedResizeObserver.disconnect(); + sharedResizeObserver = null; + } elementRef = null; }, }; @@ -314,6 +308,11 @@ export function createVirtualizer( destroy() { node.removeEventListener('scroll', handleScroll); resizeObserver.disconnect(); + // Disconnect shared ResizeObserver + if (sharedResizeObserver) { + sharedResizeObserver.disconnect(); + sharedResizeObserver = null; + } elementRef = null; }, }; @@ -325,51 +324,64 @@ export function createVirtualizer( // Signal to trigger updates when mutating measuredSizes in place let _version = $state(0); + // Single shared ResizeObserver for all items (performance optimization) + let sharedResizeObserver: ResizeObserver | null = null; + /** * Svelte action to measure individual item elements for dynamic height support. * - * Attaches a ResizeObserver to track actual element height and updates - * measured sizes when dimensions change. Requires `data-index` attribute on the element. + * Uses a single shared ResizeObserver for all items to track actual element heights. + * Requires `data-index` attribute on the element. * * @param node - The DOM element to measure (should have `data-index` attribute) * @returns Object with destroy method for cleanup */ function measureElement(node: HTMLElement) { - const resizeObserver = new ResizeObserver(([entry]) => { - if (!entry) return; - const index = parseInt(node.dataset.index || '', 10); - const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; + // Initialize shared observer on first use + if (!sharedResizeObserver) { + sharedResizeObserver = new ResizeObserver(entries => { + // Process all entries in a single batch + for (const entry of entries) { + const target = entry.target as HTMLElement; + const index = parseInt(target.dataset.index || '', 10); + const height = entry.borderBoxSize[0]?.blockSize ?? target.offsetHeight; - if (!isNaN(index)) { - // Accessing the version ensures we have the latest state if needed, - // though here we just read the raw object. - const oldHeight = measuredSizes[index]; + if (!isNaN(index)) { + const oldHeight = measuredSizes[index]; - // Only update if the height difference is significant (> 0.5px) - if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) { - // Stuff the measurement into a temporary buffer to batch updates - measurementBuffer[index] = height; - - // Schedule a single update for the next animation frame - if (frameId === null) { - frameId = requestAnimationFrame(() => { - // Mutation in place for performance - Object.assign(measuredSizes, measurementBuffer); - - // Trigger reactivity - _version += 1; - - // Reset buffer - measurementBuffer = {}; - frameId = null; - }); + // Only update if the height difference is significant (> 0.5px) + if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) { + measurementBuffer[index] = height; + } } } - } - }); - resizeObserver.observe(node); - return { destroy: () => resizeObserver.disconnect() }; + // Schedule a single update for the next animation frame + if (frameId === null && Object.keys(measurementBuffer).length > 0) { + frameId = requestAnimationFrame(() => { + // Mutation in place for performance + Object.assign(measuredSizes, measurementBuffer); + + // Trigger reactivity + _version += 1; + + // Reset buffer + measurementBuffer = {}; + frameId = null; + }); + } + }); + } + + // Observe this element with the shared observer + sharedResizeObserver.observe(node); + + // Return cleanup that only unobserves this specific element + return { + destroy: () => { + sharedResizeObserver?.unobserve(node); + }, + }; } // Programmatic Scroll @@ -409,6 +421,28 @@ export function createVirtualizer( } } + /** + * Scrolls the container to a specific pixel offset. + * Used for preserving scroll position during data updates. + * + * @param offset - The scroll offset in pixels + * @param behavior - Scroll behavior: 'auto' for instant, 'smooth' for animated + * + * @example + * ```ts + * virtualizer.scrollToOffset(1000, 'auto'); // Instant scroll to 1000px + * ``` + */ + function scrollToOffset(offset: number, behavior: ScrollBehavior = 'auto') { + const { useWindowScroll } = optionsGetter(); + + if (useWindowScroll) { + window.scrollTo({ top: offset + elementOffsetTop, behavior }); + } else if (elementRef) { + elementRef.scrollTo({ top: offset, behavior }); + } + } + return { get scrollOffset() { return scrollOffset; @@ -430,6 +464,8 @@ export function createVirtualizer( measureElement, /** Programmatic scroll method to scroll to a specific item */ scrollToIndex, + /** Programmatic scroll method to scroll to a specific pixel offset */ + scrollToOffset, }; }