@humanspeak/svelte-virtual-list 0.3.6 β†’ 0.3.9

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.
package/README.md CHANGED
@@ -29,6 +29,7 @@ A high-performance virtual list component for Svelte 5 applications that efficie
29
29
  - πŸ§ͺ Comprehensive test coverage (vitest and playwright)
30
30
  - πŸš€ Progressive initialization for large datasets
31
31
  - πŸ•ΉοΈ Programmatic scrolling with `scroll`
32
+ - ♾️ Infinite scroll support with `onLoadMore`
32
33
 
33
34
  ## scroll: Programmatic Scrolling
34
35
 
@@ -76,6 +77,53 @@ You can now programmatically scroll to any item in the list using the `scroll` m
76
77
  </button>
77
78
  ```
78
79
 
80
+ ## Infinite Scroll
81
+
82
+ Load more data automatically as users scroll near the end of the list. Perfect for paginated APIs, infinite feeds, and chat applications.
83
+
84
+ ```svelte
85
+ <script lang="ts">
86
+ import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
87
+
88
+ let items = $state([...initialItems])
89
+ let hasMore = $state(true)
90
+
91
+ async function loadMore() {
92
+ const newItems = await fetchMoreItems()
93
+ items = [...items, ...newItems]
94
+ if (newItems.length === 0) {
95
+ hasMore = false
96
+ }
97
+ }
98
+ </script>
99
+
100
+ <SvelteVirtualList {items} onLoadMore={loadMore} loadMoreThreshold={20} {hasMore}>
101
+ {#snippet renderItem(item)}
102
+ <div>{item.text}</div>
103
+ {/snippet}
104
+ </SvelteVirtualList>
105
+ ```
106
+
107
+ ### Infinite Scroll Props
108
+
109
+ | Prop | Type | Default | Description |
110
+ | ------------------- | ----------------------------- | ------- | ---------------------------------------------------- |
111
+ | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed (supports async) |
112
+ | `loadMoreThreshold` | `number` | `20` | Number of items from the end to trigger `onLoadMore` |
113
+ | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
114
+
115
+ ### Infinite Scroll Behavior
116
+
117
+ - Triggers when scrolling near the end of the list
118
+ - Automatically triggers on mount if initial items are below threshold
119
+ - Prevents concurrent `onLoadMore` calls while loading
120
+ - Works with both sync and async callbacks
121
+ - Supports both `topToBottom` and `bottomToTop` modes
122
+
123
+ ### Integration Guides
124
+
125
+ - [Infinite Scroll with Convex](documentation/CONVEX_INFINITE_SCROLL.md) - Real-time data + pagination with Convex backend
126
+
79
127
  ## Installation
80
128
 
81
129
  ```bash
@@ -174,6 +222,9 @@ Use `mode="bottomToTop"` for chat-like lists anchored to the bottom. Programmati
174
222
  | `contentClass` | `string` | `''` | Class for content wrapper |
175
223
  | `itemsClass` | `string` | `''` | Class for items container |
176
224
  | `testId` | `string` | `''` | Base test id used in internal test hooks (useful for E2E/tests and debugging) |
225
+ | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed for infinite scroll |
226
+ | `loadMoreThreshold` | `number` | `20` | Items from end to trigger `onLoadMore` |
227
+ | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
177
228
 
178
229
  ## Testing
179
230
 
@@ -163,10 +163,12 @@
163
163
  import { createRafScheduler } from './utils/raf.js'
164
164
  import { isSignificantHeightChange } from './utils/heightChangeDetection.js'
165
165
  import {
166
- calculateScrollPosition,
167
166
  calculateTransformY,
168
167
  calculateVisibleRange,
169
- updateHeightAndScroll as utilsUpdateHeightAndScroll
168
+ clampValue,
169
+ updateHeightAndScroll as utilsUpdateHeightAndScroll,
170
+ getScrollOffsetForIndex,
171
+ buildBlockSums
170
172
  } from './utils/virtualList.js'
171
173
  import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
172
174
  import { calculateScrollTarget } from './utils/scrollCalculation.js'
@@ -184,8 +186,22 @@
184
186
  // Avoid SvelteKit-only $env imports so library works in non-Kit/Vitest contexts
185
187
  const INTERNAL_DEBUG = Boolean(
186
188
  typeof process !== 'undefined' &&
187
- (process?.env?.PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG === 'true' ||
188
- process?.env?.SVELTE_VIRTUAL_LIST_DEBUG === 'true')
189
+ (process?.env?.PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG === 'true' ||
190
+ process?.env?.SVELTE_VIRTUAL_LIST_DEBUG === 'true')
191
+ )
192
+ // Feature flags - default off; enable via env for incremental rollout
193
+ const anchorModeEnabled = Boolean(
194
+ typeof process !== 'undefined' &&
195
+ (process?.env?.PUBLIC_SVL_ANCHOR_MODE === 'true' ||
196
+ process?.env?.SVL_ANCHOR_MODE === 'true')
197
+ )
198
+ const idleCorrectionsOnly = Boolean(
199
+ typeof process !== 'undefined' &&
200
+ (process?.env?.PUBLIC_SVL_IDLE_ONLY === 'true' || process?.env?.SVL_IDLE_ONLY === 'true')
201
+ )
202
+ const batchUpdatesEnabled = Boolean(
203
+ typeof process !== 'undefined' &&
204
+ (process?.env?.PUBLIC_SVL_BATCH === 'true' || process?.env?.SVL_BATCH === 'true')
189
205
  )
190
206
  /**
191
207
  * Core configuration props with default values
@@ -203,7 +219,10 @@
203
219
  debugFunction, // Custom debug logging function
204
220
  mode = 'topToBottom', // Scroll direction mode
205
221
  bufferSize = 20, // Number of items to render outside visible area
206
- testId // Base test ID for component elements (undefined = no data-testid attributes)
222
+ testId, // Base test ID for component elements (undefined = no data-testid attributes)
223
+ onLoadMore, // Callback when more data needed (supports sync and async)
224
+ loadMoreThreshold = 20, // Items from end to trigger load
225
+ hasMore = true // Set false when all data loaded
207
226
  }: SvelteVirtualListProps<TItem> = $props()
208
227
 
209
228
  /**
@@ -221,7 +240,101 @@
221
240
  */
222
241
 
223
242
  const isCalculatingHeight = $state(false) // Prevents concurrent height calculations
243
+ let isLoadingMore = $state(false) // Prevents concurrent onLoadMore calls
224
244
  let isScrolling = $state(false) // Tracks active scrolling state
245
+ let scrollIdleTimer: number | null = null
246
+ // Anchor state (read-only capture; used when anchorModeEnabled)
247
+ let lastAnchorIndex = $state(0)
248
+ let lastAnchorOffset = $state(0) // offset within anchored item (px)
249
+ let pendingAnchorReconcile = $state(false)
250
+ let batchDepth = $state(0)
251
+
252
+ const captureAnchor = () => {
253
+ if (!heightManager.viewportElement) return
254
+ const vr = visibleItems()
255
+ const anchorIndex = Math.max(0, vr.start)
256
+ const cache = heightManager.getHeightCache()
257
+ const est = heightManager.averageHeight
258
+ const maxScrollTop = Math.max(0, totalHeight() - (height || 0))
259
+ // Offset from start to anchored item
260
+ const blockSums = buildBlockSums(cache, est, items.length)
261
+ const offsetToIndex = getScrollOffsetForIndex(cache, est, anchorIndex, blockSums)
262
+ const currentTop = heightManager.viewport.scrollTop
263
+ let offsetWithin = 0
264
+ if (mode === 'bottomToTop') {
265
+ // Convert distance-from-end to distance-from-start
266
+ const distanceFromStart = maxScrollTop - currentTop
267
+ offsetWithin = distanceFromStart - offsetToIndex
268
+ } else {
269
+ offsetWithin = currentTop - offsetToIndex
270
+ }
271
+ lastAnchorIndex = anchorIndex
272
+ lastAnchorOffset = Math.max(0, Math.round(offsetWithin))
273
+ // Expose for tests
274
+ ;(heightManager.viewport as unknown as Record<string, unknown>).__svlAnchor = {
275
+ index: lastAnchorIndex,
276
+ offset: lastAnchorOffset
277
+ }
278
+ pendingAnchorReconcile = true
279
+ }
280
+
281
+ const reconcileToAnchorIfEnabled = () => {
282
+ if (!anchorModeEnabled || !heightManager.viewportElement) return
283
+ if (!pendingAnchorReconcile) return
284
+ const cache = heightManager.getHeightCache()
285
+ const est = heightManager.averageHeight
286
+ const blockSums = buildBlockSums(cache, est, items.length)
287
+ const offsetToIndex = getScrollOffsetForIndex(
288
+ cache,
289
+ est,
290
+ Math.max(0, lastAnchorIndex),
291
+ blockSums
292
+ )
293
+ const maxScrollTop = clampValue(totalHeight() - (height || 0), 0, Infinity)
294
+ let targetTop: number
295
+ if (mode === 'bottomToTop') {
296
+ const distanceFromStart = clampValue(offsetToIndex + lastAnchorOffset, 0, Infinity)
297
+ targetTop = clampValue(Math.round(maxScrollTop - distanceFromStart), 0, maxScrollTop)
298
+ } else {
299
+ targetTop = clampValue(Math.round(offsetToIndex + lastAnchorOffset), 0, maxScrollTop)
300
+ }
301
+ if (Math.abs(heightManager.viewport.scrollTop - targetTop) >= 2) {
302
+ syncScrollTop(targetTop)
303
+ }
304
+ pendingAnchorReconcile = false
305
+ }
306
+
307
+ /**
308
+ * Runs a batch of updates with scroll corrections coalesced until the batch completes.
309
+ *
310
+ * Use this method when making multiple changes to the items array to prevent
311
+ * intermediate scroll corrections. The scroll position reconciliation is deferred
312
+ * until the batch exits, ensuring smooth visual updates.
313
+ *
314
+ * @param {() => void} fn - The function containing batch updates to execute.
315
+ * @returns {void}
316
+ *
317
+ * @example
318
+ * ```typescript
319
+ * // Add multiple items without intermediate scroll corrections
320
+ * list.runInBatch(() => {
321
+ * items.push(newItem1);
322
+ * items.push(newItem2);
323
+ * items.push(newItem3);
324
+ * });
325
+ * ```
326
+ */
327
+ export const runInBatch = (fn: () => void): void => {
328
+ batchDepth += 1
329
+ try {
330
+ fn()
331
+ } finally {
332
+ batchDepth = Math.max(0, batchDepth - 1)
333
+ if (batchUpdatesEnabled && batchDepth === 0) {
334
+ reconcileToAnchorIfEnabled()
335
+ }
336
+ }
337
+ }
225
338
  let lastMeasuredIndex = $state(-1) // Index of last measured item
226
339
  let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
227
340
 
@@ -267,6 +380,23 @@
267
380
  }
