@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.
@@ -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() - (height || 0))
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() - (height || 0), 0, Infinity)
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(): Uses actual heightCache measurements instead of skewed averages
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() - height)
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() - height)
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
- // Critical updates that must trigger reactive effects immediately
651
- heightManager.itemHeight = result.newHeight
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(() => heightManager.totalHeight)
741
+ const totalHeight = $derived(heightManager.totalHeight)
752
742
 
753
- const atBottom = $derived(heightManager.scrollTop >= totalHeight() - height - 1)
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() - height)
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() - height)
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() - height, 0, Infinity)
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(() => Math.max(height, totalHeight()))
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() guarantees Item 0 is rendered during initialization
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() stays active until bottomToTopScrollComplete
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() // Cache once to avoid reactive loops
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()}px"
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()}px)"
1640
+ style:transform="translateY({transformY}px)"
1633
1641
  >
1634
- {#each displayItems() as currentItemWithIndex, _i (currentItemWithIndex.originalIndex)}
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(() => heightManager.totalHeight)
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.getDerivedTotalHeight()
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.estimatedHeight` - Default height for unmeasured items
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
- estimatedHeight: defaultEstimatedItemHeight
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(() => heightManager.totalHeight)
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, estimatedHeight: 50 })
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 → getDerivedTotalHeight() → Reactive UI
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 _estimatedHeight = $state(40) // Default estimate
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
- estimatedHeight: number
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
- estimatedHeight: number
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, estimatedHeight: 40 })
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 disables UA scroll anchoring,
130
- * subsequent calls just increment depth. Safe to call when not wired; styles are only toggled
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 re-enables UA scroll anchoring. Guards against underflow.
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, then restore it.
141
- * Accepts a sync or async function and ensures `overflow-anchor` is toggled
142
- * around the operation. If the manager isn't ready yet, it simply executes `fn`.
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, estimatedHeight: 40 })
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 disables UA scroll anchoring,
245
- * subsequent calls just increment depth. Safe to call when not wired; styles are only toggled
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 re-enables UA scroll anchoring. Guards against underflow.
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', 'auto');
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, then restore it.
277
- * Accepts a sync or async function and ensures `overflow-anchor` is toggled
278
- * around the operation. If the manager isn't ready yet, it simply executes `fn`.
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();
@@ -23,7 +23,7 @@
23
23
  * manager.processDirtyHeights(heightChanges)
24
24
  *
25
25
  * // Get reactive total height
26
- * const totalHeight = manager.getDerivedTotalHeight(calculatedItemHeight)
26
+ * const totalHeight = manager.totalHeight
27
27
  * ```
28
28
  *
29
29
  * @example Performance Monitoring
@@ -23,7 +23,7 @@
23
23
  * manager.processDirtyHeights(heightChanges)
24
24
  *
25
25
  * // Get reactive total height
26
- * const totalHeight = manager.getDerivedTotalHeight(calculatedItemHeight)
26
+ * const totalHeight = manager.totalHeight
27
27
  * ```
28
28
  *
29
29
  * @example Performance Monitoring
@@ -35,7 +35,7 @@ import type { SvelteVirtualListMode } from '../types.js';
35
35
  * calculateAverageHeightDebounced(
36
36
  * false,
37
37
  * null,
38
- * () => getVisibleRange(),
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 visibleItemsGetter - Function to get current visible range
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, visibleItemsGetter: () => {
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
- * () => getVisibleRange(),
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 visibleItemsGetter - Function to get current visible range
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, visibleItemsGetter, itemElements, heightCache, lastMeasuredIndex, calculatedItemHeight,
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 visibleRange = visibleItemsGetter();
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, visibleRange, heightCache, calculatedItemHeight, dirtyItems, currentTotalHeight, currentValidCount, mode);
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.12",
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.55.0",
76
- "@typescript-eslint/parser": "^8.55.0",
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.55.0",
99
+ "typescript-eslint": "^8.56.0",
99
100
  "vite": "^7.3.1",
100
101
  "vitest": "^4.0.18"
101
102
  },