@humanspeak/svelte-virtual-list 0.4.5 → 0.5.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.
@@ -2,14 +2,13 @@
2
2
  @component SvelteVirtualList
3
3
 
4
4
  A high-performance, memory-efficient virtualized list component for Svelte 5.
5
- Renders only visible items plus a buffer, supporting dynamic item heights,
6
- bi-directional (top-to-bottom and bottom-to-top) scrolling, and programmatic control.
5
+ Renders only visible items plus a buffer, supporting dynamic item heights
6
+ and programmatic control.
7
7
 
8
8
  =============================
9
9
  == Key Features ==
10
10
  =============================
11
11
  - Dynamic item height support (no fixed height required)
12
- - Top-to-bottom and bottom-to-top (chat-style) scrolling
13
12
  - Programmatic scrolling with flexible alignment (top, bottom, auto)
14
13
  - Smooth scrolling and buffer size configuration
15
14
  - SSR compatible and hydration-friendly
@@ -25,7 +24,6 @@
25
24
  ```svelte
26
25
  <SvelteVirtualList
27
26
  items={data}
28
- mode="bottomToTop"
29
27
  bind:this={listRef}
30
28
  >
31
29
  {#snippet renderItem(item)}
@@ -43,7 +41,6 @@
43
41
  - Handles resize events and dynamic content changes
44
42
  - Optimized for very large lists through virtualization
45
43
  - Modular architecture with extracted utility functions
46
- - Bi-directional support: mode="topToBottom" or "bottomToTop"
47
44
  - Designed for extensibility and easy debugging
48
45
 
49
46
  =============================
@@ -76,49 +73,44 @@
76
73
  * - Implemented debounced measurements
77
74
  * - Created height averaging mechanism for performance
78
75
  *
79
- * 3. Bidirectional Scrolling
80
- * - Added bottomToTop mode
81
- * - Solved complex initialization issues with flexbox
82
- * - Implemented careful scroll position management
83
- *
84
- * 4. Performance Optimizations ✓
76
+ * 3. Performance Optimizations
85
77
  * - Added element recycling through keyed each blocks
86
78
  * - Implemented RAF for smooth animations
87
79
  * - Optimized DOM updates with transform translations
88
80
  *
89
- * 5. Stability Improvements ✓
81
+ * 4. Stability Improvements ✓
90
82
  * - Added ResizeObserver for responsive updates
91
83
  * - Implemented proper cleanup on component destruction
92
84
  * - Added debug mode for development assistance
93
85
  *
94
- * 6. Large Dataset Optimizations ✓
86
+ * 5. Large Dataset Optimizations ✓
95
87
  * - Implemented chunked processing for 10k+ items
96
88
  * - Added progressive initialization system
97
89
  * - Deferred height calculations for better initial load
98
90
  * - Optimized memory usage for large lists
99
91
  * - Added progress tracking for initialization
100
92
  *
101
- * 7. Size Management Improvements ✓
93
+ * 6. Size Management Improvements ✓
102
94
  * - Implemented height caching system for measured items
103
95
  * - Added smart height estimation for unmeasured items
104
96
  * - Optimized resize handling with debouncing
105
97
  * - Added height recalculation on content changes
106
98
  * - Implemented progressive height adjustments
107
99
  *
108
- * 8. Code Quality & Maintainability ✓
100
+ * 7. Code Quality & Maintainability ✓
109
101
  * - Extracted debug utilities for better testing
110
102
  * - Improved type safety throughout
111
103
  * - Added comprehensive documentation
112
104
  * - Optimized debug output to reduce noise
113
105
  *
114
- * 9. Architecture Refactoring ✓
106
+ * 8. Architecture Refactoring ✓
115
107
  * - Extracted scroll calculation logic to scrollCalculation.ts utility
116
108
  * - Extracted ResizeObserver utilities to resizeObserver.ts
117
109
  * - Added comprehensive test coverage for extracted utilities
118
110
  * - Improved separation of concerns and maintainability
119
111
  * - Simplified initialization (removed unnecessary chunked processing)
120
112
  *
121
- * 10. Future Improvements (Planned)
113
+ * 9. Future Improvements (Planned)
122
114
  * - Add horizontal scrolling support
123
115
  * - Implement variable-sized item caching
124
116
  * - Add keyboard navigation support
@@ -126,7 +118,6 @@
126
118
  * - Add accessibility enhancements
127
119
  *
128
120
  * Technical Challenges Solved:
129
- * - Bottom-to-top scrolling in flexbox layouts
130
121
  * - Dynamic height calculations without layout thrashing
131
122
  * - Smooth scrolling on various devices
132
123
  * - Memory management for large lists
@@ -166,9 +157,7 @@
166
157
  calculateTransformY,
167
158
  calculateVisibleRange,
168
159
  clampValue,
169
- updateHeightAndScroll as utilsUpdateHeightAndScroll,
170
- getScrollOffsetForIndex,
171
- buildBlockSums
160
+ updateHeightAndScroll as utilsUpdateHeightAndScroll
172
161
  } from './utils/virtualList.js'
173
162
  import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
174
163
  import { calculateScrollTarget } from './utils/scrollCalculation.js'
@@ -179,11 +168,7 @@
179
168
 
180
169
  const rafSchedule = createRafScheduler()
181
170
  // Timing constants
182
- const GLOBAL_CORRECTION_COOLDOWN_MS = 16
183
- const SCROLL_IDLE_DELAY_MS = 250
184
- const SUPPRESSION_WINDOW_MS = 450
185
171
  const HEIGHT_DEBOUNCE_MS = 100
186
- const lastCorrectionTimestampByViewport = new WeakMap<HTMLElement, number>()
187
172
  // Package-specific debug flag - safe for library distribution
188
173
  // Enable with: PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG=true (preferred) or SVELTE_VIRTUAL_LIST_DEBUG=true
189
174
  // Avoid SvelteKit-only $env imports so library works in non-Kit/Vitest contexts
@@ -192,20 +177,6 @@
192
177
  (process?.env?.PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG === 'true' ||
193
178
  process?.env?.SVELTE_VIRTUAL_LIST_DEBUG === 'true')
194
179
  )
195
- // Feature flags - default off; enable via env for incremental rollout
196
- const anchorModeEnabled = Boolean(
197
- typeof process !== 'undefined' &&
198
- (process?.env?.PUBLIC_SVL_ANCHOR_MODE === 'true' ||
199
- process?.env?.SVL_ANCHOR_MODE === 'true')
200
- )
201
- const idleCorrectionsOnly = Boolean(
202
- typeof process !== 'undefined' &&
203
- (process?.env?.PUBLIC_SVL_IDLE_ONLY === 'true' || process?.env?.SVL_IDLE_ONLY === 'true')
204
- )
205
- const batchUpdatesEnabled = Boolean(
206
- typeof process !== 'undefined' &&
207
- (process?.env?.PUBLIC_SVL_BATCH === 'true' || process?.env?.SVL_BATCH === 'true')
208
- )
209
180
  /**
210
181
  * Core configuration props with default values
211
182
  * @type {SvelteVirtualListProps<TItem>}
@@ -220,7 +191,6 @@
220
191
  contentClass, // Custom class for the content wrapper
221
192
  itemsClass, // Custom class for the items wrapper
222
193
  debugFunction, // Custom debug logging function
223
- mode = 'topToBottom', // Scroll direction mode
224
194
  bufferSize = 20, // Number of items to render outside visible area
225
195
  testId, // Base test ID for component elements (undefined = no data-testid attributes)
226
196
  onLoadMore, // Callback when more data needed (supports sync and async)
@@ -244,102 +214,7 @@
244
214
 
245
215
  const isCalculatingHeight = $state(false) // Prevents concurrent height calculations
246
216
  let isLoadingMore = $state(false) // Prevents concurrent onLoadMore calls
247
- let isScrolling = $state(false) // Tracks active scrolling state
248
- let scrollIdleTimer: number | null = null
249
- // Anchor state (read-only capture; used when anchorModeEnabled)
250
- let lastAnchorIndex = $state(0)
251
- let lastAnchorOffset = $state(0) // offset within anchored item (px)
252
- let pendingAnchorReconcile = $state(false)
253
- let batchDepth = $state(0)
254
-
255
- const captureAnchor = () => {
256
- if (!heightManager.viewportElement) return
257
- const vr = visibleItems
258
- const anchorIndex = Math.max(0, vr.start)
259
- const cache = heightManager.getHeightCache()
260
- const est = heightManager.averageHeight
261
- const maxScrollTop = Math.max(0, totalHeight - (height || 0))
262
- // Offset from start to anchored item
263
- const blockSums = buildBlockSums(cache, est, items.length)
264
- const offsetToIndex = getScrollOffsetForIndex(cache, est, anchorIndex, blockSums)
265
- const currentTop = heightManager.viewport.scrollTop
266
- let offsetWithin: number
267
- if (mode === 'bottomToTop') {
268
- // Convert distance-from-end to distance-from-start
269
- const distanceFromStart = maxScrollTop - currentTop
270
- offsetWithin = distanceFromStart - offsetToIndex
271
- } else {
272
- offsetWithin = currentTop - offsetToIndex
273
- }
274
- lastAnchorIndex = anchorIndex
275
- lastAnchorOffset = Math.max(0, Math.round(offsetWithin))
276
- // Expose for tests
277
- ;(heightManager.viewport as unknown as Record<string, unknown>).__svlAnchor = {
278
- index: lastAnchorIndex,
279
- offset: lastAnchorOffset
280
- }
281
- pendingAnchorReconcile = true
282
- }
283
-
284
- const reconcileToAnchorIfEnabled = () => {
285
- if (!anchorModeEnabled || !heightManager.viewportElement) return
286
- if (!pendingAnchorReconcile) return
287
- const cache = heightManager.getHeightCache()
288
- const est = heightManager.averageHeight
289
- const blockSums = buildBlockSums(cache, est, items.length)
290
- const offsetToIndex = getScrollOffsetForIndex(
291
- cache,
292
- est,
293
- Math.max(0, lastAnchorIndex),
294
- blockSums
295
- )
296
- const maxScrollTop = clampValue(totalHeight - (height || 0), 0, Infinity)
297
- let targetTop: number
298
- if (mode === 'bottomToTop') {
299
- const distanceFromStart = clampValue(offsetToIndex + lastAnchorOffset, 0, Infinity)
300
- targetTop = clampValue(Math.round(maxScrollTop - distanceFromStart), 0, maxScrollTop)
301
- } else {
302
- targetTop = clampValue(Math.round(offsetToIndex + lastAnchorOffset), 0, maxScrollTop)
303
- }
304
- if (Math.abs(heightManager.viewport.scrollTop - targetTop) >= 2) {
305
- syncScrollTop(targetTop)
306
- }
307
- pendingAnchorReconcile = false
308
- }
309
-
310
- /**
311
- * Runs a batch of updates with scroll corrections coalesced until the batch completes.
312
- *
313
- * Use this method when making multiple changes to the items array to prevent
314
- * intermediate scroll corrections. The scroll position reconciliation is deferred
315
- * until the batch exits, ensuring smooth visual updates.
316
- *
317
- * @param {() => void} fn - The function containing batch updates to execute.
318
- * @returns {void}
319
- *
320
- * @example
321
- * ```typescript
322
- * // Add multiple items without intermediate scroll corrections
323
- * list.runInBatch(() => {
324
- * items.push(newItem1);
325
- * items.push(newItem2);
326
- * items.push(newItem3);
327
- * });
328
- * ```
329
- */
330
- export const runInBatch = (fn: () => void): void => {
331
- batchDepth += 1
332
- try {
333
- fn()
334
- } finally {
335
- batchDepth = Math.max(0, batchDepth - 1)
336
- if (batchUpdatesEnabled && batchDepth === 0) {
337
- reconcileToAnchorIfEnabled()
338
- }
339
- }
340
- }
341
217
  let lastMeasuredIndex = $state(-1) // Index of last measured item
342
- let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
343
218
 
344
219
  /**
345
220
  * Timers and Observers
@@ -353,15 +228,12 @@
353
228
  */
354
229
  const dirtyItems = $state(new Set<number>()) // Set of item indices that need height recalculation
355
230
  let dirtyItemsCount = $state(0) // Reactive count of dirty items
356
- // Fallback measurement used only when height has not been established yet
357
- let measuredFallbackHeight = $state(0)
358
231
  // Scroll delta threshold optimization - track last scroll position used for range calculation
359
232
  let lastProcessedScrollTop = $state(0)
360
233
 
361
234
  let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
362
235
  let prevHeight = $state<number>(0)
363
236
  let prevTotalHeightForScrollCorrection = $state<number>(0)
364
- let lastBottomDistance = $state<number | null>(null)
365
237
 
366
238
  /**
367
239
  * Reactive Height Manager - O(1) height calculation system
@@ -402,187 +274,11 @@
402
274
  heightManager.scrollTop = scrollValue
403
275
  }
404
276
 
405
- // Dynamic update coordination to avoid UA scroll anchoring interference
406
- let suppressBottomAnchoringUntilMs = $state(0)
407
-
408
- /**
409
- * Handles scroll position corrections when item heights change, ensuring proper positioning
410
- * relative to the user's scroll context. This function calculates the cumulative impact of
411
- * height changes above the current viewport and adjusts the scroll position accordingly.
412
- *
413
- * The correction logic considers:
414
- * - Height changes occurring above the visible area (which would shift content)
415
- * - The current scroll position and visible range
416
- * - Whether height changes warrant a scroll adjustment
417
- *
418
- * This prevents jarring jumps when items resize, maintaining the user's visual context
419
- * and where they are positioned relative to the current scroll position.
420
- */
421
- const handleHeightChangesScrollCorrection = (
422
- heightChanges: Array<{ index: number; oldHeight: number; newHeight: number; delta: number }>
423
- ) => {
424
- if (!heightManager.viewportElement || !heightManager.initialized || userHasScrolledAway) {
425
- return
426
- }
427
- // Coalesce adjustments during active scroll; apply on idle
428
- if (isScrolling) {
429
- // Accumulate net change above viewport and defer application
430
- let pending = 0
431
- const currentVisibleRange = visibleItems
432
- for (const change of heightChanges) {
433
- if (change.index < currentVisibleRange.start) pending += change.delta
434
- }
435
- if (pending !== 0) {
436
- // Store on the viewport element to avoid extra module globals
437
- const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
438
- const prev = (heightManager.viewport as unknown as Record<string, number>)[
439
- key as string
440
- ] as number | undefined
441
- ;(heightManager.viewport as unknown as Record<string, number>)[key as string] =
442
- (prev ?? 0) + pending
443
- }
444
- return
445
- }
446
-
447
- /**
448
- * CRITICAL: BottomToTop Mode Height Change Fix
449
- * ============================================
450
- *
451
- * Problem: In bottomToTop mode, when items change height while user is at bottom,
452
- * the list would jump to middle positions (e.g. items 1032-1096) instead of
453
- * staying anchored at bottom showing Item 0.
454
- *
455
- * Root Cause: Height calculations using simple averages (items.length * calculatedItemHeight)
456
- * were drastically skewed by single item changes. Example:
457
- * - 1 item changes from 20px to 100px (+80px actual change)
458
- * - Average jumps from 20px to 22.35px (+2.35px per item)
459
- * - Across 10,000 items: 2.35px × 10,000 = 23,500px total height error!
460
- * - This caused massive scroll position overshoots and incorrect positioning
461
- *
462
- * Solution: Two-step native scrollIntoView approach
463
- * 1. Fixed skewed height calculations using actual heightCache measurements (see totalHeight)
464
- * 2. When wasAtBottomBeforeHeightChange=true (captured before any height processing):
465
- * a) First scroll to approximate bottom position to render Item 0 in virtual viewport
466
- * b) Use native scrollIntoView() with block:'end' for precise bottom alignment
467
- *
468
- * Why This Works:
469
- * - Uses browser's native scroll logic instead of error-prone manual calculations
470
- * - Two-step ensures Item 0 exists in DOM before attempting to scroll to it
471
- * - Native scrollIntoView handles all edge cases (subpixel precision, browser differences)
472
- * - Eliminates complex math that was accumulating rounding errors
473
- * - Smooth behavior provides better UX than instant jumps
474
- *
475
- * Dependencies:
476
- * - wasAtBottomBeforeHeightChange: Set to true when first item marked dirty, prevents cascading corrections
477
- * - totalHeight: Uses actual heightCache measurements instead of skewed averages
478
- * - Aggressive scroll correction: Blocked when wasAtBottomBeforeHeightChange=true
479
- *
480
- * ⚠️ DO NOT MODIFY WITHOUT EXTENSIVE TESTING ⚠️
481
- * This fix resolves a complex interaction between:
482
- * - Virtual list rendering (only ~20 items visible, rest virtualized)
483
- * - Height change calculations (prone to average skewing with large datasets)
484
- * - Multiple scroll correction mechanisms (specific vs aggressive)
485
- * - Bottom anchor positioning in reversed list mode (bottomToTop)
486
- *
487
- * Test coverage: tests/bottomToTop/firstItemHeightChange.spec.ts (45 comprehensive tests)
488
- * Related fixes: See aggressive scroll correction logic ~line 410 with !wasAtBottomBeforeHeightChange
489
- */
490
- if (
491
- mode === 'bottomToTop' &&
492
- wasAtBottomBeforeHeightChange &&
493
- !programmaticScrollInProgress &&
494
- performance.now() >= suppressBottomAnchoringUntilMs
495
- ) {
496
- // Prevent same-frame corrections; defer if this viewport just corrected
497
- const now = performance.now()
498
- const viewportEl = heightManager.viewport
499
- const lastCorrectionMs = lastCorrectionTimestampByViewport.get(viewportEl) ?? 0
500
- if (now - lastCorrectionMs < GLOBAL_CORRECTION_COOLDOWN_MS) {
501
- suppressBottomAnchoringUntilMs = now + 50
502
- return
503
- }
504
- lastCorrectionTimestampByViewport.set(viewportEl, now)
505
-
506
- // Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
507
- const approximateScrollTop = Math.max(0, totalHeight - height)
508
- log('[SVL] b2t-correction-approx', { approximateScrollTop })
509
- syncScrollTop(approximateScrollTop)
510
-
511
- // Step 2: Use native scrollIntoView for precise bottom-edge positioning after DOM updates
512
- tick().then(() => {
513
- const item0Element = heightManager.viewport.querySelector(
514
- '[data-original-index="0"]'
515
- )
516
- if (item0Element) {
517
- // Verify alignment via rects; if off, perform one-time scrollIntoView
518
- const contRect = heightManager.viewport.getBoundingClientRect()
519
- const itemRect = (item0Element as HTMLElement).getBoundingClientRect()
520
- const tol = 4
521
- const aligned =
522
- Math.abs(contRect.y + contRect.height - (itemRect.y + itemRect.height)) <=
523
- tol
524
- if (!aligned) {
525
- // Use manual scrollTop instead of scrollIntoView to prevent parent scroll
526
- // (scrollIntoView scrolls all ancestor containers, not just the viewport)
527
- // Note: `container: 'nearest'` option could replace this once browser support improves
528
- const currentScrollTop = heightManager.viewport.scrollTop
529
- const offset = itemRect.bottom - contRect.bottom
530
- syncScrollTop(currentScrollTop + offset)
531
- log('[SVL] b2t-correction-manual', { offset })
532
- } else {
533
- // Sync our internal scroll state with actual DOM position
534
- heightManager.scrollTop = heightManager.viewport.scrollTop
535
- }
536
- // After peer correction, delay further corrections briefly
537
- suppressBottomAnchoringUntilMs = performance.now() + 200
538
- }
539
- })
540
-
541
- return // Skip remaining scroll correction logic - we've handled bottomToTop case
542
- }
543
-
544
- const currentScrollTop = heightManager.viewport.scrollTop
545
- const maxScrollTop = Math.max(0, totalHeight - height)
546
-
547
- // Calculate total height change impact above current visible area
548
- let heightChangeAboveViewport = 0
549
- const currentVisibleRange = visibleItems
550
-
551
- for (const change of heightChanges) {
552
- // Only consider items that are above the current visible range
553
- if (change.index < currentVisibleRange.start) {
554
- heightChangeAboveViewport += change.delta
555
- }
556
- }
557
-
558
- // If there are height changes above the viewport, adjust scroll to maintain position
559
- // Include any pending coalesced delta (when scrolling)
560
- {
561
- const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
562
- const pending =
563
- (heightManager.viewport as unknown as Record<string, number>)[key as string] ?? 0
564
- if (pending) {
565
- heightChangeAboveViewport += pending
566
- ;(heightManager.viewport as unknown as Record<string, number>)[key as string] = 0
567
- }
568
- }
569
- if (Math.abs(heightChangeAboveViewport) > 2) {
570
- const newScrollTop = clampValue(
571
- currentScrollTop + heightChangeAboveViewport,
572
- 0,
573
- maxScrollTop
574
- )
575
- syncScrollTop(newScrollTop)
576
- }
577
- }
578
-
579
277
  // Height update function - removed throttling to fix race condition on initial load
580
278
  // Create throttled height update function with trailing execution to ensure measurement always happens
581
279
  const triggerHeightUpdate = createAdvancedThrottledCallback(
582
280
  () => {
583
281
  if (BROWSER && dirtyItemsCount > 0) {
584
- // Capture bottom state before any height processing to prevent cascading corrections
585
- wasAtBottomBeforeHeightChange = atBottom
586
282
  heightManager.startDynamicUpdate()
587
283
  updateHeight()
588
284
  }
@@ -602,14 +298,11 @@
602
298
  // Keep height manager synchronized with items length
603
299
  $effect(() => {
604
300
  heightManager.updateItemLength(items.length)
605
- stabilizedContentHeight = 0
606
301
  })
607
302
 
608
303
  // Infinite scroll: trigger onLoadMore when approaching end of list
609
304
  $effect(() => {
610
305
  if (!BROWSER || !onLoadMore || !hasMore || isLoadingMore) return
611
- // Skip loading during bottomToTop initialization (init path renders all items artificially)
612
- if (mode === 'bottomToTop' && !bottomToTopScrollComplete) return
613
306
 
614
307
  const range = visibleItems
615
308
  const atLoadingEdge = range.end >= items.length - loadMoreThreshold
@@ -624,7 +317,7 @@
624
317
  })
625
318
 
626
319
  const updateHeight = () => {
627
- // Capture previous total height for scroll correction (topToBottom anchoring)
320
+ // Capture previous total height for scroll correction.
628
321
  prevTotalHeightForScrollCorrection = heightManager.totalHeight
629
322
  heightUpdateTimeout = calculateAverageHeightDebounced(
630
323
  isCalculatingHeight,
@@ -651,15 +344,8 @@
651
344
  heightManager.processDirtyHeights(result.heightChanges)
652
345
  }
653
346
 
654
- // Handle height changes for scroll correction (manager totals already updated)
655
- if (result.heightChanges.length > 0 && mode === 'bottomToTop') {
656
- // Run correction after dynamic update finishes to avoid blocking conditions
657
- const changes = result.heightChanges
658
- queueMicrotask(() => handleHeightChangesScrollCorrection(changes))
659
- }
660
-
661
- // TopToBottom: maintain bottom anchoring when total height changes
662
- if (mode === 'topToBottom' && heightManager.isReady && heightManager.initialized) {
347
+ // Keep the end item visually stable when total height changes at the end.
348
+ if (heightManager.isReady && heightManager.initialized) {
663
349
  const oldTotal = prevTotalHeightForScrollCorrection
664
350
  const newTotal = heightManager.totalHeight
665
351
  const deltaTotal = newTotal - oldTotal
@@ -670,7 +356,7 @@
670
356
  const currentScrollTop = heightManager.viewport.scrollTop
671
357
  const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) <= tolerance
672
358
  if (isAtBottom) {
673
- // Adjust scrollTop by total height delta to hold bottom anchor
359
+ // Adjust scrollTop by total height delta to keep the same end position.
674
360
  const adjusted = clampValue(
675
361
  currentScrollTop + deltaTotal,
676
362
  0,
@@ -686,38 +372,21 @@
686
372
  // Clear processed dirty items (all dirty items were processed)
687
373
  dirtyItems.clear()
688
374
  dirtyItemsCount = 0
689
-
690
- // Reset bottom state flag
691
- wasAtBottomBeforeHeightChange = false
692
375
  })
693
376
  heightManager.endDynamicUpdate()
694
377
  },
695
378
  lastMeasuredIndex < 0 || dirtyItems.size > 0 ? 0 : HEIGHT_DEBOUNCE_MS,
696
379
  dirtyItems, // Pass dirty items for processing
697
380
  0, // Don't pass ReactiveListManager state - let each system manage its own totals
698
- 0, // Don't pass ReactiveListManager state - let each system manage its own totals
699
- mode // Pass mode for correct element indexing
381
+ 0 // Don't pass ReactiveListManager state - let each system manage its own totals
700
382
  )
701
383
  }
702
384
 
703
- // Add new effect to handle height changes
704
- // Track if user has scrolled away from bottom to prevent snap-back
705
- let userHasScrolledAway = $state(false)
706
- let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
707
- let lastCalculatedHeight = $state(0)
708
- let lastItemsLength = $state(0)
709
- // Track last observed total height to compute precise deltas on item count changes
710
- let lastTotalHeightObserved = $state(0)
711
- // For bottomToTop mode: keep init path active until scroll positioning is complete
712
- // This ensures Item 0 stays in the DOM throughout initialization
713
- let bottomToTopScrollComplete = $state(false)
714
-
715
385
  /**
716
386
  * CRITICAL: O(1) Reactive Total Height Calculation
717
387
  * ===============================================
718
388
  *
719
389
  * Uses ReactiveListManager for O(1) height calculations instead of O(n) loops.
720
- * This fixes the root cause of massive scroll jumps in bottomToTop mode.
721
390
  *
722
391
  * Problem with Previous O(n) Approach:
723
392
  * - Looped through ALL items on every reactive update
@@ -741,12 +410,10 @@
741
410
  * - Updates incrementally using processDirtyHeights() instead of recalculating all
742
411
  *
743
412
  * This getter is reactive and updates whenever heightManager's internal state changes.
744
- * Used by: atBottom calculation, scroll corrections, maxScrollTop calculations
413
+ * Used by scroll corrections and maxScrollTop calculations.
745
414
  */
746
415
  const totalHeight = $derived(heightManager.totalHeight)
747
416
 
748
- const atBottom = $derived(heightManager.scrollTop >= totalHeight - height - 1)
749
- let wasAtBottomBeforeHeightChange = false
750
417
  let lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null = null
751
418
 
752
419
  function updateDebugTailDistance() {
@@ -757,174 +424,14 @@
757
424
  if (!last) return
758
425
  const v = heightManager.viewport.getBoundingClientRect()
759
426
  const r = last.getBoundingClientRect()
760
- lastBottomDistance = Math.round(Math.abs(r.bottom - v.bottom))
427
+ const bottomDistance = Math.round(Math.abs(r.bottom - v.bottom))
761
428
  if (INTERNAL_DEBUG) {
762
- console.info('[SVL] bottomDistance(px):', lastBottomDistance)
429
+ console.info('[SVL] bottomDistance(px):', bottomDistance)
763
430
  }
764
431
  }
765
432
 
766
433
  // no UI export; rely on console logs when debug=true
767
434
 
768
- // $inspect('scrollState: atTop', atTop)
769
- // $inspect('scrollState: atBottom', atBottom)
770
-
771
- $effect(() => {
772
- if (
773
- BROWSER &&
774
- heightManager.initialized &&
775
- mode === 'bottomToTop' &&
776
- heightManager.viewportElement
777
- ) {
778
- const targetScrollTop = Math.max(0, totalHeight - height)
779
- const currentScrollTop = heightManager.viewport.scrollTop
780
- const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
781
-
782
- // Only correct scroll if:
783
- // 1. Item height changed significantly (not just user scrolling)
784
- // 2. User hasn't intentionally scrolled away from bottom
785
- // 3. We're significantly off target
786
- // 4. We're not at the bottom (where height changes should be handled more carefully)
787
- const heightChanged = Math.abs(heightManager.averageHeight - lastCalculatedHeight) > 1
788
- const maxScrollTop = Math.max(0, totalHeight - height)
789
-
790
- // In bottomToTop mode, we're "at bottom" when scroll is at max position
791
- const isAtBottom =
792
- Math.abs(currentScrollTop - maxScrollTop) < heightManager.averageHeight
793
- const shouldCorrect =
794
- heightChanged &&
795
- !userHasScrolledAway &&
796
- !isAtBottom && // Don't apply aggressive correction when at bottom
797
- !isScrolling && // Skip aggressive corrections during active scroll
798
- !programmaticScrollInProgress && // Don't interfere with programmatic scrolls
799
- performance.now() >= suppressBottomAnchoringUntilMs &&
800
- !heightManager.isDynamicUpdateInProgress &&
801
- scrollDifference > heightManager.averageHeight * 3
802
-
803
- if (shouldCorrect) {
804
- // Round to avoid subpixel positioning issues in bottomToTop mode
805
- syncScrollTop(targetScrollTop, true)
806
- }
807
-
808
- // Track if user has scrolled significantly away from bottom
809
- if (scrollDifference > heightManager.averageHeight * 5) {
810
- userHasScrolledAway = true
811
- }
812
-
813
- lastCalculatedHeight = heightManager.averageHeight
814
- }
815
- })
816
-
817
- // Handle items being added/removed in bottomToTop mode
818
- $effect(() => {
819
- // Only track items.length to prevent re-runs on other reactive changes
820
- const currentItemsLength = items.length
821
-
822
- if (
823
- BROWSER &&
824
- heightManager.initialized &&
825
- mode === 'bottomToTop' &&
826
- heightManager.isReady &&
827
- lastItemsLength > 0
828
- ) {
829
- const itemsAdded = currentItemsLength - lastItemsLength
830
-
831
- if (itemsAdded !== 0) {
832
- // Capture all reactive values immediately to prevent re-triggering
833
- const currentScrollTop = heightManager.viewport.scrollTop
834
- const currentCalculatedItemHeight = heightManager.averageHeight
835
- const currentHeight = height
836
- const currentTotalHeight = totalHeight
837
- const prevTotalHeight =
838
- lastTotalHeightObserved ||
839
- currentTotalHeight - itemsAdded * currentCalculatedItemHeight
840
- const prevMaxScrollTop = Math.max(0, prevTotalHeight - currentHeight)
841
- const nextMaxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
842
- const deltaMax = nextMaxScrollTop - prevMaxScrollTop
843
- log('[SVL] items-length-change:before', {
844
- instanceId,
845
- itemsAdded,
846
- lastItemsLength,
847
- currentItemsLength,
848
- currentScrollTop,
849
- prevTotalHeight,
850
- currentTotalHeight,
851
- prevMaxScrollTop,
852
- nextMaxScrollTop,
853
- deltaMax,
854
- averageItemHeight: currentCalculatedItemHeight
855
- })
856
-
857
- // Maintain visual position for ALL cases by advancing scrollTop by deltaMax.
858
- // If near the bottom, this naturally pins to the new max; otherwise it preserves the current content.
859
- programmaticScrollInProgress = true
860
- void heightManager.runDynamicUpdate(() => {
861
- const newScrollTop = clampValue(
862
- currentScrollTop + deltaMax,
863
- 0,
864
- nextMaxScrollTop
865
- )
866
- syncScrollTop(newScrollTop)
867
- log('[SVL] items-length-change:applied', {
868
- instanceId,
869
- previousScrollTop: currentScrollTop,
870
- appliedScrollTop: newScrollTop,
871
- prevMaxScrollTop,
872
- nextMaxScrollTop,
873
- deltaMax
874
- })
875
-
876
- // We are explicitly managing position; consider this a programmatic action.
877
- // Do not flip userHasScrolledAway here; it should reflect user intent only.
878
-
879
- // Reconcile on next frame in case measured heights adjust totals
880
- requestAnimationFrame(() => {
881
- const beforeReconcileScrollTop = heightManager.viewport.scrollTop
882
- const reconciledNextMax = clampValue(totalHeight - height, 0, Infinity)
883
- const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
884
- // Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
885
- const desiredScrollTop = clampValue(
886
- newScrollTop + reconciledDeltaMaxChange,
887
- 0,
888
- reconciledNextMax
889
- )
890
- // Snap to integer pixels to prevent oscillation due to subpixel rounding
891
- const desiredRounded = Math.round(desiredScrollTop)
892
- const diffToDesired = desiredRounded - heightManager.viewport.scrollTop
893
- if (Math.abs(diffToDesired) >= 2) {
894
- const adjusted = clampValue(desiredRounded, 0, reconciledNextMax)
895
- syncScrollTop(adjusted)
896
- log('[SVL] items-length-change:reconciled', {
897
- instanceId,
898
- beforeReconcileScrollTop,
899
- adjustedScrollTop: adjusted,
900
- reconciledNextMax,
901
- reconciledDeltaMaxChange,
902
- desiredScrollTop,
903
- desiredRounded,
904
- diffToDesired
905
- })
906
- } else {
907
- log('[SVL] items-length-change:reconciled-skip', {
908
- instanceId,
909
- beforeReconcileScrollTop,
910
- reconciledNextMax,
911
- reconciledDeltaMaxChange,
912
- desiredScrollTop,
913
- desiredRounded,
914
- diffToDesired
915
- })
916
- }
917
- programmaticScrollInProgress = false
918
- })
919
- })
920
- }
921
- }
922
-
923
- lastItemsLength = currentItemsLength
924
- // Update last observed total height at the end of the effect
925
- lastTotalHeightObserved = totalHeight
926
- })
927
-
928
435
  // Update container height continuously to reflect layout changes that
929
436
  // may occur outside ResizeObserver timing (keeps buffers correct across engines)
930
437
  $effect(() => {
@@ -934,17 +441,6 @@
934
441
  }
935
442
  })
936
443
 
937
- // One-time fallback measurement when height hasn't been established yet
938
-
939
- // Provide a one-time synchronous measurement only when height is still 0,
940
- // to avoid DOM reads inside render-time expressions.
941
- $effect(() => {
942
- if (BROWSER && height === 0 && heightManager.isReady) {
943
- const h = heightManager.container.getBoundingClientRect().height
944
- if (Number.isFinite(h) && h > 0) measuredFallbackHeight = h
945
- }
946
- })
947
-
948
444
  /**
949
445
  * Calculates the range of items that should be rendered based on current scroll position.
950
446
  *
@@ -954,7 +450,6 @@
954
450
  * - Viewport height
955
451
  * - Item height estimates
956
452
  * - Buffer size
957
- * - Scroll direction mode
958
453
  *
959
454
  * @example
960
455
  * ```typescript