268
381
  }
269
382
 
383
+ /**
384
+ * Synchronizes the scroll position between the viewport element and internal state.
385
+ *
386
+ * This helper consolidates the repeated pattern of updating both
387
+ * heightManager.viewport.scrollTop and heightManager.scrollTop together,
388
+ * ensuring they stay in sync.
389
+ *
390
+ * @param {number} value - The scroll position to set
391
+ * @param {boolean} round - Whether to round the value to the nearest integer (default: false)
392
+ */
393
+ const syncScrollTop = (value: number, round = false) => {
394
+ if (!heightManager.viewportElement) return
395
+ const scrollValue = round ? Math.round(value) : value
396
+ heightManager.viewport.scrollTop = scrollValue
397
+ heightManager.scrollTop = scrollValue
398
+ }
399
+
270
400
  // Dynamic update coordination to avoid UA scroll anchoring interference
271
401
  let suppressBottomAnchoringUntilMs = $state(0)
272
402
 
@@ -306,6 +436,25 @@
306
436
  if (!heightManager.viewportElement || !heightManager.initialized || userHasScrolledAway) {
307
437
  return
308
438
  }
439
+ // Coalesce adjustments during active scroll; apply on idle
440
+ if (isScrolling) {
441
+ // Accumulate net change above viewport and defer application
442
+ let pending = 0
443
+ const currentVisibleRange = visibleItems()
444
+ for (const change of heightChanges) {
445
+ if (change.index < currentVisibleRange.start) pending += change.delta
446
+ }
447
+ if (pending !== 0) {
448
+ // Store on the viewport element to avoid extra module globals
449
+ const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
450
+ const prev = (heightManager.viewport as unknown as Record<string, number>)[
451
+ key as string
452
+ ] as number | undefined
453
+ ;(heightManager.viewport as unknown as Record<string, number>)[key as string] =
454
+ (prev ?? 0) + pending
455
+ }
456
+ return
457
+ }
309
458
 
