@humanspeak/svelte-virtual-list 0.3.5 β 0.3.8
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 +301 -58
- 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.js +18 -2
- package/dist/utils/virtualList.d.ts +28 -0
- package/dist/utils/virtualList.js +46 -2
- package/package.json +34 -33
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
|
|
|
@@ -166,7 +166,9 @@
|
|
|
166
166
|
calculateScrollPosition,
|
|
167
167
|
calculateTransformY,
|
|
168
168
|
calculateVisibleRange,
|
|
169
|
-
updateHeightAndScroll as utilsUpdateHeightAndScroll
|
|
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,108 @@
|
|
|
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 = Math.max(0, totalHeight() - (height || 0))
|
|
294
|
+
let targetTop: number
|
|
295
|
+
if (mode === 'bottomToTop') {
|
|
296
|
+
const distanceFromStart = Math.max(0, offsetToIndex + lastAnchorOffset)
|
|
297
|
+
targetTop = Math.max(
|
|
298
|
+
0,
|
|
299
|
+
Math.min(maxScrollTop, Math.round(maxScrollTop - distanceFromStart))
|
|
300
|
+
)
|
|
301
|
+
} else {
|
|
302
|
+
targetTop = Math.max(
|
|
303
|
+
0,
|
|
304
|
+
Math.min(maxScrollTop, Math.round(offsetToIndex + lastAnchorOffset))
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
if (Math.abs(heightManager.viewport.scrollTop - targetTop) >= 2) {
|
|
308
|
+
heightManager.viewport.scrollTop = targetTop
|
|
309
|
+
heightManager.scrollTop = targetTop
|
|
310
|
+
}
|
|
311
|
+
pendingAnchorReconcile = false
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Runs a batch of updates with scroll corrections coalesced until the batch completes.
|
|
316
|
+
*
|
|
317
|
+
* Use this method when making multiple changes to the items array to prevent
|
|
318
|
+
* intermediate scroll corrections. The scroll position reconciliation is deferred
|
|
319
|
+
* until the batch exits, ensuring smooth visual updates.
|
|
320
|
+
*
|
|
321
|
+
* @param {() => void} fn - The function containing batch updates to execute.
|
|
322
|
+
* @returns {void}
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* ```typescript
|
|
326
|
+
* // Add multiple items without intermediate scroll corrections
|
|
327
|
+
* list.runInBatch(() => {
|
|
328
|
+
* items.push(newItem1);
|
|
329
|
+
* items.push(newItem2);
|
|
330
|
+
* items.push(newItem3);
|
|
331
|
+
* });
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
export const runInBatch = (fn: () => void): void => {
|
|
335
|
+
batchDepth += 1
|
|
336
|
+
try {
|
|
337
|
+
fn()
|
|
338
|
+
} finally {
|
|
339
|
+
batchDepth = Math.max(0, batchDepth - 1)
|
|
340
|
+
if (batchUpdatesEnabled && batchDepth === 0) {
|
|
341
|
+
reconcileToAnchorIfEnabled()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
225
345
|
let lastMeasuredIndex = $state(-1) // Index of last measured item
|
|
226
346
|
let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
|
|
227
347
|
|
|
@@ -306,6 +426,25 @@
|
|
|
306
426
|
if (!heightManager.viewportElement || !heightManager.initialized || userHasScrolledAway) {
|
|
307
427
|
return
|
|
308
428
|
}
|
|
429
|
+
// Coalesce adjustments during active scroll; apply on idle
|
|
430
|
+
if (isScrolling) {
|
|
431
|
+
// Accumulate net change above viewport and defer application
|
|
432
|
+
let pending = 0
|
|
433
|
+
const currentVisibleRange = visibleItems()
|
|
434
|
+
for (const change of heightChanges) {
|
|
435
|
+
if (change.index < currentVisibleRange.start) pending += change.delta
|
|
436
|
+
}
|
|
437
|
+
if (pending !== 0) {
|
|
438
|
+
// Store on the viewport element to avoid extra module globals
|
|
439
|
+
const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
|
|
440
|
+
const prev = (heightManager.viewport as unknown as Record<string, number>)[
|
|
441
|
+
key as string
|
|
442
|
+
] as number | undefined
|
|
443
|
+
;(heightManager.viewport as unknown as Record<string, number>)[key as string] =
|
|
444
|
+
(prev ?? 0) + pending
|
|
445
|
+
}
|
|
446
|
+
return
|
|
447
|
+
}
|
|
309
448
|
|
|
310
449
|
/**
|
|
311
450
|
* CRITICAL: BottomToTop Mode Height Change Fix
|
|
@@ -368,7 +507,7 @@
|
|
|
368
507
|
|
|
369
508
|
// Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
|
|
370
509
|
const approximateScrollTop = Math.max(0, totalHeight() - height)
|
|
371
|
-
log('b2t-correction-approx', { approximateScrollTop })
|
|
510
|
+
log('[SVL] b2t-correction-approx', { approximateScrollTop })
|
|
372
511
|
heightManager.viewport.scrollTop = approximateScrollTop
|
|
373
512
|
heightManager.scrollTop = approximateScrollTop
|
|
374
513
|
|
|
@@ -392,7 +531,7 @@
|
|
|
392
531
|
behavior: 'smooth', // Smooth animation for better UX
|
|
393
532
|
inline: 'nearest' // Minimal horizontal adjustment
|
|
394
533
|
})
|
|
395
|
-
log('b2t-correction-native', {
|
|
534
|
+
log('[SVL] b2t-correction-native', {
|
|
396
535
|
containerBottom: contRect.y + contRect.height,
|
|
397
536
|
itemBottom: itemRect.y + itemRect.height
|
|
398
537
|
})
|
|
@@ -422,7 +561,17 @@
|
|
|
422
561
|
}
|
|
423
562
|
|
|
424
563
|
// If there are height changes above the viewport, adjust scroll to maintain position
|
|
425
|
-
|
|
564
|
+
// Include any pending coalesced delta (when scrolling)
|
|
565
|
+
{
|
|
566
|
+
const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
|
|
567
|
+
const pending =
|
|
568
|
+
(heightManager.viewport as unknown as Record<string, number>)[key as string] ?? 0
|
|
569
|
+
if (pending) {
|
|
570
|
+
heightChangeAboveViewport += pending
|
|
571
|
+
;(heightManager.viewport as unknown as Record<string, number>)[key as string] = 0
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (Math.abs(heightChangeAboveViewport) > 2) {
|
|
426
575
|
const newScrollTop = Math.min(
|
|
427
576
|
maxScrollTop,
|
|
428
577
|
Math.max(0, currentScrollTop + heightChangeAboveViewport)
|
|
@@ -461,6 +610,22 @@
|
|
|
461
610
|
heightManager.updateItemLength(items.length)
|
|
462
611
|
})
|
|
463
612
|
|
|
613
|
+
// Infinite scroll: trigger onLoadMore when approaching end of list
|
|
614
|
+
$effect(() => {
|
|
615
|
+
if (!BROWSER || !onLoadMore || !hasMore || isLoadingMore) return
|
|
616
|
+
|
|
617
|
+
const range = visibleItems()
|
|
618
|
+
const atLoadingEdge = range.end >= items.length - loadMoreThreshold
|
|
619
|
+
const insufficientItems = items.length < loadMoreThreshold && heightManager.initialized
|
|
620
|
+
|
|
621
|
+
if (atLoadingEdge || insufficientItems) {
|
|
622
|
+
isLoadingMore = true
|
|
623
|
+
Promise.resolve(onLoadMore()).finally(() => {
|
|
624
|
+
isLoadingMore = false
|
|
625
|
+
})
|
|
626
|
+
}
|
|
627
|
+
})
|
|
628
|
+
|
|
464
629
|
const updateHeight = () => {
|
|
465
630
|
// Capture previous total height for scroll correction (topToBottom anchoring)
|
|
466
631
|
prevTotalHeightForScrollCorrection = heightManager.totalHeight
|
|
@@ -502,9 +667,8 @@
|
|
|
502
667
|
const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) <= tolerance
|
|
503
668
|
if (isAtBottom) {
|
|
504
669
|
// Adjust scrollTop by total height delta to hold bottom anchor
|
|
505
|
-
const adjusted = Math.
|
|
506
|
-
maxScrollTop,
|
|
507
|
-
Math.max(0, currentScrollTop + deltaTotal)
|
|
670
|
+
const adjusted = Math.round(
|
|
671
|
+
Math.min(maxScrollTop, Math.max(0, currentScrollTop + deltaTotal))
|
|
508
672
|
)
|
|
509
673
|
heightManager.viewport.scrollTop = adjusted
|
|
510
674
|
heightManager.scrollTop = adjusted
|
|
@@ -537,6 +701,8 @@
|
|
|
537
701
|
let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
|
|
538
702
|
let lastCalculatedHeight = $state(0)
|
|
539
703
|
let lastItemsLength = $state(0)
|
|
704
|
+
// Track last observed total height to compute precise deltas on item count changes
|
|
705
|
+
let lastTotalHeightObserved = $state(0)
|
|
540
706
|
|
|
541
707
|
/**
|
|
542
708
|
* CRITICAL: O(1) Reactive Total Height Calculation
|
|
@@ -620,6 +786,7 @@
|
|
|
620
786
|
heightChanged &&
|
|
621
787
|
!userHasScrolledAway &&
|
|
622
788
|
!isAtBottom && // Don't apply aggressive correction when at bottom
|
|
789
|
+
!isScrolling && // Skip aggressive corrections during active scroll
|
|
623
790
|
!programmaticScrollInProgress && // Don't interfere with programmatic scrolls
|
|
624
791
|
performance.now() >= suppressBottomAnchoringUntilMs &&
|
|
625
792
|
!heightManager.isDynamicUpdateInProgress &&
|
|
@@ -661,34 +828,96 @@
|
|
|
661
828
|
const currentCalculatedItemHeight = heightManager.averageHeight
|
|
662
829
|
const currentHeight = height
|
|
663
830
|
const currentTotalHeight = totalHeight()
|
|
664
|
-
const
|
|
831
|
+
const prevTotalHeight =
|
|
832
|
+
lastTotalHeightObserved ||
|
|
833
|
+
currentTotalHeight - itemsAdded * currentCalculatedItemHeight
|
|
834
|
+
const prevMaxScrollTop = Math.max(0, prevTotalHeight - currentHeight)
|
|
835
|
+
const nextMaxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
|
|
836
|
+
const deltaMax = nextMaxScrollTop - prevMaxScrollTop
|
|
837
|
+
log('[SVL] items-length-change:before', {
|
|
838
|
+
instanceId,
|
|
839
|
+
itemsAdded,
|
|
840
|
+
lastItemsLength,
|
|
841
|
+
currentItemsLength,
|
|
842
|
+
currentScrollTop,
|
|
843
|
+
prevTotalHeight,
|
|
844
|
+
currentTotalHeight,
|
|
845
|
+
prevMaxScrollTop,
|
|
846
|
+
nextMaxScrollTop,
|
|
847
|
+
deltaMax,
|
|
848
|
+
averageItemHeight: currentCalculatedItemHeight
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
// Maintain visual position for ALL cases by advancing scrollTop by deltaMax.
|
|
852
|
+
// If near the bottom, this naturally pins to the new max; otherwise it preserves the current content.
|
|
853
|
+
programmaticScrollInProgress = true
|
|
854
|
+
void heightManager.runDynamicUpdate(() => {
|
|
855
|
+
const unclamped = currentScrollTop + deltaMax
|
|
856
|
+
const newScrollTop = Math.max(0, Math.min(nextMaxScrollTop, unclamped))
|
|
857
|
+
heightManager.viewport.scrollTop = newScrollTop
|
|
858
|
+
heightManager.scrollTop = newScrollTop
|
|
859
|
+
log('[SVL] items-length-change:applied', {
|
|
860
|
+
instanceId,
|
|
861
|
+
previousScrollTop: currentScrollTop,
|
|
862
|
+
appliedScrollTop: newScrollTop,
|
|
863
|
+
prevMaxScrollTop,
|
|
864
|
+
nextMaxScrollTop,
|
|
865
|
+
deltaMax
|
|
866
|
+
})
|
|
665
867
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
868
|
+
// We are explicitly managing position; consider this a programmatic action.
|
|
869
|
+
// Do not flip userHasScrolledAway here; it should reflect user intent only.
|
|
870
|
+
|
|
871
|
+
// Reconcile on next frame in case measured heights adjust totals
|
|
872
|
+
requestAnimationFrame(() => {
|
|
873
|
+
const beforeReconcileScrollTop = heightManager.viewport.scrollTop
|
|
874
|
+
const reconciledNextMax = Math.max(0, totalHeight() - height)
|
|
875
|
+
const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
|
|
876
|
+
// Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
|
|
877
|
+
const desiredScrollTop = Math.max(
|
|
878
|
+
0,
|
|
879
|
+
Math.min(reconciledNextMax, newScrollTop + reconciledDeltaMaxChange)
|
|
880
|
+
)
|
|
881
|
+
// Snap to integer pixels to prevent oscillation due to subpixel rounding
|
|
882
|
+
const desiredRounded = Math.round(desiredScrollTop)
|
|
883
|
+
const diffToDesired = desiredRounded - heightManager.viewport.scrollTop
|
|
884
|
+
if (Math.abs(diffToDesired) >= 2) {
|
|
885
|
+
const adjusted = Math.max(
|
|
671
886
|
0,
|
|
672
|
-
|
|
887
|
+
Math.min(reconciledNextMax, desiredRounded)
|
|
673
888
|
)
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
889
|
+
heightManager.viewport.scrollTop = adjusted
|
|
890
|
+
heightManager.scrollTop = adjusted
|
|
891
|
+
log('[SVL] items-length-change:reconciled', {
|
|
892
|
+
instanceId,
|
|
893
|
+
beforeReconcileScrollTop,
|
|
894
|
+
adjustedScrollTop: adjusted,
|
|
895
|
+
reconciledNextMax,
|
|
896
|
+
reconciledDeltaMaxChange,
|
|
897
|
+
desiredScrollTop,
|
|
898
|
+
desiredRounded,
|
|
899
|
+
diffToDesired
|
|
900
|
+
})
|
|
901
|
+
} else {
|
|
902
|
+
log('[SVL] items-length-change:reconciled-skip', {
|
|
903
|
+
instanceId,
|
|
904
|
+
beforeReconcileScrollTop,
|
|
905
|
+
reconciledNextMax,
|
|
906
|
+
reconciledDeltaMaxChange,
|
|
907
|
+
desiredScrollTop,
|
|
908
|
+
desiredRounded,
|
|
909
|
+
diffToDesired
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
programmaticScrollInProgress = false
|
|
686
913
|
})
|
|
687
|
-
}
|
|
914
|
+
})
|
|
688
915
|
}
|
|
689
916
|
}
|
|
690
917
|
|
|
691
918
|
lastItemsLength = currentItemsLength
|
|
919
|
+
// Update last observed total height at the end of the effect
|
|
920
|
+
lastTotalHeightObserved = totalHeight()
|
|
692
921
|
})
|
|
693
922
|
|
|
694
923
|
// Update container height continuously to reflect layout changes that
|
|
@@ -804,35 +1033,49 @@
|
|
|
804
1033
|
const handleScroll = () => {
|
|
805
1034
|
if (!BROWSER || !heightManager.viewportElement) return
|
|
806
1035
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
const delta = lastScrollTopSnapshot - current
|
|
813
|
-
if (delta > 0.5) {
|
|
814
|
-
// Widen suppression to avoid fighting peer instance corrections
|
|
815
|
-
suppressBottomAnchoringUntilMs = performance.now() + 450
|
|
816
|
-
userHasScrolledAway = true
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
lastScrollTopSnapshot = current
|
|
820
|
-
heightManager.scrollTop = current
|
|
821
|
-
updateDebugTailDistance()
|
|
822
|
-
if (INTERNAL_DEBUG) {
|
|
823
|
-
const vr = visibleItems()
|
|
824
|
-
log('scroll', {
|
|
825
|
-
mode,
|
|
826
|
-
scrollTop: heightManager.scrollTop,
|
|
827
|
-
height,
|
|
828
|
-
totalHeight: totalHeight(),
|
|
829
|
-
averageItemHeight: heightManager.averageHeight,
|
|
830
|
-
visibleRange: vr
|
|
831
|
-
})
|
|
832
|
-
}
|
|
833
|
-
isScrolling = false
|
|
834
|
-
})
|
|
1036
|
+
// Mark active scrolling and debounce idle transition (~120ms)
|
|
1037
|
+
isScrolling = true
|
|
1038
|
+
if (scrollIdleTimer) {
|
|
1039
|
+
clearTimeout(scrollIdleTimer)
|
|
1040
|
+
scrollIdleTimer = null
|
|
835
1041
|
}
|
|
1042
|
+
scrollIdleTimer = window.setTimeout(() => {
|
|
1043
|
+
isScrolling = false
|
|
1044
|
+
// Apply deferred anchor correction on idle
|
|
1045
|
+
if (idleCorrectionsOnly || anchorModeEnabled) {
|
|
1046
|
+
reconcileToAnchorIfEnabled()
|
|
1047
|
+
}
|
|
1048
|
+
}, 250)
|
|
1049
|
+
|
|
1050
|
+
rafSchedule(() => {
|
|
1051
|
+
const current = heightManager.viewport.scrollTop
|
|
1052
|
+
if (mode === 'bottomToTop') {
|
|
1053
|
+
const delta = lastScrollTopSnapshot - current
|
|
1054
|
+
if (delta > 0.5) {
|
|
1055
|
+
// Widen suppression to avoid fighting peer instance corrections
|
|
1056
|
+
suppressBottomAnchoringUntilMs = performance.now() + 450
|
|
1057
|
+
userHasScrolledAway = true
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
lastScrollTopSnapshot = current
|
|
1061
|
+
heightManager.scrollTop = current
|
|
1062
|
+
updateDebugTailDistance()
|
|
1063
|
+
if (anchorModeEnabled) {
|
|
1064
|
+
captureAnchor()
|
|
1065
|
+
}
|
|
1066
|
+
if (INTERNAL_DEBUG) {
|
|
1067
|
+
const vr = visibleItems()
|
|
1068
|
+
log('[SVL] scroll', {
|
|
1069
|
+
mode,
|
|
1070
|
+
scrollTop: heightManager.scrollTop,
|
|
1071
|
+
height,
|
|
1072
|
+
totalHeight: totalHeight(),
|
|
1073
|
+
averageItemHeight: heightManager.averageHeight,
|
|
1074
|
+
visibleRange: vr
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
// isScrolling cleared by idle timer
|
|
1078
|
+
})
|
|
836
1079
|
}
|
|
837
1080
|
|
|
838
1081
|
/**
|
|
@@ -92,6 +92,26 @@ import { type SvelteVirtualListProps, type SvelteVirtualListScrollOptions } from
|
|
|
92
92
|
declare function $$render<TItem = unknown>(): {
|
|
93
93
|
props: SvelteVirtualListProps<TItem>;
|
|
94
94
|
exports: {
|
|
95
|
+
/**
|
|
96
|
+
* Runs a batch of updates with scroll corrections coalesced until the batch completes.
|
|
97
|
+
*
|
|
98
|
+
* Use this method when making multiple changes to the items array to prevent
|
|
99
|
+
* intermediate scroll corrections. The scroll position reconciliation is deferred
|
|
100
|
+
* until the batch exits, ensuring smooth visual updates.
|
|
101
|
+
*
|
|
102
|
+
* @param {() => void} fn - The function containing batch updates to execute.
|
|
103
|
+
* @returns {void}
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* // Add multiple items without intermediate scroll corrections
|
|
108
|
+
* list.runInBatch(() => {
|
|
109
|
+
* items.push(newItem1);
|
|
110
|
+
* items.push(newItem2);
|
|
111
|
+
* items.push(newItem3);
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/ runInBatch: (fn: () => void) => void;
|
|
95
115
|
/**
|
|
96
116
|
* Scrolls the virtual list to the item at the given index.
|
|
97
117
|
*
|
|
@@ -159,6 +179,26 @@ declare class __sveltets_Render<TItem = unknown> {
|
|
|
159
179
|
slots(): ReturnType<typeof $$render<TItem>>['slots'];
|
|
160
180
|
bindings(): "";
|
|
161
181
|
exports(): {
|
|
182
|
+
/**
|
|
183
|
+
* Runs a batch of updates with scroll corrections coalesced until the batch completes.
|
|
184
|
+
*
|
|
185
|
+
* Use this method when making multiple changes to the items array to prevent
|
|
186
|
+
* intermediate scroll corrections. The scroll position reconciliation is deferred
|
|
187
|
+
* until the batch exits, ensuring smooth visual updates.
|
|
188
|
+
*
|
|
189
|
+
* @param {() => void} fn - The function containing batch updates to execute.
|
|
190
|
+
* @returns {void}
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```typescript
|
|
194
|
+
* // Add multiple items without intermediate scroll corrections
|
|
195
|
+
* list.runInBatch(() => {
|
|
196
|
+
* items.push(newItem1);
|
|
197
|
+
* items.push(newItem2);
|
|
198
|
+
* items.push(newItem3);
|
|
199
|
+
* });
|
|
200
|
+
* ```
|
|
201
|
+
*/ runInBatch: (fn: () => void) => void;
|
|
162
202
|
/**
|
|
163
203
|
* Scrolls the virtual list to the item at the given index.
|
|
164
204
|
*
|
|
@@ -1,13 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler that coalesces recompute requests to the next animation frame.
|
|
3
|
+
*
|
|
4
|
+
* This class provides efficient batching of recompute operations by scheduling
|
|
5
|
+
* them to run on the next animation frame in browser environments. In non-browser
|
|
6
|
+
* or jsdom environments, it falls back to setTimeout(0) for deterministic testing.
|
|
7
|
+
*
|
|
8
|
+
* Key features:
|
|
9
|
+
* - Coalesces multiple schedule() calls into a single recompute
|
|
10
|
+
* - Supports temporary blocking during critical sections
|
|
11
|
+
* - Handles nested block/unblock calls with depth tracking
|
|
12
|
+
* - Environment-aware: uses RAF in browsers, setTimeout in tests
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const scheduler = new RecomputeScheduler(() => {
|
|
17
|
+
* console.log('Recomputing derived state');
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* // Multiple calls within the same frame are coalesced
|
|
21
|
+
* scheduler.schedule();
|
|
22
|
+
* scheduler.schedule();
|
|
23
|
+
* scheduler.schedule(); // Only one recompute will run
|
|
24
|
+
*
|
|
25
|
+
* // Block during critical sections
|
|
26
|
+
* scheduler.block();
|
|
27
|
+
* scheduler.schedule(); // Marked as pending, won't run yet
|
|
28
|
+
* scheduler.unblock(); // Runs immediately if pending
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @class
|
|
32
|
+
*/
|
|
1
33
|
export declare class RecomputeScheduler {
|
|
34
|
+
/** Callback function to execute on recompute. */
|
|
2
35
|
private onRecompute;
|
|
36
|
+
/** Whether a recompute is currently scheduled. */
|
|
3
37
|
private isScheduled;
|
|
38
|
+
/** Whether a recompute is pending due to blocking. */
|
|
4
39
|
private isPending;
|
|
40
|
+
/** Current nesting depth of block() calls. */
|
|
5
41
|
private blockDepth;
|
|
42
|
+
/** ID of the pending setTimeout (non-browser fallback). */
|
|
6
43
|
private timeoutId;
|
|
44
|
+
/** ID of the pending requestAnimationFrame. */
|
|
7
45
|
private rafId;
|
|
46
|
+
/**
|
|
47
|
+
* Creates a new RecomputeScheduler instance.
|
|
48
|
+
*
|
|
49
|
+
* @param {() => void} onRecompute - Callback function to execute when recompute runs.
|
|
50
|
+
*/
|
|
8
51
|
constructor(onRecompute: () => void);
|
|
52
|
+
/**
|
|
53
|
+
* Schedules a recompute for the next animation frame.
|
|
54
|
+
*
|
|
55
|
+
* If the scheduler is blocked, the request is marked as pending and will
|
|
56
|
+
* execute when unblocked. Multiple calls while a recompute is already
|
|
57
|
+
* scheduled are coalesced into a single execution.
|
|
58
|
+
*
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
9
61
|
schedule: () => void;
|
|
62
|
+
/**
|
|
63
|
+
* Temporarily blocks recompute execution.
|
|
64
|
+
*
|
|
65
|
+
* Cancels any in-flight timers and marks any pending recompute request.
|
|
66
|
+
* Block calls can be nested; the scheduler remains blocked until all
|
|
67
|
+
* corresponding unblock() calls are made.
|
|
68
|
+
*
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*/
|
|
10
71
|
block: () => void;
|
|
72
|
+
/**
|
|
73
|
+
* Unblocks the scheduler and runs pending recompute if any.
|
|
74
|
+
*
|
|
75
|
+
* Decrements the block depth counter. When the depth reaches zero and
|
|
76
|
+
* a recompute was pending, it executes immediately (synchronously).
|
|
77
|
+
* Guards against underflow if unblock is called without matching block.
|
|
78
|
+
*
|
|
79
|
+
* @returns {void}
|
|
80
|
+
*/
|
|
11
81
|
unblock: () => void;
|
|
82
|
+
/**
|
|
83
|
+
* Cancels any scheduled or pending recompute.
|
|
84
|
+
*
|
|
85
|
+
* Clears all timers (setTimeout and RAF) and resets the scheduled
|
|
86
|
+
* and pending flags. Does not affect the block depth.
|
|
87
|
+
*
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
12
90
|
cancel: () => void;
|
|
13
91
|
}
|
|
@@ -1,19 +1,65 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler that coalesces recompute requests to the next animation frame.
|
|
3
|
+
*
|
|
4
|
+
* This class provides efficient batching of recompute operations by scheduling
|
|
5
|
+
* them to run on the next animation frame in browser environments. In non-browser
|
|
6
|
+
* or jsdom environments, it falls back to setTimeout(0) for deterministic testing.
|
|
7
|
+
*
|
|
8
|
+
* Key features:
|
|
9
|
+
* - Coalesces multiple schedule() calls into a single recompute
|
|
10
|
+
* - Supports temporary blocking during critical sections
|
|
11
|
+
* - Handles nested block/unblock calls with depth tracking
|
|
12
|
+
* - Environment-aware: uses RAF in browsers, setTimeout in tests
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const scheduler = new RecomputeScheduler(() => {
|
|
17
|
+
* console.log('Recomputing derived state');
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* // Multiple calls within the same frame are coalesced
|
|
21
|
+
* scheduler.schedule();
|
|
22
|
+
* scheduler.schedule();
|
|
23
|
+
* scheduler.schedule(); // Only one recompute will run
|
|
24
|
+
*
|
|
25
|
+
* // Block during critical sections
|
|
26
|
+
* scheduler.block();
|
|
27
|
+
* scheduler.schedule(); // Marked as pending, won't run yet
|
|
28
|
+
* scheduler.unblock(); // Runs immediately if pending
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @class
|
|
32
|
+
*/
|
|
6
33
|
export class RecomputeScheduler {
|
|
34
|
+
/** Callback function to execute on recompute. */
|
|
7
35
|
onRecompute;
|
|
36
|
+
/** Whether a recompute is currently scheduled. */
|
|
8
37
|
isScheduled = false;
|
|
38
|
+
/** Whether a recompute is pending due to blocking. */
|
|
9
39
|
isPending = false;
|
|
40
|
+
/** Current nesting depth of block() calls. */
|
|
10
41
|
blockDepth = 0;
|
|
42
|
+
/** ID of the pending setTimeout (non-browser fallback). */
|
|
11
43
|
timeoutId = null;
|
|
44
|
+
/** ID of the pending requestAnimationFrame. */
|
|
12
45
|
rafId = null;
|
|
46
|
+
/**
|
|
47
|
+
* Creates a new RecomputeScheduler instance.
|
|
48
|
+
*
|
|
49
|
+
* @param {() => void} onRecompute - Callback function to execute when recompute runs.
|
|
50
|
+
*/
|
|
13
51
|
constructor(onRecompute) {
|
|
14
52
|
this.onRecompute = onRecompute;
|
|
15
53
|
}
|
|
16
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Schedules a recompute for the next animation frame.
|
|
56
|
+
*
|
|
57
|
+
* If the scheduler is blocked, the request is marked as pending and will
|
|
58
|
+
* execute when unblocked. Multiple calls while a recompute is already
|
|
59
|
+
* scheduled are coalesced into a single execution.
|
|
60
|
+
*
|
|
61
|
+
* @returns {void}
|
|
62
|
+
*/
|
|
17
63
|
schedule = () => {
|
|
18
64
|
if (this.blockDepth > 0) {
|
|
19
65
|
this.isPending = true;
|
|
@@ -45,7 +91,15 @@ export class RecomputeScheduler {
|
|
|
45
91
|
this.onRecompute();
|
|
46
92
|
});
|
|
47
93
|
};
|
|
48
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Temporarily blocks recompute execution.
|
|
96
|
+
*
|
|
97
|
+
* Cancels any in-flight timers and marks any pending recompute request.
|
|
98
|
+
* Block calls can be nested; the scheduler remains blocked until all
|
|
99
|
+
* corresponding unblock() calls are made.
|
|
100
|
+
*
|
|
101
|
+
* @returns {void}
|
|
102
|
+
*/
|
|
49
103
|
block = () => {
|
|
50
104
|
this.blockDepth += 1;
|
|
51
105
|
if (this.timeoutId) {
|
|
@@ -61,7 +115,15 @@ export class RecomputeScheduler {
|
|
|
61
115
|
this.isPending = true;
|
|
62
116
|
}
|
|
63
117
|
};
|
|
64
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Unblocks the scheduler and runs pending recompute if any.
|
|
120
|
+
*
|
|
121
|
+
* Decrements the block depth counter. When the depth reaches zero and
|
|
122
|
+
* a recompute was pending, it executes immediately (synchronously).
|
|
123
|
+
* Guards against underflow if unblock is called without matching block.
|
|
124
|
+
*
|
|
125
|
+
* @returns {void}
|
|
126
|
+
*/
|
|
65
127
|
unblock = () => {
|
|
66
128
|
if (this.blockDepth === 0)
|
|
67
129
|
return;
|
|
@@ -71,7 +133,14 @@ export class RecomputeScheduler {
|
|
|
71
133
|
this.onRecompute();
|
|
72
134
|
}
|
|
73
135
|
};
|
|
74
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Cancels any scheduled or pending recompute.
|
|
138
|
+
*
|
|
139
|
+
* Clears all timers (setTimeout and RAF) and resets the scheduled
|
|
140
|
+
* and pending flags. Does not affect the block depth.
|
|
141
|
+
*
|
|
142
|
+
* @returns {void}
|
|
143
|
+
*/
|
|
75
144
|
cancel = () => {
|
|
76
145
|
if (this.timeoutId) {
|
|
77
146
|
clearTimeout(this.timeoutId);
|
package/dist/types.d.ts
CHANGED
|
@@ -63,6 +63,21 @@ export type SvelteVirtualListProps<TItem = any> = {
|
|
|
63
63
|
* CSS class to apply to the scrollable viewport element.
|
|
64
64
|
*/
|
|
65
65
|
viewportClass?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Callback when more data is needed. Supports sync and async functions.
|
|
68
|
+
* Called when the user scrolls near the end of the list (based on loadMoreThreshold).
|
|
69
|
+
*/
|
|
70
|
+
onLoadMore?: () => void | Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Number of items from the end to trigger onLoadMore.
|
|
73
|
+
* @default 20
|
|
74
|
+
*/
|
|
75
|
+
loadMoreThreshold?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Set to false when all data has been loaded to stop triggering onLoadMore.
|
|
78
|
+
* @default true
|
|
79
|
+
*/
|
|
80
|
+
hasMore?: boolean;
|
|
66
81
|
};
|
|
67
82
|
/**
|
|
68
83
|
* Debug information provided by the virtual list during rendering.
|
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Utility functions for detecting significant height changes in virtual list items
|
|
2
|
+
* Utility functions for detecting significant height changes in virtual list items.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Provides height change detection utilities for virtual list optimization.
|
|
5
|
+
* These functions help determine when item height changes are significant enough to
|
|
6
|
+
* trigger recalculations, preventing unnecessary updates for sub-pixel variations.
|
|
3
7
|
*/
|
|
4
8
|
/**
|
|
5
|
-
* Checks if a height change is significant enough to warrant marking an item as dirty
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Checks if a height change is significant enough to warrant marking an item as dirty.
|
|
10
|
+
*
|
|
11
|
+
* This function compares the new measured height against the cached height for an item
|
|
12
|
+
* and determines if the difference exceeds the specified margin of error. Items with
|
|
13
|
+
* no previous measurement are always considered significant.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} itemIndex - The index of the item in the virtual list.
|
|
16
|
+
* @param {number} newHeight - The new measured height in pixels.
|
|
17
|
+
* @param {Record<number, number>} heightCache - Cache of previously measured item heights.
|
|
18
|
+
* @param {number} [marginOfError=1] - Height difference threshold in pixels. Changes
|
|
19
|
+
* smaller than this value are considered insignificant.
|
|
20
|
+
* @returns {boolean} Returns true if the height change exceeds the margin of error
|
|
21
|
+
* or if this is the first measurement for the item.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* const heightCache = { 0: 40, 1: 50 };
|
|
26
|
+
*
|
|
27
|
+
* // First-time measurement (no cache entry)
|
|
28
|
+
* isSignificantHeightChange(2, 45, heightCache); // true
|
|
29
|
+
*
|
|
30
|
+
* // Significant change (exceeds 1px threshold)
|
|
31
|
+
* isSignificantHeightChange(0, 45, heightCache); // true
|
|
32
|
+
*
|
|
33
|
+
* // Insignificant change (within 1px threshold)
|
|
34
|
+
* isSignificantHeightChange(0, 40.5, heightCache); // false
|
|
35
|
+
* ```
|
|
11
36
|
*/
|
|
12
37
|
export declare const isSignificantHeightChange: (itemIndex: number, newHeight: number, heightCache: Record<number, number>, marginOfError?: number) => boolean;
|
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Utility functions for detecting significant height changes in virtual list items
|
|
2
|
+
* Utility functions for detecting significant height changes in virtual list items.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Provides height change detection utilities for virtual list optimization.
|
|
5
|
+
* These functions help determine when item height changes are significant enough to
|
|
6
|
+
* trigger recalculations, preventing unnecessary updates for sub-pixel variations.
|
|
3
7
|
*/
|
|
4
8
|
/**
|
|
5
|
-
* Checks if a height change is significant enough to warrant marking an item as dirty
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Checks if a height change is significant enough to warrant marking an item as dirty.
|
|
10
|
+
*
|
|
11
|
+
* This function compares the new measured height against the cached height for an item
|
|
12
|
+
* and determines if the difference exceeds the specified margin of error. Items with
|
|
13
|
+
* no previous measurement are always considered significant.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} itemIndex - The index of the item in the virtual list.
|
|
16
|
+
* @param {number} newHeight - The new measured height in pixels.
|
|
17
|
+
* @param {Record<number, number>} heightCache - Cache of previously measured item heights.
|
|
18
|
+
* @param {number} [marginOfError=1] - Height difference threshold in pixels. Changes
|
|
19
|
+
* smaller than this value are considered insignificant.
|
|
20
|
+
* @returns {boolean} Returns true if the height change exceeds the margin of error
|
|
21
|
+
* or if this is the first measurement for the item.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* const heightCache = { 0: 40, 1: 50 };
|
|
26
|
+
*
|
|
27
|
+
* // First-time measurement (no cache entry)
|
|
28
|
+
* isSignificantHeightChange(2, 45, heightCache); // true
|
|
29
|
+
*
|
|
30
|
+
* // Significant change (exceeds 1px threshold)
|
|
31
|
+
* isSignificantHeightChange(0, 45, heightCache); // true
|
|
32
|
+
*
|
|
33
|
+
* // Insignificant change (within 1px threshold)
|
|
34
|
+
* isSignificantHeightChange(0, 40.5, heightCache); // false
|
|
35
|
+
* ```
|
|
11
36
|
*/
|
|
12
37
|
export const isSignificantHeightChange = (itemIndex, newHeight, heightCache, marginOfError = 1) => {
|
|
13
38
|
const previousHeight = heightCache[itemIndex];
|
|
@@ -58,7 +58,15 @@ export const calculateScrollTarget = (params) => {
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
/**
|
|
61
|
-
* Calculates
|
|
61
|
+
* Calculates the target scroll position for bottom-to-top mode.
|
|
62
|
+
*
|
|
63
|
+
* In bottom-to-top mode, items are rendered from the bottom of the viewport upward,
|
|
64
|
+
* which requires different scroll calculations than the standard top-to-bottom mode.
|
|
65
|
+
* This function handles the coordinate system translation and alignment logic.
|
|
66
|
+
*
|
|
67
|
+
* @param {BottomToTopScrollParams} params - Parameters for scroll calculation.
|
|
68
|
+
* @returns {number | null} The target scroll position in pixels, or null if no
|
|
69
|
+
* scroll is needed (item already visible with 'nearest' alignment).
|
|
62
70
|
*/
|
|
63
71
|
const calculateBottomToTopScrollTarget = (params) => {
|
|
64
72
|
const { align, targetIndex, itemsLength, calculatedItemHeight, height, scrollTop, firstVisibleIndex, lastVisibleIndex, heightCache } = params;
|
|
@@ -106,7 +114,15 @@ const calculateBottomToTopScrollTarget = (params) => {
|
|
|
106
114
|
return null;
|
|
107
115
|
};
|
|
108
116
|
/**
|
|
109
|
-
* Calculates
|
|
117
|
+
* Calculates the target scroll position for top-to-bottom mode.
|
|
118
|
+
*
|
|
119
|
+
* This is the standard scroll mode where items are rendered from the top of the
|
|
120
|
+
* viewport downward. The function calculates the optimal scroll position based
|
|
121
|
+
* on the alignment option and current viewport state.
|
|
122
|
+
*
|
|
123
|
+
* @param {TopToBottomScrollParams} params - Parameters for scroll calculation.
|
|
124
|
+
* @returns {number | null} The target scroll position in pixels, or null if no
|
|
125
|
+
* scroll is needed (item already visible with 'nearest' alignment).
|
|
110
126
|
*/
|
|
111
127
|
const calculateTopToBottomScrollTarget = (params) => {
|
|
112
128
|
const { align, targetIndex, calculatedItemHeight, height, scrollTop, firstVisibleIndex, lastVisibleIndex, heightCache } = params;
|
|
@@ -151,3 +151,31 @@ onComplete: () => void) => Promise<void>;
|
|
|
151
151
|
* const offset = getScrollOffsetForIndex(heightCache, calculatedItemHeight, 12345, blockSums);
|
|
152
152
|
*/
|
|
153
153
|
export declare const getScrollOffsetForIndex: (heightCache: Record<number, number>, calculatedItemHeight: number, idx: number, blockSums?: number[], blockSize?: number) => number;
|
|
154
|
+
/**
|
|
155
|
+
* Builds block prefix sums for heightCache to accelerate offset queries.
|
|
156
|
+
*
|
|
157
|
+
* This function precomputes cumulative height sums for blocks of items, enabling
|
|
158
|
+
* O(blockSize) offset calculations instead of O(n). The returned array contains
|
|
159
|
+
* the total height of all items up to and including each completed block.
|
|
160
|
+
*
|
|
161
|
+
* For example, with blockSize=1000:
|
|
162
|
+
* - Entry 0: sum of heights for items 0-999
|
|
163
|
+
* - Entry 1: sum of heights for items 0-1999
|
|
164
|
+
* - Entry 2: sum of heights for items 0-2999
|
|
165
|
+
*
|
|
166
|
+
* @param {Record<number, number>} heightCache - Cache of measured item heights.
|
|
167
|
+
* @param {number} calculatedItemHeight - Estimated height for unmeasured items.
|
|
168
|
+
* @param {number} totalItems - Total number of items in the list.
|
|
169
|
+
* @param {number} [blockSize=1000] - Number of items per block for memoization.
|
|
170
|
+
* @returns {number[]} Array of cumulative height sums for each completed block.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```typescript
|
|
174
|
+
* const heightCache = { 0: 40, 1: 50, 2: 45 };
|
|
175
|
+
* const blockSums = buildBlockSums(heightCache, 40, 5000, 1000);
|
|
176
|
+
*
|
|
177
|
+
* // Use with getScrollOffsetForIndex for efficient lookups
|
|
178
|
+
* const offset = getScrollOffsetForIndex(heightCache, 40, 2500, blockSums);
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
export declare const buildBlockSums: (heightCache: Record<number, number>, calculatedItemHeight: number, totalItems: number, blockSize?: number) => number[];
|
|
@@ -115,7 +115,8 @@ export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart,
|
|
|
115
115
|
const basicTransform = (totalItems - visibleEnd) * itemHeight;
|
|
116
116
|
// When content is smaller than viewport, push to bottom
|
|
117
117
|
const bottomOffset = Math.max(0, effectiveViewport - actualTotalHeight);
|
|
118
|
-
|
|
118
|
+
// Snap to integer pixels to avoid subpixel oscillation
|
|
119
|
+
return Math.round(basicTransform + bottomOffset);
|
|
119
120
|
}
|
|
120
121
|
else {
|
|
121
122
|
// For topToBottom, prefer precise offset using measured heights when available
|
|
@@ -123,7 +124,7 @@ export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart,
|
|
|
123
124
|
const offset = getScrollOffsetForIndex(heightCache, itemHeight, visibleStart);
|
|
124
125
|
return Math.max(0, Math.round(offset));
|
|
125
126
|
}
|
|
126
|
-
return visibleStart * itemHeight;
|
|
127
|
+
return Math.round(visibleStart * itemHeight);
|
|
127
128
|
}
|
|
128
129
|
};
|
|
129
130
|
/**
|
|
@@ -386,3 +387,46 @@ export const getScrollOffsetForIndex = (heightCache, calculatedItemHeight, idx,
|
|
|
386
387
|
}
|
|
387
388
|
return offset;
|
|
388
389
|
};
|
|
390
|
+
/**
|
|
391
|
+
* Builds block prefix sums for heightCache to accelerate offset queries.
|
|
392
|
+
*
|
|
393
|
+
* This function precomputes cumulative height sums for blocks of items, enabling
|
|
394
|
+
* O(blockSize) offset calculations instead of O(n). The returned array contains
|
|
395
|
+
* the total height of all items up to and including each completed block.
|
|
396
|
+
*
|
|
397
|
+
* For example, with blockSize=1000:
|
|
398
|
+
* - Entry 0: sum of heights for items 0-999
|
|
399
|
+
* - Entry 1: sum of heights for items 0-1999
|
|
400
|
+
* - Entry 2: sum of heights for items 0-2999
|
|
401
|
+
*
|
|
402
|
+
* @param {Record<number, number>} heightCache - Cache of measured item heights.
|
|
403
|
+
* @param {number} calculatedItemHeight - Estimated height for unmeasured items.
|
|
404
|
+
* @param {number} totalItems - Total number of items in the list.
|
|
405
|
+
* @param {number} [blockSize=1000] - Number of items per block for memoization.
|
|
406
|
+
* @returns {number[]} Array of cumulative height sums for each completed block.
|
|
407
|
+
*
|
|
408
|
+
* @example
|
|
409
|
+
* ```typescript
|
|
410
|
+
* const heightCache = { 0: 40, 1: 50, 2: 45 };
|
|
411
|
+
* const blockSums = buildBlockSums(heightCache, 40, 5000, 1000);
|
|
412
|
+
*
|
|
413
|
+
* // Use with getScrollOffsetForIndex for efficient lookups
|
|
414
|
+
* const offset = getScrollOffsetForIndex(heightCache, 40, 2500, blockSums);
|
|
415
|
+
* ```
|
|
416
|
+
*/
|
|
417
|
+
export const buildBlockSums = (heightCache, calculatedItemHeight, totalItems, blockSize = 1000) => {
|
|
418
|
+
const blocks = Math.ceil(totalItems / blockSize);
|
|
419
|
+
const sums = new Array(Math.max(0, blocks - 1));
|
|
420
|
+
let running = 0;
|
|
421
|
+
for (let b = 0; b < blocks - 1; b++) {
|
|
422
|
+
const start = b * blockSize;
|
|
423
|
+
const end = start + blockSize;
|
|
424
|
+
for (let i = start; i < end; i++) {
|
|
425
|
+
const raw = heightCache[i];
|
|
426
|
+
const h = Number.isFinite(raw) && raw > 0 ? raw : calculatedItemHeight;
|
|
427
|
+
running += h;
|
|
428
|
+
}
|
|
429
|
+
sums[b] = running;
|
|
430
|
+
}
|
|
431
|
+
return sums;
|
|
432
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-virtual-list",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "A lightweight, high-performance virtual list component for Svelte 5 that renders large datasets with minimal memory usage. Features include dynamic height support, smooth scrolling, TypeScript support, and efficient DOM recycling. Ideal for infinite scrolling lists, data tables, chat interfaces, and any application requiring the rendering of thousands of items without compromising performance. Zero dependencies and fully customizable.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -59,51 +59,51 @@
|
|
|
59
59
|
"esm-env": "^1.2.2"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@eslint/compat": "^
|
|
63
|
-
"@eslint/js": "^9.
|
|
64
|
-
"@faker-js/faker": "^10.
|
|
65
|
-
"@playwright/test": "^1.
|
|
66
|
-
"@sveltejs/adapter-auto": "^
|
|
67
|
-
"@sveltejs/kit": "^2.
|
|
68
|
-
"@sveltejs/package": "^2.5.
|
|
69
|
-
"@sveltejs/vite-plugin-svelte": "^6.2.
|
|
70
|
-
"@tailwindcss/vite": "^4.1.
|
|
62
|
+
"@eslint/compat": "^2.0.1",
|
|
63
|
+
"@eslint/js": "^9.39.2",
|
|
64
|
+
"@faker-js/faker": "^10.2.0",
|
|
65
|
+
"@playwright/test": "^1.58.0",
|
|
66
|
+
"@sveltejs/adapter-auto": "^7.0.0",
|
|
67
|
+
"@sveltejs/kit": "^2.50.1",
|
|
68
|
+
"@sveltejs/package": "^2.5.7",
|
|
69
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
70
|
+
"@tailwindcss/vite": "^4.1.18",
|
|
71
71
|
"@testing-library/jest-dom": "^6.9.1",
|
|
72
|
-
"@testing-library/svelte": "^5.
|
|
72
|
+
"@testing-library/svelte": "^5.3.1",
|
|
73
73
|
"@testing-library/user-event": "^14.6.1",
|
|
74
|
-
"@types/node": "^
|
|
75
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
76
|
-
"@typescript-eslint/parser": "^8.
|
|
77
|
-
"@vitest/coverage-v8": "^
|
|
74
|
+
"@types/node": "^25.1.0",
|
|
75
|
+
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
|
76
|
+
"@typescript-eslint/parser": "^8.54.0",
|
|
77
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
78
78
|
"concurrently": "^9.2.1",
|
|
79
|
-
"eslint": "^9.
|
|
79
|
+
"eslint": "^9.39.2",
|
|
80
80
|
"eslint-config-prettier": "^10.1.8",
|
|
81
81
|
"eslint-plugin-import": "^2.32.0",
|
|
82
|
-
"eslint-plugin-svelte": "^3.
|
|
83
|
-
"eslint-plugin-unused-imports": "^4.
|
|
84
|
-
"globals": "^
|
|
82
|
+
"eslint-plugin-svelte": "^3.14.0",
|
|
83
|
+
"eslint-plugin-unused-imports": "^4.3.0",
|
|
84
|
+
"globals": "^17.2.0",
|
|
85
85
|
"husky": "^9.1.7",
|
|
86
|
-
"jsdom": "^27.
|
|
87
|
-
"prettier": "^3.
|
|
86
|
+
"jsdom": "^27.4.0",
|
|
87
|
+
"prettier": "^3.8.1",
|
|
88
88
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
89
|
-
"prettier-plugin-sort-json": "^4.
|
|
90
|
-
"prettier-plugin-svelte": "^3.4.
|
|
91
|
-
"prettier-plugin-tailwindcss": "^0.
|
|
92
|
-
"publint": "^0.3.
|
|
93
|
-
"svelte": "^5.
|
|
94
|
-
"svelte-check": "^4.3.
|
|
95
|
-
"tailwindcss": "^4.1.
|
|
89
|
+
"prettier-plugin-sort-json": "^4.2.0",
|
|
90
|
+
"prettier-plugin-svelte": "^3.4.1",
|
|
91
|
+
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
92
|
+
"publint": "^0.3.17",
|
|
93
|
+
"svelte": "^5.48.5",
|
|
94
|
+
"svelte-check": "^4.3.5",
|
|
95
|
+
"tailwindcss": "^4.1.18",
|
|
96
96
|
"tw-animate-css": "^1.4.0",
|
|
97
97
|
"typescript": "^5.9.3",
|
|
98
|
-
"typescript-eslint": "^8.
|
|
99
|
-
"vite": "^7.1
|
|
100
|
-
"vitest": "^
|
|
98
|
+
"typescript-eslint": "^8.54.0",
|
|
99
|
+
"vite": "^7.3.1",
|
|
100
|
+
"vitest": "^4.0.18"
|
|
101
101
|
},
|
|
102
102
|
"peerDependencies": {
|
|
103
103
|
"svelte": "^5.0.0"
|
|
104
104
|
},
|
|
105
105
|
"volta": {
|
|
106
|
-
"node": "
|
|
106
|
+
"node": "24.13.0"
|
|
107
107
|
},
|
|
108
108
|
"publishConfig": {
|
|
109
109
|
"access": "public"
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
124
124
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
125
125
|
"dev": "vite dev",
|
|
126
|
-
"dev:all": "concurrently -k -n pkg,docs,sitemap -c green,cyan,magenta \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev
|
|
126
|
+
"dev:all": "concurrently -k -n pkg,docs,sitemap -c green,cyan,magenta \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev\" \"pnpm --filter docs run dev\" \"pnpm --filter docs run sitemap:watch\"",
|
|
127
127
|
"dev:pkg": "svelte-kit sync && svelte-package --watch",
|
|
128
128
|
"format": "prettier --write .",
|
|
129
129
|
"lint": "prettier --check . && eslint .",
|
|
@@ -138,6 +138,7 @@
|
|
|
138
138
|
"test:e2e:report": "playwright show-report",
|
|
139
139
|
"test:e2e:ui": "playwright test --ui",
|
|
140
140
|
"test:only": "vitest run --",
|
|
141
|
+
"test:unit": "vitest run --coverage",
|
|
141
142
|
"test:watch": "vitest --"
|
|
142
143
|
}
|
|
143
144
|
}
|