@@ -968,29 +463,11 @@
968
463
  if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
969
464
  const viewportHeight = height || 0
970
465
 
971
- // For bottomToTop mode, always render items starting from index 0 during initialization
972
- // This ensures Item 0 is in the DOM so we can use scrollIntoView for precise positioning
973
- // The scrollIntoView in updateHeightAndScroll will handle correct alignment after heights are measured
974
- // Use bottomToTopScrollComplete (not just initialized) to keep init path active until scroll is done
975
- if (mode === 'bottomToTop' && !bottomToTopScrollComplete) {
976
- // Use a reasonable default if viewport height isn't measured yet
977
- const effectiveViewport = viewportHeight || 400
978
- const visibleCount = Math.ceil(effectiveViewport / heightManager.averageHeight) + 1
979
- lastVisibleRange = {
980
- start: 0,
981
- end: Math.min(items.length, visibleCount + bufferSize * 2)
982
- } as SvelteVirtualListPreviousVisibleRange
983
-
984
- return lastVisibleRange
985
- }
986
-
987
466
  // Scroll delta threshold optimization: skip recalculation if scroll delta is less than
988
467
  // half the average item height and we have a cached range. This reduces unnecessary
989
468
  // calculations during smooth scrolling.
990
- // Note: Only applied in topToBottom mode - bottomToTop has complex scroll correction
991
- // logic that requires precise visible range calculations.
992
469
  // Note: We use lastProcessedScrollTop read-only here; it's updated in the scroll handler
