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:
@@ -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,33 +324,40 @@ 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];
|
||||
|
||||
// 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) {
|
||||
if (frameId === null && Object.keys(measurementBuffer).length > 0) {
|
||||
frameId = requestAnimationFrame(() => {
|
||||
// Mutation in place for performance
|
||||
Object.assign(measuredSizes, measurementBuffer);
|
||||
@@ -364,12 +370,18 @@ export function createVirtualizer<T>(
|
||||
frameId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resizeObserver.observe(node);
|
||||
return { destroy: () => resizeObserver.disconnect() };
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user