@humanspeak/svelte-virtual-list 0.3.12 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SvelteVirtualList.svelte +62 -54
- package/dist/reactive-list-manager/INTEGRATION_EXAMPLE.md +2 -2
- package/dist/reactive-list-manager/README.md +9 -9
- package/dist/reactive-list-manager/ReactiveListManager.svelte.d.ts +12 -8
- package/dist/reactive-list-manager/ReactiveListManager.svelte.js +13 -9
- package/dist/reactive-list-manager/index.d.ts +1 -1
- package/dist/reactive-list-manager/index.js +1 -1
- package/dist/utils/heightCalculation.d.ts +3 -3
- package/dist/utils/heightCalculation.js +5 -6
- package/package.json +5 -4
|
@@ -251,11 +251,11 @@
|
|
|
251
251
|
|
|
252
252
|
const captureAnchor = () => {
|
|
253
253
|
if (!heightManager.viewportElement) return
|
|
254
|
-
const vr = visibleItems
|
|
254
|
+
const vr = visibleItems
|
|
255
255
|
const anchorIndex = Math.max(0, vr.start)
|
|
256
256
|
const cache = heightManager.getHeightCache()
|
|
257
257
|
const est = heightManager.averageHeight
|
|
258
|
-
const maxScrollTop = Math.max(0, totalHeight
|
|
258
|
+
const maxScrollTop = Math.max(0, totalHeight - (height || 0))
|
|
259
259
|
// Offset from start to anchored item
|
|
260
260
|
const blockSums = buildBlockSums(cache, est, items.length)
|
|
261
261
|
const offsetToIndex = getScrollOffsetForIndex(cache, est, anchorIndex, blockSums)
|
|
@@ -290,7 +290,7 @@
|
|
|
290
290
|
Math.max(0, lastAnchorIndex),
|
|
291
291
|
blockSums
|
|
292
292
|
)
|
|
293
|
-
const maxScrollTop = clampValue(totalHeight
|
|
293
|
+
const maxScrollTop = clampValue(totalHeight - (height || 0), 0, Infinity)
|
|
294
294
|
let targetTop: number
|
|
295
295
|
if (mode === 'bottomToTop') {
|
|
296
296
|
const distanceFromStart = clampValue(offsetToIndex + lastAnchorOffset, 0, Infinity)
|
|
@@ -402,23 +402,6 @@
|
|
|
402
402
|
// Dynamic update coordination to avoid UA scroll anchoring interference
|
|
403
403
|
let suppressBottomAnchoringUntilMs = $state(0)
|
|
404
404
|
|
|
405
|
-
const displayItems = $derived(() => {
|
|
406
|
-
const visibleRange = visibleItems()
|
|
407
|
-
const slice =
|
|
408
|
-
mode === 'bottomToTop'
|
|
409
|
-
? items.slice(visibleRange.start, visibleRange.end).reverse()
|
|
410
|
-
: items.slice(visibleRange.start, visibleRange.end)
|
|
411
|
-
|
|
412
|
-
return slice.map((item, sliceIndex) => ({
|
|
413
|
-
item,
|
|
414
|
-
originalIndex:
|
|
415
|
-
mode === 'bottomToTop'
|
|
416
|
-
? visibleRange.end - 1 - sliceIndex
|
|
417
|
-
: visibleRange.start + sliceIndex,
|
|
418
|
-
sliceIndex
|
|
419
|
-
}))
|
|
420
|
-
})
|
|
421
|
-
|
|
422
405
|
/**
|
|
423
406
|
* Handles scroll position corrections when item heights change, ensuring proper positioning
|
|
424
407
|
* relative to the user's scroll context. This function calculates the cumulative impact of
|
|
@@ -442,7 +425,7 @@
|
|
|
442
425
|
if (isScrolling) {
|
|
443
426
|
// Accumulate net change above viewport and defer application
|
|
444
427
|
let pending = 0
|
|
445
|
-
const currentVisibleRange = visibleItems
|
|
428
|
+
const currentVisibleRange = visibleItems
|
|
446
429
|
for (const change of heightChanges) {
|
|
447
430
|
if (change.index < currentVisibleRange.start) pending += change.delta
|
|
448
431
|
}
|
|
@@ -488,7 +471,7 @@
|
|
|
488
471
|
*
|
|
489
472
|
* Dependencies:
|
|
490
473
|
* - wasAtBottomBeforeHeightChange: Set to true when first item marked dirty, prevents cascading corrections
|
|
491
|
-
* - totalHeight
|
|
474
|
+
* - totalHeight: Uses actual heightCache measurements instead of skewed averages
|
|
492
475
|
* - Aggressive scroll correction: Blocked when wasAtBottomBeforeHeightChange=true
|
|
493
476
|
*
|
|
494
477
|
* ⚠️ DO NOT MODIFY WITHOUT EXTENSIVE TESTING ⚠️
|
|
@@ -518,7 +501,7 @@
|
|
|
518
501
|
lastCorrectionTimestampByViewport.set(viewportEl, now)
|
|
519
502
|
|
|
520
503
|
// Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
|
|
521
|
-
const approximateScrollTop = Math.max(0, totalHeight
|
|
504
|
+
const approximateScrollTop = Math.max(0, totalHeight - height)
|
|
522
505
|
log('[SVL] b2t-correction-approx', { approximateScrollTop })
|
|
523
506
|
syncScrollTop(approximateScrollTop)
|
|
524
507
|
|
|
@@ -555,11 +538,11 @@
|
|
|
555
538
|
}
|
|
556
539
|
|
|
557
540
|
const currentScrollTop = heightManager.viewport.scrollTop
|
|
558
|
-
const maxScrollTop = Math.max(0, totalHeight
|
|
541
|
+
const maxScrollTop = Math.max(0, totalHeight - height)
|
|
559
542
|
|
|
560
543
|
// Calculate total height change impact above current visible area
|
|
561
544
|
let heightChangeAboveViewport = 0
|
|
562
|
-
const currentVisibleRange = visibleItems
|
|
545
|
+
const currentVisibleRange = visibleItems
|
|
563
546
|
|
|
564
547
|
for (const change of heightChanges) {
|
|
565
548
|
// Only consider items that are above the current visible range
|
|
@@ -623,7 +606,7 @@
|
|
|
623
606
|
// Skip loading during bottomToTop initialization (init path renders all items artificially)
|
|
624
607
|
if (mode === 'bottomToTop' && !bottomToTopScrollComplete) return
|
|
625
608
|
|
|
626
|
-
const range = visibleItems
|
|
609
|
+
const range = visibleItems
|
|
627
610
|
const atLoadingEdge = range.end >= items.length - loadMoreThreshold
|
|
628
611
|
const insufficientItems = items.length < loadMoreThreshold && heightManager.initialized
|
|
629
612
|
|
|
@@ -647,8 +630,15 @@
|
|
|
647
630
|
lastMeasuredIndex,
|
|
648
631
|
heightManager.averageHeight,
|
|
649
632
|
(result) => {
|
|
650
|
-
//
|
|
651
|
-
|
|
633
|
+
// Only update the estimated item height from statistically meaningful
|
|
634
|
+
// samples. With _measuredCount === 0 (browser path), the formula
|
|
635
|
+
// _totalHeight = _itemLength × _itemHeight means a single expanded
|
|
636
|
+
// accordion item (e.g., 117px) would balloon _totalHeight from
|
|
637
|
+
// 49,000 to 117,000px — a visible flash. Requiring ≥ 2 valid
|
|
638
|
+
// measurements prevents single-item outliers from swinging the estimate.
|
|
639
|
+
if (result.newValidCount !== 1) {
|
|
640
|
+
heightManager.itemHeight = result.newHeight
|
|
641
|
+
}
|
|
652
642
|
lastMeasuredIndex = result.newLastMeasuredIndex
|
|
653
643
|
|
|
654
644
|
// Update manager totals/cache before any scroll correction logic relies on them
|
|
@@ -748,9 +738,9 @@
|
|
|
748
738
|
* This getter is reactive and updates whenever heightManager's internal state changes.
|
|
749
739
|
* Used by: atBottom calculation, scroll corrections, maxScrollTop calculations
|
|
750
740
|
*/
|
|
751
|
-
const totalHeight = $derived(
|
|
741
|
+
const totalHeight = $derived(heightManager.totalHeight)
|
|
752
742
|
|
|
753
|
-
const atBottom = $derived(heightManager.scrollTop >= totalHeight
|
|
743
|
+
const atBottom = $derived(heightManager.scrollTop >= totalHeight - height - 1)
|
|
754
744
|
let wasAtBottomBeforeHeightChange = false
|
|
755
745
|
let lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null = null
|
|
756
746
|
|
|
@@ -780,7 +770,7 @@
|
|
|
780
770
|
mode === 'bottomToTop' &&
|
|
781
771
|
heightManager.viewportElement
|
|
782
772
|
) {
|
|
783
|
-
const targetScrollTop = Math.max(0, totalHeight
|
|
773
|
+
const targetScrollTop = Math.max(0, totalHeight - height)
|
|
784
774
|
const currentScrollTop = heightManager.viewport.scrollTop
|
|
785
775
|
const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
|
|
786
776
|
|
|
@@ -790,7 +780,7 @@
|
|
|
790
780
|
// 3. We're significantly off target
|
|
791
781
|
// 4. We're not at the bottom (where height changes should be handled more carefully)
|
|
792
782
|
const heightChanged = Math.abs(heightManager.averageHeight - lastCalculatedHeight) > 1
|
|
793
|
-
const maxScrollTop = Math.max(0, totalHeight
|
|
783
|
+
const maxScrollTop = Math.max(0, totalHeight - height)
|
|
794
784
|
|
|
795
785
|
// In bottomToTop mode, we're "at bottom" when scroll is at max position
|
|
796
786
|
const isAtBottom =
|
|
@@ -838,7 +828,7 @@
|
|
|
838
828
|
const currentScrollTop = heightManager.viewport.scrollTop
|
|
839
829
|
const currentCalculatedItemHeight = heightManager.averageHeight
|
|
840
830
|
const currentHeight = height
|
|
841
|
-
const currentTotalHeight = totalHeight
|
|
831
|
+
const currentTotalHeight = totalHeight
|
|
842
832
|
const prevTotalHeight =
|
|
843
833
|
lastTotalHeightObserved ||
|
|
844
834
|
currentTotalHeight - itemsAdded * currentCalculatedItemHeight
|
|
@@ -884,7 +874,7 @@
|
|
|
884
874
|
// Reconcile on next frame in case measured heights adjust totals
|
|
885
875
|
requestAnimationFrame(() => {
|
|
886
876
|
const beforeReconcileScrollTop = heightManager.viewport.scrollTop
|
|
887
|
-
const reconciledNextMax = clampValue(totalHeight
|
|
877
|
+
const reconciledNextMax = clampValue(totalHeight - height, 0, Infinity)
|
|
888
878
|
const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
|
|
889
879
|
// Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
|
|
890
880
|
const desiredScrollTop = clampValue(
|
|
@@ -927,7 +917,7 @@
|
|
|
927
917
|
|
|
928
918
|
lastItemsLength = currentItemsLength
|
|
929
919
|
// Update last observed total height at the end of the effect
|
|
930
|
-
lastTotalHeightObserved = totalHeight
|
|
920
|
+
lastTotalHeightObserved = totalHeight
|
|
931
921
|
})
|
|
932
922
|
|
|
933
923
|
// Update container height continuously to reflect layout changes that
|
|
@@ -963,13 +953,13 @@
|
|
|
963
953
|
*
|
|
964
954
|
* @example
|
|
965
955
|
* ```typescript
|
|
966
|
-
* const range = visibleItems
|
|
956
|
+
* const range = visibleItems
|
|
967
957
|
* console.info(`Rendering items from ${range.start} to ${range.end}`)
|
|
968
958
|
* ```
|
|
969
959
|
*
|
|
970
960
|
* @returns {SvelteVirtualListPreviousVisibleRange} Object containing start and end indices of visible items
|
|
971
961
|
*/
|
|
972
|
-
const visibleItems = $derived((): SvelteVirtualListPreviousVisibleRange => {
|
|
962
|
+
const visibleItems = $derived.by((): SvelteVirtualListPreviousVisibleRange => {
|
|
973
963
|
if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
|
|
974
964
|
const viewportHeight = height || 0
|
|
975
965
|
|
|
@@ -1014,7 +1004,7 @@
|
|
|
1014
1004
|
atBottom,
|
|
1015
1005
|
wasAtBottomBeforeHeightChange,
|
|
1016
1006
|
lastVisibleRange,
|
|
1017
|
-
totalHeight
|
|
1007
|
+
totalHeight,
|
|
1018
1008
|
heightManager.getHeightCache()
|
|
1019
1009
|
)
|
|
1020
1010
|
|
|
@@ -1026,15 +1016,15 @@
|
|
|
1026
1016
|
* Uses the maximum of container height and total content height to ensure
|
|
1027
1017
|
* proper scrolling behavior.
|
|
1028
1018
|
*/
|
|
1029
|
-
const contentHeight = $derived(
|
|
1019
|
+
const contentHeight = $derived(Math.max(height, totalHeight))
|
|
1030
1020
|
|
|
1031
1021
|
/**
|
|
1032
1022
|
* Computed transform Y value for positioning the visible items.
|
|
1033
1023
|
* Extracted from inline IIFE for better performance and readability.
|
|
1034
1024
|
*/
|
|
1035
|
-
const transformY = $derived(() => {
|
|
1025
|
+
const transformY = $derived.by(() => {
|
|
1036
1026
|
const viewportHeight = height || measuredFallbackHeight || 0
|
|
1037
|
-
const visibleRange = visibleItems
|
|
1027
|
+
const visibleRange = visibleItems
|
|
1038
1028
|
|
|
1039
1029
|
// Avoid synchronous DOM reads here; fall back once if height is 0
|
|
1040
1030
|
const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
|
|
@@ -1048,13 +1038,30 @@
|
|
|
1048
1038
|
visibleRange.start,
|
|
1049
1039
|
heightManager.averageHeight,
|
|
1050
1040
|
effectiveHeight,
|
|
1051
|
-
totalHeight
|
|
1041
|
+
totalHeight,
|
|
1052
1042
|
heightManager.getHeightCache(),
|
|
1053
1043
|
measuredFallbackHeight
|
|
1054
1044
|
)
|
|
1055
1045
|
)
|
|
1056
1046
|
})
|
|
1057
1047
|
|
|
1048
|
+
const displayItems = $derived.by(() => {
|
|
1049
|
+
const visibleRange = visibleItems
|
|
1050
|
+
const slice =
|
|
1051
|
+
mode === 'bottomToTop'
|
|
1052
|
+
? items.slice(visibleRange.start, visibleRange.end).reverse()
|
|
1053
|
+
: items.slice(visibleRange.start, visibleRange.end)
|
|
1054
|
+
|
|
1055
|
+
return slice.map((item, sliceIndex) => ({
|
|
1056
|
+
item,
|
|
1057
|
+
originalIndex:
|
|
1058
|
+
mode === 'bottomToTop'
|
|
1059
|
+
? visibleRange.end - 1 - sliceIndex
|
|
1060
|
+
: visibleRange.start + sliceIndex,
|
|
1061
|
+
sliceIndex
|
|
1062
|
+
}))
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1058
1065
|
/**
|
|
1059
1066
|
* Handles scroll events in the viewport using requestAnimationFrame for performance.
|
|
1060
1067
|
*
|
|
@@ -1117,12 +1124,12 @@
|
|
|
1117
1124
|
captureAnchor()
|
|
1118
1125
|
}
|
|
1119
1126
|
if (INTERNAL_DEBUG) {
|
|
1120
|
-
const vr = visibleItems
|
|
1127
|
+
const vr = visibleItems
|
|
1121
1128
|
log('[SVL] scroll', {
|
|
1122
1129
|
mode,
|
|
1123
1130
|
scrollTop: heightManager.scrollTop,
|
|
1124
1131
|
height,
|
|
1125
|
-
totalHeight: totalHeight
|
|
1132
|
+
totalHeight: totalHeight,
|
|
1126
1133
|
averageItemHeight: heightManager.averageHeight,
|
|
1127
1134
|
visibleRange: vr
|
|
1128
1135
|
})
|
|
@@ -1153,7 +1160,7 @@
|
|
|
1153
1160
|
})
|
|
1154
1161
|
if (!heightManager.initialized && mode === 'bottomToTop') {
|
|
1155
1162
|
// bottomToTop initialization: use scrollIntoView on Item 0 for precise positioning
|
|
1156
|
-
// visibleItems
|
|
1163
|
+
// visibleItems guarantees Item 0 is rendered during initialization
|
|
1157
1164
|
tick().then(() => {
|
|
1158
1165
|
requestAnimationFrame(() => {
|
|
1159
1166
|
requestAnimationFrame(() => {
|
|
@@ -1174,7 +1181,7 @@
|
|
|
1174
1181
|
|
|
1175
1182
|
setTimeout(() => {
|
|
1176
1183
|
// Step 1: Set initialized (for other purposes like scroll event handling)
|
|
1177
|
-
// The init path in visibleItems
|
|
1184
|
+
// The init path in visibleItems stays active until bottomToTopScrollComplete
|
|
1178
1185
|
if (!heightManager.initialized) {
|
|
1179
1186
|
heightManager.initialized = true
|
|
1180
1187
|
}
|
|
@@ -1258,7 +1265,7 @@
|
|
|
1258
1265
|
rafSchedule(() => {
|
|
1259
1266
|
log('item-resize-observer', { entries: entries.length })
|
|
1260
1267
|
let shouldRecalculate = false
|
|
1261
|
-
void visibleItems
|
|
1268
|
+
void visibleItems // Cache once to avoid reactive loops
|
|
1262
1269
|
|
|
1263
1270
|
for (const entry of entries) {
|
|
1264
1271
|
const element = entry.target as HTMLElement
|
|
@@ -1342,7 +1349,7 @@
|
|
|
1342
1349
|
// Add the effect in the script section
|
|
1343
1350
|
$effect(() => {
|
|
1344
1351
|
if (INTERNAL_DEBUG) {
|
|
1345
|
-
prevVisibleRange = visibleItems
|
|
1352
|
+
prevVisibleRange = visibleItems
|
|
1346
1353
|
prevHeight = heightManager.averageHeight
|
|
1347
1354
|
}
|
|
1348
1355
|
})
|
|
@@ -1351,7 +1358,7 @@
|
|
|
1351
1358
|
// the callback writes to $state (which is forbidden during render effects)
|
|
1352
1359
|
$effect(() => {
|
|
1353
1360
|
if (!debug) return
|
|
1354
|
-
const currentVisibleRange = visibleItems
|
|
1361
|
+
const currentVisibleRange = visibleItems
|
|
1355
1362
|
if (
|
|
1356
1363
|
!shouldShowDebugInfo(
|
|
1357
1364
|
prevVisibleRange,
|
|
@@ -1369,7 +1376,7 @@
|
|
|
1369
1376
|
heightManager.averageHeight,
|
|
1370
1377
|
heightManager.scrollTop,
|
|
1371
1378
|
height || 0,
|
|
1372
|
-
totalHeight
|
|
1379
|
+
totalHeight
|
|
1373
1380
|
)
|
|
1374
1381
|
|
|
1375
1382
|
if (debugFunction) {
|
|
@@ -1477,7 +1484,7 @@
|
|
|
1477
1484
|
}
|
|
1478
1485
|
}
|
|
1479
1486
|
|
|
1480
|
-
const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems
|
|
1487
|
+
const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems
|
|
1481
1488
|
|
|
1482
1489
|
// Use extracted scroll calculation utility
|
|
1483
1490
|
const scrollTarget = calculateScrollTarget({
|
|
@@ -1616,22 +1623,23 @@
|
|
|
1616
1623
|
class={viewportClass ?? 'virtual-list-viewport'}
|
|
1617
1624
|
bind:this={heightManager.viewportElement}
|
|
1618
1625
|
onscroll={handleScroll}
|
|
1626
|
+
style:overflow-anchor="none"
|
|
1619
1627
|
>
|
|
1620
1628
|
<!-- Content provides full scrollable height -->
|
|
1621
1629
|
<div
|
|
1622
1630
|
id="virtual-list-content"
|
|
1623
1631
|
{...testId ? { 'data-testid': `${testId}-content` } : {}}
|
|
1624
1632
|
class={contentClass ?? 'virtual-list-content'}
|
|
1625
|
-
style:height="{contentHeight
|
|
1633
|
+
style:height="{contentHeight}px"
|
|
1626
1634
|
>
|
|
1627
1635
|
<!-- Items container is translated to show correct items -->
|
|
1628
1636
|
<div
|
|
1629
1637
|
id="virtual-list-items"
|
|
1630
1638
|
{...testId ? { 'data-testid': `${testId}-items` } : {}}
|
|
1631
1639
|
class={itemsClass ?? 'virtual-list-items'}
|
|
1632
|
-
style:transform="translateY({transformY
|
|
1640
|
+
style:transform="translateY({transformY}px)"
|
|
1633
1641
|
>
|
|
1634
|
-
{#each displayItems
|
|
1642
|
+
{#each displayItems as currentItemWithIndex, _i (currentItemWithIndex.originalIndex)}
|
|
1635
1643
|
<!-- Render each visible item -->
|
|
1636
1644
|
<div
|
|
1637
1645
|
bind:this={itemElements[currentItemWithIndex.sliceIndex]}
|
|
@@ -71,7 +71,7 @@ const updateHeight = () => {
|
|
|
71
71
|
|
|
72
72
|
```typescript
|
|
73
73
|
// OLD: O(n) calculation every time
|
|
74
|
-
// let totalHeight = $derived(() => {
|
|
74
|
+
// let totalHeight = $derived.by(() => {
|
|
75
75
|
// let total = 0
|
|
76
76
|
// for (let i = 0; i < items.length; i++) {
|
|
77
77
|
// total += heightCache[i] || calculatedItemHeight
|
|
@@ -80,7 +80,7 @@ const updateHeight = () => {
|
|
|
80
80
|
// })
|
|
81
81
|
|
|
82
82
|
// NEW: O(1) reactive calculation 🚀
|
|
83
|
-
let totalHeight = $derived(
|
|
83
|
+
let totalHeight = $derived(heightManager.totalHeight)
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
## Performance Benefits
|
|
@@ -32,7 +32,7 @@ ReactiveListManager processes only **dirty/changed items**:
|
|
|
32
32
|
```typescript
|
|
33
33
|
// ✅ O(dirty items) - Fast and reactive
|
|
34
34
|
manager.processDirtyHeights(changedItems)
|
|
35
|
-
const totalHeight = manager.
|
|
35
|
+
const totalHeight = manager.totalHeight
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
## 📦 Installation
|
|
@@ -82,7 +82,7 @@ new ReactiveListManager(config: ListManagerConfig)
|
|
|
82
82
|
**Parameters:**
|
|
83
83
|
|
|
84
84
|
- `config.itemLength` - Total number of items
|
|
85
|
-
- `config.
|
|
85
|
+
- `config.itemHeight` - Default height for unmeasured items
|
|
86
86
|
|
|
87
87
|
### Core Methods
|
|
88
88
|
|
|
@@ -154,7 +154,7 @@ Check if manager has sufficient measurement data.
|
|
|
154
154
|
// Create manager
|
|
155
155
|
const heightManager = new ReactiveListManager({
|
|
156
156
|
itemLength: items.length,
|
|
157
|
-
|
|
157
|
+
itemHeight: defaultEstimatedItemHeight
|
|
158
158
|
})
|
|
159
159
|
|
|
160
160
|
// Update on items change
|
|
@@ -182,7 +182,7 @@ $effect(() => {
|
|
|
182
182
|
})
|
|
183
183
|
|
|
184
184
|
// Reactive total height (automatically updates)
|
|
185
|
-
let totalHeight = $derived(
|
|
185
|
+
let totalHeight = $derived(heightManager.totalHeight)
|
|
186
186
|
```
|
|
187
187
|
|
|
188
188
|
### Standalone Usage
|
|
@@ -190,7 +190,7 @@ let totalHeight = $derived(() => heightManager.totalHeight)
|
|
|
190
190
|
```typescript
|
|
191
191
|
import { ReactiveListManager, benchmarkHeightManager } from './reactive-list-manager'
|
|
192
192
|
|
|
193
|
-
const manager = new ReactiveListManager({ itemLength: 1000,
|
|
193
|
+
const manager = new ReactiveListManager({ itemLength: 1000, itemHeight: 50 })
|
|
194
194
|
|
|
195
195
|
// Performance monitoring
|
|
196
196
|
const results = benchmarkHeightManager(10000, 1000, 100)
|
|
@@ -264,7 +264,7 @@ npm run test -- --grep "Performance Tests"
|
|
|
264
264
|
|
|
265
265
|
```text
|
|
266
266
|
|
|
267
|
-
Height Changes → processDirtyHeights() → Update State →
|
|
267
|
+
Height Changes → processDirtyHeights() → Update State → totalHeight → Reactive UI
|
|
268
268
|
|
|
269
269
|
```
|
|
270
270
|
|
|
@@ -274,7 +274,7 @@ Height Changes → processDirtyHeights() → Update State → getDerivedTotalHei
|
|
|
274
274
|
private _totalMeasuredHeight = $state(0) // Sum of all measured heights
|
|
275
275
|
private _measuredCount = $state(0) // Count of measured items
|
|
276
276
|
private _itemLength = $state(0) // Total items
|
|
277
|
-
private
|
|
277
|
+
private _itemHeight = $state(40) // Default estimate
|
|
278
278
|
```
|
|
279
279
|
|
|
280
280
|
## 🔧 Types
|
|
@@ -288,7 +288,7 @@ interface HeightChange {
|
|
|
288
288
|
|
|
289
289
|
interface ListManagerConfig {
|
|
290
290
|
itemLength: number
|
|
291
|
-
|
|
291
|
+
itemHeight: number
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
interface ListManagerDebugInfo {
|
|
@@ -296,7 +296,7 @@ interface ListManagerDebugInfo {
|
|
|
296
296
|
measuredCount: number
|
|
297
297
|
itemLength: number
|
|
298
298
|
coveragePercent: number
|
|
299
|
-
|
|
299
|
+
itemHeight: number
|
|
300
300
|
}
|
|
301
301
|
```
|
|
302
302
|
|
|
@@ -10,7 +10,7 @@ import type { HeightChange, ListManagerConfig, ListManagerDebugInfo } from './ty
|
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```typescript
|
|
13
|
-
* const manager = new ReactiveListManager({ itemLength: 10000,
|
|
13
|
+
* const manager = new ReactiveListManager({ itemLength: 10000, itemHeight: 40 })
|
|
14
14
|
*
|
|
15
15
|
* // Process height changes incrementally
|
|
16
16
|
* manager.processDirtyHeights(dirtyResults)
|
|
@@ -126,20 +126,24 @@ export declare class ReactiveListManager {
|
|
|
126
126
|
*/
|
|
127
127
|
get isDynamicUpdateInProgress(): boolean;
|
|
128
128
|
/**
|
|
129
|
-
* Begin a dynamic update. Handles nested calls: the first call
|
|
130
|
-
* subsequent calls just increment depth. Safe to call when not wired; styles
|
|
131
|
-
* when both container and viewport are ready.
|
|
129
|
+
* Begin a dynamic update. Handles nested calls: the first call ensures UA scroll anchoring
|
|
130
|
+
* is disabled, subsequent calls just increment depth. Safe to call when not wired; styles
|
|
131
|
+
* are only set when both container and viewport are ready.
|
|
132
|
+
*
|
|
133
|
+
* Note: overflow-anchor is kept permanently as 'none' to prevent browser scroll anchoring
|
|
134
|
+
* from interfering with the virtual list's own scroll correction logic.
|
|
132
135
|
*/
|
|
133
136
|
startDynamicUpdate(): void;
|
|
134
137
|
/**
|
|
135
138
|
* End a dynamic update started by `startDynamicUpdate`. Handles nesting: only the final
|
|
136
|
-
* corresponding end call
|
|
139
|
+
* corresponding end call completes the update. overflow-anchor remains 'none' permanently.
|
|
140
|
+
* Guards against underflow.
|
|
137
141
|
*/
|
|
138
142
|
endDynamicUpdate(): void;
|
|
139
143
|
/**
|
|
140
|
-
* Run a dynamic update with UA scroll anchoring disabled
|
|
141
|
-
* Accepts a sync or async function and ensures `overflow-anchor`
|
|
142
|
-
*
|
|
144
|
+
* Run a dynamic update with UA scroll anchoring disabled.
|
|
145
|
+
* Accepts a sync or async function and ensures `overflow-anchor` stays 'none'
|
|
146
|
+
* throughout. If the manager isn't ready yet, it simply executes `fn`.
|
|
143
147
|
*/
|
|
144
148
|
runDynamicUpdate<T>(fn: () => T | Promise<T>): Promise<T>;
|
|
145
149
|
/**
|
|
@@ -10,7 +10,7 @@ import { RecomputeScheduler } from './RecomputeScheduler.js';
|
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```typescript
|
|
13
|
-
* const manager = new ReactiveListManager({ itemLength: 10000,
|
|
13
|
+
* const manager = new ReactiveListManager({ itemLength: 10000, itemHeight: 40 })
|
|
14
14
|
*
|
|
15
15
|
* // Process height changes incrementally
|
|
16
16
|
* manager.processDirtyHeights(dirtyResults)
|
|
@@ -241,9 +241,12 @@ export class ReactiveListManager {
|
|
|
241
241
|
return this._dynamicUpdateDepth > 0;
|
|
242
242
|
}
|
|
243
243
|
/**
|
|
244
|
-
* Begin a dynamic update. Handles nested calls: the first call
|
|
245
|
-
* subsequent calls just increment depth. Safe to call when not wired; styles
|
|
246
|
-
* when both container and viewport are ready.
|
|
244
|
+
* Begin a dynamic update. Handles nested calls: the first call ensures UA scroll anchoring
|
|
245
|
+
* is disabled, subsequent calls just increment depth. Safe to call when not wired; styles
|
|
246
|
+
* are only set when both container and viewport are ready.
|
|
247
|
+
*
|
|
248
|
+
* Note: overflow-anchor is kept permanently as 'none' to prevent browser scroll anchoring
|
|
249
|
+
* from interfering with the virtual list's own scroll correction logic.
|
|
247
250
|
*/
|
|
248
251
|
startDynamicUpdate() {
|
|
249
252
|
const isOuter = this._dynamicUpdateDepth === 0;
|
|
@@ -257,7 +260,8 @@ export class ReactiveListManager {
|
|
|
257
260
|
}
|
|
258
261
|
/**
|
|
259
262
|
* End a dynamic update started by `startDynamicUpdate`. Handles nesting: only the final
|
|
260
|
-
* corresponding end call
|
|
263
|
+
* corresponding end call completes the update. overflow-anchor remains 'none' permanently.
|
|
264
|
+
* Guards against underflow.
|
|
261
265
|
*/
|
|
262
266
|
endDynamicUpdate() {
|
|
263
267
|
if (this._dynamicUpdateDepth <= 0) {
|
|
@@ -266,16 +270,16 @@ export class ReactiveListManager {
|
|
|
266
270
|
this._dynamicUpdateDepth -= 1;
|
|
267
271
|
if (this._dynamicUpdateDepth === 0) {
|
|
268
272
|
if (this._isReady && this._viewportElement) {
|
|
269
|
-
this._viewportElement.style.setProperty('overflow-anchor', '
|
|
273
|
+
this._viewportElement.style.setProperty('overflow-anchor', 'none');
|
|
270
274
|
}
|
|
271
275
|
this._dynamicUpdateInProgress = false;
|
|
272
276
|
this._scheduler.unblock();
|
|
273
277
|
}
|
|
274
278
|
}
|
|
275
279
|
/**
|
|
276
|
-
* Run a dynamic update with UA scroll anchoring disabled
|
|
277
|
-
* Accepts a sync or async function and ensures `overflow-anchor`
|
|
278
|
-
*
|
|
280
|
+
* Run a dynamic update with UA scroll anchoring disabled.
|
|
281
|
+
* Accepts a sync or async function and ensures `overflow-anchor` stays 'none'
|
|
282
|
+
* throughout. If the manager isn't ready yet, it simply executes `fn`.
|
|
279
283
|
*/
|
|
280
284
|
async runDynamicUpdate(fn) {
|
|
281
285
|
this.startDynamicUpdate();
|
|
@@ -35,7 +35,7 @@ import type { SvelteVirtualListMode } from '../types.js';
|
|
|
35
35
|
* calculateAverageHeightDebounced(
|
|
36
36
|
* false,
|
|
37
37
|
* null,
|
|
38
|
-
*
|
|
38
|
+
* visibleRange,
|
|
39
39
|
* itemElements,
|
|
40
40
|
* heightCache,
|
|
41
41
|
* lastMeasuredIndex,
|
|
@@ -59,7 +59,7 @@ import type { SvelteVirtualListMode } from '../types.js';
|
|
|
59
59
|
*
|
|
60
60
|
* @param isCalculatingHeight - Flag to prevent concurrent calculations
|
|
61
61
|
* @param heightUpdateTimeout - Reference to existing update timeout
|
|
62
|
-
* @param
|
|
62
|
+
* @param visibleItems - Current visible range
|
|
63
63
|
* @param itemElements - Array of DOM elements to measure
|
|
64
64
|
* @param heightCache - Cache of previously measured heights with dirty tracking
|
|
65
65
|
* @param lastMeasuredIndex - Index of last measured element
|
|
@@ -68,7 +68,7 @@ import type { SvelteVirtualListMode } from '../types.js';
|
|
|
68
68
|
* @param debounceTime - Time to wait between calculations (default: 200ms)
|
|
69
69
|
* @returns Timeout object or null if calculation was skipped
|
|
70
70
|
*/
|
|
71
|
-
export declare const calculateAverageHeightDebounced: (isCalculatingHeight: boolean, heightUpdateTimeout: ReturnType<typeof setTimeout> | null,
|
|
71
|
+
export declare const calculateAverageHeightDebounced: (isCalculatingHeight: boolean, heightUpdateTimeout: ReturnType<typeof setTimeout> | null, visibleItems: {
|
|
72
72
|
start: number;
|
|
73
73
|
end: number;
|
|
74
74
|
}, itemElements: HTMLElement[], heightCache: Record<number, number>, lastMeasuredIndex: number, calculatedItemHeight: number, onUpdate: (result: {
|
|
@@ -36,7 +36,7 @@ import { BROWSER } from 'esm-env';
|
|
|
36
36
|
* calculateAverageHeightDebounced(
|
|
37
37
|
* false,
|
|
38
38
|
* null,
|
|
39
|
-
*
|
|
39
|
+
* visibleRange,
|
|
40
40
|
* itemElements,
|
|
41
41
|
* heightCache,
|
|
42
42
|
* lastMeasuredIndex,
|
|
@@ -60,7 +60,7 @@ import { BROWSER } from 'esm-env';
|
|
|
60
60
|
*
|
|
61
61
|
* @param isCalculatingHeight - Flag to prevent concurrent calculations
|
|
62
62
|
* @param heightUpdateTimeout - Reference to existing update timeout
|
|
63
|
-
* @param
|
|
63
|
+
* @param visibleItems - Current visible range
|
|
64
64
|
* @param itemElements - Array of DOM elements to measure
|
|
65
65
|
* @param heightCache - Cache of previously measured heights with dirty tracking
|
|
66
66
|
* @param lastMeasuredIndex - Index of last measured element
|
|
@@ -69,19 +69,18 @@ import { BROWSER } from 'esm-env';
|
|
|
69
69
|
* @param debounceTime - Time to wait between calculations (default: 200ms)
|
|
70
70
|
* @returns Timeout object or null if calculation was skipped
|
|
71
71
|
*/
|
|
72
|
-
export const calculateAverageHeightDebounced = (isCalculatingHeight, heightUpdateTimeout,
|
|
72
|
+
export const calculateAverageHeightDebounced = (isCalculatingHeight, heightUpdateTimeout, visibleItems, itemElements, heightCache, lastMeasuredIndex, calculatedItemHeight,
|
|
73
73
|
/* trunk-ignore(eslint/no-unused-vars) */
|
|
74
74
|
onUpdate, debounceTime, dirtyItems, currentTotalHeight = 0, currentValidCount = 0, mode = 'topToBottom') => {
|
|
75
75
|
if (!BROWSER || isCalculatingHeight)
|
|
76
76
|
return null;
|
|
77
|
-
const
|
|
78
|
-
const currentIndex = visibleRange.start;
|
|
77
|
+
const currentIndex = visibleItems.start;
|
|
79
78
|
if (currentIndex === lastMeasuredIndex && dirtyItems.size === 0)
|
|
80
79
|
return null;
|
|
81
80
|
if (heightUpdateTimeout)
|
|
82
81
|
clearTimeout(heightUpdateTimeout);
|
|
83
82
|
return setTimeout(() => {
|
|
84
|
-
const { newHeight, newLastMeasuredIndex, updatedHeightCache, clearedDirtyItems, newTotalHeight, newValidCount, heightChanges } = calculateAverageHeight(itemElements,
|
|
83
|
+
const { newHeight, newLastMeasuredIndex, updatedHeightCache, clearedDirtyItems, newTotalHeight, newValidCount, heightChanges } = calculateAverageHeight(itemElements, visibleItems, heightCache, calculatedItemHeight, dirtyItems, currentTotalHeight, currentValidCount, mode);
|
|
85
84
|
if (Math.abs(newHeight - calculatedItemHeight) > 1 || dirtyItems.size > 0) {
|
|
86
85
|
onUpdate({
|
|
87
86
|
newHeight,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-virtual-list",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"@eslint/compat": "^2.0.2",
|
|
63
63
|
"@eslint/js": "^10.0.1",
|
|
64
64
|
"@faker-js/faker": "^10.3.0",
|
|
65
|
+
"@playwright/cli": "^0.1.1",
|
|
65
66
|
"@playwright/test": "^1.58.2",
|
|
66
67
|
"@sveltejs/adapter-auto": "^7.0.1",
|
|
67
68
|
"@sveltejs/kit": "^2.52.0",
|
|
@@ -72,8 +73,8 @@
|
|
|
72
73
|
"@testing-library/svelte": "^5.3.1",
|
|
73
74
|
"@testing-library/user-event": "^14.6.1",
|
|
74
75
|
"@types/node": "^25.2.3",
|
|
75
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
76
|
-
"@typescript-eslint/parser": "^8.
|
|
76
|
+
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
77
|
+
"@typescript-eslint/parser": "^8.56.0",
|
|
77
78
|
"@vitest/coverage-v8": "^4.0.18",
|
|
78
79
|
"concurrently": "^9.2.1",
|
|
79
80
|
"eslint": "^10.0.0",
|
|
@@ -95,7 +96,7 @@
|
|
|
95
96
|
"tailwindcss": "^4.1.18",
|
|
96
97
|
"tw-animate-css": "^1.4.0",
|
|
97
98
|
"typescript": "^5.9.3",
|
|
98
|
-
"typescript-eslint": "^8.
|
|
99
|
+
"typescript-eslint": "^8.56.0",
|
|
99
100
|
"vite": "^7.3.1",
|
|
100
101
|
"vitest": "^4.0.18"
|
|
101
102
|
},
|