993
- if (mode === 'topToBottom') {
470
+ {
994
471
  const scrollDelta = Math.abs(heightManager.scrollTop - lastProcessedScrollTop)
995
472
  const threshold = heightManager.averageHeight * 0.5
996
473
  if (lastVisibleRange && scrollDelta < threshold && scrollDelta > 0) {
@@ -999,19 +476,15 @@
999
476
  }
1000
477
  }
1001
478
 
1002
- lastVisibleRange = calculateVisibleRange(
1003
- heightManager.scrollTop,
479
+ lastVisibleRange = calculateVisibleRange({
480
+ scrollTop: heightManager.scrollTop,
1004
481
  viewportHeight,
1005
- heightManager.averageHeight,
1006
- items.length,
482
+ itemHeight: heightManager.averageHeight,
483
+ totalItems: items.length,
1007
484
  bufferSize,
1008
- mode,
1009
- atBottom,
1010
- wasAtBottomBeforeHeightChange,
1011
- lastVisibleRange,
1012
- totalHeight,
1013
- heightManager.getHeightCache()
1014
- )
485
+ totalContentHeight: totalHeight,
486
+ heightCache: heightManager.getHeightCache()
487
+ })
1015
488
 
1016
489
  return lastVisibleRange
1017
490
  })
@@ -1020,74 +493,34 @@
1020
493
  * Computed content height for the virtual list.
