@humanspeak/svelte-virtual-list 0.3.13 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,108 +31,20 @@ A high-performance virtual list component for Svelte 5 applications that efficie
31
31
  - 🕹️ Programmatic scrolling with `scroll`
32
32
  - ♾️ Infinite scroll support with `onLoadMore`
33
33
 
34
- ## scroll: Programmatic Scrolling
34
+ ## Requirements
35
35
 
36
- You can now programmatically scroll to any item in the list using the `scroll` method. This is useful for chat apps, jump-to-item navigation, and more. You can check the usage in `src/routes/tests/scroll`. Thank you for the feature request!
37
-
38
- ### Usage Example
39
-
40
- ```svelte
41
- <script lang="ts">
42
- import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
43
- let listRef
44
- const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }))
45
-
46
- function goToItem5000() {
47
- // Scroll to item 5000 with smooth scrolling and auto alignment
48
- listRef.scroll({ index: 5000, smoothScroll: true, align: 'auto' })
49
- }
50
- </script>
51
-
52
- <button on:click={goToItem5000}> Scroll to item 5000 </button>
53
- <SvelteVirtualList {items} bind:this={listRef}>
54
- {#snippet renderItem(item)}
55
- <div>{item.text}</div>
56
- {/snippet}
57
- </SvelteVirtualList>
58
- ```
59
-
60
- ### API
61
-
62
- - `scroll(options: { index: number; smoothScroll?: boolean; shouldThrowOnBounds?: boolean; align?: 'auto' | 'top' | 'bottom' | 'nearest' })`
63
- - `index`: The item index to scroll to (0-based)
64
- - `smoothScroll`: If true, uses smooth scrolling (default: true)
65
- - `shouldThrowOnBounds`: If true, throws if index is out of bounds (default: true)
66
- - `align`: Where to align the item in the viewport:
67
- - `'auto'` (default): Only scroll if not visible, align to top or bottom as appropriate
68
- - `'top'`: Always align to the top
69
- - `'bottom'`: Always align to the bottom
70
- - `'nearest'`: Scroll as little as possible to bring the item into view (like native scrollIntoView({ block: 'nearest' }))
71
-
72
- #### Usage Examples
73
-
74
- ```svelte
75
- <button on:click={() => listRef.scroll({ index: 5000, align: 'nearest' })}>
76
- Scroll to item 5000 (nearest)
77
- </button>
78
- ```
79
-
80
- ## Infinite Scroll
81
-
82
- Load more data automatically as users scroll near the end of the list. Perfect for paginated APIs, infinite feeds, and chat applications.
83
-
84
- ```svelte
85
- <script lang="ts">
86
- import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
87
-
88
- let items = $state([...initialItems])
89
- let hasMore = $state(true)
90
-
91
- async function loadMore() {
92
- const newItems = await fetchMoreItems()
93
- items = [...items, ...newItems]
94
- if (newItems.length === 0) {
95
- hasMore = false
96
- }
97
- }
98
- </script>
99
-
100
- <SvelteVirtualList {items} onLoadMore={loadMore} loadMoreThreshold={20} {hasMore}>
101
- {#snippet renderItem(item)}
102
- <div>{item.text}</div>
103
- {/snippet}
104
- </SvelteVirtualList>
105
- ```
106
-
107
- ### Infinite Scroll Props
108
-
109
- | Prop | Type | Default | Description |
110
- | ------------------- | ----------------------------- | ------- | ---------------------------------------------------- |
111
- | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed (supports async) |
112
- | `loadMoreThreshold` | `number` | `20` | Number of items from the end to trigger `onLoadMore` |
113
- | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
114
-
115
- ### Infinite Scroll Behavior
116
-
117
- - Triggers when scrolling near the end of the list
118
- - Automatically triggers on mount if initial items are below threshold
119
- - Prevents concurrent `onLoadMore` calls while loading
120
- - Works with both sync and async callbacks
121
- - Supports both `topToBottom` and `bottomToTop` modes
122
-
123
- ### Integration Guides
124
-
125
- - [Infinite Scroll with Convex](documentation/CONVEX_INFINITE_SCROLL.md) - Real-time data + pagination with Convex backend
36
+ - Svelte 5
37
+ - Node.js 18+
126
38
 
127
39
  ## Installation
128
40
 
