@humanspeak/svelte-virtual-list 0.3.1-beta.1 → 0.3.1

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.
Files changed (26) hide show
  1. package/dist/SvelteVirtualList.svelte +262 -191
  2. package/dist/SvelteVirtualList.svelte.d.ts +5 -5
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +2 -0
  5. package/dist/{reactive-height-manager → reactive-list-manager}/INTEGRATION_EXAMPLE.md +10 -10
  6. package/dist/{reactive-height-manager → reactive-list-manager}/README.md +17 -17
  7. package/dist/reactive-list-manager/ReactiveListManager.svelte.d.ts +221 -0
  8. package/dist/reactive-list-manager/ReactiveListManager.svelte.js +635 -0
  9. package/dist/reactive-list-manager/RecomputeScheduler.d.ts +12 -0
  10. package/dist/reactive-list-manager/RecomputeScheduler.js +54 -0
  11. package/dist/reactive-list-manager/benchmark.d.ts +5 -0
  12. package/dist/{reactive-height-manager → reactive-list-manager}/benchmark.js +3 -3
  13. package/dist/{reactive-height-manager → reactive-list-manager}/index.d.ts +8 -12
  14. package/dist/{reactive-height-manager → reactive-list-manager}/index.js +10 -13
  15. package/dist/{reactive-height-manager → reactive-list-manager}/test/TestComponent.svelte +9 -9
  16. package/dist/{reactive-height-manager → reactive-list-manager}/test/TestComponent.svelte.d.ts +5 -5
  17. package/dist/{reactive-height-manager → reactive-list-manager}/types.d.ts +9 -3
  18. package/dist/utils/virtualList.d.ts +2 -2
  19. package/dist/utils/virtualList.js +44 -17
  20. package/package.json +134 -133
  21. package/dist/reactive-height-manager/ReactiveHeightManager.svelte.d.ts +0 -116
  22. package/dist/reactive-height-manager/ReactiveHeightManager.svelte.js +0 -200
  23. package/dist/reactive-height-manager/benchmark.d.ts +0 -5
  24. package/dist/utils/resizeObserver.d.ts +0 -89
  25. package/dist/utils/resizeObserver.js +0 -119
  26. /package/dist/{reactive-height-manager → reactive-list-manager}/types.js +0 -0
@@ -61,7 +61,7 @@
61
61
  MIT License © Humanspeak, Inc.
62
62
  -->
63
63
 
64
- <script lang="ts" generics="TItem = any">
64
+ <script lang="ts" generics="TItem = unknown">
65
65
  /**
66
66
  * SvelteVirtualList Implementation Journey
67
67
  *
@@ -171,7 +171,7 @@
171
171
  import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
172
172
  import { calculateScrollTarget } from './utils/scrollCalculation.js'
173
173
  import { createAdvancedThrottledCallback } from './utils/throttle.js'
174
- import { ReactiveHeightManager } from './reactive-height-manager/index.js'
174
+ import { ReactiveListManager } from './index.js'
175
175
  import { BROWSER } from 'esm-env'
176
176
  import { onMount, tick, untrack } from 'svelte'
177
177
 
@@ -202,22 +202,18 @@
202
202
  /**
203
203
  * DOM References and Core State
204
204
  */
205
- let containerElement: HTMLElement // Reference to the main container element
206
- let viewportElement: HTMLElement // Reference to the scrollable viewport element
207
205
  const itemElements = $state<HTMLElement[]>([]) // Array of rendered item element references
208
206
 
209
207
  /**
210
208
  * Scroll and Height Management
211
209
  */
212
- let scrollTop = $state(0) // Current scroll position
213
210
  let height = $state(0) // Container height
214
- let calculatedItemHeight = $state(defaultEstimatedItemHeight) // Current average item height
215
211
 
216
212
  /**
217
213
  * State Flags and Control
218
214
  */
219
- let initialized = $state(false) // Tracks if initial setup is complete
220
- let isCalculatingHeight = $state(false) // Prevents concurrent height calculations
215
+
216
+ const isCalculatingHeight = $state(false) // Prevents concurrent height calculations
221
217
  let isScrolling = $state(false) // Tracks active scrolling state
222
218
  let lastMeasuredIndex = $state(-1) // Index of last measured item
223
219
  let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
@@ -232,37 +228,45 @@
232
228
  /**
233
229
  * Performance Optimization State
234
230
  */
235
- let heightCache = $state<Record<number, number>>({}) // Cache of measured item heights
236
- let dirtyItems = $state(new Set<number>()) // Set of item indices that need height recalculation
231
+ const dirtyItems = $state(new Set<number>()) // Set of item indices that need height recalculation
237
232
  let dirtyItemsCount = $state(0) // Reactive count of dirty items
233
+ // Fallback measurement used only when height has not been established yet
234
+ let measuredFallbackHeight = $state(0)
238
235
 
239
236
  let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
240
237
  let prevHeight = $state<number>(0)
238
+ let prevTotalHeightForScrollCorrection = $state<number>(0)
239
+ let lastBottomDistance = $state<number | null>(null)
241
240
 