1021
494
  * Uses the maximum of container height and total content height to ensure
1022
495
  * proper scrolling behavior.
1023
- *
1024
- * In bottomToTop mode during active scroll, contentHeight is "ratcheted" —
1025
- * it can grow but never shrink. This prevents a feedback loop where
1026
- * averageHeight oscillation causes scrollHeight to bounce, triggering
1027
- * browser scrollTop adjustments that fire new scroll events.
1028
- * When scrolling stops (isScrolling goes false), it snaps to the true value.
1029
496
  */
1030
- let stabilizedContentHeight = 0
1031
-
1032
- const contentHeight = $derived.by(() => {
1033
- const raw = Math.max(height, totalHeight)
1034
-
1035
- if (mode !== 'bottomToTop' || !isScrolling) {
1036
- stabilizedContentHeight = raw
1037
- return raw
1038
- }
1039
-
1040
- // During active scroll in bottomToTop: only allow growth (ratchet)
1041
- // Prevents shrink → scrollTop adjust → new scroll event feedback loop
1042
- if (raw > stabilizedContentHeight) {
1043
- stabilizedContentHeight = raw
1044
- }
1045
-
1046
- return stabilizedContentHeight
1047
- })
497
+ const contentHeight = $derived(Math.max(height, totalHeight))
1048
498
 