129
41
  ```bash
130
- # Using npm
131
- npm install @humanspeak/svelte-virtual-list
132
-
133
42
  # Using pnpm (recommended)
134
43
  pnpm add @humanspeak/svelte-virtual-list
135
44
 
45
+ # Using npm
46
+ npm install @humanspeak/svelte-virtual-list
47
+
136
48
  # Using yarn
137
49
  yarn add @humanspeak/svelte-virtual-list
138
50
  ```
@@ -156,9 +68,27 @@ yarn add @humanspeak/svelte-virtual-list
156
68
  </SvelteVirtualList>
157
69
  ```
158
70
 
159
- ## Advanced Features
71
+ ## Props
160
72
 
161
- ### Chat Application Example
73
+ | Prop | Type | Default | Description |
74
+ | ---------------------------- | -------------------------------- | --------------- | ----------------------------------------------------------------------------- |
75
+ | `items` | `T[]` | Required | Array of items to render |
76
+ | `defaultEstimatedItemHeight` | `number` | `40` | Initial height estimate used until items are measured |
77
+ | `mode` | `'topToBottom' \| 'bottomToTop'` | `'topToBottom'` | Scroll direction and anchoring behavior |
78
+ | `bufferSize` | `number` | `20` | Number of items rendered outside the viewport |
79
+ | `debug` | `boolean` | `false` | Enable debug logging and visualizations |
80
+ | `containerClass` | `string` | `''` | Class for outer container |
81
+ | `viewportClass` | `string` | `''` | Class for scrollable viewport |
82
+ | `contentClass` | `string` | `''` | Class for content wrapper |
83
+ | `itemsClass` | `string` | `''` | Class for items container |
84
+ | `testId` | `string` | `''` | Base test id used in internal test hooks (useful for E2E/tests and debugging) |
85
+ | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed for infinite scroll |
86
+ | `loadMoreThreshold` | `number` | `20` | Items from end to trigger `onLoadMore` |
87
+ | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
88
+
89
+ ## Bottom-to-Top Mode
90
+
91
+ Use `mode="bottomToTop"` for chat-like lists anchored to the bottom:
162
92
 
163
93
  ```svelte
164
94
  <script lang="ts">
@@ -178,7 +108,7 @@ yarn add @humanspeak/svelte-virtual-list
178
108
  </script>
179
109
 
180
110
  <div style="height: 500px;">
181
- <SvelteVirtualList items={messages} mode="bottomToTop" debug>
111
+ <SvelteVirtualList items={messages} mode="bottomToTop">
182
112
  {#snippet renderItem(message)}
183
113
  <div class="message-container">
184
114
  <p>{message.text}</p>
@@ -191,40 +121,100 @@ yarn add @humanspeak/svelte-virtual-list
191
121
  </div>
192
122
  ```
193
123
 
194
- ### Bottom-to-top mode
124
+ ## Programmatic Scrolling
195
125
 
196
- Use `mode="bottomToTop"` for chat-like lists anchored to the bottom. Programmatic scrolling uses the same API as top-to-bottom lists:
126
+ Scroll to any item in the list using the `scroll` method. Useful for chat apps, jump-to-item navigation, and more.
197
127
 
198
128
  ```svelte
199
129
  <script lang="ts">
200
130
  import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
201
131
  let listRef
202
- const messages = Array.from({ length: 2000 }, (_, i) => ({ id: i, text: `Msg ${i}` }))
132
+ const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }))
133
+
134
+ function goToItem5000() {
135
+ listRef.scroll({ index: 5000, smoothScroll: true, align: 'auto' })
136
+ }
203
137
  </script>
204
138
 
