@humanspeak/svelte-virtual-list 0.2.6-beta.5 → 0.2.6-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -161,6 +161,7 @@
161
161
  } from './types.js'
162
162
  import { calculateAverageHeightDebounced } from './utils/heightCalculation.js'
163
163
  import { createRafScheduler } from './utils/raf.js'
164
+ import { isSignificantHeightChange } from './utils/heightChangeDetection.js'
164
165
  import {
165
166
  calculateScrollPosition,
166
167
  calculateTransformY,
@@ -169,12 +170,16 @@
169
170
  } from './utils/virtualList.js'
170
171
  import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
171
172
  import { calculateScrollTarget } from './utils/scrollCalculation.js'
172
-
173
+ import { createAdvancedThrottledCallback } from './utils/throttle.js'
174
+ import { ReactiveHeightManager } from './reactive-height-manager/index.js'
173
175
  import { BROWSER } from 'esm-env'
174
- import { onMount, tick } from 'svelte'
176
+ import { onMount, tick, untrack } from 'svelte'
175
177
 
176
178
  const rafSchedule = createRafScheduler()
177
- const INTERNAL_DEBUG = false
179
+ // Package-specific debug flag - safe for library distribution
180
+ // Enable with: NODE_ENV=development SVELTE_VIRTUAL_LIST_DEBUG=true
181
+ const INTERNAL_DEBUG =
182
+ import.meta.env.DEV && import.meta.env.VITE_SVELTE_VIRTUAL_LIST_DEBUG === 'true'
178
183
  /**
179
184
  * Core configuration props with default values
180
185
  * @type {SvelteVirtualListProps}
@@ -215,6 +220,7 @@
215
220
  let isCalculatingHeight = $state(false) // Prevents concurrent height calculations
216
221
  let isScrolling = $state(false) // Tracks active scrolling state
217
222
  let lastMeasuredIndex = $state(-1) // Index of last measured item
223
+ let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
218
224
 
219
225
  /**
220
226
  * Timers and Observers
@@ -228,15 +234,182 @@
228
234
  */
229
235
  let heightCache = $state<Record<number, number>>({}) // Cache of measured item heights
230
236
  let dirtyItems = $state(new Set<number>()) // Set of item indices that need height recalculation
237
+ let dirtyItemsCount = $state(0) // Reactive count of dirty items
231
238
 
232
239
  let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
233
240
  let prevHeight = $state<number>(0)
234
241
 