1049
499
  /**
1050
500
  * Computed transform Y value for positioning the visible items.
1051
501
  * Extracted from inline IIFE for better performance and readability.
1052
502
  */
1053
503
  const transformY = $derived.by(() => {
1054
- const viewportHeight = height || measuredFallbackHeight || 0
1055
504
  const visibleRange = visibleItems
1056
505
 
1057
- // Avoid synchronous DOM reads here; fall back once if height is 0
1058
- const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
1059
-
1060
506
  // Use precise offset using measured heights when available.
1061
- // For bottomToTop, pass ratcheted contentHeight so the transform stays
1062
- // stable while scrollHeight is stabilized (prevents visual shift).
1063
507
  return Math.round(
1064
508
  calculateTransformY(
1065
- mode,
1066
509
  items.length,
1067
- visibleRange.end,
1068
510
  visibleRange.start,
1069
511
  heightManager.averageHeight,
1070
- effectiveHeight,
1071
- mode === 'bottomToTop' ? contentHeight : totalHeight,
1072
- heightManager.getHeightCache(),
1073
- measuredFallbackHeight
512
+ heightManager.getHeightCache()
1074
513
  )
1075
514
  )
1076
515
  })
1077
516
 
1078
517
  const displayItems = $derived.by(() => {
1079
518
  const visibleRange = visibleItems
1080
- const slice =
1081
- mode === 'bottomToTop'
1082
- ? items.slice(visibleRange.start, visibleRange.end).reverse()
1083
- : items.slice(visibleRange.start, visibleRange.end)
519
+ const slice = items.slice(visibleRange.start, visibleRange.end)
1084
520
 
1085
521
  return slice.map((item, sliceIndex) => ({
1086
522
  item,
1087
- originalIndex:
1088
- mode === 'bottomToTop'
1089
- ? visibleRange.end - 1 - sliceIndex
1090
- : visibleRange.start + sliceIndex,
523
+ originalIndex: visibleRange.start + sliceIndex,
1091
524
  sliceIndex
1092
525
  }))
1093
526
  })
