@humanspeak/svelte-virtual-list 0.2.6 → 0.3.1-beta.0

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 (32) hide show
  1. package/README.md +14 -2
  2. package/dist/SvelteVirtualList.svelte +619 -179
  3. package/dist/SvelteVirtualList.svelte.d.ts +156 -65
  4. package/dist/reactive-height-manager/INTEGRATION_EXAMPLE.md +136 -0
  5. package/dist/reactive-height-manager/README.md +324 -0
  6. package/dist/reactive-height-manager/ReactiveHeightManager.svelte.d.ts +116 -0
  7. package/dist/reactive-height-manager/ReactiveHeightManager.svelte.js +200 -0
  8. package/dist/reactive-height-manager/benchmark.d.ts +5 -0
  9. package/dist/reactive-height-manager/benchmark.js +25 -0
  10. package/dist/reactive-height-manager/index.d.ts +50 -0
  11. package/dist/reactive-height-manager/index.js +55 -0
  12. package/dist/reactive-height-manager/test/TestComponent.svelte +78 -0
  13. package/dist/reactive-height-manager/test/TestComponent.svelte.d.ts +23 -0
  14. package/dist/reactive-height-manager/types.d.ts +41 -0
  15. package/dist/reactive-height-manager/types.js +1 -0
  16. package/dist/types.d.ts +24 -5
  17. package/dist/utils/heightCalculation.d.ts +18 -8
  18. package/dist/utils/heightCalculation.js +18 -11
  19. package/dist/utils/heightChangeDetection.d.ts +12 -0
  20. package/dist/utils/heightChangeDetection.js +20 -0
  21. package/dist/utils/resizeObserver.d.ts +89 -0
  22. package/dist/utils/resizeObserver.js +119 -0
  23. package/dist/utils/scrollCalculation.d.ts +47 -0
  24. package/dist/utils/scrollCalculation.js +167 -0
  25. package/dist/utils/throttle.d.ts +95 -0
  26. package/dist/utils/throttle.js +155 -0
  27. package/dist/utils/types.d.ts +0 -6
  28. package/dist/utils/virtualList.d.ts +20 -23
  29. package/dist/utils/virtualList.js +153 -61
  30. package/dist/utils/virtualListDebug.d.ts +12 -7
  31. package/dist/utils/virtualListDebug.js +19 -9
  32. package/package.json +33 -31
@@ -41,15 +41,16 @@
41
41
  - Only visible items + buffer are mounted in the DOM
42
42
  - Height caching and estimation for dynamic content
43
43
  - Handles resize events and dynamic content changes
44
- - Supports chunked initialization for very large lists
45
- - All scrolling logic is centralized in the scroll() method
44
+ - Optimized for very large lists through virtualization
45
+ - Modular architecture with extracted utility functions
46
46
  - Bi-directional support: mode="topToBottom" or "bottomToTop"
47
47
  - Designed for extensibility and easy debugging
48
48
 
49
49
  =============================
50
50
  == For Contributors ==
51
51
  =============================
52
- - Please keep all scrolling logic in the scroll() method
52
+ - Complex logic is extracted to dedicated utility files in src/lib/utils/
53
+ - Scroll positioning logic is in scrollCalculation.ts (well-tested)
53
54
  - Add new features behind feature flags or as optional props
54
55
  - Write tests for all new features (see /test and /tests/scroll)
55
56
  - Use TypeScript and Svelte 5 runes for all new code
@@ -60,7 +61,7 @@
60
61
  MIT License © Humanspeak, Inc.
61
62
  -->
62
63
 
