@humanspeak/svelte-virtual-list 0.3.8 → 0.3.10
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 +174 -128
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/reactive-list-manager/ReactiveListManager.svelte.d.ts +27 -0
- package/dist/reactive-list-manager/ReactiveListManager.svelte.js +72 -0
- package/dist/utils/perfMetrics.d.ts +114 -0
- package/dist/utils/perfMetrics.js +252 -0
- package/dist/utils/scrollCalculation.d.ts +35 -0
- package/dist/utils/scrollCalculation.js +81 -69
- package/dist/utils/virtualList.d.ts +36 -0
- package/dist/utils/virtualList.js +40 -13
- package/package.json +9 -9
|
@@ -163,9 +163,9 @@
|
|
|
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,
|
|
168
|
+
clampValue,
|
|
169
169
|
updateHeightAndScroll as utilsUpdateHeightAndScroll,
|
|
170
170
|
getScrollOffsetForIndex,
|
|
171
171
|
buildBlockSums
|
|
@@ -290,23 +290,16 @@
|
|
|
290
290
|
Math.max(0, lastAnchorIndex),
|
|
291
291
|
blockSums
|
|
292
292
|
)
|
|
293
|
-
const maxScrollTop =
|
|
293
|
+
const maxScrollTop = clampValue(totalHeight() - (height || 0), 0, Infinity)
|
|
294
294
|
let targetTop: number
|
|
295
295
|
if (mode === 'bottomToTop') {
|
|
296
|
-
const distanceFromStart =
|
|
297
|
-
targetTop = Math.
|
|
298
|
-
0,
|
|
299
|
-
Math.min(maxScrollTop, Math.round(maxScrollTop - distanceFromStart))
|
|
300
|
-
)
|
|
296
|
+
const distanceFromStart = clampValue(offsetToIndex + lastAnchorOffset, 0, Infinity)
|
|
297
|
+
targetTop = clampValue(Math.round(maxScrollTop - distanceFromStart), 0, maxScrollTop)
|
|
301
298
|
} else {
|
|
302
|
-
targetTop = Math.
|
|
303
|
-
0,
|
|
304
|
-
Math.min(maxScrollTop, Math.round(offsetToIndex + lastAnchorOffset))
|
|
305
|
-
)
|
|
299
|
+
targetTop = clampValue(Math.round(offsetToIndex + lastAnchorOffset), 0, maxScrollTop)
|
|
306
300
|
}
|
|
307
301
|
if (Math.abs(heightManager.viewport.scrollTop - targetTop) >= 2) {
|
|
308
|
-
|
|
309
|
-
heightManager.scrollTop = targetTop
|
|
302
|
+
syncScrollTop(targetTop)
|
|
310
303
|
}
|
|
311
304
|
pendingAnchorReconcile = false
|
|
312
305
|
}
|
|
@@ -359,6 +352,8 @@
|
|
|
359
352
|
let dirtyItemsCount = $state(0) // Reactive count of dirty items
|
|
360
353
|
// Fallback measurement used only when height has not been established yet
|
|
361
354
|
let measuredFallbackHeight = $state(0)
|
|
355
|
+
// Scroll delta threshold optimization - track last scroll position used for range calculation
|
|
356
|
+
let lastProcessedScrollTop = $state(0)
|
|
362
357
|
|
|
363
358
|
let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
|
|
364
359
|
let prevHeight = $state<number>(0)
|
|
@@ -387,6 +382,23 @@
|
|
|
387
382
|
}
|
|
388
383
|
}
|
|
389
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Synchronizes the scroll position between the viewport element and internal state.
|
|
387
|
+
*
|
|
388
|
+
* This helper consolidates the repeated pattern of updating both
|
|
389
|
+
* heightManager.viewport.scrollTop and heightManager.scrollTop together,
|
|
390
|
+
* ensuring they stay in sync.
|
|
391
|
+
*
|
|
392
|
+
* @param {number} value - The scroll position to set
|
|
393
|
+
* @param {boolean} round - Whether to round the value to the nearest integer (default: false)
|
|
394
|
+
*/
|
|
395
|
+
const syncScrollTop = (value: number, round = false) => {
|
|
396
|
+
if (!heightManager.viewportElement) return
|
|
397
|
+
const scrollValue = round ? Math.round(value) : value
|
|
398
|
+
heightManager.viewport.scrollTop = scrollValue
|
|
399
|
+
heightManager.scrollTop = scrollValue
|
|
400
|
+
}
|
|
401
|
+
|
|
390
402
|
// Dynamic update coordination to avoid UA scroll anchoring interference
|
|
391
403
|
let suppressBottomAnchoringUntilMs = $state(0)
|
|
392
404
|
|
|
@@ -508,8 +520,7 @@
|
|
|
508
520
|
// Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
|
|
509
521
|
const approximateScrollTop = Math.max(0, totalHeight() - height)
|
|
510
522
|
log('[SVL] b2t-correction-approx', { approximateScrollTop })
|
|
511
|
-
|
|
512
|
-
heightManager.scrollTop = approximateScrollTop
|
|
523
|
+
syncScrollTop(approximateScrollTop)
|
|
513
524
|
|
|
514
525
|
// Step 2: Use native scrollIntoView for precise bottom-edge positioning after DOM updates
|
|
515
526
|
tick().then(() => {
|
|
@@ -525,16 +536,13 @@
|
|
|
525
536
|
Math.abs(contRect.y + contRect.height - (itemRect.y + itemRect.height)) <=
|
|
526
537
|
tol
|
|
527
538
|
if (!aligned) {
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
log('[SVL] b2t-correction-
|
|
535
|
-
containerBottom: contRect.y + contRect.height,
|
|
536
|
-
itemBottom: itemRect.y + itemRect.height
|
|
537
|
-
})
|
|
539
|
+
// Use manual scrollTop instead of scrollIntoView to prevent parent scroll
|
|
540
|
+
// (scrollIntoView scrolls all ancestor containers, not just the viewport)
|
|
541
|
+
// Note: `container: 'nearest'` option could replace this once browser support improves
|
|
542
|
+
const currentScrollTop = heightManager.viewport.scrollTop
|
|
543
|
+
const offset = itemRect.bottom - contRect.bottom
|
|
544
|
+
heightManager.viewport.scrollTop = currentScrollTop + offset
|
|
545
|
+
log('[SVL] b2t-correction-manual', { offset })
|
|
538
546
|
}
|
|
539
547
|
// Sync our internal scroll state with actual DOM position
|
|
540
548
|
heightManager.scrollTop = heightManager.viewport.scrollTop
|
|
@@ -572,13 +580,12 @@
|
|
|
572
580
|
}
|
|
573
581
|
}
|
|
574
582
|
if (Math.abs(heightChangeAboveViewport) > 2) {
|
|
575
|
-
const newScrollTop =
|
|
576
|
-
|
|
577
|
-
|
|
583
|
+
const newScrollTop = clampValue(
|
|
584
|
+
currentScrollTop + heightChangeAboveViewport,
|
|
585
|
+
0,
|
|
586
|
+
maxScrollTop
|
|
578
587
|
)
|
|
579
|
-
|
|
580
|
-
heightManager.viewport.scrollTop = newScrollTop
|
|
581
|
-
heightManager.scrollTop = newScrollTop
|
|
588
|
+
syncScrollTop(newScrollTop)
|
|
582
589
|
}
|
|
583
590
|
}
|
|
584
591
|
|
|
@@ -613,6 +620,8 @@
|
|
|
613
620
|
// Infinite scroll: trigger onLoadMore when approaching end of list
|
|
614
621
|
$effect(() => {
|
|
615
622
|
if (!BROWSER || !onLoadMore || !hasMore || isLoadingMore) return
|
|
623
|
+
// Skip loading during bottomToTop initialization (init path renders all items artificially)
|
|
624
|
+
if (mode === 'bottomToTop' && !bottomToTopScrollComplete) return
|
|
616
625
|
|
|
617
626
|
const range = visibleItems()
|
|
618
627
|
const atLoadingEdge = range.end >= items.length - loadMoreThreshold
|
|
@@ -667,11 +676,12 @@
|
|
|
667
676
|
const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) <= tolerance
|
|
668
677
|
if (isAtBottom) {
|
|
669
678
|
// Adjust scrollTop by total height delta to hold bottom anchor
|
|
670
|
-
const adjusted =
|
|
671
|
-
|
|
679
|
+
const adjusted = clampValue(
|
|
680
|
+
currentScrollTop + deltaTotal,
|
|
681
|
+
0,
|
|
682
|
+
maxScrollTop
|
|
672
683
|
)
|
|
673
|
-
|
|
674
|
-
heightManager.scrollTop = adjusted
|
|
684
|
+
syncScrollTop(adjusted, true)
|
|
675
685
|
}
|
|
676
686
|
}
|
|
677
687
|
}
|
|
@@ -703,6 +713,9 @@
|
|
|
703
713
|
let lastItemsLength = $state(0)
|
|
704
714
|
// Track last observed total height to compute precise deltas on item count changes
|
|
705
715
|
let lastTotalHeightObserved = $state(0)
|
|
716
|
+
// For bottomToTop mode: keep init path active until scroll positioning is complete
|
|
717
|
+
// This ensures Item 0 stays in the DOM throughout initialization
|
|
718
|
+
let bottomToTopScrollComplete = $state(false)
|
|
706
719
|
|
|
707
720
|
/**
|
|
708
721
|
* CRITICAL: O(1) Reactive Total Height Calculation
|
|
@@ -794,9 +807,7 @@
|
|
|
794
807
|
|
|
795
808
|
if (shouldCorrect) {
|
|
796
809
|
// Round to avoid subpixel positioning issues in bottomToTop mode
|
|
797
|
-
|
|
798
|
-
heightManager.viewport.scrollTop = roundedTargetScrollTop
|
|
799
|
-
heightManager.scrollTop = roundedTargetScrollTop
|
|
810
|
+
syncScrollTop(targetScrollTop, true)
|
|
800
811
|
}
|
|
801
812
|
|
|
802
813
|
// Track if user has scrolled significantly away from bottom
|
|
@@ -852,10 +863,12 @@
|
|
|
852
863
|
// If near the bottom, this naturally pins to the new max; otherwise it preserves the current content.
|
|
853
864
|
programmaticScrollInProgress = true
|
|
854
865
|
void heightManager.runDynamicUpdate(() => {
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
866
|
+
const newScrollTop = clampValue(
|
|
867
|
+
currentScrollTop + deltaMax,
|
|
868
|
+
0,
|
|
869
|
+
nextMaxScrollTop
|
|
870
|
+
)
|
|
871
|
+
syncScrollTop(newScrollTop)
|
|
859
872
|
log('[SVL] items-length-change:applied', {
|
|
860
873
|
instanceId,
|
|
861
874
|
previousScrollTop: currentScrollTop,
|
|
@@ -871,23 +884,20 @@
|
|
|
871
884
|
// Reconcile on next frame in case measured heights adjust totals
|
|
872
885
|
requestAnimationFrame(() => {
|
|
873
886
|
const beforeReconcileScrollTop = heightManager.viewport.scrollTop
|
|
874
|
-
const reconciledNextMax =
|
|
887
|
+
const reconciledNextMax = clampValue(totalHeight() - height, 0, Infinity)
|
|
875
888
|
const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
|
|
876
889
|
// Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
|
|
877
|
-
const desiredScrollTop =
|
|
890
|
+
const desiredScrollTop = clampValue(
|
|
891
|
+
newScrollTop + reconciledDeltaMaxChange,
|
|
878
892
|
0,
|
|
879
|
-
|
|
893
|
+
reconciledNextMax
|
|
880
894
|
)
|
|
881
895
|
// Snap to integer pixels to prevent oscillation due to subpixel rounding
|
|
882
896
|
const desiredRounded = Math.round(desiredScrollTop)
|
|
883
897
|
const diffToDesired = desiredRounded - heightManager.viewport.scrollTop
|
|
884
898
|
if (Math.abs(diffToDesired) >= 2) {
|
|
885
|
-
const adjusted =
|
|
886
|
-
|
|
887
|
-
Math.min(reconciledNextMax, desiredRounded)
|
|
888
|
-
)
|
|
889
|
-
heightManager.viewport.scrollTop = adjusted
|
|
890
|
-
heightManager.scrollTop = adjusted
|
|
899
|
+
const adjusted = clampValue(desiredRounded, 0, reconciledNextMax)
|
|
900
|
+
syncScrollTop(adjusted)
|
|
891
901
|
log('[SVL] items-length-change:reconciled', {
|
|
892
902
|
instanceId,
|
|
893
903
|
beforeReconcileScrollTop,
|
|
@@ -963,35 +973,37 @@
|
|
|
963
973
|
if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
|
|
964
974
|
const viewportHeight = height || 0
|
|
965
975
|
|
|
966
|
-
// For bottomToTop mode,
|
|
967
|
-
// This
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
viewportHeight
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
lastVisibleRange = calculateVisibleRange(
|
|
979
|
-
targetScrollTop,
|
|
980
|
-
viewportHeight,
|
|
981
|
-
heightManager.averageHeight,
|
|
982
|
-
items.length,
|
|
983
|
-
bufferSize,
|
|
984
|
-
mode,
|
|
985
|
-
atBottom,
|
|
986
|
-
wasAtBottomBeforeHeightChange,
|
|
987
|
-
lastVisibleRange,
|
|
988
|
-
totalHeight(),
|
|
989
|
-
heightManager.getHeightCache()
|
|
990
|
-
)
|
|
976
|
+
// For bottomToTop mode, always render items starting from index 0 during initialization
|
|
977
|
+
// This ensures Item 0 is in the DOM so we can use scrollIntoView for precise positioning
|
|
978
|
+
// The scrollIntoView in updateHeightAndScroll will handle correct alignment after heights are measured
|
|
979
|
+
// Use bottomToTopScrollComplete (not just initialized) to keep init path active until scroll is done
|
|
980
|
+
if (mode === 'bottomToTop' && !bottomToTopScrollComplete) {
|
|
981
|
+
// Use a reasonable default if viewport height isn't measured yet
|
|
982
|
+
const effectiveViewport = viewportHeight || 400
|
|
983
|
+
const visibleCount = Math.ceil(effectiveViewport / heightManager.averageHeight) + 1
|
|
984
|
+
lastVisibleRange = {
|
|
985
|
+
start: 0,
|
|
986
|
+
end: Math.min(items.length, visibleCount + bufferSize * 2)
|
|
987
|
+
} as SvelteVirtualListPreviousVisibleRange
|
|
991
988
|
|
|
992
989
|
return lastVisibleRange
|
|
993
990
|
}
|
|
994
991
|
|
|
992
|
+
// Scroll delta threshold optimization: skip recalculation if scroll delta is less than
|
|
993
|
+
// half the average item height and we have a cached range. This reduces unnecessary
|
|
994
|
+
// calculations during smooth scrolling.
|
|
995
|
+
// Note: Only applied in topToBottom mode - bottomToTop has complex scroll correction
|
|
996
|
+
// logic that requires precise visible range calculations.
|
|
997
|
+
// Note: We use lastProcessedScrollTop read-only here; it's updated in the scroll handler
|
|
998
|
+
if (mode === 'topToBottom') {
|
|
999
|
+
const scrollDelta = Math.abs(heightManager.scrollTop - lastProcessedScrollTop)
|
|
1000
|
+
const threshold = heightManager.averageHeight * 0.5
|
|
1001
|
+
if (lastVisibleRange && scrollDelta < threshold && scrollDelta > 0) {
|
|
1002
|
+
// Reuse cached range for small scroll movements
|
|
1003
|
+
return lastVisibleRange
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
995
1007
|
lastVisibleRange = calculateVisibleRange(
|
|
996
1008
|
heightManager.scrollTop,
|
|
997
1009
|
viewportHeight,
|
|
@@ -1009,6 +1021,40 @@
|
|
|
1009
1021
|
return lastVisibleRange
|
|
1010
1022
|
})
|
|
1011
1023
|
|
|
1024
|
+
/**
|
|
1025
|
+
* Computed content height for the virtual list.
|
|
1026
|
+
* Uses the maximum of container height and total content height to ensure
|
|
1027
|
+
* proper scrolling behavior.
|
|
1028
|
+
*/
|
|
1029
|
+
const contentHeight = $derived(() => Math.max(height, totalHeight()))
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Computed transform Y value for positioning the visible items.
|
|
1033
|
+
* Extracted from inline IIFE for better performance and readability.
|
|
1034
|
+
*/
|
|
1035
|
+
const transformY = $derived(() => {
|
|
1036
|
+
const viewportHeight = height || measuredFallbackHeight || 0
|
|
1037
|
+
const visibleRange = visibleItems()
|
|
1038
|
+
|
|
1039
|
+
// Avoid synchronous DOM reads here; fall back once if height is 0
|
|
1040
|
+
const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
|
|
1041
|
+
|
|
1042
|
+
// Use precise offset for topToBottom using measured heights when available
|
|
1043
|
+
return Math.round(
|
|
1044
|
+
calculateTransformY(
|
|
1045
|
+
mode,
|
|
1046
|
+
items.length,
|
|
1047
|
+
visibleRange.end,
|
|
1048
|
+
visibleRange.start,
|
|
1049
|
+
heightManager.averageHeight,
|
|
1050
|
+
effectiveHeight,
|
|
1051
|
+
totalHeight(),
|
|
1052
|
+
heightManager.getHeightCache(),
|
|
1053
|
+
measuredFallbackHeight
|
|
1054
|
+
)
|
|
1055
|
+
)
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1012
1058
|
/**
|
|
1013
1059
|
* Handles scroll events in the viewport using requestAnimationFrame for performance.
|
|
1014
1060
|
*
|
|
@@ -1059,6 +1105,13 @@
|
|
|
1059
1105
|
}
|
|
1060
1106
|
lastScrollTopSnapshot = current
|
|
1061
1107
|
heightManager.scrollTop = current
|
|
1108
|
+
// Update last processed scroll position for delta threshold optimization
|
|
1109
|
+
// Only update when we actually process a scroll (i.e., recalculate visible range)
|
|
1110
|
+
const scrollDelta = Math.abs(current - lastProcessedScrollTop)
|
|
1111
|
+
const threshold = heightManager.averageHeight * 0.5
|
|
1112
|
+
if (scrollDelta >= threshold || lastVisibleRange === null) {
|
|
1113
|
+
lastProcessedScrollTop = current
|
|
1114
|
+
}
|
|
1062
1115
|
updateDebugTailDistance()
|
|
1063
1116
|
if (anchorModeEnabled) {
|
|
1064
1117
|
captureAnchor()
|
|
@@ -1099,7 +1152,8 @@
|
|
|
1099
1152
|
mode
|
|
1100
1153
|
})
|
|
1101
1154
|
if (!heightManager.initialized && mode === 'bottomToTop') {
|
|
1102
|
-
//
|
|
1155
|
+
// bottomToTop initialization: use scrollIntoView on Item 0 for precise positioning
|
|
1156
|
+
// visibleItems() guarantees Item 0 is rendered during initialization
|
|
1103
1157
|
tick().then(() => {
|
|
1104
1158
|
requestAnimationFrame(() => {
|
|
1105
1159
|
requestAnimationFrame(() => {
|
|
@@ -1107,11 +1161,7 @@
|
|
|
1107
1161
|
const measuredHeight =
|
|
1108
1162
|
heightManager.container.getBoundingClientRect().height
|
|
1109
1163
|
height = measuredHeight
|
|
1110
|
-
|
|
1111
|
-
items.length,
|
|
1112
|
-
heightManager.averageHeight,
|
|
1113
|
-
measuredHeight
|
|
1114
|
-
)
|
|
1164
|
+
|
|
1115
1165
|
// Instance jitter to avoid same-frame collisions when two lists init together
|
|
1116
1166
|
const cleanedId = String(instanceId)
|
|
1117
1167
|
.toLowerCase()
|
|
@@ -1121,32 +1171,52 @@
|
|
|
1121
1171
|
const jitterMs = Number.isNaN(parsed)
|
|
1122
1172
|
? Math.floor(Math.random() * 3)
|
|
1123
1173
|
: parsed % 3
|
|
1124
|
-
|
|
1174
|
+
|
|
1125
1175
|
setTimeout(() => {
|
|
1126
|
-
|
|
1127
|
-
|
|
1176
|
+
// Step 1: Set initialized (for other purposes like scroll event handling)
|
|
1177
|
+
// The init path in visibleItems() stays active until bottomToTopScrollComplete
|
|
1178
|
+
if (!heightManager.initialized) {
|
|
1179
|
+
heightManager.initialized = true
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Step 2: Use scrollIntoView on Item 0 for precise positioning
|
|
1183
|
+
// Use double RAF to ensure heights are measured and layout is stable
|
|
1128
1184
|
requestAnimationFrame(() => {
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1185
|
+
requestAnimationFrame(() => {
|
|
1186
|
+
// Item 0 is guaranteed to be in DOM due to init path
|
|
1187
|
+
// Skip if user has already scrolled (scrollTop significantly != 0)
|
|
1188
|
+
const currentScroll = heightManager.viewport.scrollTop
|
|
1189
|
+
const userHasScrolled =
|
|
1190
|
+
currentScroll > heightManager.averageHeight
|
|
1133
1191
|
const el = heightManager.viewport.querySelector(
|
|
1134
1192
|
'[data-original-index="0"]'
|
|
1135
1193
|
) as HTMLElement | null
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
el.
|
|
1194
|
+
|
|
1195
|
+
if (el && !userHasScrolled) {
|
|
1196
|
+
// Use manual scrollTop instead of scrollIntoView to prevent parent scroll
|
|
1197
|
+
// (scrollIntoView scrolls all ancestor containers, not just the viewport)
|
|
1198
|
+
// Note: `container: 'nearest'` option could replace this once browser support improves
|
|
1199
|
+
const viewportRect =
|
|
1200
|
+
heightManager.viewport.getBoundingClientRect()
|
|
1201
|
+
const elRect = el.getBoundingClientRect()
|
|
1202
|
+
const offset = elRect.bottom - viewportRect.bottom
|
|
1203
|
+
heightManager.viewport.scrollTop += offset
|
|
1144
1204
|
heightManager.scrollTop = heightManager.viewport.scrollTop
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
})
|
|
1205
|
+
} else if (userHasScrolled) {
|
|
1206
|
+
// Sync internal state with current scroll
|
|
1207
|
+
heightManager.scrollTop = currentScroll
|
|
1149
1208
|
}
|
|
1209
|
+
|
|
1210
|
+
// Step 3: Mark scroll complete - switches visibleItems to normal mode
|
|
1211
|
+
requestAnimationFrame(() => {
|
|
1212
|
+
bottomToTopScrollComplete = true
|
|
1213
|
+
// Reset bottom-anchoring flag to prevent stale state from init
|
|
1214
|
+
// affecting later operations (e.g., adding items while scrolled away)
|
|
1215
|
+
wasAtBottomBeforeHeightChange = false
|
|
1216
|
+
// Suppress bottom-anchoring briefly to let heights stabilize
|
|
1217
|
+
// after switching to normal mode
|
|
1218
|
+
suppressBottomAnchoringUntilMs = performance.now() + 200
|
|
1219
|
+
})
|
|
1150
1220
|
})
|
|
1151
1221
|
})
|
|
1152
1222
|
}, jitterMs)
|
|
@@ -1371,7 +1441,7 @@
|
|
|
1371
1441
|
`scroll: index ${targetIndex} is out of bounds (0-${items.length - 1})`
|
|
1372
1442
|
)
|
|
1373
1443
|
} else {
|
|
1374
|
-
targetIndex =
|
|
1444
|
+
targetIndex = clampValue(targetIndex, 0, items.length - 1)
|
|
1375
1445
|
}
|
|
1376
1446
|
}
|
|
1377
1447
|
|
|
@@ -1520,38 +1590,14 @@
|
|
|
1520
1590
|
id="virtual-list-content"
|
|
1521
1591
|
{...testId ? { 'data-testid': `${testId}-content` } : {}}
|
|
1522
1592
|
class={contentClass ?? 'virtual-list-content'}
|
|
1523
|
-
style:height="{(
|
|
1593
|
+
style:height="{contentHeight()}px"
|
|
1524
1594
|
>
|
|
1525
1595
|
<!-- Items container is translated to show correct items -->
|
|
1526
1596
|
<div
|
|
1527
1597
|
id="virtual-list-items"
|
|
1528
1598
|
{...testId ? { 'data-testid': `${testId}-items` } : {}}
|
|
1529
1599
|
class={itemsClass ?? 'virtual-list-items'}
|
|
1530
|
-
style:
|
|
1531
|
-
style:transform="translateY({(() => {
|
|
1532
|
-
const viewportHeight = height || measuredFallbackHeight || 0
|
|
1533
|
-
const visibleRange = visibleItems()
|
|
1534
|
-
|
|
1535
|
-
// Avoid synchronous DOM reads here; fall back once if height is 0
|
|
1536
|
-
const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
|
|
1537
|
-
|
|
1538
|
-
// Use precise offset for topToBottom using measured heights when available
|
|
1539
|
-
const transform = Math.round(
|
|
1540
|
-
calculateTransformY(
|
|
1541
|
-
mode,
|
|
1542
|
-
items.length,
|
|
1543
|
-
visibleRange.end,
|
|
1544
|
-
visibleRange.start,
|
|
1545
|
-
heightManager.averageHeight,
|
|
1546
|
-
effectiveHeight,
|
|
1547
|
-
totalHeight(),
|
|
1548
|
-
heightManager.getHeightCache(),
|
|
1549
|
-
measuredFallbackHeight
|
|
1550
|
-
)
|
|
1551
|
-
)
|
|
1552
|
-
|
|
1553
|
-
return transform
|
|
1554
|
-
})()}px)"
|
|
1600
|
+
style:transform="translateY({transformY()}px)"
|
|
1555
1601
|
>
|
|
1556
1602
|
{#each displayItems() as currentItemWithIndex, i (currentItemWithIndex.originalIndex)}
|
|
1557
1603
|
<!-- Only debug when visible range or average height changes -->
|
package/dist/index.d.ts
CHANGED
|
@@ -3,4 +3,6 @@ import type { SvelteVirtualListDebugInfo, SvelteVirtualListMode, SvelteVirtualLi
|
|
|
3
3
|
export type { SvelteVirtualListDebugInfo, SvelteVirtualListMode, SvelteVirtualListProps, SvelteVirtualListScrollAlign, SvelteVirtualListScrollOptions };
|
|
4
4
|
export { ReactiveListManager } from './reactive-list-manager/index.js';
|
|
5
5
|
export type { ListManagerConfig } from './reactive-list-manager/index.js';
|
|
6
|
+
export { formatBytes, getCurrentFps, getMemoryUsage, isPerfEnabled, measureAsync, measureSync, perfMetrics, recordDuration, startFpsTracking, startMeasure, stopFpsTracking } from './utils/perfMetrics.js';
|
|
7
|
+
export type { MetricEntry, MetricName, MetricStats, PerfMetrics } from './utils/perfMetrics.js';
|
|
6
8
|
export default SvelteVirtualList;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import SvelteVirtualList from './SvelteVirtualList.svelte';
|
|
2
2
|
// Re-export renamed manager from existing package location to avoid churn
|
|
3
3
|
export { ReactiveListManager } from './reactive-list-manager/index.js';
|
|
4
|
+
// Re-export performance metrics utilities
|
|
5
|
+
export { formatBytes, getCurrentFps, getMemoryUsage, isPerfEnabled, measureAsync, measureSync, perfMetrics, recordDuration, startFpsTracking, startMeasure, stopFpsTracking } from './utils/perfMetrics.js';
|
|
4
6
|
export default SvelteVirtualList;
|
|
@@ -46,6 +46,9 @@ export declare class ReactiveListManager {
|
|
|
46
46
|
private _mutationObserver;
|
|
47
47
|
private _heightCache;
|
|
48
48
|
private _scheduler;
|
|
49
|
+
private _blockSums;
|
|
50
|
+
private _blockSumsValid;
|
|
51
|
+
private _blockSize;
|
|
49
52
|
private recomputeDerivedHeights;
|
|
50
53
|
private recomputeIsReady;
|
|
51
54
|
private scheduleRecomputeDerivedHeights;
|
|
@@ -157,6 +160,30 @@ export declare class ReactiveListManager {
|
|
|
157
160
|
* Read-only view of measured heights cache
|
|
158
161
|
*/
|
|
159
162
|
getHeightCache(): Readonly<Record<number, number>>;
|
|
163
|
+
/**
|
|
164
|
+
* Invalidate block sums from a given index onwards.
|
|
165
|
+
* Call this when item heights change to ensure block sums are recalculated.
|
|
166
|
+
*
|
|
167
|
+
* @param index - The index from which to invalidate block sums
|
|
168
|
+
*/
|
|
169
|
+
invalidateBlockSumsFrom(index: number): void;
|
|
170
|
+
/**
|
|
171
|
+
* Get the block sums array, rebuilding if necessary.
|
|
172
|
+
* Block sums enable O(blockSize) offset calculations instead of O(n).
|
|
173
|
+
*
|
|
174
|
+
* Each entry contains the cumulative height sum up to and including that block.
|
|
175
|
+
* For example, with blockSize=1000:
|
|
176
|
+
* - Entry 0: sum of heights for items 0-999
|
|
177
|
+
* - Entry 1: sum of heights for items 0-1999
|
|
178
|
+
*
|
|
179
|
+
* @returns Array of cumulative block sums
|
|
180
|
+
*/
|
|
181
|
+
getBlockSums(): number[];
|
|
182
|
+
/**
|
|
183
|
+
* Build block prefix sums for efficient offset calculations.
|
|
184
|
+
* Uses the same algorithm as the utility function but leverages internal state.
|
|
185
|
+
*/
|
|
186
|
+
private buildBlockSums;
|
|
160
187
|
/**
|
|
161
188
|
* Create a new ReactiveListManager instance
|
|
162
189
|
*
|
|
@@ -49,6 +49,10 @@ export class ReactiveListManager {
|
|
|
49
49
|
_heightCache = {};
|
|
50
50
|
// Recompute scheduling
|
|
51
51
|
_scheduler = new RecomputeScheduler(() => this.recomputeDerivedHeights());
|
|
52
|
+
// Block sum caching for O(blockSize) offset calculations instead of O(n)
|
|
53
|
+
_blockSums = [];
|
|
54
|
+
_blockSumsValid = false;
|
|
55
|
+
_blockSize = 1000;
|
|
52
56
|
recomputeDerivedHeights() {
|
|
53
57
|
const average = this._measuredCount > 0
|
|
54
58
|
? this._totalMeasuredHeight / this._measuredCount
|
|
@@ -343,6 +347,57 @@ export class ReactiveListManager {
|
|
|
343
347
|
getHeightCache() {
|
|
344
348
|
return this._heightCache;
|
|
345
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Invalidate block sums from a given index onwards.
|
|
352
|
+
* Call this when item heights change to ensure block sums are recalculated.
|
|
353
|
+
*
|
|
354
|
+
* @param index - The index from which to invalidate block sums
|
|
355
|
+
*/
|
|
356
|
+
invalidateBlockSumsFrom(index) {
|
|
357
|
+
const blockIndex = Math.floor(index / this._blockSize);
|
|
358
|
+
// Truncate to remove invalidated blocks
|
|
359
|
+
if (blockIndex < this._blockSums.length) {
|
|
360
|
+
this._blockSums.length = blockIndex;
|
|
361
|
+
}
|
|
362
|
+
this._blockSumsValid = false;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Get the block sums array, rebuilding if necessary.
|
|
366
|
+
* Block sums enable O(blockSize) offset calculations instead of O(n).
|
|
367
|
+
*
|
|
368
|
+
* Each entry contains the cumulative height sum up to and including that block.
|
|
369
|
+
* For example, with blockSize=1000:
|
|
370
|
+
* - Entry 0: sum of heights for items 0-999
|
|
371
|
+
* - Entry 1: sum of heights for items 0-1999
|
|
372
|
+
*
|
|
373
|
+
* @returns Array of cumulative block sums
|
|
374
|
+
*/
|
|
375
|
+
getBlockSums() {
|
|
376
|
+
if (!this._blockSumsValid || this._blockSums.length === 0) {
|
|
377
|
+
this._blockSums = this.buildBlockSums();
|
|
378
|
+
this._blockSumsValid = true;
|
|
379
|
+
}
|
|
380
|
+
return this._blockSums;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Build block prefix sums for efficient offset calculations.
|
|
384
|
+
* Uses the same algorithm as the utility function but leverages internal state.
|
|
385
|
+
*/
|
|
386
|
+
buildBlockSums() {
|
|
387
|
+
const blocks = Math.ceil(this._itemLength / this._blockSize);
|
|
388
|
+
const sums = new Array(Math.max(0, blocks - 1));
|
|
389
|
+
let running = 0;
|
|
390
|
+
for (let b = 0; b < blocks - 1; b++) {
|
|
391
|
+
const start = b * this._blockSize;
|
|
392
|
+
const end = start + this._blockSize;
|
|
393
|
+
for (let i = start; i < end; i++) {
|
|
394
|
+
const height = this._heightCache[i];
|
|
395
|
+
running += Number.isFinite(height) && height > 0 ? height : this._averageHeight;
|
|
396
|
+
}
|
|
397
|
+
sums[b] = running;
|
|
398
|
+
}
|
|
399
|
+
return sums;
|
|
400
|
+
}
|
|
346
401
|
/**
|
|
347
402
|
* Create a new ReactiveListManager instance
|
|
348
403
|
*
|
|
@@ -372,8 +427,13 @@ export class ReactiveListManager {
|
|
|
372
427
|
// Batch calculate changes to trigger reactivity only once
|
|
373
428
|
let heightDelta = 0;
|
|
374
429
|
let countDelta = 0;
|
|
430
|
+
let minChangedIndex = Infinity;
|
|
375
431
|
for (const change of dirtyResults) {
|
|
376
432
|
const { index, oldHeight, newHeight } = change;
|
|
433
|
+
// Track minimum changed index for block sum invalidation
|
|
434
|
+
if (index < minChangedIndex) {
|
|
435
|
+
minChangedIndex = index;
|
|
436
|
+
}
|
|
377
437
|
// Remove old contribution if it existed
|
|
378
438
|
if (oldHeight !== undefined) {
|
|
379
439
|
heightDelta -= oldHeight;
|
|
@@ -394,6 +454,10 @@ export class ReactiveListManager {
|
|
|
394
454
|
this._measuredFlags[index] = 1;
|
|
395
455
|
}
|
|
396
456
|
}
|
|
457
|
+
// Invalidate block sums from the minimum changed index
|
|
458
|
+
if (minChangedIndex < Infinity) {
|
|
459
|
+
this.invalidateBlockSumsFrom(minChangedIndex);
|
|
460
|
+
}
|
|
397
461
|
// IDK... no one can explain it to me,.. but its here like this... it cannot be:
|
|
398
462
|
// if (heightDelta === 0 && countDelta === 0) return
|
|
399
463
|
const isJsdom = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
|
@@ -421,6 +485,9 @@ export class ReactiveListManager {
|
|
|
421
485
|
updateItemLength(newLength) {
|
|
422
486
|
this._itemLength = newLength;
|
|
423
487
|
this._measuredFlags = new Uint8Array(Math.max(0, newLength));
|
|
488
|
+
// Reset block sums since length changed
|
|
489
|
+
this._blockSums = [];
|
|
490
|
+
this._blockSumsValid = false;
|
|
424
491
|
// Immediate recompute so new items become visible without delay
|
|
425
492
|
this.recomputeDerivedHeights();
|
|
426
493
|
}
|
|
@@ -450,6 +517,8 @@ export class ReactiveListManager {
|
|
|
450
517
|
if (Number.isFinite(height) && height > 0) {
|
|
451
518
|
this._heightCache[index] = height;
|
|
452
519
|
this._totalMeasuredHeight += height;
|
|
520
|
+
// Invalidate block sums from this index
|
|
521
|
+
this.invalidateBlockSumsFrom(index);
|
|
453
522
|
this.scheduleRecomputeDerivedHeights();
|
|
454
523
|
}
|
|
455
524
|
}
|
|
@@ -462,6 +531,9 @@ export class ReactiveListManager {
|
|
|
462
531
|
this._totalMeasuredHeight = 0;
|
|
463
532
|
this._measuredCount = 0;
|
|
464
533
|
this._measuredFlags = this._itemLength > 0 ? new Uint8Array(this._itemLength) : null;
|
|
534
|
+
// Reset block sums
|
|
535
|
+
this._blockSums = [];
|
|
536
|
+
this._blockSumsValid = false;
|
|
465
537
|
// Note: Don't reset _itemLength, _itemHeight as they represent configuration, not measured state
|
|
466
538
|
this.scheduleRecomputeDerivedHeights();
|
|
467
539
|
}
|