@@ -1100,8 +533,7 @@
1100
533
  * smooth scrolling performance.
1101
534
  *
1102
535
  * Implementation details:
1103
- * - Uses isScrolling flag to prevent multiple RAF calls
1104
- * - Updates scrollTop state only when scrolling has settled
536
+ * - Updates scrollTop state through RAF scheduling
1105
537
  * - Browser-only functionality
1106
538
  *
1107
539
  * @example
@@ -1116,31 +548,8 @@
1116
548
  const handleScroll = () => {
1117
549
  if (!BROWSER || !heightManager.viewportElement) return
1118
550
 
1119
- // Mark active scrolling and debounce idle transition (~120ms)
1120
- isScrolling = true
1121
- if (scrollIdleTimer) {
1122
- clearTimeout(scrollIdleTimer)
1123
- scrollIdleTimer = null
1124
- }
1125
- scrollIdleTimer = window.setTimeout(() => {
1126
- isScrolling = false
1127
- // Apply deferred anchor correction on idle
1128
- if (idleCorrectionsOnly || anchorModeEnabled) {
1129
- reconcileToAnchorIfEnabled()
1130
- }
1131
- }, SCROLL_IDLE_DELAY_MS)
1132
-
1133
551
  rafSchedule(() => {
1134
552
  const current = heightManager.viewport.scrollTop
1135
- if (mode === 'bottomToTop') {
1136
- const delta = lastScrollTopSnapshot - current
1137
- if (delta > 0.5) {
1138
- // Widen suppression to avoid fighting peer instance corrections
1139
- suppressBottomAnchoringUntilMs = performance.now() + SUPPRESSION_WINDOW_MS
1140
- userHasScrolledAway = true
1141
- }
1142
- }
1143
- lastScrollTopSnapshot = current
1144
553
  heightManager.scrollTop = current
1145
554
  // Update last processed scroll position for delta threshold optimization
1146
555
  // Only update when we actually process a scroll (i.e., recalculate visible range)
@@ -1150,13 +559,9 @@
1150
559
  lastProcessedScrollTop = current
1151
560
  }
1152
561
  updateDebugTailDistance()
1153
- if (anchorModeEnabled) {
1154
- captureAnchor()
1155
- }
1156
562
  if (INTERNAL_DEBUG) {
1157
563
  const vr = visibleItems
1158
564
  log('[SVL] scroll', {
1159
- mode,
1160
565
  scrollTop: heightManager.scrollTop,
1161
566
  height,
1162
567
  totalHeight: totalHeight,
@@ -1164,109 +569,23 @@
1164
569
  visibleRange: vr
1165
570
  })
1166
571
  }
1167
- // isScrolling cleared by idle timer
1168
572
  })