63
- <script lang="ts">
64
+ <script lang="ts" generics="TItem = any">
64
65
  /**
65
66
  * SvelteVirtualList Implementation Journey
66
67
  *
@@ -110,7 +111,14 @@
110
111
  * - Added comprehensive documentation
111
112
  * - Optimized debug output to reduce noise
112
113
  *
113
- * 9. Future Improvements (Planned)
114
+ * 9. Architecture Refactoring
115
+ * - Extracted scroll calculation logic to scrollCalculation.ts utility
116
+ * - Extracted ResizeObserver utilities to resizeObserver.ts
117
+ * - Added comprehensive test coverage for extracted utilities
118
+ * - Improved separation of concerns and maintainability
119
+ * - Simplified initialization (removed unnecessary chunked processing)
120
+ *
121
+ * 10. Future Improvements (Planned)
114
122
  * - Add horizontal scrolling support
115
123
  * - Implement variable-sized item caching
116
124
  * - Add keyboard navigation support
@@ -128,42 +136,53 @@
128
136
  * - Debug output optimization
129
137
  * - Accurate size calculations with caching
130
138
  * - Responsive size adjustments
139
+ * - Modular architecture with testable utility functions
131
140
  *
132
141
  * Current Architecture:
133
142
  * - Four-layer DOM structure for optimal performance
134
143
  * - State management using Svelte 5's $state
135
144
  * - Reactive height and scroll calculations
136
145
  * - Configurable buffer zones for smooth scrolling
137
- * - Chunked processing system for large datasets
138
- * - Separated debug utilities for better testing
146
+ * - Modular utility system with dedicated helper files:
147
+ * * scrollCalculation.ts: Complex scroll positioning logic
148
+ * * resizeObserver.ts: ResizeObserver management utilities
149
+ * * heightCalculation.ts: Debounced height measurement
150
+ * * virtualList.ts: Core virtual list calculations
151
+ * * virtualListDebug.ts: Debug information utilities
139
152
  * - Height caching and estimation system
140
153
  * - Progressive size adjustment system
141
154
  */
142
155
 
143
156
  import {
144
157
  DEFAULT_SCROLL_OPTIONS,
158
+ type SvelteVirtualListPreviousVisibleRange,
145
159
  type SvelteVirtualListProps,
146
160
  type SvelteVirtualListScrollOptions
147
161
  } from './types.js'
148
162
  import { calculateAverageHeightDebounced } from './utils/heightCalculation.js'
149
163
  import { createRafScheduler } from './utils/raf.js'
164
+ import { isSignificantHeightChange } from './utils/heightChangeDetection.js'
150
165
  import {
151
166
  calculateScrollPosition,
152
167
  calculateTransformY,
153
168
  calculateVisibleRange,
154
- getScrollOffsetForIndex,
155
- processChunked,
156
169
  updateHeightAndScroll as utilsUpdateHeightAndScroll
157
170
  } from './utils/virtualList.js'
158
171
  import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
172
+ import { calculateScrollTarget } from './utils/scrollCalculation.js'
173
+ import { createAdvancedThrottledCallback } from './utils/throttle.js'
174
+ import { ReactiveHeightManager } from './reactive-height-manager/index.js'
159
175
  import { BROWSER } from 'esm-env'
160
- import { onMount, tick } from 'svelte'
176
+ import { onMount, tick, untrack } from 'svelte'
161
177
 
162
178
  const rafSchedule = createRafScheduler()
163
-
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'
164
183
  /**
165
184
  * Core configuration props with default values
166
- * @type {SvelteVirtualListProps}
185
+ * @type {SvelteVirtualListProps<TItem>}
167
186
  */
168
187
  const {
169
188
  items = [], // Array of items to be rendered in the virtual list
@@ -178,7 +197,7 @@
178
197
  mode = 'topToBottom', // Scroll direction mode
179
198
  bufferSize = 20, // Number of items to render outside visible area
180
199
  testId // Base test ID for component elements (undefined = no data-testid attributes)
181
- }: SvelteVirtualListProps = $props()
200
+ }: SvelteVirtualListProps<TItem> = $props()
182
201
 