242
241
  /**
243
242
  * Reactive Height Manager - O(1) height calculation system
244
243
  * Replaces O(n) totalHeight loop with incremental updates
245
244
  */
246
- let heightManager = new ReactiveHeightManager({
245
+ const heightManager = new ReactiveListManager({
247
246
  itemLength: items.length,
248
- itemHeight: defaultEstimatedItemHeight
247
+ itemHeight: defaultEstimatedItemHeight,
248
+ internalDebug: INTERNAL_DEBUG
249
249
  })
250
250
 
251
251
  // Dynamic update coordination to avoid UA scroll anchoring interference
252
- let dynamicUpdateInProgress = $state(false)
253
252
  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
- }
253
+
254
+ const displayItems = $derived(() => {
255
+ const visibleRange = visibleItems()
256
+ const slice =
257
+ mode === 'bottomToTop'
258
+ ? items.slice(visibleRange.start, visibleRange.end).reverse()
259
+ : items.slice(visibleRange.start, visibleRange.end)
260
+
261
+ return slice.map((item, sliceIndex) => ({
262
+ item,
263
+ originalIndex:
264
+ mode === 'bottomToTop'
265
+ ? visibleRange.end - 1 - sliceIndex
266
+ : visibleRange.start + sliceIndex,
267
+ sliceIndex
268
+ }))
269
+ })
266
270
 
267
271
  /**
268
272
  * Handles scroll position corrections when item heights change, ensuring proper positioning
@@ -280,7 +284,7 @@
280
284
  const handleHeightChangesScrollCorrection = (
281
285
  heightChanges: Array<{ index: number; oldHeight: number; newHeight: number; delta: number }>
282
286
  ) => {
283
- if (!viewportElement || !initialized || userHasScrolledAway) {
287
+ if (!heightManager.viewportElement || !heightManager.initialized || userHasScrolledAway) {
284
288
  return
285
289
  }
286
290
 
@@ -332,16 +336,18 @@
332
336
  wasAtBottomBeforeHeightChange &&
333
337
  !programmaticScrollInProgress &&
334
338
  performance.now() >= suppressBottomAnchoringUntilMs &&
335
- !dynamicUpdateInProgress
339
+ !heightManager.isDynamicUpdateInProgress
336
340
  ) {
337
341
  // Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
338
342
  const approximateScrollTop = Math.max(0, totalHeight() - height)
339
- viewportElement.scrollTop = approximateScrollTop
340
- scrollTop = approximateScrollTop
343
+ heightManager.viewport.scrollTop = approximateScrollTop
344
+ heightManager.scrollTop = approximateScrollTop
341
345
 
342
346
  // Step 2: Use native scrollIntoView for precise bottom-edge positioning after DOM updates
343
347
  tick().then(() => {
344
- const item0Element = viewportElement.querySelector('[data-original-index="0"]')
348
+ const item0Element = heightManager.viewport.querySelector(
349
+ '[data-original-index="0"]'
350
+ )
345
351
  if (item0Element) {
346
352
  // Native browser API handles all positioning edge cases perfectly
347
353
  item0Element.scrollIntoView({
@@ -351,14 +357,14 @@
351
357
  })
352
358
 
353
359
  // Sync our internal scroll state with actual DOM position
354
- scrollTop = viewportElement.scrollTop
360
+ heightManager.scrollTop = heightManager.viewport.scrollTop
355
361
  }
356
362
  })
357
363
 
358
364
  return // Skip remaining scroll correction logic - we've handled bottomToTop case
359
365
  }
360
366
 
361
- const currentScrollTop = viewportElement.scrollTop
367
+ const currentScrollTop = heightManager.viewport.scrollTop
362
368
  const maxScrollTop = Math.max(0, totalHeight() - height)
363
369
 
364
370
  // Calculate total height change impact above current visible area
@@ -379,8 +385,8 @@
379
385
  Math.max(0, currentScrollTop + heightChangeAboveViewport)
380
386
  )
381
387
 
382
- viewportElement.scrollTop = newScrollTop
383
- scrollTop = newScrollTop
388
+ heightManager.viewport.scrollTop = newScrollTop
389
+ heightManager.scrollTop = newScrollTop
384
390
  }
385
391
  }
386
392
 
@@ -391,7 +397,7 @@
391
397
  if (BROWSER && dirtyItemsCount > 0) {
392
398
  // Capture bottom state before any height processing to prevent cascading corrections
393
399
  wasAtBottomBeforeHeightChange = atBottom
394
- beginDynamicUpdate()
400
+ heightManager.startDynamicUpdate()
395
401
  updateHeight()
396
402
  }
397
403
  },
@@ -413,32 +419,56 @@
413
419
  })
414
420
 
415
421
  const updateHeight = () => {
422
+ // Capture previous total height for scroll correction (topToBottom anchoring)
423
+ prevTotalHeightForScrollCorrection = heightManager.totalHeight
416
424
  heightUpdateTimeout = calculateAverageHeightDebounced(
417
425
  isCalculatingHeight,
418
426
  heightUpdateTimeout,
419
427
  visibleItems,
420
428
  itemElements,
421
- heightCache,
429
+ heightManager.getHeightCache(),
422
430
  lastMeasuredIndex,
423
431
  heightManager.averageHeight,
424
432
  (result) => {
425
433
  // Critical updates that must trigger reactive effects immediately
426
434
  heightManager.itemHeight = result.newHeight
427
435
  lastMeasuredIndex = result.newLastMeasuredIndex
428
- heightCache = result.updatedHeightCache
429
436
 
430
- // Handle height changes for scroll correction (needs updated heightCache)
437
+ // Update manager totals/cache before any scroll correction logic relies on them
438
+ if (result.heightChanges.length > 0) {
439
+ heightManager.processDirtyHeights(result.heightChanges)
440
+ }
441
+
442
+ // Handle height changes for scroll correction (manager totals already updated)
431
443
  if (result.heightChanges.length > 0 && mode === 'bottomToTop') {
432
444
  handleHeightChangesScrollCorrection(result.heightChanges)
433
445
  }
434
446
 
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)
447
+ // TopToBottom: maintain bottom anchoring when total height changes
448
+ if (mode === 'topToBottom' && heightManager.isReady && heightManager.initialized) {
449
+ const oldTotal = prevTotalHeightForScrollCorrection
450
+ const newTotal = heightManager.totalHeight
451
+ const deltaTotal = newTotal - oldTotal
452
+ // Ignore micro deltas to prevent oscillation
453
+ if (Math.abs(deltaTotal) > 1) {
454
+ const maxScrollTop = Math.max(0, newTotal - (height || 0))
455
+ const tolerance = Math.max(heightManager.averageHeight, 10)
456
+ const currentScrollTop = heightManager.viewport.scrollTop
457
+ const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) <= tolerance
458
+ if (isAtBottom) {
459
+ // Adjust scrollTop by total height delta to hold bottom anchor
460
+ const adjusted = Math.min(
461
+ maxScrollTop,
462
+ Math.max(0, currentScrollTop + deltaTotal)
463
+ )
464
+ heightManager.viewport.scrollTop = adjusted
465
+ heightManager.scrollTop = adjusted
466
+ }
440
467
  }
468
+ }
441
469
 
470
+ // Non-critical updates wrapped in untrack to prevent reactive cascades
471
+ untrack(() => {
442
472
  // Clear processed dirty items (all dirty items were processed)
443
473
  dirtyItems.clear()
444
474
  dirtyItemsCount = 0
@@ -446,12 +476,12 @@
446
476
  // Reset bottom state flag
447
477
  wasAtBottomBeforeHeightChange = false
448
478
  })
449
- endDynamicUpdate()
479
+ heightManager.endDynamicUpdate()
450
480
  },
451
- 100, // debounceTime
481
+ lastMeasuredIndex < 0 ? 0 : 100, // debounceTime (no debounce on first pass)
452
482
  dirtyItems, // Pass dirty items for processing
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
483
+ 0, // Don't pass ReactiveListManager state - let each system manage its own totals
484
+ 0, // Don't pass ReactiveListManager state - let each system manage its own totals
455
485
  mode // Pass mode for correct element indexing
456
486
  )
457
487
  }
@@ -467,7 +497,7 @@
467
497
  * CRITICAL: O(1) Reactive Total Height Calculation
468
498
  * ===============================================
469
499
  *
470
- * Uses ReactiveHeightManager for O(1) height calculations instead of O(n) loops.
500
+ * Uses ReactiveListManager for O(1) height calculations instead of O(n) loops.
471
501
  * This fixes the root cause of massive scroll jumps in bottomToTop mode.
472
502
  *
473
503
  * Problem with Previous O(n) Approach:
@@ -478,7 +508,7 @@
478
508
  * - Total height jumps from 200,000px to 223,500px (+23,500px!)
479
509
  * - This 23,500px error caused massive scroll position overshoots
480
510
  *
481
- * Solution with ReactiveHeightManager:
511
+ * Solution with ReactiveListManager:
482
512
  * - O(1) reactive calculations using incremental updates
483
513
  * - Uses actual measured heights from heightCache where available
484
514
  * - Only estimates heights for items that haven't been measured yet
@@ -494,20 +524,40 @@
494
524
  * This getter is reactive and updates whenever heightManager's internal state changes.
495
525
  * Used by: atBottom calculation, scroll corrections, maxScrollTop calculations
496
526
  */
497
- let totalHeight = $derived(() => heightManager.totalHeight)
527
+ const totalHeight = $derived(() => heightManager.totalHeight)
498
528
 
499
- let atTop = $derived(scrollTop <= 1)
500
- let atBottom = $derived(scrollTop >= totalHeight() - height - 1)
529
+ const atBottom = $derived(heightManager.scrollTop >= totalHeight() - height - 1)
501
530
  let wasAtBottomBeforeHeightChange = false
502
531
  let lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null = null
503
532
 
504
- $inspect('scrollState: atTop', atTop)
505
- $inspect('scrollState: atBottom', atBottom)
533
+ function updateDebugTailDistance() {
534
+ if (!heightManager.viewportElement) return
535
+ const last = heightManager.viewport.querySelector(
536
+ '[data-original-index="999"]'
537
+ ) as HTMLElement | null
538
+ if (!last) return
539
+ const v = heightManager.viewport.getBoundingClientRect()
540
+ const r = last.getBoundingClientRect()
541
+ lastBottomDistance = Math.round(Math.abs(r.bottom - v.bottom))
542
+ if (INTERNAL_DEBUG) {
543
+ console.info('[SVL] bottomDistance(px):', lastBottomDistance)
544
+ }
545
+ }
546
+
547
+ // no UI export; rely on console logs when debug=true
548
+
549
+ // $inspect('scrollState: atTop', atTop)
550
+ // $inspect('scrollState: atBottom', atBottom)
506
551
 
507
552
  $effect(() => {
508
- if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
553
+ if (
554
+ BROWSER &&
555
+ heightManager.initialized &&
556
+ mode === 'bottomToTop' &&
557
+ heightManager.viewportElement
558
+ ) {
509
559
  const targetScrollTop = Math.max(0, totalHeight() - height)
510
- const currentScrollTop = viewportElement.scrollTop
560
+ const currentScrollTop = heightManager.viewport.scrollTop
511
561
  const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
512
562
 
513
563
  // Only correct scroll if:
@@ -515,33 +565,34 @@
515
565
  // 2. User hasn't intentionally scrolled away from bottom
516
566
  // 3. We're significantly off target
517
567
  // 4. We're not at the bottom (where height changes should be handled more carefully)
518
- const heightChanged = Math.abs(calculatedItemHeight - lastCalculatedHeight) > 1
568
+ const heightChanged = Math.abs(heightManager.averageHeight - lastCalculatedHeight) > 1
519
569
  const maxScrollTop = Math.max(0, totalHeight() - height)
520
570
 
521
571
  // In bottomToTop mode, we're "at bottom" when scroll is at max position
522
- const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) < calculatedItemHeight
572
+ const isAtBottom =
573
+ Math.abs(currentScrollTop - maxScrollTop) < heightManager.averageHeight
523
574
  const shouldCorrect =
524
575
  heightChanged &&
525
576
  !userHasScrolledAway &&
526
577
  !isAtBottom && // Don't apply aggressive correction when at bottom
527
578
  !programmaticScrollInProgress && // Don't interfere with programmatic scrolls
528
579
  performance.now() >= suppressBottomAnchoringUntilMs &&
529
- !dynamicUpdateInProgress &&
530
- scrollDifference > calculatedItemHeight * 3
580
+ !heightManager.isDynamicUpdateInProgress &&
581
+ scrollDifference > heightManager.averageHeight * 3
531
582
 
532
583
  if (shouldCorrect) {
533
584
  // Round to avoid subpixel positioning issues in bottomToTop mode
534
585
  const roundedTargetScrollTop = Math.round(targetScrollTop)
535
- viewportElement.scrollTop = roundedTargetScrollTop
536
- scrollTop = roundedTargetScrollTop
586
+ heightManager.viewport.scrollTop = roundedTargetScrollTop
587
+ heightManager.scrollTop = roundedTargetScrollTop
537
588
  }
538
589
 
539
590
  // Track if user has scrolled significantly away from bottom
540
- if (scrollDifference > calculatedItemHeight * 5) {
591
+ if (scrollDifference > heightManager.averageHeight * 5) {
541
592
  userHasScrolledAway = true
542
593
  }
543
594
 
544
- lastCalculatedHeight = calculatedItemHeight
595
+ lastCalculatedHeight = heightManager.averageHeight
545
596
  }
546
597
  })
547
598
 
@@ -552,17 +603,17 @@
552
603
 
553
604
  if (
554
605
  BROWSER &&
555
- initialized &&
606
+ heightManager.initialized &&
556
607
  mode === 'bottomToTop' &&
557
- viewportElement &&
608
+ heightManager.isReady &&
558
609
  lastItemsLength > 0
559
610
  ) {
560
611
  const itemsAdded = currentItemsLength - lastItemsLength
561
612
 
562
613
  if (itemsAdded !== 0) {
563
614
  // Capture all reactive values immediately to prevent re-triggering
564
- const currentScrollTop = viewportElement.scrollTop
565
- const currentCalculatedItemHeight = calculatedItemHeight
615
+ const currentScrollTop = heightManager.viewport.scrollTop
616
+ const currentCalculatedItemHeight = heightManager.averageHeight
566
617
  const currentHeight = height
567
618
  const currentTotalHeight = totalHeight()
568
619
  const maxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
@@ -580,14 +631,14 @@
580
631
 
581
632
  if (wasNearBottom || currentScrollTop === 0) {
582
633
  // 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()
634
+ void heightManager.runDynamicUpdate(() => {
635
+ const newScrollTop = maxScrollTop
636
+ heightManager.viewport.scrollTop = newScrollTop
637
+ heightManager.scrollTop = newScrollTop
638
+
639
+ // Reset the "scrolled away" flag since we're actively managing position
640
+ userHasScrolledAway = false
641
+ })
591
642
  }
592
643
  }
593
644
  }
@@ -595,42 +646,23 @@
595
646
  lastItemsLength = currentItemsLength
596
647
  })