235
- // Trigger height calculation when items are rendered
236
- $effect(() => {
237
- if (BROWSER && itemElements.length > 0 && !isCalculatingHeight) {
238
- updateHeight()
242
+ /**
243
+ * Reactive Height Manager - O(1) height calculation system
244
+ * Replaces O(n) totalHeight loop with incremental updates
245
+ */
246
+ let heightManager = new ReactiveHeightManager({
247
+ itemLength: items.length,
248
+ itemHeight: defaultEstimatedItemHeight
249
+ })
250
+
251
+ // Dynamic update coordination to avoid UA scroll anchoring interference
252
+ let dynamicUpdateInProgress = $state(false)
253
+ let suppressBottomAnchoringUntilMs = $state(0)
254
+ function beginDynamicUpdate(): void {
255
+ dynamicUpdateInProgress = true
256
+ if (viewportElement) {
257
+ viewportElement.style.setProperty('overflow-anchor', 'none')
258
+ }
259
+ }
260
+ function endDynamicUpdate(): void {
261
+ dynamicUpdateInProgress = false
262
+ if (viewportElement) {
263
+ viewportElement.style.setProperty('overflow-anchor', 'auto')
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Handles scroll position corrections when item heights change, ensuring proper positioning
269
+ * relative to the user's scroll context. This function calculates the cumulative impact of
270
+ * height changes above the current viewport and adjusts the scroll position accordingly.
271
+ *
272
+ * The correction logic considers:
273
+ * - Height changes occurring above the visible area (which would shift content)
274
+ * - The current scroll position and visible range
275
+ * - Whether height changes warrant a scroll adjustment
276
+ *
277
+ * This prevents jarring jumps when items resize, maintaining the user's visual context
278
+ * and where they are positioned relative to the current scroll position.
279
+ */
280
+ const handleHeightChangesScrollCorrection = (
281
+ heightChanges: Array<{ index: number; oldHeight: number; newHeight: number; delta: number }>
282
+ ) => {
283
+ if (!viewportElement || !initialized || userHasScrolledAway) {
284
+ return
285
+ }
286
+
287
+ /**
288
+ * CRITICAL: BottomToTop Mode Height Change Fix
289
+ * ============================================
290
+ *
291
+ * Problem: In bottomToTop mode, when items change height while user is at bottom,
292
+ * the list would jump to middle positions (e.g. items 1032-1096) instead of
293
+ * staying anchored at bottom showing Item 0.
294
+ *
295
+ * Root Cause: Height calculations using simple averages (items.length * calculatedItemHeight)
296
+ * were drastically skewed by single item changes. Example:
297
+ * - 1 item changes from 20px to 100px (+80px actual change)
298
+ * - Average jumps from 20px to 22.35px (+2.35px per item)
299
+ * - Across 10,000 items: 2.35px × 10,000 = 23,500px total height error!
300
+ * - This caused massive scroll position overshoots and incorrect positioning
301
+ *
302
+ * Solution: Two-step native scrollIntoView approach
303
+ * 1. Fixed skewed height calculations using actual heightCache measurements (see totalHeight)
304
+ * 2. When wasAtBottomBeforeHeightChange=true (captured before any height processing):
305
+ * a) First scroll to approximate bottom position to render Item 0 in virtual viewport
306
+ * b) Use native scrollIntoView() with block:'end' for precise bottom alignment
307
+ *
308
+ * Why This Works:
309
+ * - Uses browser's native scroll logic instead of error-prone manual calculations
310
+ * - Two-step ensures Item 0 exists in DOM before attempting to scroll to it
311
+ * - Native scrollIntoView handles all edge cases (subpixel precision, browser differences)
312
+ * - Eliminates complex math that was accumulating rounding errors
313
+ * - Smooth behavior provides better UX than instant jumps
314
+ *
315
+ * Dependencies:
316
+ * - wasAtBottomBeforeHeightChange: Set to true when first item marked dirty, prevents cascading corrections
317
+ * - totalHeight(): Uses actual heightCache measurements instead of skewed averages
318
+ * - Aggressive scroll correction: Blocked when wasAtBottomBeforeHeightChange=true
319
+ *
320
+ * ⚠️ DO NOT MODIFY WITHOUT EXTENSIVE TESTING ⚠️
321
+ * This fix resolves a complex interaction between:
322
+ * - Virtual list rendering (only ~20 items visible, rest virtualized)
323
+ * - Height change calculations (prone to average skewing with large datasets)
324
+ * - Multiple scroll correction mechanisms (specific vs aggressive)
325
+ * - Bottom anchor positioning in reversed list mode (bottomToTop)
326
+ *
327
+ * Test coverage: tests/bottomToTop/firstItemHeightChange.spec.ts (45 comprehensive tests)
328
+ * Related fixes: See aggressive scroll correction logic ~line 410 with !wasAtBottomBeforeHeightChange
329
+ */
330
+ if (
331
+ mode === 'bottomToTop' &&
332
+ wasAtBottomBeforeHeightChange &&
333
+ !programmaticScrollInProgress &&
334
+ performance.now() >= suppressBottomAnchoringUntilMs &&
335
+ !dynamicUpdateInProgress
336
+ ) {
337
+ // Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
338
+ const approximateScrollTop = Math.max(0, totalHeight() - height)
339
+ viewportElement.scrollTop = approximateScrollTop
340
+ scrollTop = approximateScrollTop
341
+
342
+ // Step 2: Use native scrollIntoView for precise bottom-edge positioning after DOM updates
343
+ tick().then(() => {
344
+ const item0Element = viewportElement.querySelector('[data-original-index="0"]')
345
+ if (item0Element) {
346
+ // Native browser API handles all positioning edge cases perfectly
347
+ item0Element.scrollIntoView({
348
+ block: 'end', // Align Item 0 to bottom edge of viewport
349
+ behavior: 'smooth', // Smooth animation for better UX
350
+ inline: 'nearest' // Minimal horizontal adjustment
351
+ })
352
+
353
+ // Sync our internal scroll state with actual DOM position
354
+ scrollTop = viewportElement.scrollTop
355
+ }
356
+ })
357
+
358
+ return // Skip remaining scroll correction logic - we've handled bottomToTop case
359
+ }
360
+
361
+ const currentScrollTop = viewportElement.scrollTop
362
+ const maxScrollTop = Math.max(0, totalHeight() - height)
363
+
364
+ // Calculate total height change impact above current visible area
365
+ let heightChangeAboveViewport = 0
366
+ const currentVisibleRange = visibleItems()
367
+
368
+ for (const change of heightChanges) {
369
+ // Only consider items that are above the current visible range
370
+ if (change.index < currentVisibleRange.start) {
371
+ heightChangeAboveViewport += change.delta
372
+ }
373
+ }
374
+
375
+ // If there are height changes above the viewport, adjust scroll to maintain position
376
+ if (Math.abs(heightChangeAboveViewport) > 1) {
377
+ const newScrollTop = Math.min(
378
+ maxScrollTop,
379
+ Math.max(0, currentScrollTop + heightChangeAboveViewport)
380
+ )
381
+
382
+ viewportElement.scrollTop = newScrollTop
383
+ scrollTop = newScrollTop
384
+ }
385
+ }
386
+
387
+ // Height update function - removed throttling to fix race condition on initial load
388
+ // Create throttled height update function with trailing execution to ensure measurement always happens
389
+ const triggerHeightUpdate = createAdvancedThrottledCallback(
390
+ () => {
391
+ if (BROWSER && dirtyItemsCount > 0) {
392
+ // Capture bottom state before any height processing to prevent cascading corrections
393
+ wasAtBottomBeforeHeightChange = atBottom
394
+ beginDynamicUpdate()
395
+ updateHeight()
396
+ }
397
+ },
398
+ 16,
399
+ {
400
+ leading: true, // Execute immediately for responsiveness
401
+ trailing: true // CRUCIAL: Execute the last call after delay to ensure measurement always happens
239
402
  }
403
+ )
404
+
405
+ // Trigger height calculation when dirty items are added
406
+ $effect(() => {
407
+ triggerHeightUpdate()
408
+ })
409
+
410
+ // Keep height manager synchronized with items length
411
+ $effect(() => {
412
+ heightManager.updateItemLength(items.length)
240
413
  })
241
414
 
242
415
  const updateHeight = () => {
@@ -247,44 +420,93 @@
247
420
  itemElements,
248
421
  heightCache,
249
422
  lastMeasuredIndex,
250
- calculatedItemHeight,
423
+ heightManager.averageHeight,
251
424
  (result) => {
252
- calculatedItemHeight = result.newHeight
425
+ // Critical updates that must trigger reactive effects immediately
426
+ heightManager.itemHeight = result.newHeight
253
427
  lastMeasuredIndex = result.newLastMeasuredIndex
254
428
  heightCache = result.updatedHeightCache
255
429
 
256
- // Update running totals efficiently (O(1) instead of O(n)!)
257
- totalMeasuredHeight = result.newTotalHeight
258
- measuredCount = result.newValidCount
430
+ // Handle height changes for scroll correction (needs updated heightCache)
431
+ if (result.heightChanges.length > 0 && mode === 'bottomToTop') {
432
+ handleHeightChangesScrollCorrection(result.heightChanges)
433
+ }
259
434
 
260
- // Clear processed dirty items
261
- result.clearedDirtyItems.forEach((index) => {
262
- dirtyItems.delete(index)
263
- })
435
+ // Non-critical updates wrapped in untrack to prevent reactive cascades
436
+ untrack(() => {
437
+ // Process height changes with ReactiveHeightManager (O(dirty) instead of O(n)!)
438
+ if (result.heightChanges.length > 0) {
439
+ heightManager.processDirtyHeights(result.heightChanges)
440
+ }
264
441
 
265
- if (INTERNAL_DEBUG && result.clearedDirtyItems.size > 0) {
266
- console.log(
267
- `Cleared ${result.clearedDirtyItems.size} dirty items:`,
268
- Array.from(result.clearedDirtyItems)
269
- )
270
- }
442
+ // Clear processed dirty items (all dirty items were processed)
443
+ dirtyItems.clear()
444
+ dirtyItemsCount = 0
445
+
446
+ // Reset bottom state flag
447
+ wasAtBottomBeforeHeightChange = false
448
+ })
449
+ endDynamicUpdate()
271
450
  },
272
451
  100, // debounceTime
273
452
  dirtyItems, // Pass dirty items for processing
274
- totalMeasuredHeight, // Current running total height
275
- measuredCount // Current running total count
453
+ 0, // Don't pass ReactiveHeightManager state - let each system manage its own totals
454
+ 0, // Don't pass ReactiveHeightManager state - let each system manage its own totals
455
+ mode // Pass mode for correct element indexing
276
456
  )
277
457
  }
278
458
 
279
459
  // Add new effect to handle height changes
280
460
  // Track if user has scrolled away from bottom to prevent snap-back
281
461
  let userHasScrolledAway = $state(false)
462
+ let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
282
463
  let lastCalculatedHeight = $state(0)
464
+ let lastItemsLength = $state(0)
465
+
466
+ /**
467
+ * CRITICAL: O(1) Reactive Total Height Calculation
468
+ * ===============================================
469
+ *
470
+ * Uses ReactiveHeightManager for O(1) height calculations instead of O(n) loops.
471
+ * This fixes the root cause of massive scroll jumps in bottomToTop mode.
472
+ *
473
+ * Problem with Previous O(n) Approach:
474
+ * - Looped through ALL items on every reactive update
475
+ * - Used simple: items.length * calculatedItemHeight
476
+ * - When 1 item changes from 20px to 100px in 10,000 items:
477
+ * - calculatedItemHeight jumps from 20 to 22.35 (+2.35px)
478
+ * - Total height jumps from 200,000px to 223,500px (+23,500px!)
479
+ * - This 23,500px error caused massive scroll position overshoots
480
+ *
481
+ * Solution with ReactiveHeightManager:
482
+ * - O(1) reactive calculations using incremental updates
483
+ * - Uses actual measured heights from heightCache where available
484
+ * - Only estimates heights for items that haven't been measured yet
485
+ * - Processes only dirty/changed heights instead of all items
486
+ *
487
+ * Example with O(1) Approach:
488
+ * - 20 items measured: 19 × 20px + 1 × 100px = 460px measured
489
+ * - 9,980 unmeasured: 9,980 × 23px (avg of measured) = 229,540px estimated
490
+ * - Total: 460px + 229,540px = 230,000px (only +30,000px vs +23,500px error)
491
+ * - Much smaller error that doesn't cause massive scroll jumps
492
+ * - Updates incrementally using processDirtyHeights() instead of recalculating all
493
+ *
494
+ * This getter is reactive and updates whenever heightManager's internal state changes.
495
+ * Used by: atBottom calculation, scroll corrections, maxScrollTop calculations
496
+ */
497
+ let totalHeight = $derived(() => heightManager.totalHeight)
498
+
499
+ let atTop = $derived(scrollTop <= 1)
500
+ let atBottom = $derived(scrollTop >= totalHeight() - height - 1)
501
+ let wasAtBottomBeforeHeightChange = false
502
+ let lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null = null
503
+
504
+ $inspect('scrollState: atTop', atTop)
505
+ $inspect('scrollState: atBottom', atBottom)
283
506
 
284
507
  $effect(() => {
285
508
  if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
286
- const totalHeight = Math.max(0, items.length * calculatedItemHeight)
287
- const targetScrollTop = Math.max(0, totalHeight - height)
509
+ const targetScrollTop = Math.max(0, totalHeight() - height)
288
510
  const currentScrollTop = viewportElement.scrollTop
289
511
  const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
290
512
 
@@ -292,25 +514,26 @@
292
514
  // 1. Item height changed significantly (not just user scrolling)
293
515
  // 2. User hasn't intentionally scrolled away from bottom
294
516
  // 3. We're significantly off target
517
+ // 4. We're not at the bottom (where height changes should be handled more carefully)
295
518
  const heightChanged = Math.abs(calculatedItemHeight - lastCalculatedHeight) > 1
519
+ const maxScrollTop = Math.max(0, totalHeight() - height)
520
+
521
+ // In bottomToTop mode, we're "at bottom" when scroll is at max position
522
+ const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) < calculatedItemHeight
296
523
  const shouldCorrect =
297
- heightChanged && !userHasScrolledAway && scrollDifference > calculatedItemHeight * 3
524
+ heightChanged &&
525
+ !userHasScrolledAway &&
526
+ !isAtBottom && // Don't apply aggressive correction when at bottom
527
+ !programmaticScrollInProgress && // Don't interfere with programmatic scrolls
528
+ performance.now() >= suppressBottomAnchoringUntilMs &&
529
+ !dynamicUpdateInProgress &&
530
+ scrollDifference > calculatedItemHeight * 3
298
531
 
299
532
  if (shouldCorrect) {
300
- if (INTERNAL_DEBUG) {
301
- console.log(
302
- '🔄 Correcting scroll position from',
303
- currentScrollTop,
304
- 'to',
305
- targetScrollTop,
306
- 'diff:',
307
- scrollDifference,
308
- 'heightChanged:',
309
- heightChanged
310
- )
311
- }
312
- viewportElement.scrollTop = targetScrollTop
313
- scrollTop = targetScrollTop
533
+ // Round to avoid subpixel positioning issues in bottomToTop mode
534
+ const roundedTargetScrollTop = Math.round(targetScrollTop)
535
+ viewportElement.scrollTop = roundedTargetScrollTop
536
+ scrollTop = roundedTargetScrollTop
314
537
  }
315
538
 
316
539
  // Track if user has scrolled significantly away from bottom
@@ -322,6 +545,56 @@
322
545
  }
323
546
  })
324
547
 
548
+ // Handle items being added/removed in bottomToTop mode
549
+ $effect(() => {
550
+ // Only track items.length to prevent re-runs on other reactive changes
551
+ const currentItemsLength = items.length
552
+
553
+ if (
554
+ BROWSER &&
555
+ initialized &&
556
+ mode === 'bottomToTop' &&
557
+ viewportElement &&
558
+ lastItemsLength > 0
559
+ ) {
560
+ const itemsAdded = currentItemsLength - lastItemsLength
561
+
562
+ if (itemsAdded !== 0) {
563
+ // Capture all reactive values immediately to prevent re-triggering
564
+ const currentScrollTop = viewportElement.scrollTop
565
+ const currentCalculatedItemHeight = calculatedItemHeight
566
+ const currentHeight = height
567
+ const currentTotalHeight = totalHeight()
568
+ const maxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
569
+
570
+ // Check if user was at/near the bottom before items were added
571
+ const wasNearBottom =
572
+ Math.abs(
573
+ currentScrollTop -
574
+ Math.max(
575
+ 0,
576
+ lastItemsLength * currentCalculatedItemHeight - currentHeight
577
+ )
578
+ ) <
579
+ currentCalculatedItemHeight * 2
580
+
581
+ if (wasNearBottom || currentScrollTop === 0) {
582
+ // User was at bottom, keep them at bottom after new items are added
583
+ beginDynamicUpdate()
584
+ const newScrollTop = maxScrollTop
585
+ viewportElement.scrollTop = newScrollTop
586
+ scrollTop = newScrollTop
587
+
588
+ // Reset the "scrolled away" flag since we're actively managing position
589
+ userHasScrolledAway = false
590
+ endDynamicUpdate()
591
+ }
592
+ }
593
+ }
594
+
595
+ lastItemsLength = currentItemsLength
596
+ })
597
+
325
598
  // Update container height when element is mounted
326
599
  $effect(() => {
327
600
  if (BROWSER && containerElement) {
@@ -339,8 +612,7 @@
339
612
  items.length &&
340
613
  !initialized
341
614
  ) {
342
- const totalHeight = Math.max(0, items.length * calculatedItemHeight)
343
- const targetScrollTop = Math.max(0, totalHeight - height)
615
+ const targetScrollTop = Math.max(0, totalHeight() - height)
344
616
 
345
617
  // Add delay to ensure layout is complete
346
618
  tick().then(() => {
@@ -362,23 +634,6 @@
362
634
  }
363
635
  })
364
636
 
365
- /**
366
- * Calculate precise item height based on actual measurements when available
367
- */
368
- // Running totals for efficient precise height calculation
369
- let totalMeasuredHeight = $state(0)
370
- let measuredCount = $state(0)
371
- const preciseItemHeight = $derived(() => {
372
- if (measuredCount > 100) {
373
- const avgHeight = totalMeasuredHeight / measuredCount
374
- // Only use if the difference is significant (more than 0.5px)
375
- if (Math.abs(avgHeight - calculatedItemHeight) > 0.5) {
376
- return avgHeight
377
- }
378
- }
379
- return calculatedItemHeight
380
- })
381
-
382
637
  /**
383
638
  * Calculates the range of items that should be rendered based on current scroll position.
384
639
  *
@@ -406,32 +661,39 @@
406
661
  // This prevents showing wrong items when scrollTop starts at 0
407
662
  if (mode === 'bottomToTop' && !initialized && scrollTop === 0 && viewportHeight > 0) {
408
663
  // Calculate what the correct scroll position should be
409
- const totalHeight = items.length * calculatedItemHeight
410
- const targetScrollTop = Math.max(0, totalHeight - viewportHeight)
664
+ const targetScrollTop = Math.max(0, totalHeight() - viewportHeight)
411
665
 
412
666
  // Use the target scroll position for visible range calculation
413
- const result = calculateVisibleRange(
667
+ lastVisibleRange = calculateVisibleRange(
414
668
  targetScrollTop,
415
669
  viewportHeight,
416
- calculatedItemHeight,
670
+ heightManager.averageHeight,
417
671
  items.length,
418
672
  bufferSize,
419
- mode
673
+ mode,
674
+ atBottom,
675
+ wasAtBottomBeforeHeightChange,
676
+ lastVisibleRange,
677
+ totalHeight()
420
678
  )
421
679
 
422
- return result
680
+ return lastVisibleRange
423
681
  }
424
682
 
425
- const result = calculateVisibleRange(
683
+ lastVisibleRange = calculateVisibleRange(
426
684
  scrollTop,
427
685
  viewportHeight,
428
- calculatedItemHeight,
686
+ heightManager.averageHeight,
429
687
  items.length,
430
688
  bufferSize,
431
- mode
689
+ mode,
690
+ atBottom,
691
+ wasAtBottomBeforeHeightChange,
692
+ lastVisibleRange,
693
+ totalHeight()
432
694
  )
433
695
 
434
- return result
696
+ return lastVisibleRange
435
697
  })
436
698
 
437
699
  /**
@@ -461,7 +723,16 @@
461
723
  if (!isScrolling) {
462
724
  isScrolling = true
463
725
  rafSchedule(() => {
464
- scrollTop = viewportElement.scrollTop
726
+ const current = viewportElement.scrollTop
727
+ if (mode === 'bottomToTop') {
728
+ const delta = lastScrollTopSnapshot - current
729
+ if (delta > 0.5) {
730
+ suppressBottomAnchoringUntilMs = performance.now() + 300
731
+ userHasScrolledAway = true
732
+ }
733
+ }
734
+ lastScrollTopSnapshot = current
735
+ scrollTop = current
465
736
  isScrolling = false
466
737
  })
467
738
  }
@@ -544,37 +815,45 @@
544
815
  if (BROWSER) {
545
816
  // Watch for individual item size changes
546
817
  itemResizeObserver = new ResizeObserver((entries) => {
547
- let shouldRecalculate = false
548
-
549
- if (INTERNAL_DEBUG) {
550
- console.log(`ResizeObserver fired for ${entries.length} entries`)
551
- }
552
-
553
- for (const entry of entries) {
554
- const element = entry.target as HTMLElement
555
- const elementIndex = itemElements.indexOf(element)
556
-
557
- if (elementIndex !== -1) {
558
- const actualIndex = visibleItems().start + elementIndex
818
+ tick().then(() => {
819
+ let shouldRecalculate = false
820
+ const visibleRange = visibleItems() // Cache once to avoid reactive loops
821
+
822
+ for (const entry of entries) {
823
+ const element = entry.target as HTMLElement
824
+ const elementIndex = itemElements.indexOf(element)
825
+ const actualIndex = parseInt(element.dataset.originalIndex || '-1', 10)
826
+
827
+ if (elementIndex !== -1) {
828
+ if (actualIndex >= 0) {
829
+ const currentHeight = element.getBoundingClientRect().height
830
+ const isSignificant = isSignificantHeightChange(
831
+ actualIndex,
832
+ currentHeight,
833
+ heightCache
834
+ )
559
835
 
560
- // ResizeObserver fired = element resized, so add to dirty queue
561
- dirtyItems.add(actualIndex)
562
- shouldRecalculate = true
836
+ // Only mark as dirty if height change is significant
837
+ if (isSignificant) {
838
+ // Capture bottom state when FIRST item gets marked dirty
839
+ if (dirtyItemsCount === 0) {
840
+ wasAtBottomBeforeHeightChange = atBottom
841
+ }
563
842
 
564
- if (INTERNAL_DEBUG) {
565
- console.log(
566
- `Item ${actualIndex} marked dirty (resized), queue size: ${dirtyItems.size}`
567
- )
843
+ dirtyItems.add(actualIndex)
844
+ dirtyItemsCount = dirtyItems.size
845
+ shouldRecalculate = true
846
+ }
847
+ }
568
848
  }
569
849
  }
570
- }
571
850
 
572
- if (shouldRecalculate) {
573
- // Trigger virtual list recalculation
574
- rafSchedule(() => {
575
- updateHeight()
576
- })
577
- }
851
+ if (shouldRecalculate) {
852
+ rafSchedule(() => {
853
+ updateHeight()
854
+ })
855
+ }
856
+ })
578
857
  })
579
858
  }
580
859
 
@@ -681,10 +960,10 @@
681
960
  * {/snippet}
682
961
  * </SvelteVirtualList>
683
962
  *
684
- * @returns {void}
963
+ * @returns {Promise<void>} Promise that resolves when scrolling is complete
685
964
  * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
686
965
  */
687
- export const scroll = (options: SvelteVirtualListScrollOptions): void => {
966
+ export const scroll = async (options: SvelteVirtualListScrollOptions): Promise<void> => {
688
967
  const { index, smoothScroll, shouldThrowOnBounds, align } = {
689
968
  ...DEFAULT_SCROLL_OPTIONS,
690
969
  ...options
@@ -716,10 +995,10 @@
716
995
  // Use extracted scroll calculation utility
717
996
  const scrollTarget = calculateScrollTarget({
718
997
  mode,
719
- align,
998
+ align: align || 'auto',
720
999
  targetIndex,
721
1000
  itemsLength: items.length,
722
- calculatedItemHeight,
1001
+ calculatedItemHeight: heightManager.averageHeight, // Use dynamic average from ReactiveHeightManager
723
1002
  height,
724
1003
  scrollTop,
725
1004
  firstVisibleIndex,
@@ -732,10 +1011,52 @@
732
1011
  return
733
1012
  }
734
1013
 
1014
+ // Prevent bottom-anchoring logic from interfering with programmatic scroll
1015
+ programmaticScrollInProgress = true
1016
+
1017
+ // CROSS-BROWSER COMPATIBILITY FIX:
1018
+ // All major browsers (Chrome, Firefox, Safari) have inconsistent behavior with scrollTo()
1019
+ // in bottomToTop mode when using smooth scrolling. Using scrollIntoView() on the highest
1020
+ // visible element provides consistent cross-browser smooth scrolling behavior.
1021
+ // This approach works universally and maintains the user's expected smooth scroll experience.
1022
+ if (mode === 'bottomToTop' && smoothScroll) {
1023
+ // Find the element with the highest original-index in the current viewport
1024
+ const visibleElements = viewportElement.querySelectorAll('[data-original-index]')
1025
+ let maxIndex = -1
1026
+ let maxElement: HTMLElement | null = null
1027
+ for (const el of visibleElements) {
1028
+ const index = parseInt(el.getAttribute('data-original-index') || '-1')
1029
+ if (index > maxIndex) {
1030
+ maxIndex = index
1031
+ maxElement = el as HTMLElement
1032
+ }
1033
+ }
1034
+
1035
+ maxElement?.scrollIntoView({
1036
+ behavior: 'smooth'
1037
+ })
1038
+ await tick()
1039
+ await new Promise((resolve) => setTimeout(resolve, 100))
1040
+ await tick()
1041
+ }
1042
+
735
1043
  viewportElement.scrollTo({
736
1044
  top: scrollTarget,
737
1045
  behavior: smoothScroll ? 'smooth' : 'auto'
738
1046
  })
1047
+
1048
+ // Update scrollTop state in next frame to avoid synchronous re-renders
1049
+ requestAnimationFrame(() => {
1050
+ scrollTop = scrollTarget
1051
+ })
1052
+
1053
+ // Clear the flag after scroll completes
1054
+ setTimeout(
1055
+ () => {
1056
+ programmaticScrollInProgress = false
1057
+ },
1058
+ smoothScroll ? 500 : 100
1059
+ )
739
1060
  }
740
1061
 
741
1062
  /**
@@ -748,25 +1069,12 @@
748
1069
  function autoObserveItemResize(element: HTMLElement) {
749
1070
  if (itemResizeObserver) {
750
1071
  itemResizeObserver.observe(element)
751
- if (INTERNAL_DEBUG) {
752
- console.log(
753
- 'Started observing element:',
754
- element,
755
- 'Current height:',
756
- element.getBoundingClientRect().height
757
- )
758
- }
759
- } else if (INTERNAL_DEBUG) {
760
- console.log('itemResizeObserver not available for element:', element)
761
1072
  }
762
1073
 
763
1074
  return {
764
1075
  destroy() {
765
1076
  if (itemResizeObserver) {
766
1077
  itemResizeObserver.unobserve(element)
767
- if (INTERNAL_DEBUG) {
768
- console.log('Stopped observing element:', element)
769
- }
770
1078
  }
771
1079
  }
772
1080
  }
@@ -800,9 +1108,8 @@
800
1108
  {...testId ? { 'data-testid': `${testId}-content` } : {}}
801
1109
  class={contentClass ?? 'virtual-list-content'}
802
1110
  style:height="{(() => {
803
- // Use precise height when available for better cross-browser compatibility
804
- const totalActualHeight = items.length * preciseItemHeight()
805
- return Math.max(height, totalActualHeight)
1111
+ // Use ReactiveHeightManager's accurate total height for better cross-browser compatibility
1112
+ return Math.max(height, totalHeight())
806
1113
  })()}px"
807
1114
  >
808
1115
  <!-- Items container is translated to show correct items -->
@@ -810,25 +1117,46 @@
810
1117
  id="virtual-list-items"
811
1118
  {...testId ? { 'data-testid': `${testId}-items` } : {}}
812
1119
  class={itemsClass ?? 'virtual-list-items'}
1120
+ style:visibility={height === 0 && mode === 'bottomToTop' ? 'hidden' : 'visible'}
813
1121
  style:transform="translateY({(() => {
1122
+ const viewportHeight = height || 0
1123
+ const visibleRange = visibleItems()
1124
+
1125
+ // For bottomToTop mode with few items, provide reasonable initial positioning
1126
+ // even when height is not yet measured to prevent flash
1127
+ let effectiveHeight = viewportHeight
1128
+ if (mode === 'bottomToTop' && viewportHeight === 0 && containerElement) {
1129
+ // Measure height synchronously if available
1130
+ effectiveHeight = containerElement.getBoundingClientRect().height || 400
1131
+ } else if (mode === 'bottomToTop' && viewportHeight === 0) {
1132
+ // Fallback to reasonable default height estimate for initial positioning
1133
+ effectiveHeight = 400
1134
+ }
1135
+
814
1136
  const transform = calculateTransformY(
815
1137
  mode,
816
1138
  items.length,
817
- visibleItems().end,
818
- visibleItems().start,
819
- calculatedItemHeight
1139
+ visibleRange.end,
1140
+ visibleRange.start,
1141
+ heightManager.averageHeight,
1142
+ effectiveHeight,
1143
+ totalHeight() // Pass ReactiveHeightManager's accurate total height
820
1144
  )
821
1145
 
822
1146
  return transform
823
1147
  })()}px)"
824
1148
  >
825
1149
  {#each (() => {
1150
+ const visibleRange = visibleItems()
826
1151
  const slice = mode === 'bottomToTop' ? items
827
- .slice(visibleItems().start, visibleItems().end)
828
- .reverse() : items.slice(visibleItems().start, visibleItems().end)
1152
+ .slice(visibleRange.start, visibleRange.end)
1153
+ .reverse() : items.slice(visibleRange.start, visibleRange.end)
1154
+
1155
+ // Map each item with its original index for proper DOM element tracking
1156
+ const itemsWithOriginalIndex = slice.map( (item, sliceIndex) => ({ item, originalIndex: mode === 'bottomToTop' ? visibleRange.end - 1 - sliceIndex : visibleRange.start + sliceIndex, sliceIndex }) )
829
1157
 
830
- return slice
831
- })() as currentItem, i (currentItem?.id ?? i)}
1158
+ return itemsWithOriginalIndex
1159
+ })() as currentItemWithIndex, i (currentItemWithIndex.originalIndex)}
832
1160
  <!-- Only debug when visible range or average height changes -->
833
1161
  {#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, calculatedItemHeight)}
834
1162
  {@const debugInfo = createDebugInfo(
@@ -837,19 +1165,22 @@
837
1165
  Object.keys(heightCache).length,
838
1166
  calculatedItemHeight,
839
1167
  scrollTop,
840
- height || 0
1168
+ height || 0,
1169
+ totalHeight()
841
1170
  )}
842
1171
  {debugFunction
843
1172
  ? debugFunction(debugInfo)
844
1173
  : console.info('Virtual List Debug:', debugInfo)}
845
1174
  {/if}
846
1175
  <!-- Render each visible item -->
847
- <div bind:this={itemElements[i]} use:autoObserveItemResize>
1176
+ <div
1177
+ bind:this={itemElements[currentItemWithIndex.sliceIndex]}
1178
+ use:autoObserveItemResize
1179
+ data-original-index={currentItemWithIndex.originalIndex}
1180
+ >
848
1181
  {@render renderItem(
849
- currentItem,
850
- mode === 'bottomToTop'
851
- ? items.length - (visibleItems().start + i) - 1
852
- : visibleItems().start + i
1182
+ currentItemWithIndex.item,
1183
+ currentItemWithIndex.originalIndex
853
1184
  )}
854
1185
  </div>
855
1186
  {/each}