183
202
  /**
184
203
  * DOM References and Core State
@@ -201,59 +220,379 @@
201
220
  let isCalculatingHeight = $state(false) // Prevents concurrent height calculations
202
221
  let isScrolling = $state(false) // Tracks active scrolling state
203
222
  let lastMeasuredIndex = $state(-1) // Index of last measured item
223
+ let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
204
224
 
205
225
  /**
206
226
  * Timers and Observers
207
227
  */
208
228
  let heightUpdateTimeout: ReturnType<typeof setTimeout> | null = null // Debounce timer for height updates
209
229
  let resizeObserver: ResizeObserver | null = null // Watches for container size changes
230
+ let itemResizeObserver: ResizeObserver | null = null // Watches for individual item size changes
210
231
 
211
232
  /**
212
233
  * Performance Optimization State
213
234
  */
214
235
  let heightCache = $state<Record<number, number>>({}) // Cache of measured item heights
215
- const chunkSize = $state(50) // Number of items to process in each chunk
216
- let processedItems = $state(0) // Number of items processed during initialization
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
217
238
 
218
- let prevVisibleRange = $state<{ start: number; end: number } | null>(null)
239
+ let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
219
240
  let prevHeight = $state<number>(0)
220
241
 
221
- // Trigger height calculation when items are rendered
222
- $effect(() => {
223
- if (BROWSER && itemElements.length > 0 && !isCalculatingHeight) {
224
- heightUpdateTimeout = calculateAverageHeightDebounced(
225
- isCalculatingHeight,
226
- heightUpdateTimeout,
227
- visibleItems,
228
- itemElements,
229
- heightCache,
230
- lastMeasuredIndex,
231
- calculatedItemHeight,
232
- (result) => {
233
- calculatedItemHeight = result.newHeight
234
- lastMeasuredIndex = result.newLastMeasuredIndex
235
- heightCache = result.updatedHeightCache
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
236
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)
237
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
238
402
  }
403
+ )
404
+
405
+ // Trigger height calculation when dirty items are added
406
+ $effect(() => {
407
+ triggerHeightUpdate()
239
408
  })
240
409
 
241
- // Add new effect to handle height changes
410
+ // Keep height manager synchronized with items length
242
411
  $effect(() => {
243
- if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
244
- const totalHeight = Math.max(0, items.length * calculatedItemHeight)
245
- const targetScrollTop = Math.max(0, totalHeight - height)
246
-
247
- // Only update if the difference is significant
248
- if (Math.abs(viewportElement.scrollTop - targetScrollTop) > calculatedItemHeight) {
249
- requestAnimationFrame(() => {
250
- if (viewportElement) {
251
- viewportElement.scrollTop = targetScrollTop
252
- scrollTop = targetScrollTop
412
+ heightManager.updateItemLength(items.length)
413
+ })
414
+
415
+ const updateHeight = () => {
416
+ heightUpdateTimeout = calculateAverageHeightDebounced(
417
+ isCalculatingHeight,
418
+ heightUpdateTimeout,
419
+ visibleItems,
420
+ itemElements,
421
+ heightCache,
422
+ lastMeasuredIndex,
423
+ heightManager.averageHeight,
424
+ (result) => {
425
+ // Critical updates that must trigger reactive effects immediately
426
+ heightManager.itemHeight = result.newHeight
427
+ lastMeasuredIndex = result.newLastMeasuredIndex
428
+ heightCache = result.updatedHeightCache
429
+
430
+ // Handle height changes for scroll correction (needs updated heightCache)
431
+ if (result.heightChanges.length > 0 && mode === 'bottomToTop') {
432
+ handleHeightChangesScrollCorrection(result.heightChanges)
433
+ }
434
+
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)
253
440
  }
441
+
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
254
448
  })
449
+ endDynamicUpdate()
450
+ },
451
+ 100, // debounceTime
452
+ 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
455
+ mode // Pass mode for correct element indexing
456
+ )
457
+ }
458
+
459
+ // Add new effect to handle height changes
460
+ // Track if user has scrolled away from bottom to prevent snap-back
461
+ let userHasScrolledAway = $state(false)
462
+ let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
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)
506
+
507
+ $effect(() => {
508
+ if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
509
+ const targetScrollTop = Math.max(0, totalHeight() - height)
510
+ const currentScrollTop = viewportElement.scrollTop
511
+ const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
512
+
513
+ // Only correct scroll if:
514
+ // 1. Item height changed significantly (not just user scrolling)
515
+ // 2. User hasn't intentionally scrolled away from bottom
516
+ // 3. We're significantly off target
517
+ // 4. We're not at the bottom (where height changes should be handled more carefully)
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
523
+ const shouldCorrect =
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
531
+
532
+ if (shouldCorrect) {
533
+ // Round to avoid subpixel positioning issues in bottomToTop mode
534
+ const roundedTargetScrollTop = Math.round(targetScrollTop)
535
+ viewportElement.scrollTop = roundedTargetScrollTop
536
+ scrollTop = roundedTargetScrollTop
537
+ }
538
+
539
+ // Track if user has scrolled significantly away from bottom
540
+ if (scrollDifference > calculatedItemHeight * 5) {
541
+ userHasScrolledAway = true
542
+ }
543
+
544
+ lastCalculatedHeight = calculatedItemHeight
545
+ }
546
+ })
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
+ }
255
592
  }