597
648
 
598
- // Update container height when element is mounted
649
+ // Update container height continuously to reflect layout changes that
650
+ // may occur outside ResizeObserver timing (keeps buffers correct across engines)
599
651
  $effect(() => {
600
- if (BROWSER && containerElement) {
601
- height = containerElement.getBoundingClientRect().height
652
+ if (BROWSER && heightManager.isReady) {
653
+ const h = heightManager.container.getBoundingClientRect().height
654
+ if (Number.isFinite(h) && h > 0) height = h
602
655
  }
603
656
  })
604
657
 
605
- // Special handling for bottom-to-top mode initialization
606
- $effect(() => {
607
- if (
608
- BROWSER &&
609
- mode === 'bottomToTop' &&
610
- viewportElement &&
611
- height > 0 &&
612
- items.length &&
613
- !initialized
614
- ) {
615
- const targetScrollTop = Math.max(0, totalHeight() - height)
658
+ // One-time fallback measurement when height hasn't been established yet
616
659
 
617
- // Add delay to ensure layout is complete
618
- tick().then(() => {
619
- if (viewportElement) {
620
- // Start at the bottom for bottom-to-top mode
621
- viewportElement.scrollTop = targetScrollTop
622
- scrollTop = targetScrollTop
623
-
624
- // Double-check the scroll position after a frame
625
- requestAnimationFrame(() => {
626
- if (viewportElement && viewportElement.scrollTop !== targetScrollTop) {
627
- viewportElement.scrollTop = targetScrollTop
628
- scrollTop = targetScrollTop
629
- }
630
- initialized = true
631
- })
632
- }
633
- })
660
+ // Provide a one-time synchronous measurement only when height is still 0,
661
+ // to avoid DOM reads inside render-time expressions.
662
+ $effect(() => {
663
+ if (BROWSER && height === 0 && heightManager.isReady) {
664
+ const h = heightManager.container.getBoundingClientRect().height
665
+ if (Number.isFinite(h) && h > 0) measuredFallbackHeight = h
634
666
  }
635
667
  })
636
668
 
@@ -648,7 +680,7 @@
648
680
  * @example
649
681
  * ```typescript
650
682
  * const range = visibleItems()
651
- * console.log(`Rendering items from ${range.start} to ${range.end}`)
683
+ * console.info(`Rendering items from ${range.start} to ${range.end}`)
652
684
  * ```
653
685
  *
654
686
  * @returns {SvelteVirtualListPreviousVisibleRange} Object containing start and end indices of visible items
@@ -659,7 +691,12 @@
659
691
 
660
692
  // For bottomToTop mode, don't calculate visible range until properly initialized
661
693
  // This prevents showing wrong items when scrollTop starts at 0
662
- if (mode === 'bottomToTop' && !initialized && scrollTop === 0 && viewportHeight > 0) {
694
+ if (
695
+ mode === 'bottomToTop' &&
696
+ !heightManager.initialized &&
697
+ heightManager.scrollTop === 0 &&
698
+ viewportHeight > 0
699
+ ) {
663
700
  // Calculate what the correct scroll position should be
664
701
  const targetScrollTop = Math.max(0, totalHeight() - viewportHeight)
665
702
 
@@ -674,14 +711,15 @@
674
711
  atBottom,
675
712
  wasAtBottomBeforeHeightChange,
676
713
  lastVisibleRange,
677
- totalHeight()
714
+ totalHeight(),
715
+ heightManager.getHeightCache()
678
716
  )
679
717
 
680
718
  return lastVisibleRange
681
719
  }
682
720
 
683
721
  lastVisibleRange = calculateVisibleRange(
684
- scrollTop,
722
+ heightManager.scrollTop,
685
723
  viewportHeight,
686
724
  heightManager.averageHeight,
687
725
  items.length,
@@ -690,7 +728,8 @@
690
728
  atBottom,
691
729
  wasAtBottomBeforeHeightChange,
692
730
  lastVisibleRange,
693
- totalHeight()
731
+ totalHeight(),
732
+ heightManager.getHeightCache()
694
733
  )
695
734
 
696
735
  return lastVisibleRange
@@ -718,12 +757,12 @@
718
757
  * @returns {void}
719
758
  */
720
759
  const handleScroll = () => {
721
- if (!BROWSER || !viewportElement) return
760
+ if (!BROWSER || !heightManager.viewportElement) return
722
761
 
723
762
  if (!isScrolling) {
724
763
  isScrolling = true
725
764
  rafSchedule(() => {
726
- const current = viewportElement.scrollTop
765
+ const current = heightManager.viewport.scrollTop
727
766
  if (mode === 'bottomToTop') {
728
767
  const delta = lastScrollTopSnapshot - current
729
768
  if (delta > 0.5) {
@@ -732,7 +771,19 @@
732
771
  }
733
772
  }
734
773
  lastScrollTopSnapshot = current
735
- scrollTop = current
774
+ heightManager.scrollTop = current
775
+ updateDebugTailDistance()
776
+ if (INTERNAL_DEBUG) {
777
+ const vr = visibleItems()
778
+ console.info('[SVL] onscroll', {
779
+ mode,
780
+ scrollTop: heightManager.scrollTop,
781
+ height,
782
+ totalHeight: totalHeight(),
783
+ averageItemHeight: heightManager.averageHeight,
784
+ visibleRange: vr
785
+ })
786
+ }
736
787
  isScrolling = false
737
788
  })
738
789
  }
@@ -753,37 +804,36 @@
753
804
  * @param immediate - Whether to skip the delay (used for resize events)
754
805
  */
755
806
  const updateHeightAndScroll = (immediate = false) => {
756
- if (!initialized && mode === 'bottomToTop') {
807
+ if (!heightManager.initialized && mode === 'bottomToTop') {
757
808
  tick().then(() => {
758
- if (containerElement) {
759
- const initialHeight = containerElement.getBoundingClientRect().height
809
+ if (heightManager.isReady) {
810
+ const initialHeight = heightManager.container.getBoundingClientRect().height
760
811
  height = initialHeight
761
812
 
762
813
  tick().then(() => {
763
- if (containerElement && viewportElement) {
764
- const finalHeight = containerElement.getBoundingClientRect().height
814
+ if (heightManager.isReady) {
815
+ const finalHeight =
816
+ heightManager.container.getBoundingClientRect().height
765
817
  height = finalHeight
766
818
 
767
819
  const targetScrollTop = calculateScrollPosition(
768
820
  items.length,
769
- calculatedItemHeight,
821
+ heightManager.averageHeight,
770
822
  finalHeight
771
823
  )
772
824
 
773
- void containerElement.offsetHeight
825
+ void heightManager.container.offsetHeight
774
826
 
775
- viewportElement.scrollTop = targetScrollTop
776
- scrollTop = targetScrollTop
827
+ heightManager.viewport.scrollTop = targetScrollTop
828
+ heightManager.scrollTop = targetScrollTop
777
829
 
778
830
  requestAnimationFrame(() => {
779
- if (viewportElement) {
780
- const currentScroll = viewportElement.scrollTop
781
- if (currentScroll !== scrollTop) {
782
- viewportElement.scrollTop = targetScrollTop
783
- scrollTop = targetScrollTop
784
- }
785
- initialized = true
831
+ const currentScroll = heightManager.viewport.scrollTop
832
+ if (currentScroll !== heightManager.scrollTop) {
833
+ heightManager.viewport.scrollTop = targetScrollTop
834
+ heightManager.scrollTop = targetScrollTop
786
835
  }
836
+ heightManager.initialized = true
787
837
  })
788
838
  }
789
839
  })
@@ -794,18 +844,18 @@
794
844
 
795
845
  utilsUpdateHeightAndScroll(
796
846
  {
797
- initialized,
847
+ initialized: heightManager.initialized,
798
848
  mode,
799
- containerElement,
800
- viewportElement,
801
- calculatedItemHeight,
849
+ containerElement: heightManager.containerElement,
850
+ viewportElement: heightManager.viewportElement,
851
+ calculatedItemHeight: heightManager.averageHeight,
802
852
  height,
803
- scrollTop
853
+ scrollTop: heightManager.scrollTop
804
854
  },
805
855
  {
806
856
  setHeight: (h) => (height = h),
807
- setScrollTop: (st) => (scrollTop = st),
808
- setInitialized: (i) => (initialized = i)
857
+ setScrollTop: (st) => (heightManager.scrollTop = st),
858
+ setInitialized: (i) => (heightManager.initialized = i)
809
859
  },
810
860
  immediate
811
861
  )
@@ -817,7 +867,7 @@
817
867
  itemResizeObserver = new ResizeObserver((entries) => {
818
868
  tick().then(() => {
819
869
  let shouldRecalculate = false
820
- const visibleRange = visibleItems() // Cache once to avoid reactive loops
870
+ void visibleItems() // Cache once to avoid reactive loops
821
871
 
822
872
  for (const entry of entries) {
823
873
  const element = entry.target as HTMLElement
@@ -830,7 +880,7 @@
830
880
  const isSignificant = isSignificantHeightChange(
831
881
  actualIndex,
832
882
  currentHeight,
833
- heightCache
883
+ heightManager.getHeightCache()
834
884
  )
835
885
 
836
886
  // Only mark as dirty if height change is significant
@@ -862,14 +912,18 @@
862
912
  if (BROWSER) {
863
913
  // Initial setup of heights and scroll position
864
914
  updateHeightAndScroll()
915
+ // Ensure one initial measurement pass even if no ResizeObserver fires
916
+ tick().then(() =>
917
+ requestAnimationFrame(() => requestAnimationFrame(() => updateHeight()))
918
+ )
865
919
 
866
920
  // Watch for container size changes
867
921
  resizeObserver = new ResizeObserver(() => {
868
922
  updateHeightAndScroll(true)
869
923
  })
870
924
 
871
- if (containerElement) {
872
- resizeObserver.observe(containerElement)
925
+ if (heightManager.isReady) {
926
+ resizeObserver.observe(heightManager.container)
873
927
  }
874
928
 
875
929
  // Cleanup on component destruction
@@ -888,7 +942,7 @@
888
942
  $effect(() => {
889
943
  if (INTERNAL_DEBUG) {
890
944
  prevVisibleRange = visibleItems()
891
- prevHeight = calculatedItemHeight
945
+ prevHeight = heightManager.averageHeight
892
946
  }
893
947
  })
894
948
 
@@ -970,9 +1024,9 @@
970
1024
  }
971
1025
 
972
1026
  if (!items.length) return
973
- if (!viewportElement) {
1027
+ if (!heightManager.viewportElement) {
974
1028
  tick().then(() => {
975
- if (!viewportElement) return
1029
+ if (!heightManager.viewportElement) return
976
1030
  scroll({ index, smoothScroll, shouldThrowOnBounds, align })
977
1031
  })
978
1032
  return
@@ -998,12 +1052,12 @@
998
1052
  align: align || 'auto',
999
1053
  targetIndex,
1000
1054
  itemsLength: items.length,
1001
- calculatedItemHeight: heightManager.averageHeight, // Use dynamic average from ReactiveHeightManager
1055
+ calculatedItemHeight: heightManager.averageHeight, // Use dynamic average from ReactiveListManager
1002
1056
  height,
1003
- scrollTop,
1057
+ scrollTop: heightManager.scrollTop,
1004
1058
  firstVisibleIndex,
1005
1059
  lastVisibleIndex,
1006
- heightCache
1060
+ heightCache: heightManager.getHeightCache()
1007
1061
  })
1008
1062
 
1009
1063
  // Handle early return for 'nearest' alignment when item is already visible
@@ -1014,6 +1068,22 @@
1014
1068
  // Prevent bottom-anchoring logic from interfering with programmatic scroll
1015
1069
  programmaticScrollInProgress = true
1016
1070
 
1071
+ if (INTERNAL_DEBUG && heightManager.viewportElement) {
1072
+ const domMax = Math.max(
1073
+ 0,
1074
+ heightManager.viewport.scrollHeight - heightManager.viewport.clientHeight
1075
+ )
1076
+ console.info('[SVL] scroll-intent', {
1077
+ targetIndex,
1078
+ align: align || 'auto',
1079
+ firstVisibleIndex,
1080
+ lastVisibleIndex,
1081
+ currentScrollTop: heightManager.scrollTop,
1082
+ scrollTarget,
1083
+ domMaxScrollTop: domMax
1084
+ })
1085
+ }
1086
+
1017
1087
  // CROSS-BROWSER COMPATIBILITY FIX:
1018
1088
  // All major browsers (Chrome, Firefox, Safari) have inconsistent behavior with scrollTo()
1019
1089
  // in bottomToTop mode when using smooth scrolling. Using scrollIntoView() on the highest
@@ -1021,7 +1091,7 @@
1021
1091
  // This approach works universally and maintains the user's expected smooth scroll experience.
1022
1092
  if (mode === 'bottomToTop' && smoothScroll) {
1023
1093
  // Find the element with the highest original-index in the current viewport
1024
- const visibleElements = viewportElement.querySelectorAll('[data-original-index]')
1094
+ const visibleElements = heightManager.viewport.querySelectorAll('[data-original-index]')
1025
1095
  let maxIndex = -1
1026
1096
  let maxElement: HTMLElement | null = null
1027
1097
  for (const el of visibleElements) {
@@ -1040,16 +1110,28 @@
1040
1110
  await tick()
1041
1111
  }
1042
1112
 
1043
- viewportElement.scrollTo({
1113
+ heightManager.viewport.scrollTo({
1044
1114
  top: scrollTarget,
1045
1115
  behavior: smoothScroll ? 'smooth' : 'auto'
1046
1116
  })
1047
1117
 
1048
1118
  // Update scrollTop state in next frame to avoid synchronous re-renders
1049
1119
  requestAnimationFrame(() => {
1050
- scrollTop = scrollTarget
1120
+ heightManager.scrollTop = scrollTarget
1121
+ if (INTERNAL_DEBUG && heightManager.viewportElement) {
1122
+ const domMax = Math.max(
1123
+ 0,
1124
+ heightManager.viewport.scrollHeight - heightManager.viewport.clientHeight
1125
+ )
1126
+ console.info('[SVL] scroll-after-call', {
1127
+ scrollTop: heightManager.scrollTop,
1128
+ domMaxScrollTop: domMax
1129
+ })
1130
+ }
1051
1131
  })
1052
1132
 
1133
+ // No extra alignment step here; allow native smooth scroll to reach DOM max scrollTop
1134
+
1053
1135
  // Clear the flag after scroll completes
1054
1136
  setTimeout(
1055
1137
  () => {
@@ -1092,14 +1174,14 @@
1092
1174
  id="virtual-list-container"
1093
1175
  {...testId ? { 'data-testid': `${testId}-container` } : {}}
1094
1176
  class={containerClass ?? 'virtual-list-container'}
1095
- bind:this={containerElement}
1177
+ bind:this={heightManager.containerElement}
1096
1178
  >
1097
1179
  <!-- Viewport handles scrolling -->
1098
1180
  <div
1099
1181
  id="virtual-list-viewport"
1100
1182
  {...testId ? { 'data-testid': `${testId}-viewport` } : {}}
1101
1183
  class={viewportClass ?? 'virtual-list-viewport'}
1102
- bind:this={viewportElement}
1184
+ bind:this={heightManager.viewportElement}
1103
1185
  onscroll={handleScroll}
1104
1186
  >
1105
1187
  <!-- Content provides full scrollable height -->
@@ -1107,10 +1189,7 @@
1107
1189
  id="virtual-list-content"
1108
1190
  {...testId ? { 'data-testid': `${testId}-content` } : {}}
1109
1191
  class={contentClass ?? 'virtual-list-content'}
1110
- style:height="{(() => {
1111
- // Use ReactiveHeightManager's accurate total height for better cross-browser compatibility
1112
- return Math.max(height, totalHeight())
1113
- })()}px"
1192
+ style:height="{(() => Math.max(height, totalHeight()))()}px"
1114
1193
  >
1115
1194
  <!-- Items container is translated to show correct items -->
1116
1195
  <div
@@ -1119,52 +1198,39 @@
1119
1198
  class={itemsClass ?? 'virtual-list-items'}
1120
1199
  style:visibility={height === 0 && mode === 'bottomToTop' ? 'hidden' : 'visible'}
1121
1200
  style:transform="translateY({(() => {
1122
- const viewportHeight = height || 0
1201
+ const viewportHeight = height || measuredFallbackHeight || 0
1123
1202
  const visibleRange = visibleItems()
1124
1203
 
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
- }
1204
+ // Avoid synchronous DOM reads here; fall back once if height is 0
1205
+ const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
1135
1206
 
1136
- const transform = calculateTransformY(
1137
- mode,
1138
- items.length,
1139
- visibleRange.end,
1140
- visibleRange.start,
1141
- heightManager.averageHeight,
1142
- effectiveHeight,
1143
- totalHeight() // Pass ReactiveHeightManager's accurate total height
1207
+ // Use precise offset for topToBottom using measured heights when available
1208
+ const transform = Math.round(
1209
+ calculateTransformY(
1210
+ mode,
1211
+ items.length,
1212
+ visibleRange.end,
1213
+ visibleRange.start,
1214
+ heightManager.averageHeight,
1215
+ effectiveHeight,
1216
+ totalHeight(),
1217
+ heightManager.getHeightCache(),
1218
+ measuredFallbackHeight
1219
+ )
1144
1220
  )
1145
1221
 
1146
1222
  return transform
1147
1223
  })()}px)"
1148
1224
  >
1149
- {#each (() => {
1150
- const visibleRange = visibleItems()
1151
- const slice = mode === 'bottomToTop' ? items
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 }) )
1157
-
1158
- return itemsWithOriginalIndex
1159
- })() as currentItemWithIndex, i (currentItemWithIndex.originalIndex)}
1225
+ {#each displayItems() as currentItemWithIndex, i (currentItemWithIndex.originalIndex)}
1160
1226
  <!-- Only debug when visible range or average height changes -->
1161
- {#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, calculatedItemHeight)}
1227
+ {#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, heightManager.averageHeight)}
1162
1228
  {@const debugInfo = createDebugInfo(
1163
1229
  visibleItems(),
1164
1230
  items.length,
1165
- Object.keys(heightCache).length,
1166
- calculatedItemHeight,
1167
- scrollTop,
1231
+ Object.keys(heightManager.getHeightCache()).length,
1232
+ heightManager.averageHeight,
1233
+ heightManager.scrollTop,
1168
1234
  height || 0,
1169
1235
  totalHeight()
1170
1236
  )}
@@ -1177,6 +1243,11 @@
1177
1243
  bind:this={itemElements[currentItemWithIndex.sliceIndex]}
1178
1244
  use:autoObserveItemResize
1179
1245
  data-original-index={currentItemWithIndex.originalIndex}
1246
+ {...testId
1247
+ ? {
1248
+ 'data-testid': `${testId}-item-${currentItemWithIndex.originalIndex}`
1249
+ }
1250
+ : {}}
1180
1251
  >
1181
1252
  {@render renderItem(
1182
1253
  currentItemWithIndex.item,