@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 +51 -0
- package/dist/SvelteVirtualList.svelte +307 -118
- package/dist/SvelteVirtualList.svelte.d.ts +40 -0
- package/dist/reactive-list-manager/RecomputeScheduler.d.ts +78 -0
- package/dist/reactive-list-manager/RecomputeScheduler.js +78 -9
- package/dist/types.d.ts +15 -0
- package/dist/utils/heightChangeDetection.d.ts +32 -7
- package/dist/utils/heightChangeDetection.js +32 -7
- package/dist/utils/scrollCalculation.d.ts +35 -0
- package/dist/utils/scrollCalculation.js +99 -71
- package/dist/utils/virtualList.d.ts +64 -0
- package/dist/utils/virtualList.js +83 -12
- package/package.json +30 -30
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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 =
|
|
506
|
-
|
|
507
|
-
|
|
680
|
+
const adjusted = clampValue(
|
|
681
|
+
currentScrollTop + deltaTotal,
|
|
682
|
+
0,
|
|
683
|
+
maxScrollTop
|
|
508
684
|
)
|
|
509
|
-
|
|
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
|
-
|
|
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
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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 =
|
|
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 =
|
|
891
|
+
const desiredScrollTop = clampValue(
|
|
892
|
+
newScrollTop + reconciledDeltaMaxChange,
|
|
713
893
|
0,
|
|
714
|
-
|
|
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) >=
|
|
720
|
-
const adjusted =
|
|
721
|
-
|
|
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,
|
|
802
|
-
// This
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
viewportHeight
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1119
|
+
|
|
946
1120
|
setTimeout(() => {
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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 =
|
|
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()
|