256
593
  }
594
+
595
+ lastItemsLength = currentItemsLength
257
596
  })
258
597
 
259
598
  // Update container height when element is mounted
@@ -273,8 +612,7 @@
273
612
  items.length &&
274
613
  !initialized
275
614
  ) {
276
- const totalHeight = Math.max(0, items.length * calculatedItemHeight)
277
- const targetScrollTop = Math.max(0, totalHeight - height)
615
+ const targetScrollTop = Math.max(0, totalHeight() - height)
278
616
 
279
617
  // Add delay to ensure layout is complete
280
618
  tick().then(() => {
@@ -313,20 +651,49 @@
313
651
  * console.log(`Rendering items from ${range.start} to ${range.end}`)
314
652
  * ```
315
653
  *
316
- * @returns {{ start: number, end: number }} Object containing start and end indices of visible items
654
+ * @returns {SvelteVirtualListPreviousVisibleRange} Object containing start and end indices of visible items
317
655
  */
318
- const visibleItems = $derived(() => {
319
- if (!items.length) return { start: 0, end: 0 }
656
+ const visibleItems = $derived((): SvelteVirtualListPreviousVisibleRange => {
657
+ if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
320
658
  const viewportHeight = height || 0
321
659
 
322
- return calculateVisibleRange(
660
+ // For bottomToTop mode, don't calculate visible range until properly initialized
661
+ // This prevents showing wrong items when scrollTop starts at 0
662
+ if (mode === 'bottomToTop' && !initialized && scrollTop === 0 && viewportHeight > 0) {
663
+ // Calculate what the correct scroll position should be
664
+ const targetScrollTop = Math.max(0, totalHeight() - viewportHeight)
665
+
666
+ // Use the target scroll position for visible range calculation
667
+ lastVisibleRange = calculateVisibleRange(
668
+ targetScrollTop,
669
+ viewportHeight,
670
+ heightManager.averageHeight,
671
+ items.length,
672
+ bufferSize,
673
+ mode,
674
+ atBottom,
675
+ wasAtBottomBeforeHeightChange,
676
+ lastVisibleRange,
677
+ totalHeight()
678
+ )
679
+
680
+ return lastVisibleRange
681
+ }
682
+
683
+ lastVisibleRange = calculateVisibleRange(
323
684
  scrollTop,
324
685
  viewportHeight,
325
- calculatedItemHeight,
686
+ heightManager.averageHeight,
326
687
  items.length,
327
688
  bufferSize,
328
- mode
689
+ mode,
690
+ atBottom,
691
+ wasAtBottomBeforeHeightChange,
692
+ lastVisibleRange,
693
+ totalHeight()
329
694
  )
695
+
696
+ return lastVisibleRange
330
697
  })
331
698
 
332
699
  /**
@@ -356,7 +723,16 @@
356
723
  if (!isScrolling) {
357
724
  isScrolling = true
358
725
  rafSchedule(() => {
359
- 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
360
736
  isScrolling = false
361
737
  })
362
738
  }
@@ -435,52 +811,51 @@
435
811
  )
436
812
  }
437
813
 
438
- /**
439
- * Initializes large datasets in chunks to prevent UI blocking.
440
- *
441
- * This function processes items in smaller chunks using setTimeout to yield
442
- * to the main thread, allowing other UI operations to remain responsive.
443
- * Progress is tracked and reported through the processedItems state.
444
- *
445
- * For datasets larger than 1000 items, this method is automatically used
446
- * instead of immediate initialization. The chunk size is controlled by the
447
- * component's chunkSize state (default: 50).
448
- *
449
- * @async
450
- * @example
451
- * ```typescript
452
- * // Component initialization
453
- * $effect(() => {
454
- * if (BROWSER && items.length > 1000) {
455
- * initializeChunked()
456
- * } else {
457
- * initialized = true
458
- * }
459
- * })
460
- * ```
461
- *
462
- * @throws {Error} If processChunked fails to complete initialization
463
- * @returns {Promise<void>} Resolves when all chunks have been processed
464
- */
465
- const initializeChunked = async () => {
466
- if (!items.length) return
814
+ // Create itemResizeObserver immediately when in browser
815
+ if (BROWSER) {
816
+ // Watch for individual item size changes
817
+ itemResizeObserver = new ResizeObserver((entries) => {
818
+ tick().then(() => {
819
+ let shouldRecalculate = false
820
+ const visibleRange = visibleItems() // Cache once to avoid reactive loops
467
821
 
468
- await processChunked(
469
- items,
470
- chunkSize,
471
- (processed) => (processedItems = processed),
472
- () => (initialized = true)
473
- )
474
- }
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)
475
826
 
476
- // Modify the mount effect to use chunked initialization
477
- $effect(() => {
478
- if (BROWSER && items.length > 1000) {
479
- initializeChunked()
480
- } else {
481
- initialized = true
482
- }
483
- })
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
+ )
835
+
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
+ }
842
+
843
+ dirtyItems.add(actualIndex)
844
+ dirtyItemsCount = dirtyItems.size
845
+ shouldRecalculate = true
846
+ }
847
+ }
848
+ }
849
+ }
850
+
851
+ if (shouldRecalculate) {
852
+ rafSchedule(() => {
853
+ updateHeight()
854
+ })
855
+ }
856
+ })
857
+ })
858
+ }
484
859
 
485
860
  // Setup and cleanup
486
861
  onMount(() => {
@@ -502,13 +877,16 @@
502
877
  if (resizeObserver) {
503
878
  resizeObserver.disconnect()
504
879
  }
880
+ if (itemResizeObserver) {
881
+ itemResizeObserver.disconnect()
882
+ }
505
883
  }
506
884
  }
507
885
  })
508
886
 
509
887
  // Add the effect in the script section
510
888
  $effect(() => {
511
- if (debug) {
889
+ if (INTERNAL_DEBUG) {
512
890
  prevVisibleRange = visibleItems()
513
891
  prevHeight = calculatedItemHeight
514
892
  }
@@ -582,10 +960,10 @@
582
960
  * {/snippet}
583
961
  * </SvelteVirtualList>
584
962
  *
585
- * @returns {void}
963
+ * @returns {Promise<void>} Promise that resolves when scrolling is complete
586
964
  * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
587
965
  */
588
- export const scroll = (options: SvelteVirtualListScrollOptions): void => {
966
+ export const scroll = async (options: SvelteVirtualListScrollOptions): Promise<void> => {
589
967
  const { index, smoothScroll, shouldThrowOnBounds, align } = {
590
968
  ...DEFAULT_SCROLL_OPTIONS,
591
969
  ...options
@@ -613,73 +991,92 @@
613
991
  }
614
992
 
615
993
  const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems()
616
- let scrollTarget: number | null = null
617
-
618
- if (mode === 'bottomToTop') {
619
- const totalHeight = items.length * calculatedItemHeight
620
- const itemOffset = targetIndex * calculatedItemHeight
621
- const itemHeight = calculatedItemHeight
622
- if (align === 'auto') {
623
- if (targetIndex < firstVisibleIndex) {
624
- // Align to top
625
- scrollTarget = Math.max(0, totalHeight - (itemOffset + itemHeight))
626
- } else if (targetIndex > lastVisibleIndex - 1) {
627
- // Align to bottom
628
- scrollTarget = Math.max(0, totalHeight - itemOffset - height)
629
- } else {
630
- // Already in view, do nothing
631
- return
632
- }
633
- } else if (align === 'top') {
634
- // Align to top
635
- scrollTarget = Math.max(0, totalHeight - (itemOffset + itemHeight))
636
- } else if (align === 'bottom') {
637
- // Align to bottom
638
- scrollTarget = Math.max(0, totalHeight - itemOffset - height)
639
- }
640
- } else {
641
- // topToBottom (default)
642
- if (align === 'auto') {
643
- if (targetIndex < firstVisibleIndex) {
644
- // Scroll so item is at the top
645
- scrollTarget = getScrollOffsetForIndex(
646
- heightCache,
647
- calculatedItemHeight,
648
- targetIndex
649
- )
650
- } else if (targetIndex > lastVisibleIndex - 1) {
651
- // Scroll so item is at the bottom
652
- const itemBottom = getScrollOffsetForIndex(
653
- heightCache,
654
- calculatedItemHeight,
655
- targetIndex + 1
656
- )
657
- scrollTarget = Math.max(0, itemBottom - height)
658
- } else {
659
- // Already in view, do nothing
660
- return
994
+
995
+ // Use extracted scroll calculation utility
996
+ const scrollTarget = calculateScrollTarget({
997
+ mode,
998
+ align: align || 'auto',
999
+ targetIndex,
1000
+ itemsLength: items.length,
1001
+ calculatedItemHeight: heightManager.averageHeight, // Use dynamic average from ReactiveHeightManager
1002
+ height,
1003
+ scrollTop,
1004
+ firstVisibleIndex,
1005
+ lastVisibleIndex,
1006
+ heightCache
1007
+ })
1008
+
1009
+ // Handle early return for 'nearest' alignment when item is already visible
1010
+ if (scrollTarget === null) {
1011
+ return
1012
+ }
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
661
1032
  }
662
- } else if (align === 'top') {
663
- scrollTarget = getScrollOffsetForIndex(
664
- heightCache,
665
- calculatedItemHeight,
666
- targetIndex
667
- )
668
- } else if (align === 'bottom') {
669
- const itemBottom = getScrollOffsetForIndex(
670
- heightCache,
671
- calculatedItemHeight,
672
- targetIndex + 1
673
- )
674
- scrollTarget = Math.max(0, itemBottom - height)
675
1033
  }
676
- }
677
1034
 
678
- if (scrollTarget !== null) {
679
- viewportElement.scrollTo({
680
- top: scrollTarget,
681
- behavior: smoothScroll ? 'smooth' : 'auto'
1035
+ maxElement?.scrollIntoView({
1036
+ behavior: 'smooth'
682
1037
  })
1038
+ await tick()
1039
+ await new Promise((resolve) => setTimeout(resolve, 100))
1040
+ await tick()
1041
+ }
1042
+
1043
+ viewportElement.scrollTo({
1044
+ top: scrollTarget,
1045
+ behavior: smoothScroll ? 'smooth' : 'auto'
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
+ )
1060
+ }
1061
+
1062
+ /**
1063
+ * Custom Svelte action to automatically observe item elements for size changes.
1064
+ * This action is applied to each item element to detect when its dimensions change.
1065
+ *
1066
+ * @param element - The HTML element to observe
1067
+ * @returns {{ destroy: () => void }} Object with destroy method for cleanup
1068
+ */
1069
+ function autoObserveItemResize(element: HTMLElement) {
1070
+ if (itemResizeObserver) {
1071
+ itemResizeObserver.observe(element)
1072
+ }
1073
+
1074
+ return {
1075
+ destroy() {
1076
+ if (itemResizeObserver) {
1077
+ itemResizeObserver.unobserve(element)
1078
+ }
1079
+ }
683
1080
  }
684
1081
  }
685
1082
  </script>
@@ -710,43 +1107,80 @@
710
1107
  id="virtual-list-content"
711
1108
  {...testId ? { 'data-testid': `${testId}-content` } : {}}
712
1109
  class={contentClass ?? 'virtual-list-content'}
713
- style:height="{Math.max(height, items.length * calculatedItemHeight)}px"
1110
+ style:height="{(() => {
1111
+ // Use ReactiveHeightManager's accurate total height for better cross-browser compatibility
1112
+ return Math.max(height, totalHeight())
1113
+ })()}px"
714
1114
  >
715
1115
  <!-- Items container is translated to show correct items -->
716
1116
  <div
717
1117
  id="virtual-list-items"
718
1118
  {...testId ? { 'data-testid': `${testId}-items` } : {}}
719
1119
  class={itemsClass ?? 'virtual-list-items'}
720
- style:transform="translateY({calculateTransformY(
721
- mode,
722
- items.length,
723
- visibleItems().end,
724
- visibleItems().start,
725
- calculatedItemHeight
726
- )}px)"
1120
+ style:visibility={height === 0 && mode === 'bottomToTop' ? 'hidden' : 'visible'}
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
+
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
1144
+ )
1145
+
1146
+ return transform
1147
+ })()}px)"
727
1148
  >
728
- {#each mode === 'bottomToTop' ? items
729
- .slice(visibleItems().start, visibleItems().end)
730
- .reverse() : items.slice(visibleItems().start, visibleItems().end) as currentItem, i (currentItem?.id ?? i)}
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)}
731
1160
  <!-- Only debug when visible range or average height changes -->
732
1161
  {#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, calculatedItemHeight)}
733
1162
  {@const debugInfo = createDebugInfo(
734
1163
  visibleItems(),
735
1164
  items.length,
736
- processedItems,
737
- calculatedItemHeight
1165
+ Object.keys(heightCache).length,
1166
+ calculatedItemHeight,
1167
+ scrollTop,
1168
+ height || 0,
1169
+ totalHeight()
738
1170
  )}
739
1171
  {debugFunction
740
1172
  ? debugFunction(debugInfo)
741
1173
  : console.info('Virtual List Debug:', debugInfo)}
742
1174
  {/if}
743
1175
  <!-- Render each visible item -->
744
- <div bind:this={itemElements[i]}>
1176
+ <div
1177
+ bind:this={itemElements[currentItemWithIndex.sliceIndex]}
1178
+ use:autoObserveItemResize
1179
+ data-original-index={currentItemWithIndex.originalIndex}
1180
+ >
745
1181
  {@render renderItem(
746
- currentItem,
747
- mode === 'bottomToTop'
748
- ? items.length - (visibleItems().start + i) - 1
749
- : visibleItems().start + i
1182
+ currentItemWithIndex.item,
1183
+ currentItemWithIndex.originalIndex
750
1184
  )}
751
1185
  </div>
752
1186
  {/each}
@@ -789,4 +1223,10 @@
789
1223
  left: 0;
790
1224
  top: 0;
791
1225
  }
1226
+
1227
+ /* Item wrapper divs should size to their content */
1228
+ .virtual-list-items > div {
1229
+ width: 100%;
1230
+ display: block;
1231
+ }
792
1232
  </style>