@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.
@@ -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 = Math.max(0, totalHeight() - (height || 0))
293
+ const maxScrollTop = clampValue(totalHeight() - (height || 0), 0, Infinity)
294
294
  let targetTop: number
295
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
- )
296
+ const distanceFromStart = clampValue(offsetToIndex + lastAnchorOffset, 0, Infinity)
297
+ targetTop = clampValue(Math.round(maxScrollTop - distanceFromStart), 0, maxScrollTop)
301
298
  } else {
302
- targetTop = Math.max(
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
- heightManager.viewport.scrollTop = targetTop
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
- heightManager.viewport.scrollTop = approximateScrollTop
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
- // Native browser API handles all positioning edge cases perfectly
529
- item0Element.scrollIntoView({
530
- block: 'end', // Align Item 0 to bottom edge of viewport
531
- behavior: 'smooth', // Smooth animation for better UX
532
- inline: 'nearest' // Minimal horizontal adjustment
533
- })
534
- log('[SVL] b2t-correction-native', {
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 = Math.min(
576
- maxScrollTop,
577
- Math.max(0, currentScrollTop + heightChangeAboveViewport)
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 = Math.round(
671
- Math.min(maxScrollTop, Math.max(0, currentScrollTop + deltaTotal))
679
+ const adjusted = clampValue(
680
+ currentScrollTop + deltaTotal,
681
+ 0,
682
+ maxScrollTop
672
683
  )
673
- heightManager.viewport.scrollTop = adjusted
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
- const roundedTargetScrollTop = Math.round(targetScrollTop)
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 unclamped = currentScrollTop + deltaMax
856
- const newScrollTop = Math.max(0, Math.min(nextMaxScrollTop, unclamped))
857
- heightManager.viewport.scrollTop = newScrollTop
858
- heightManager.scrollTop = newScrollTop
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 = Math.max(0, totalHeight() - height)
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 = Math.max(
890
+ const desiredScrollTop = clampValue(
891
+ newScrollTop + reconciledDeltaMaxChange,
878
892
  0,
879
- Math.min(reconciledNextMax, newScrollTop + reconciledDeltaMaxChange)
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 = Math.max(
886
- 0,
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, don't calculate visible range until properly initialized
967
- // This prevents showing wrong items when scrollTop starts at 0
968
- if (
969
- mode === 'bottomToTop' &&
970
- !heightManager.initialized &&
971
- heightManager.scrollTop === 0 &&
972
- viewportHeight > 0
973
- ) {
974
- // Calculate what the correct scroll position should be
975
- const targetScrollTop = Math.max(0, totalHeight() - viewportHeight)
976
-
977
- // Use the target scroll position for visible range calculation
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
- // Deterministic init order: double RAF + microtask, then apply bottom anchoring
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
- const targetScrollTop = calculateScrollPosition(
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
- log('b2t-init', { measuredHeight, targetScrollTop, jitterMs })
1174
+
1125
1175
  setTimeout(() => {
1126
- heightManager.viewport.scrollTop = targetScrollTop
1127
- heightManager.scrollTop = targetScrollTop
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
- // Guard: only transition false -> true to avoid invariant error
1130
- if (!heightManager.initialized) heightManager.initialized = true
1131
- // Post-init verification: ensure item 0 bottom aligns; fallback to native
1132
- tick().then(() => {
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
- if (!el) return
1137
- const cont = heightManager.viewport.getBoundingClientRect()
1138
- const r = el.getBoundingClientRect()
1139
- const tol = 4
1140
- const aligned =
1141
- Math.abs(cont.y + cont.height - (r.y + r.height)) <= tol
1142
- if (!aligned) {
1143
- el.scrollIntoView({ block: 'end', inline: 'nearest' })
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
- log('b2t-init-native-fallback', {
1146
- containerBottom: cont.y + cont.height,
1147
- itemBottom: r.y + r.height
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 = Math.max(0, Math.min(targetIndex, items.length - 1))
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="{(() => Math.max(height, totalHeight()))()}px"
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:visibility={height === 0 && mode === 'bottomToTop' ? 'hidden' : 'visible'}
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
  }