139
+ <button onclick={goToItem5000}> Scroll to item 5000 </button>
140
+ <SvelteVirtualList {items} bind:this={listRef}>
141
+ {#snippet renderItem(item)}
142
+ <div>{item.text}</div>
143
+ {/snippet}
144
+ </SvelteVirtualList>
145
+ ```
146
+
147
+ ### scroll() Options
148
+
149
+ | Option | Type | Default | Description |
150
+ | --------------------- | ------------------------------------------ | -------- | --------------------------------------- |
151
+ | `index` | `number` | Required | The item index to scroll to (0-based) |
152
+ | `smoothScroll` | `boolean` | `true` | Use smooth scrolling animation |
153
+ | `shouldThrowOnBounds` | `boolean` | `true` | Throw if index is out of bounds |
154
+ | `align` | `'auto' \| 'top' \| 'bottom' \| 'nearest'` | `'auto'` | Where to align the item in the viewport |
155
+
156
+ Alignment options:
157
+
158
+ - `'auto'` - Only scroll if not visible, align to nearest edge
159
+ - `'top'` - Always align to the top
160
+ - `'bottom'` - Always align to the bottom
161
+ - `'nearest'` - Scroll as little as possible to bring the item into view
162
+
163
+ Works with both `topToBottom` and `bottomToTop` modes:
164
+
165
+ ```svelte
205
166
  <SvelteVirtualList items={messages} mode="bottomToTop" bind:this={listRef} />
206
- <button on:click={() => listRef.scroll({ index: messages.length - 1, align: 'bottom' })}>
167
+ <button onclick={() => listRef.scroll({ index: messages.length - 1, align: 'bottom' })}>
207
168
  Jump to latest
208
169
  </button>
209
170
  ```
210
171
 
211
- ## Props
172
+ ## Infinite Scroll
212
173
 
213
- | Prop | Type | Default | Description |
214
- | ---------------------------- | -------------------------------- | --------------- | ----------------------------------------------------------------------------- |
215
- | `items` | `T[]` | Required | Array of items to render |
216
- | `defaultEstimatedItemHeight` | `number` | `40` | Initial height estimate used until items are measured |
217
- | `mode` | `'topToBottom' \| 'bottomToTop'` | `'topToBottom'` | Scroll direction and anchoring behavior |
218
- | `bufferSize` | `number` | `20` | Number of items rendered outside the viewport |
219
- | `debug` | `boolean` | `false` | Enable debug logging and visualizations |
220
- | `containerClass` | `string` | `''` | Class for outer container |
221
- | `viewportClass` | `string` | `''` | Class for scrollable viewport |
222
- | `contentClass` | `string` | `''` | Class for content wrapper |
223
- | `itemsClass` | `string` | `''` | Class for items container |
224
- | `testId` | `string` | `''` | Base test id used in internal test hooks (useful for E2E/tests and debugging) |
225
- | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed for infinite scroll |
226
- | `loadMoreThreshold` | `number` | `20` | Items from end to trigger `onLoadMore` |
227
- | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
174
+ Load more data automatically as users scroll near the end of the list. Perfect for paginated APIs, infinite feeds, and chat applications.
175
+
176
+ ```svelte
177
+ <script lang="ts">
178
+ import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
179
+
180
+ let items = $state([...initialItems])
181
+ let hasMore = $state(true)
182
+
183
+ async function loadMore() {
184
+ const newItems = await fetchMoreItems()
185
+ items = [...items, ...newItems]
186
+ if (newItems.length === 0) {
187
+ hasMore = false
188
+ }
189
+ }
190
+ </script>
191
+
192
+ <SvelteVirtualList {items} onLoadMore={loadMore} loadMoreThreshold={20} {hasMore}>
193
+ {#snippet renderItem(item)}
194
+ <div>{item.text}</div>
195
+ {/snippet}
196
+ </SvelteVirtualList>
197
+ ```
198
+
199
+ ### Infinite Scroll Behavior
200
+
201
+ - Triggers when scrolling near the end of the list
202
+ - Automatically triggers on mount if initial items are below threshold
203
+ - Prevents concurrent `onLoadMore` calls while loading
204
+ - Works with both sync and async callbacks
205
+ - Supports both `topToBottom` and `bottomToTop` modes
206
+
207
+ ### Integration Guides
208
+
209
+ - [Infinite Scroll with Convex](documentation/CONVEX_INFINITE_SCROLL.md) - Real-time data + pagination with Convex backend
210
+
211
+ ## Performance Considerations
212
+
213
+ - The `bufferSize` prop affects memory usage and scroll smoothness
214
+ - Items are measured and cached for optimal performance
215
+ - Dynamic height calculations happen automatically
216
+ - Resize observers handle container/content changes
217
+ - Virtual DOM updates are batched for efficiency
228
218
 
229
219
  ## Testing
230
220
 
@@ -254,33 +244,6 @@ npx playwright test tests/docs-visit.spec.ts --project=chromium
254
244
  npx playwright test --debug
255
245
  ```
256
246
 
257
- ### Development Commands
258
-
259
- ```bash
260
- # Start development server
261
- pnpm dev
262
-
263
- # Start both package and docs
264
- pnpm run dev:all
265
-
266
- # Check TypeScript/Svelte
267
- pnpm run check
268
-
269
- # Build package
270
- pnpm run build
271
-
272
- # Format and lint code
273
- pnpm run lint:fix
274
- ```
275
-
276
- ## Performance Considerations
277
-
278
- - The `bufferSize` prop affects memory usage and scroll smoothness
279
- - Items are measured and cached for optimal performance
280
- - Dynamic height calculations happen automatically
281
- - Resize observers handle container/content changes
282
- - Virtual DOM updates are batched for efficiency
283
-
284
247
  ## Project Structure
285
248
 
286
249
  This is a **PNPM workspace** with two packages:
@@ -288,22 +251,34 @@ This is a **PNPM workspace** with two packages:
288
251
  1. **`./`** - Main Svelte Virtual List component package
289
252
  2. **`./docs`** - Documentation site with live demos and examples
290
253
 
291
- ### Development Workflow
254
+ ### Development Commands
292
255
 
293
256
  ```bash
294
257
  # Install dependencies for both packages
295
258
  pnpm install
296
259
 
297
- # Run development servers simultaneously
260
+ # Start development server
261
+ pnpm dev
262
+
263
+ # Start both package and docs
298
264
  pnpm run dev:all
299
265
 
300
- # Build both packages
266
+ # Build package
301
267
  pnpm run build
302
268
 
303
- # Run tests across the workspace
269
+ # Check TypeScript/Svelte
270
+ pnpm run check
271
+
272
+ # Format and lint code (uses Trunk)
273
+ trunk fmt
274
+ trunk check
275
+
276
+ # Run all tests
304
277
  pnpm test:all
305
278
  ```
306
279
 
280
+ This project uses [Trunk](https://trunk.io) for formatting and linting. Trunk manages tool versions and runs checks automatically via pre-commit hooks.
281
+
307
282
  ## License
308
283
 
309
284
  MIT © [Humanspeak, Inc.](LICENSE)
@@ -251,16 +251,16 @@
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)
262
262
  const currentTop = heightManager.viewport.scrollTop
263
- let offsetWithin = 0
263
+ let offsetWithin: number
264
264
  if (mode === 'bottomToTop') {
265
265
  // Convert distance-from-end to distance-from-start
266
266
  const distanceFromStart = maxScrollTop - currentTop
@@ -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
@@ -615,6 +598,7 @@
615
598
  // Keep height manager synchronized with items length
616
599
  $effect(() => {
617
600
  heightManager.updateItemLength(items.length)
601
+ stabilizedContentHeight = 0
618
602
  })
619
603
 
620
604
  // Infinite scroll: trigger onLoadMore when approaching end of list
@@ -623,7 +607,7 @@
623
607
  // Skip loading during bottomToTop initialization (init path renders all items artificially)
624
608
  if (mode === 'bottomToTop' && !bottomToTopScrollComplete) return
625
609
 
626
- const range = visibleItems()
610
+ const range = visibleItems
627
611
  const atLoadingEdge = range.end >= items.length - loadMoreThreshold
628
612
  const insufficientItems = items.length < loadMoreThreshold && heightManager.initialized
629
613
 
@@ -755,9 +739,9 @@
755
739
  * This getter is reactive and updates whenever heightManager's internal state changes.
756
740
  * Used by: atBottom calculation, scroll corrections, maxScrollTop calculations
757
741
  */
758
- const totalHeight = $derived(() => heightManager.totalHeight)
742
+ const totalHeight = $derived(heightManager.totalHeight)
759
743
 
760
- const atBottom = $derived(heightManager.scrollTop >= totalHeight() - height - 1)
744
+ const atBottom = $derived(heightManager.scrollTop >= totalHeight - height - 1)
761
745
  let wasAtBottomBeforeHeightChange = false
762
746
  let lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null = null
763
747
 
@@ -787,7 +771,7 @@
787
771
  mode === 'bottomToTop' &&
788
772
  heightManager.viewportElement
789
773
  ) {
790
- const targetScrollTop = Math.max(0, totalHeight() - height)
774
+ const targetScrollTop = Math.max(0, totalHeight - height)
791
775
  const currentScrollTop = heightManager.viewport.scrollTop
792
776
  const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
793
777
 
@@ -797,7 +781,7 @@
797
781
  // 3. We're significantly off target
798
782
  // 4. We're not at the bottom (where height changes should be handled more carefully)
799
783
  const heightChanged = Math.abs(heightManager.averageHeight - lastCalculatedHeight) > 1
800
- const maxScrollTop = Math.max(0, totalHeight() - height)
784
+ const maxScrollTop = Math.max(0, totalHeight - height)
801
785
 
802
786
  // In bottomToTop mode, we're "at bottom" when scroll is at max position
803
787
  const isAtBottom =
@@ -845,7 +829,7 @@
845
829
  const currentScrollTop = heightManager.viewport.scrollTop
846
830
  const currentCalculatedItemHeight = heightManager.averageHeight
847
831
  const currentHeight = height
848
- const currentTotalHeight = totalHeight()
832
+ const currentTotalHeight = totalHeight
849
833
  const prevTotalHeight =
850
834
  lastTotalHeightObserved ||
851
835
  currentTotalHeight - itemsAdded * currentCalculatedItemHeight
@@ -891,7 +875,7 @@
891
875
  // Reconcile on next frame in case measured heights adjust totals
892
876
  requestAnimationFrame(() => {
893
877
  const beforeReconcileScrollTop = heightManager.viewport.scrollTop
894
- const reconciledNextMax = clampValue(totalHeight() - height, 0, Infinity)
878
+ const reconciledNextMax = clampValue(totalHeight - height, 0, Infinity)
895
879
  const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
896
880
  // Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
897
881
  const desiredScrollTop = clampValue(
@@ -934,7 +918,7 @@
934
918
 
935
919
  lastItemsLength = currentItemsLength
936
920
  // Update last observed total height at the end of the effect
937
- lastTotalHeightObserved = totalHeight()
921
+ lastTotalHeightObserved = totalHeight
938
922
  })
939
923
 
940
924
  // Update container height continuously to reflect layout changes that
@@ -970,13 +954,13 @@
970
954
  *
971
955
  * @example
972
956
  * ```typescript
973
- * const range = visibleItems()
957
+ * const range = visibleItems
974
958
  * console.info(`Rendering items from ${range.start} to ${range.end}`)
975
959
  * ```
976
960
  *
977
961
  * @returns {SvelteVirtualListPreviousVisibleRange} Object containing start and end indices of visible items
978
962
  */
979
- const visibleItems = $derived((): SvelteVirtualListPreviousVisibleRange => {
963
+ const visibleItems = $derived.by((): SvelteVirtualListPreviousVisibleRange => {
980
964
  if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
981
965
  const viewportHeight = height || 0
982
966
 
@@ -1021,7 +1005,7 @@
1021
1005
  atBottom,
1022
1006
  wasAtBottomBeforeHeightChange,
1023
1007
  lastVisibleRange,
1024
- totalHeight(),
1008
+ totalHeight,
1025
1009
  heightManager.getHeightCache()
1026
1010
  )
1027
1011
 
@@ -1032,21 +1016,46 @@
1032
1016
  * Computed content height for the virtual list.
1033
1017
  * Uses the maximum of container height and total content height to ensure
1034
1018
  * proper scrolling behavior.
1019
+ *
1020
+ * In bottomToTop mode during active scroll, contentHeight is "ratcheted" —
1021
+ * it can grow but never shrink. This prevents a feedback loop where
1022
+ * averageHeight oscillation causes scrollHeight to bounce, triggering
1023
+ * browser scrollTop adjustments that fire new scroll events.
1024
+ * When scrolling stops (isScrolling goes false), it snaps to the true value.
1035
1025
  */
1036
- const contentHeight = $derived(() => Math.max(height, totalHeight()))
1026
+ let stabilizedContentHeight = 0
1027
+
1028
+ const contentHeight = $derived.by(() => {
1029
+ const raw = Math.max(height, totalHeight)
1030
+
1031
+ if (mode !== 'bottomToTop' || !isScrolling) {
1032
+ stabilizedContentHeight = raw
1033
+ return raw
1034
+ }
1035
+
1036
+ // During active scroll in bottomToTop: only allow growth (ratchet)
1037
+ // Prevents shrink → scrollTop adjust → new scroll event feedback loop
1038
+ if (raw > stabilizedContentHeight) {
1039
+ stabilizedContentHeight = raw
1040
+ }
1041
+
1042
+ return stabilizedContentHeight
1043
+ })
1037
1044
 
1038
1045
  /**
1039
1046
  * Computed transform Y value for positioning the visible items.
1040
1047
  * Extracted from inline IIFE for better performance and readability.
1041
1048
  */
1042
- const transformY = $derived(() => {
1049
+ const transformY = $derived.by(() => {
1043
1050
  const viewportHeight = height || measuredFallbackHeight || 0
1044
- const visibleRange = visibleItems()
1051
+ const visibleRange = visibleItems
1045
1052
 
1046
1053
  // Avoid synchronous DOM reads here; fall back once if height is 0
1047
1054
  const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
1048
1055
 
1049
- // Use precise offset for topToBottom using measured heights when available
1056
+ // Use precise offset using measured heights when available.
1057
+ // For bottomToTop, pass ratcheted contentHeight so the transform stays
1058
+ // stable while scrollHeight is stabilized (prevents visual shift).
1050
1059
  return Math.round(
1051
1060
  calculateTransformY(
1052
1061
  mode,
@@ -1055,13 +1064,30 @@
1055
1064
  visibleRange.start,
1056
1065
  heightManager.averageHeight,
1057
1066
  effectiveHeight,
1058
- totalHeight(),
1067
+ mode === 'bottomToTop' ? contentHeight : totalHeight,
1059
1068
  heightManager.getHeightCache(),
1060
1069
  measuredFallbackHeight
1061
1070
  )
1062
1071
  )
1063
1072
  })
1064
1073
 
1074
+ const displayItems = $derived.by(() => {
1075
+ const visibleRange = visibleItems
1076
+ const slice =
1077
+ mode === 'bottomToTop'
1078
+ ? items.slice(visibleRange.start, visibleRange.end).reverse()
1079
+ : items.slice(visibleRange.start, visibleRange.end)
1080
+
1081
+ return slice.map((item, sliceIndex) => ({
1082
+ item,
1083
+ originalIndex:
1084
+ mode === 'bottomToTop'
1085
+ ? visibleRange.end - 1 - sliceIndex
1086
+ : visibleRange.start + sliceIndex,
1087
+ sliceIndex
1088
+ }))
1089
+ })
1090
+
1065
1091
  /**
1066
1092
  * Handles scroll events in the viewport using requestAnimationFrame for performance.
1067
1093
  *
@@ -1124,12 +1150,12 @@
1124
1150
  captureAnchor()
1125
1151
  }
1126
1152
  if (INTERNAL_DEBUG) {
1127
- const vr = visibleItems()
1153
+ const vr = visibleItems
1128
1154
  log('[SVL] scroll', {
1129
1155
  mode,
1130
1156
  scrollTop: heightManager.scrollTop,
1131
1157
  height,
1132
- totalHeight: totalHeight(),
1158
+ totalHeight: totalHeight,
1133
1159
  averageItemHeight: heightManager.averageHeight,
1134
1160
  visibleRange: vr
1135
1161
  })
@@ -1160,7 +1186,7 @@
1160
1186
  })
1161
1187
  if (!heightManager.initialized && mode === 'bottomToTop') {
1162
1188
  // bottomToTop initialization: use scrollIntoView on Item 0 for precise positioning
1163
- // visibleItems() guarantees Item 0 is rendered during initialization
1189
+ // visibleItems guarantees Item 0 is rendered during initialization
1164
1190
  tick().then(() => {
1165
1191
  requestAnimationFrame(() => {
1166
1192
  requestAnimationFrame(() => {
@@ -1181,7 +1207,7 @@
1181
1207
 
1182
1208
  setTimeout(() => {
1183
1209
  // Step 1: Set initialized (for other purposes like scroll event handling)
1184
- // The init path in visibleItems() stays active until bottomToTopScrollComplete
1210
+ // The init path in visibleItems stays active until bottomToTopScrollComplete
1185
1211
  if (!heightManager.initialized) {
1186
1212
  heightManager.initialized = true
1187
1213
  }
@@ -1265,7 +1291,7 @@
1265
1291
  rafSchedule(() => {
1266
1292
  log('item-resize-observer', { entries: entries.length })
1267
1293
  let shouldRecalculate = false
1268
- void visibleItems() // Cache once to avoid reactive loops
1294
+ void visibleItems // Cache once to avoid reactive loops
1269
1295
 
1270
1296
  for (const entry of entries) {
1271
1297
  const element = entry.target as HTMLElement
@@ -1349,7 +1375,7 @@
1349
1375
  // Add the effect in the script section
1350
1376
  $effect(() => {
1351
1377
  if (INTERNAL_DEBUG) {
1352
- prevVisibleRange = visibleItems()
1378
+ prevVisibleRange = visibleItems
1353
1379
  prevHeight = heightManager.averageHeight
1354
1380
  }
1355
1381
  })
@@ -1358,7 +1384,7 @@
1358
1384
  // the callback writes to $state (which is forbidden during render effects)
1359
1385
  $effect(() => {
1360
1386
  if (!debug) return
1361
- const currentVisibleRange = visibleItems()
1387
+ const currentVisibleRange = visibleItems
1362
1388
  if (
1363
1389
  !shouldShowDebugInfo(
1364
1390
  prevVisibleRange,
@@ -1376,7 +1402,7 @@
1376
1402
  heightManager.averageHeight,
1377
1403
  heightManager.scrollTop,
1378
1404
  height || 0,
1379
- totalHeight()
1405
+ totalHeight
1380
1406
  )
1381
1407
 
1382
1408
  if (debugFunction) {
@@ -1484,7 +1510,7 @@
1484
1510
  }
1485
1511
  }
1486
1512
 
1487
- const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems()
1513
+ const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems
1488
1514
 
1489
1515
  // Use extracted scroll calculation utility
1490
1516
  const scrollTarget = calculateScrollTarget({
@@ -1630,16 +1656,16 @@
1630
1656
  id="virtual-list-content"
1631
1657
  {...testId ? { 'data-testid': `${testId}-content` } : {}}
1632
1658
  class={contentClass ?? 'virtual-list-content'}
1633
- style:height="{contentHeight()}px"
1659
+ style:height="{contentHeight}px"
1634
1660
  >
1635
1661
  <!-- Items container is translated to show correct items -->
1636
1662
  <div
1637
1663
  id="virtual-list-items"
1638
1664
  {...testId ? { 'data-testid': `${testId}-items` } : {}}
1639
1665
  class={itemsClass ?? 'virtual-list-items'}
1640
- style:transform="translateY({transformY()}px)"
1666
+ style:transform="translateY({transformY}px)"
1641
1667
  >
1642
- {#each displayItems() as currentItemWithIndex, _i (currentItemWithIndex.originalIndex)}
1668
+ {#each displayItems as currentItemWithIndex, _i (currentItemWithIndex.originalIndex)}
1643
1669
  <!-- Render each visible item -->
1644
1670
  <div
1645
1671
  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)
@@ -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)
@@ -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,
@@ -144,8 +144,17 @@ export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart,
144
144
  if (mode === 'bottomToTop') {
145
145
  // In bottomToTop mode, position items so they stack from bottom up
146
146
  const actualTotalHeight = totalContentHeight ?? totalItems * itemHeight;
147
- // Calculate transform to position visible items correctly
148
- const basicTransform = (totalItems - visibleEnd) * itemHeight;
147
+ // Calculate transform to position visible items correctly.
148
+ // Use measured heights when available to avoid oscillation caused by
149
+ // averageHeight changes shifting (totalItems - visibleEnd) * avg.
150
+ let basicTransform;
151
+ if (heightCache) {
152
+ const offsetToVisibleEnd = getScrollOffsetForIndex(heightCache, itemHeight, visibleEnd);
153
+ basicTransform = actualTotalHeight - offsetToVisibleEnd;
154
+ }
155
+ else {
156
+ basicTransform = (totalItems - visibleEnd) * itemHeight;
157
+ }
149
158
  // When content is smaller than viewport, push to bottom
150
159
  const bottomOffset = Math.max(0, effectiveViewport - actualTotalHeight);
151
160
  // Snap to integer pixels to avoid subpixel oscillation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.3.13",
3
+ "version": "0.4.1",
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",
@@ -91,7 +91,7 @@
91
91
  "prettier-plugin-svelte": "^3.4.1",
92
92
  "prettier-plugin-tailwindcss": "^0.7.2",
93
93
  "publint": "^0.3.17",
94
- "svelte": "^5.51.2",
94
+ "svelte": "^5.51.3",
95
95
  "svelte-check": "^4.4.0",
96
96
  "tailwindcss": "^4.1.18",
97
97
  "tw-animate-css": "^1.4.0",