310
459
  /**
311
460
  * CRITICAL: BottomToTop Mode Height Change Fix
@@ -368,9 +517,8 @@
368
517
 
369
518
  // Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
370
519
  const approximateScrollTop = Math.max(0, totalHeight() - height)
371
- log('b2t-correction-approx', { approximateScrollTop })
372
- heightManager.viewport.scrollTop = approximateScrollTop
373
- heightManager.scrollTop = approximateScrollTop
520
+ log('[SVL] b2t-correction-approx', { approximateScrollTop })
521
+ syncScrollTop(approximateScrollTop)
374
522
 
375
523
  // Step 2: Use native scrollIntoView for precise bottom-edge positioning after DOM updates
376
524
  tick().then(() => {
@@ -392,7 +540,7 @@
392
540
  behavior: 'smooth', // Smooth animation for better UX
393
541
  inline: 'nearest' // Minimal horizontal adjustment
394
542
  })
395
- log('b2t-correction-native', {
543
+ log('[SVL] b2t-correction-native', {
396
544
  containerBottom: contRect.y + contRect.height,
397
545
  itemBottom: itemRect.y + itemRect.height
398
546
  })
@@ -422,14 +570,23 @@
422
570
  }
423
571
 
424
572
  // If there are height changes above the viewport, adjust scroll to maintain position
425
- if (Math.abs(heightChangeAboveViewport) > 1) {
426
- const newScrollTop = Math.min(
427
- maxScrollTop,
428
- Math.max(0, currentScrollTop + heightChangeAboveViewport)
573
+ // Include any pending coalesced delta (when scrolling)
574
+ {
575
+ const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
576
+ const pending =
577
+ (heightManager.viewport as unknown as Record<string, number>)[key as string] ?? 0
578
+ if (pending) {
579
+ heightChangeAboveViewport += pending
580
+ ;(heightManager.viewport as unknown as Record<string, number>)[key as string] = 0
581
+ }
582
+ }
583
+ if (Math.abs(heightChangeAboveViewport) > 2) {
584
+ const newScrollTop = clampValue(
585
+ currentScrollTop + heightChangeAboveViewport,
586
+ 0,
587
+ maxScrollTop
429
588
  )
430
-
431
- heightManager.viewport.scrollTop = newScrollTop
432
- heightManager.scrollTop = newScrollTop
589
+ syncScrollTop(newScrollTop)
433
590
  }
434
591
  }
435
592
 
@@ -461,6 +618,24 @@
461
618
  heightManager.updateItemLength(items.length)
462
619
  })
463
620
 
621
+ // Infinite scroll: trigger onLoadMore when approaching end of list
622
+ $effect(() => {
623
+ if (!BROWSER || !onLoadMore || !hasMore || isLoadingMore) return
624
+ // Skip loading during bottomToTop initialization (init path renders all items artificially)
625
+ if (mode === 'bottomToTop' && !bottomToTopScrollComplete) return
626
+
627
+ const range = visibleItems()
628
+ const atLoadingEdge = range.end >= items.length - loadMoreThreshold
629
+ const insufficientItems = items.length < loadMoreThreshold && heightManager.initialized
630
+
631
+ if (atLoadingEdge || insufficientItems) {
632
+ isLoadingMore = true
633
+ Promise.resolve(onLoadMore()).finally(() => {
634
+ isLoadingMore = false
635
+ })
636
+ }
637
+ })
638
+
464
639
  const updateHeight = () => {
465
640
  // Capture previous total height for scroll correction (topToBottom anchoring)
466
641
  prevTotalHeightForScrollCorrection = heightManager.totalHeight
@@ -502,12 +677,12 @@
502
677
  const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) <= tolerance
503
678
  if (isAtBottom) {
504
679
  // Adjust scrollTop by total height delta to hold bottom anchor
505
- const adjusted = Math.min(
506
- maxScrollTop,
507
- Math.max(0, currentScrollTop + deltaTotal)
680
+ const adjusted = clampValue(
681
+ currentScrollTop + deltaTotal,
682
+ 0,
683
+ maxScrollTop
508
684
  )
509
- heightManager.viewport.scrollTop = adjusted
510
- heightManager.scrollTop = adjusted
685
+ syncScrollTop(adjusted, true)
511
686
  }
512
687
  }
513
688
  }
@@ -539,6 +714,9 @@
539
714
  let lastItemsLength = $state(0)
540
715
  // Track last observed total height to compute precise deltas on item count changes
541
716
  let lastTotalHeightObserved = $state(0)
717
+ // For bottomToTop mode: keep init path active until scroll positioning is complete
718
+ // This ensures Item 0 stays in the DOM throughout initialization
719
+ let bottomToTopScrollComplete = $state(false)
542
720
 
543
721
  /**
544
722
  * CRITICAL: O(1) Reactive Total Height Calculation
@@ -622,6 +800,7 @@
622
800
  heightChanged &&
623
801
  !userHasScrolledAway &&
624
802
  !isAtBottom && // Don't apply aggressive correction when at bottom
803
+ !isScrolling && // Skip aggressive corrections during active scroll
625
804
  !programmaticScrollInProgress && // Don't interfere with programmatic scrolls
626
805
  performance.now() >= suppressBottomAnchoringUntilMs &&
627
806
  !heightManager.isDynamicUpdateInProgress &&
@@ -629,9 +808,7 @@
629
808
 
630
809
  if (shouldCorrect) {
631
810
  // Round to avoid subpixel positioning issues in bottomToTop mode
632
- const roundedTargetScrollTop = Math.round(targetScrollTop)
633
- heightManager.viewport.scrollTop = roundedTargetScrollTop
634
- heightManager.scrollTop = roundedTargetScrollTop
811
+ syncScrollTop(targetScrollTop, true)
635
812
  }
636
813
 
637
814
  // Track if user has scrolled significantly away from bottom
@@ -687,10 +864,12 @@
687
864
  // If near the bottom, this naturally pins to the new max; otherwise it preserves the current content.
688
865
  programmaticScrollInProgress = true
689
866
  void heightManager.runDynamicUpdate(() => {
690
- const unclamped = currentScrollTop + deltaMax
691
- const newScrollTop = Math.max(0, Math.min(nextMaxScrollTop, unclamped))
692
- heightManager.viewport.scrollTop = newScrollTop
693
- heightManager.scrollTop = newScrollTop
867
+ const newScrollTop = clampValue(
868
+ currentScrollTop + deltaMax,
869
+ 0,
870
+ nextMaxScrollTop
871
+ )
872
+ syncScrollTop(newScrollTop)
694
873
  log('[SVL] items-length-change:applied', {
695
874
  instanceId,
696
875
  previousScrollTop: currentScrollTop,
@@ -706,23 +885,20 @@
706
885
  // Reconcile on next frame in case measured heights adjust totals
707
886
  requestAnimationFrame(() => {
708
887
  const beforeReconcileScrollTop = heightManager.viewport.scrollTop
709
- const reconciledNextMax = Math.max(0, totalHeight() - height)
888
+ const reconciledNextMax = clampValue(totalHeight() - height, 0, Infinity)
710
889
  const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
711
890
  // Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
712
- const desiredScrollTop = Math.max(
891
+ const desiredScrollTop = clampValue(
892
+ newScrollTop + reconciledDeltaMaxChange,
713
893
  0,
714
- Math.min(reconciledNextMax, newScrollTop + reconciledDeltaMaxChange)
894
+ reconciledNextMax
715
895
  )
716
896
  // Snap to integer pixels to prevent oscillation due to subpixel rounding
717
897
  const desiredRounded = Math.round(desiredScrollTop)
718
898
  const diffToDesired = desiredRounded - heightManager.viewport.scrollTop
719
- if (Math.abs(diffToDesired) >= 1) {
720
- const adjusted = Math.max(
721
- 0,
722
- Math.min(reconciledNextMax, desiredRounded)
723
- )
724
- heightManager.viewport.scrollTop = adjusted
725
- heightManager.scrollTop = adjusted
899
+ if (Math.abs(diffToDesired) >= 2) {
900
+ const adjusted = clampValue(desiredRounded, 0, reconciledNextMax)
901
+ syncScrollTop(adjusted)
726
902
  log('[SVL] items-length-change:reconciled', {
727
903
  instanceId,
728
904
  beforeReconcileScrollTop,
@@ -798,31 +974,18 @@
798
974
  if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
799
975
  const viewportHeight = height || 0
800
976
 
801
- // For bottomToTop mode, don't calculate visible range until properly initialized
802
- // This prevents showing wrong items when scrollTop starts at 0
803
- if (
804
- mode === 'bottomToTop' &&
805
- !heightManager.initialized &&
806
- heightManager.scrollTop === 0 &&
807
- viewportHeight > 0
808
- ) {
809
- // Calculate what the correct scroll position should be
810
- const targetScrollTop = Math.max(0, totalHeight() - viewportHeight)
811
-
812
- // Use the target scroll position for visible range calculation
813
- lastVisibleRange = calculateVisibleRange(
814
- targetScrollTop,
815
- viewportHeight,
816
- heightManager.averageHeight,
817
- items.length,
818
- bufferSize,
819
- mode,
820
- atBottom,
821
- wasAtBottomBeforeHeightChange,
822
- lastVisibleRange,
823
- totalHeight(),
824
- heightManager.getHeightCache()
825
- )
977
+ // For bottomToTop mode, always render items starting from index 0 during initialization
978
+ // This ensures Item 0 is in the DOM so we can use scrollIntoView for precise positioning
979
+ // The scrollIntoView in updateHeightAndScroll will handle correct alignment after heights are measured
980
+ // Use bottomToTopScrollComplete (not just initialized) to keep init path active until scroll is done
981
+ if (mode === 'bottomToTop' && !bottomToTopScrollComplete) {
982
+ // Use a reasonable default if viewport height isn't measured yet
983
+ const effectiveViewport = viewportHeight || 400
984
+ const visibleCount = Math.ceil(effectiveViewport / heightManager.averageHeight) + 1
985
+ lastVisibleRange = {
986
+ start: 0,
987
+ end: Math.min(items.length, visibleCount + bufferSize * 2)
988
+ } as SvelteVirtualListPreviousVisibleRange
826
989
 
827
990
  return lastVisibleRange
828
991
  }
@@ -868,35 +1031,49 @@
868
1031
  const handleScroll = () => {
869
1032
  if (!BROWSER || !heightManager.viewportElement) return
870
1033
 
871
- if (!isScrolling) {
872
- isScrolling = true
873
- rafSchedule(() => {
874
- const current = heightManager.viewport.scrollTop
875
- if (mode === 'bottomToTop') {
876
- const delta = lastScrollTopSnapshot - current
877
- if (delta > 0.5) {
878
- // Widen suppression to avoid fighting peer instance corrections
879
- suppressBottomAnchoringUntilMs = performance.now() + 450
880
- userHasScrolledAway = true
881
- }
882
- }
883
- lastScrollTopSnapshot = current
884
- heightManager.scrollTop = current
885
- updateDebugTailDistance()
886
- if (INTERNAL_DEBUG) {
887
- const vr = visibleItems()
888
- log('scroll', {
889
- mode,
890
- scrollTop: heightManager.scrollTop,
891
- height,
892
- totalHeight: totalHeight(),
893
- averageItemHeight: heightManager.averageHeight,
894
- visibleRange: vr
895
- })
896
- }
897
- isScrolling = false
898
- })
1034
+ // Mark active scrolling and debounce idle transition (~120ms)
1035
+ isScrolling = true
1036
+ if (scrollIdleTimer) {
1037
+ clearTimeout(scrollIdleTimer)
1038
+ scrollIdleTimer = null
899
1039
  }
1040
+ scrollIdleTimer = window.setTimeout(() => {
1041
+ isScrolling = false
1042
+ // Apply deferred anchor correction on idle
1043
+ if (idleCorrectionsOnly || anchorModeEnabled) {
1044
+ reconcileToAnchorIfEnabled()
1045
+ }
1046
+ }, 250)
1047
+
1048
+ rafSchedule(() => {
1049
+ const current = heightManager.viewport.scrollTop
1050
+ if (mode === 'bottomToTop') {
1051
+ const delta = lastScrollTopSnapshot - current
1052
+ if (delta > 0.5) {
1053
+ // Widen suppression to avoid fighting peer instance corrections
1054
+ suppressBottomAnchoringUntilMs = performance.now() + 450
1055
+ userHasScrolledAway = true
1056
+ }
1057
+ }
1058
+ lastScrollTopSnapshot = current
1059
+ heightManager.scrollTop = current
1060
+ updateDebugTailDistance()
1061
+ if (anchorModeEnabled) {
1062
+ captureAnchor()
1063
+ }
1064
+ if (INTERNAL_DEBUG) {
1065
+ const vr = visibleItems()
1066
+ log('[SVL] scroll', {
1067
+ mode,
1068
+ scrollTop: heightManager.scrollTop,
1069
+ height,
1070
+ totalHeight: totalHeight(),
1071
+ averageItemHeight: heightManager.averageHeight,
1072
+ visibleRange: vr
1073
+ })
1074
+ }
1075
+ // isScrolling cleared by idle timer
1076
+ })
900
1077
  }
901
1078
 
902
1079
  /**
@@ -920,7 +1097,8 @@
920
1097
  mode
921
1098
  })
922
1099
  if (!heightManager.initialized && mode === 'bottomToTop') {
923
- // Deterministic init order: double RAF + microtask, then apply bottom anchoring
1100
+ // bottomToTop initialization: use scrollIntoView on Item 0 for precise positioning
1101
+ // visibleItems() guarantees Item 0 is rendered during initialization
924
1102
  tick().then(() => {
925
1103
  requestAnimationFrame(() => {
926
1104
  requestAnimationFrame(() => {
@@ -928,11 +1106,7 @@
928
1106
  const measuredHeight =
929
1107
  heightManager.container.getBoundingClientRect().height
930
1108
  height = measuredHeight
931
- const targetScrollTop = calculateScrollPosition(
932
- items.length,
933
- heightManager.averageHeight,
934
- measuredHeight
935
- )
1109
+
936
1110
  // Instance jitter to avoid same-frame collisions when two lists init together
937
1111
  const cleanedId = String(instanceId)
938
1112
  .toLowerCase()
@@ -942,32 +1116,48 @@
942
1116
  const jitterMs = Number.isNaN(parsed)
943
1117
  ? Math.floor(Math.random() * 3)
944
1118
  : parsed % 3
945
- log('b2t-init', { measuredHeight, targetScrollTop, jitterMs })
1119
+
946
1120
  setTimeout(() => {
947
- heightManager.viewport.scrollTop = targetScrollTop
948
- heightManager.scrollTop = targetScrollTop
1121
+ // Step 1: Set initialized (for other purposes like scroll event handling)
1122
+ // The init path in visibleItems() stays active until bottomToTopScrollComplete
1123
+ if (!heightManager.initialized) {
1124
+ heightManager.initialized = true
1125
+ }
1126
+
1127
+ // Step 2: Use scrollIntoView on Item 0 for precise positioning
1128
+ // Use double RAF to ensure heights are measured and layout is stable
949
1129
  requestAnimationFrame(() => {
950
- // Guard: only transition false -> true to avoid invariant error
951
- if (!heightManager.initialized) heightManager.initialized = true
952
- // Post-init verification: ensure item 0 bottom aligns; fallback to native
953
- tick().then(() => {
1130
+ requestAnimationFrame(() => {
1131
+ // Item 0 is guaranteed to be in DOM due to init path
1132
+ // Skip if user has already scrolled (scrollTop significantly != 0)
1133
+ const currentScroll = heightManager.viewport.scrollTop
1134
+ const userHasScrolled =
1135
+ currentScroll > heightManager.averageHeight
954
1136
  const el = heightManager.viewport.querySelector(
955
1137
  '[data-original-index="0"]'
956
1138
  ) as HTMLElement | null
957
- if (!el) return
958
- const cont = heightManager.viewport.getBoundingClientRect()
959
- const r = el.getBoundingClientRect()
960
- const tol = 4
961
- const aligned =
962
- Math.abs(cont.y + cont.height - (r.y + r.height)) <= tol
963
- if (!aligned) {
964
- el.scrollIntoView({ block: 'end', inline: 'nearest' })
965
- heightManager.scrollTop = heightManager.viewport.scrollTop
966
- log('b2t-init-native-fallback', {
967
- containerBottom: cont.y + cont.height,
968
- itemBottom: r.y + r.height
1139
+
1140
+ if (el && !userHasScrolled) {
1141
+ el.scrollIntoView({
1142
+ block: 'end',
1143
+ inline: 'nearest'
969
1144
  })
1145
+ heightManager.scrollTop = heightManager.viewport.scrollTop
1146
+ } else if (userHasScrolled) {
1147
+ // Sync internal state with current scroll
1148
+ heightManager.scrollTop = currentScroll
970
1149
  }
1150
+
1151
+ // Step 3: Mark scroll complete - switches visibleItems to normal mode
1152
+ requestAnimationFrame(() => {
1153
+ bottomToTopScrollComplete = true
1154
+ // Reset bottom-anchoring flag to prevent stale state from init
1155
+ // affecting later operations (e.g., adding items while scrolled away)
1156
+ wasAtBottomBeforeHeightChange = false
1157
+ // Suppress bottom-anchoring briefly to let heights stabilize
1158
+ // after switching to normal mode
1159
+ suppressBottomAnchoringUntilMs = performance.now() + 200
1160
+ })
971
1161
  })
972
1162
  })
973
1163
  }, jitterMs)
@@ -1192,7 +1382,7 @@
1192
1382
  `scroll: index ${targetIndex} is out of bounds (0-${items.length - 1})`
1193
1383
  )
1194
1384
  } else {
1195
- targetIndex = Math.max(0, Math.min(targetIndex, items.length - 1))
1385
+ targetIndex = clampValue(targetIndex, 0, items.length - 1)
1196
1386
  }
1197
1387
  }
1198
1388
 
@@ -1348,7 +1538,6 @@
1348
1538
  id="virtual-list-items"
1349
1539
  {...testId ? { 'data-testid': `${testId}-items` } : {}}
1350
1540
  class={itemsClass ?? 'virtual-list-items'}
1351
- style:visibility={height === 0 && mode === 'bottomToTop' ? 'hidden' : 'visible'}
1352
1541
  style:transform="translateY({(() => {
1353
1542
  const viewportHeight = height || measuredFallbackHeight || 0
1354
1543
  const visibleRange = visibleItems()