1169
573
  }
1170
574
 
1171
575
  /**
1172
576
  * Updates the height and scroll position of the virtual list.
1173
577
  *
1174
- * This function handles two scenarios:
1175
- * 1. Initial setup (critical for bottomToTop mode in flexbox layouts)
1176
- * 2. Subsequent resize events
1177
- *
1178
- * For bottomToTop mode, we need to ensure:
1179
- * - The flexbox layout is fully calculated
1180
- * - The height measurements are accurate
1181
- * - The scroll position starts at the bottom
1182
- *
1183
578
  * @param immediate - Whether to skip the delay (used for resize events)
1184
579
  */
1185
580
  const updateHeightAndScroll = (immediate = false) => {
1186
581
  log('updateHeightAndScroll-enter', {
1187
582
  immediate,
1188
- initialized: heightManager.initialized,
1189
- mode
583
+ initialized: heightManager.initialized
1190
584
  })
1191
- if (!heightManager.initialized && mode === 'bottomToTop') {
1192
- // bottomToTop initialization: use scrollIntoView on Item 0 for precise positioning
1193
- // visibleItems guarantees Item 0 is rendered during initialization
1194
- tick().then(() => {
1195
- requestAnimationFrame(() => {
1196
- requestAnimationFrame(() => {
1197
- if (!heightManager.isReady) return
1198
- const measuredHeight =
1199
- heightManager.container.getBoundingClientRect().height
1200
- height = measuredHeight
1201
-
1202
- // Instance jitter to avoid same-frame collisions when two lists init together
1203
- const cleanedId = String(instanceId)
1204
- .toLowerCase()
1205
- .replace(/[^a-z0-9]/g, '')
1206
- const suffix = cleanedId.slice(-4)
1207
- const parsed = parseInt(suffix, 36)
1208
- const jitterMs = Number.isNaN(parsed)
1209
- ? Math.floor(Math.random() * 3)
1210
- : parsed % 3
1211
-
1212
- setTimeout(() => {
1213
- // Step 1: Set initialized (for other purposes like scroll event handling)
1214
- // The init path in visibleItems stays active until bottomToTopScrollComplete
1215
- if (!heightManager.initialized) {
1216
- heightManager.initialized = true
1217
- }
1218
-
1219
- // Step 2: Use scrollIntoView on Item 0 for precise positioning
1220
- // Use double RAF to ensure heights are measured and layout is stable
1221
- requestAnimationFrame(() => {
1222
- requestAnimationFrame(() => {
1223
- // Item 0 is guaranteed to be in DOM due to init path
1224
- // Skip if user has already scrolled (scrollTop significantly != 0)
1225
- const currentScroll = heightManager.viewport.scrollTop
1226
- const userHasScrolled =
1227
- currentScroll > heightManager.averageHeight
1228
- const el = heightManager.viewport.querySelector(
1229
- '[data-original-index="0"]'
1230
- ) as HTMLElement | null
1231
-
1232
- if (el && !userHasScrolled) {
1233
- // Use manual scrollTop instead of scrollIntoView to prevent parent scroll
1234
- // (scrollIntoView scrolls all ancestor containers, not just the viewport)
1235
- // Note: `container: 'nearest'` option could replace this once browser support improves
1236
- const viewportRect =
1237
- heightManager.viewport.getBoundingClientRect()
1238
- const elRect = el.getBoundingClientRect()
1239
- const offset = elRect.bottom - viewportRect.bottom
1240
- heightManager.viewport.scrollTop += offset
1241
- heightManager.scrollTop = heightManager.viewport.scrollTop
1242
- } else if (userHasScrolled) {
1243
- // Sync internal state with current scroll
1244
- heightManager.scrollTop = currentScroll
1245
- }
1246
-
1247
- // Step 3: Mark scroll complete - switches visibleItems to normal mode
1248
- requestAnimationFrame(() => {
1249
- bottomToTopScrollComplete = true
1250
- // Reset bottom-anchoring flag to prevent stale state from init
1251
- // affecting later operations (e.g., adding items while scrolled away)
1252
- wasAtBottomBeforeHeightChange = false
1253
- // Suppress bottom-anchoring briefly to let heights stabilize
1254
- // after switching to normal mode
1255
- suppressBottomAnchoringUntilMs = performance.now() + 200
1256
- })
1257
- })
1258
- })
1259
- }, jitterMs)
1260
- })
1261
- })
1262
- })
1263
- return
1264
- }
1265
585
 
1266
586
  utilsUpdateHeightAndScroll(
1267
587
  {
1268
588
  initialized: heightManager.initialized,
1269
- mode,
1270
589
  containerElement: heightManager.containerElement,
1271
590
  viewportElement: heightManager.viewportElement,
1272
591
  calculatedItemHeight: heightManager.averageHeight,
@@ -1313,11 +632,6 @@
1313
632
 
1314
633
  // Only mark as dirty if height change is significant
1315
634
  if (isSignificant) {
1316
- // Capture bottom state when FIRST item gets marked dirty
1317
- if (dirtyItemsCount === 0) {
1318
- wasAtBottomBeforeHeightChange = atBottom
1319
- }
1320
-
1321
635
  dirtyItems.add(actualIndex)
1322
636
  dirtyItemsCount = dirtyItems.size
1323
637
  shouldRecalculate = true
@@ -1338,7 +652,7 @@
1338
652
  onMount(() => {
1339
653
  if (BROWSER) {
1340
654
  // Initial setup of heights and scroll position
1341
- log('onMount-enter', { mode, items: items.length })
655
+ log('onMount-enter', { items: items.length })
1342
656
  updateHeightAndScroll()
1343
657
  // Ensure one initial measurement pass even if no ResizeObserver fires
1344
658
  tick().then(() =>
@@ -1518,7 +832,6 @@
1518
832
 
1519
833
  // Use extracted scroll calculation utility
1520
834
  const scrollTarget = calculateScrollTarget({
1521
- mode,
1522
835
  align: align || 'auto',
1523
836
  targetIndex,
1524
837
  itemsLength: items.length,
@@ -1535,9 +848,6 @@
1535
848
  return
1536
849
  }
1537
850
 
1538
- // Prevent bottom-anchoring logic from interfering with programmatic scroll
1539
- programmaticScrollInProgress = true
1540
-
1541
851
  if (INTERNAL_DEBUG && heightManager.viewportElement) {
1542
852
  const domMax = Math.max(
1543
853
  0,
@@ -1554,32 +864,6 @@
1554
864
  })
1555
865
  }
1556
866
 
1557
- // CROSS-BROWSER COMPATIBILITY FIX:
1558
- // All major browsers (Chrome, Firefox, Safari) have inconsistent behavior with scrollTo()
1559
- // in bottomToTop mode when using smooth scrolling. Using scrollIntoView() on the highest
1560
- // visible element provides consistent cross-browser smooth scrolling behavior.
1561
- // This approach works universally and maintains the user's expected smooth scroll experience.
1562
- if (mode === 'bottomToTop' && smoothScroll) {
1563
- // Find the element with the highest original-index in the current viewport
1564
- const visibleElements = heightManager.viewport.querySelectorAll('[data-original-index]')
1565
- let maxIndex = -1
1566
- let maxElement: HTMLElement | null = null
1567
- for (const el of visibleElements) {
1568
- const index = parseInt(el.getAttribute('data-original-index') || '-1')
1569
- if (index > maxIndex) {
1570
- maxIndex = index
1571
- maxElement = el as HTMLElement
1572
- }
1573
- }
1574
-
1575
- maxElement?.scrollIntoView({
1576
- behavior: 'smooth'
1577
- })
1578
- await tick()
1579
- await new Promise((resolve) => setTimeout(resolve, 100))
1580
- await tick()
1581
- }
1582
-
1583
867
  heightManager.viewport.scrollTo({
1584
868
  top: scrollTarget,
1585
869
  behavior: smoothScroll ? 'smooth' : 'auto'
@@ -1601,14 +885,6 @@
1601
885
  })
1602
886
 
1603
887
  // No extra alignment step here; allow native smooth scroll to reach DOM max scrollTop
1604
-
1605
- // Clear the flag after scroll completes
1606
- setTimeout(
1607
- () => {
1608
- programmaticScrollInProgress = false
1609
- },
1610
- smoothScroll ? 500 : 100
1611
- )
1612
888
  }
1613
889
 
1614
890
  /**