fix(createVirtualizer): fix scroll issues that make scroll position jump when new page of fonts loads. Add some optimizations e.g. common ResizeObserver

This commit is contained in:
Ilia Mashkov
2026-02-16 14:14:06 +03:00
parent 1f793278d1
commit bee529dff8

View File

@@ -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<T>(
// Accessing measuredSizes here creates the subscription
accumulated += measuredSizes[i] ?? options.estimateSize(i);
}
return result;
});
@@ -189,10 +191,13 @@ export function createVirtualizer<T>(
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<T>(
});
}
// 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<T>(
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<T>(
cancelAnimationFrame(rafId);
rafId = null;
}
// Disconnect shared ResizeObserver
if (sharedResizeObserver) {
sharedResizeObserver.disconnect();
sharedResizeObserver = null;
}
elementRef = null;
},
};
@@ -314,6 +308,11 @@ export function createVirtualizer<T>(
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<T>(
// 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<T>(
}
}
/**
* 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<T>(
measureElement,
/** Programmatic scroll method to scroll to a specific item */
scrollToIndex,
/** Programmatic scroll method to scroll to a specific pixel offset */
scrollToOffset,
};
}