|
134 | 134 | const LOAD_MORE_THRESHOLD = 200; |
135 | 135 | /** Debounce delay (ms) for onloadmore to prevent rapid firing. */ |
136 | 136 | const DEBOUNCE_DELAY = 50; |
| 137 | + /** Duration (ms) to lock row height when it comes into view. */ |
| 138 | + const HEIGHT_LOCK_DURATION = 1000; |
137 | 139 |
|
138 | 140 | // Debounce load more callback to prevent rapid consecutive triggers |
139 | 141 | const debouncedLoadMore = $derived(debounce(() => onloadmore?.(), DEBOUNCE_DELAY)); |
|
145 | 147 | let visibleRowElements = $state<HTMLCollectionOf<Element>>(); |
146 | 148 | /** Observes size changes of visible rows. */ |
147 | 149 | 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[] = []; |
148 | 158 | /** Current height of the viewport. */ |
149 | 159 | let viewportHeight = $state(0); |
150 | 160 | /** Previous viewport height to detect resize. */ |
|
159 | 169 | start: stickToBottom ? Infinity : 0, |
160 | 170 | end: stickToBottom ? Infinity : 0 |
161 | 171 | }); |
162 | | - /** Cache of measured heights for each chunk. */ |
163 | | - let heightMap: number[] = $state([]); |
164 | 172 | /** Top and bottom padding to simulate off-screen content. */ |
165 | 173 | let offset = $state({ top: 0, bottom: 0 }); |
166 | 174 | /** Distance from bottom during last calculation (used for sticky scroll). */ |
|
207 | 215 | return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; |
208 | 216 | } |
209 | 217 |
|
210 | | - function saveDistanceFromBottom(): void { |
211 | | - if (viewport) { |
212 | | - previousDistance = getDistanceFromBottom(); |
213 | | - } |
214 | | - } |
215 | | -
|
216 | 218 | function shouldTriggerLoadMore(): boolean { |
217 | 219 | if (!viewport) return false; |
218 | 220 | if (viewport.scrollHeight <= viewport.clientHeight) return true; |
|
224 | 226 | return previousDistance < STICKY_DISTANCE; |
225 | 227 | } |
226 | 228 |
|
| 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 | +
|
227 | 254 | // ============================================================================ |
228 | 255 | // Range calculation functions |
229 | 256 | // ============================================================================ |
|
239 | 266 | let accumulatedHeight = 0; |
240 | 267 | for (let i = 0; i < itemChunks.length; i++) { |
241 | 268 | const rowHeight = visibleRowElements?.[i - visibleRange.start]?.clientHeight; |
242 | | - accumulatedHeight += rowHeight || heightMap[i] || defaultHeight; |
| 269 | + const heightToUse = rowHeight || heightMap[i] || defaultHeight; |
| 270 | + accumulatedHeight += heightToUse; |
243 | 271 | if (accumulatedHeight > viewport.scrollTop) { |
244 | 272 | return i; |
245 | 273 | } |
|
296 | 324 | visibleRange.end = i + 1; |
297 | 325 | await tick(); |
298 | 326 | const element = visibleRowElements?.item(i); |
299 | | - if (element?.clientHeight) { |
| 327 | + if (element) { |
300 | 328 | heightMap[i] = element.clientHeight; |
301 | 329 | } |
302 | 330 | if (calculateHeightSum(0, i + 1) > viewport.clientHeight) { |
|
333 | 361 | isTailInitialized = true; |
334 | 362 | } |
335 | 363 |
|
| 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 | +
|
336 | 389 | /** |
337 | 390 | * Updates the visible range based on current scroll position. |
338 | 391 | * Recalculates which chunks should be rendered and updates offsets. |
|
367 | 420 | if (!isTailInitialized) { |
368 | 421 | if (stickToBottom) { |
369 | 422 | await initializeTail(); |
370 | | - scrollTop = viewport!.scrollHeight; |
| 423 | + scrollTop = viewport.scrollHeight; |
371 | 424 | scrollToBottom(); |
372 | 425 | } else { |
373 | 426 | await initialize(); |
374 | 427 | } |
375 | 428 | } 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 |
376 | 434 | 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 | + } |
377 | 448 | } |
378 | 449 |
|
379 | 450 | // Content, sizes, and scroll are affected by this tick. |
|
400 | 471 | debouncedLoadMore(); |
401 | 472 | } |
402 | 473 |
|
| 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 |
403 | 484 | 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 | + } |
407 | 492 | } |
408 | 493 |
|
409 | 494 | // Saved distance is necessary when sticking to bottom. |
410 | | - saveDistanceFromBottom(); |
| 495 | + previousDistance = getDistanceFromBottom(); |
411 | 496 | isRecalculating = false; |
412 | 497 | } |
413 | 498 |
|
|
423 | 508 | if (!viewport) return; |
424 | 509 |
|
425 | 510 | 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 | +
|
426 | 525 | itemObserver = new ResizeObserver((entries) => |
427 | 526 | untrack(() => { |
428 | 527 | let shouldRecalculate = false; |
|
434 | 533 | const index = indexStr ? parseInt(indexStr, 10) : undefined; |
435 | 534 | if (index !== undefined) { |
436 | 535 | const firstRender = !(index in heightMap); |
| 536 | +
|
437 | 537 | if (heightMap[index] !== target.clientHeight) { |
438 | 538 | heightMap[index] = target.clientHeight; |
439 | 539 | } |
|
451 | 551 | scrollToBottom(); |
452 | 552 | } |
453 | 553 | } |
454 | | - } |
455 | | - if (index !== undefined) { |
| 554 | +
|
456 | 555 | shouldRecalculate = true; |
457 | 556 | } |
458 | 557 | } |
|
543 | 642 | style:padding-bottom={offset.bottom + 'px'} |
544 | 643 | > |
545 | 644 | {#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 | + > |
547 | 652 | {@render chunkTemplate(chunk.data)} |
548 | 653 | </div> |
549 | 654 | {/each} |
|
0 commit comments