Skip to content

Commit 3ccbc31

Browse files
committed
Make virtual list handle async content
There is a slight delay between initial render and item content rendering (e.g. file diffs), so we need to lock the height to the previous known height for a short amount of time before allowing it to adjust.
1 parent ae8c87d commit 3ccbc31

File tree

1 file changed

+123
-18
lines changed

1 file changed

+123
-18
lines changed

packages/ui/src/lib/components/VirtualList.svelte

Lines changed: 123 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@
134134
const LOAD_MORE_THRESHOLD = 200;
135135
/** Debounce delay (ms) for onloadmore to prevent rapid firing. */
136136
const DEBOUNCE_DELAY = 50;
137+
/** Duration (ms) to lock row height when it comes into view. */
138+
const HEIGHT_LOCK_DURATION = 1000;
137139
138140
// Debounce load more callback to prevent rapid consecutive triggers
139141
const debouncedLoadMore = $derived(debounce(() => onloadmore?.(), DEBOUNCE_DELAY));
@@ -145,6 +147,14 @@
145147
let visibleRowElements = $state<HTMLCollectionOf<Element>>();
146148
/** Observes size changes of visible rows. */
147149
let itemObserver: ResizeObserver | null = null;
150+
/** Array of element references for observed chunks. */
151+
let observedElements: (Element | undefined)[] = [];
152+
/** Cache of measured heights for each chunk. */
153+
let heightMap: number[] = $state([]);
154+
/** Array of locked heights for chunks (prevents layout shift during async loads). */
155+
let lockedHeights = $state<number[]>([]);
156+
/** Array of unlock timeouts for chunks. */
157+
let heightUnlockTimeouts: number[] = [];
148158
/** Current height of the viewport. */
149159
let viewportHeight = $state(0);
150160
/** Previous viewport height to detect resize. */
@@ -159,8 +169,6 @@
159169
start: stickToBottom ? Infinity : 0,
160170
end: stickToBottom ? Infinity : 0
161171
});
162-
/** Cache of measured heights for each chunk. */
163-
let heightMap: number[] = $state([]);
164172
/** Top and bottom padding to simulate off-screen content. */
165173
let offset = $state({ top: 0, bottom: 0 });
166174
/** Distance from bottom during last calculation (used for sticky scroll). */
@@ -207,12 +215,6 @@
207215
return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
208216
}
209217
210-
function saveDistanceFromBottom(): void {
211-
if (viewport) {
212-
previousDistance = getDistanceFromBottom();
213-
}
214-
}
215-
216218
function shouldTriggerLoadMore(): boolean {
217219
if (!viewport) return false;
218220
if (viewport.scrollHeight <= viewport.clientHeight) return true;
@@ -224,6 +226,31 @@
224226
return previousDistance < STICKY_DISTANCE;
225227
}
226228
229+
/**
230+
* Locks a row height based on cached value and schedules unlock.
231+
* This prevents layout shifts when async content loads.
232+
*/
233+
function lockRowHeight(index: number): void {
234+
const cachedHeight = heightMap[index];
235+
if (!cachedHeight) return;
236+
237+
lockedHeights[index] = cachedHeight;
238+
239+
// Clear any existing timeout for this index
240+
const existingTimeout = heightUnlockTimeouts[index];
241+
if (existingTimeout) {
242+
clearTimeout(existingTimeout);
243+
}
244+
245+
// Schedule removal of locked height
246+
const timeoutId = window.setTimeout(() => {
247+
delete lockedHeights[index];
248+
delete heightUnlockTimeouts[index];
249+
}, HEIGHT_LOCK_DURATION);
250+
251+
heightUnlockTimeouts[index] = timeoutId;
252+
}
253+
227254
// ============================================================================
228255
// Range calculation functions
229256
// ============================================================================
@@ -239,7 +266,8 @@
239266
let accumulatedHeight = 0;
240267
for (let i = 0; i < itemChunks.length; i++) {
241268
const rowHeight = visibleRowElements?.[i - visibleRange.start]?.clientHeight;
242-
accumulatedHeight += rowHeight || heightMap[i] || defaultHeight;
269+
const heightToUse = rowHeight || heightMap[i] || defaultHeight;
270+
accumulatedHeight += heightToUse;
243271
if (accumulatedHeight > viewport.scrollTop) {
244272
return i;
245273
}
@@ -296,7 +324,7 @@
296324
visibleRange.end = i + 1;
297325
await tick();
298326
const element = visibleRowElements?.item(i);
299-
if (element?.clientHeight) {
327+
if (element) {
300328
heightMap[i] = element.clientHeight;
301329
}
302330
if (calculateHeightSum(0, i + 1) > viewport.clientHeight) {
@@ -333,6 +361,31 @@
333361
isTailInitialized = true;
334362
}
335363
364+
/**
365+
* Calculates which chunk indices are newly visible after a range change.
366+
* Compares old and new ranges to find chunks entering the viewport.
367+
*/
368+
function getNewlyVisibleIndices(
369+
oldStart: number,
370+
oldEnd: number,
371+
newStart: number,
372+
newEnd: number
373+
): number[] {
374+
const newIndices: number[] = [];
375+
376+
// New chunks at the start (scrolling up)
377+
for (let i = newStart; i < Math.min(oldStart, newEnd); i++) {
378+
newIndices.push(i);
379+
}
380+
381+
// New chunks at the end (scrolling down)
382+
for (let i = Math.max(oldEnd, newStart); i < newEnd; i++) {
383+
newIndices.push(i);
384+
}
385+
386+
return newIndices;
387+
}
388+
336389
/**
337390
* Updates the visible range based on current scroll position.
338391
* Recalculates which chunks should be rendered and updates offsets.
@@ -367,13 +420,31 @@
367420
if (!isTailInitialized) {
368421
if (stickToBottom) {
369422
await initializeTail();
370-
scrollTop = viewport!.scrollHeight;
423+
scrollTop = viewport.scrollHeight;
371424
scrollToBottom();
372425
} else {
373426
await initialize();
374427
}
375428
} else {
429+
// Capture old range before updating
430+
const oldStart = visibleRange.start;
431+
const oldEnd = visibleRange.end;
432+
433+
// Update visible range based on scroll position
376434
updateRange();
435+
436+
// Find and lock heights for chunks entering viewport
437+
const newIndices = getNewlyVisibleIndices(
438+
oldStart,
439+
oldEnd,
440+
visibleRange.start,
441+
visibleRange.end
442+
);
443+
for (const index of newIndices) {
444+
if (heightMap[index]) {
445+
lockRowHeight(index);
446+
}
447+
}
377448
}
378449
379450
// Content, sizes, and scroll are affected by this tick.
@@ -400,14 +471,28 @@
400471
debouncedLoadMore();
401472
}
402473
474+
// Unobserve elements that are no longer in the visible range
475+
for (let i = 0; i < observedElements.length; i++) {
476+
const element = observedElements[i];
477+
if (element && (i < visibleRange.start || i >= visibleRange.end)) {
478+
itemObserver?.unobserve(element);
479+
observedElements[i] = undefined;
480+
}
481+
}
482+
483+
// Observe new visible elements
403484
for (const rowElement of visibleRowElements) {
404-
// It seems unnecessary to track duplicates and removals, so
405-
// not doing it to keep things concise.
406-
itemObserver?.observe(rowElement);
485+
const indexStr = rowElement.getAttribute('data-index');
486+
const index = indexStr ? parseInt(indexStr, 10) : undefined;
487+
488+
if (index !== undefined && !observedElements[index]) {
489+
itemObserver?.observe(rowElement);
490+
observedElements[index] = rowElement;
491+
}
407492
}
408493
409494
// Saved distance is necessary when sticking to bottom.
410-
saveDistanceFromBottom();
495+
previousDistance = getDistanceFromBottom();
411496
isRecalculating = false;
412497
}
413498
@@ -423,6 +508,20 @@
423508
if (!viewport) return;
424509
425510
visibleRowElements = viewport.getElementsByClassName('list-row');
511+
512+
// Clean up previous observer if it exists
513+
if (itemObserver) {
514+
itemObserver.disconnect();
515+
observedElements = [];
516+
517+
// Clear all pending height unlock timeouts
518+
for (const timeoutId of heightUnlockTimeouts) {
519+
if (timeoutId) clearTimeout(timeoutId);
520+
}
521+
heightUnlockTimeouts = [];
522+
lockedHeights = [];
523+
}
524+
426525
itemObserver = new ResizeObserver((entries) =>
427526
untrack(() => {
428527
let shouldRecalculate = false;
@@ -434,6 +533,7 @@
434533
const index = indexStr ? parseInt(indexStr, 10) : undefined;
435534
if (index !== undefined) {
436535
const firstRender = !(index in heightMap);
536+
437537
if (heightMap[index] !== target.clientHeight) {
438538
heightMap[index] = target.clientHeight;
439539
}
@@ -451,8 +551,7 @@
451551
scrollToBottom();
452552
}
453553
}
454-
}
455-
if (index !== undefined) {
554+
456555
shouldRecalculate = true;
457556
}
458557
}
@@ -543,7 +642,13 @@
543642
style:padding-bottom={offset.bottom + 'px'}
544643
>
545644
{#each visibleChunks as chunk, i (chunk.id)}
546-
<div class="list-row" data-index={i + visibleRange.start}>
645+
<div
646+
class="list-row"
647+
data-index={i + visibleRange.start}
648+
style:height={lockedHeights[i + visibleRange.start]
649+
? `${lockedHeights[i + visibleRange.start]}px`
650+
: undefined}
651+
>
547652
{@render chunkTemplate(chunk.data)}
548653
</div>
549654
{/each}

0 commit comments

Comments
 (0)