@humanspeak/svelte-virtual-list 0.2.6-beta.0 → 0.2.6-beta.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.
@@ -161,7 +161,7 @@
161
161
  import { onMount, tick } from 'svelte'
162
162
 
163
163
  const rafSchedule = createRafScheduler()
164
-
164
+ const INTERNAL_DEBUG = true
165
165
  /**
166
166
  * Core configuration props with default values
167
167
  * @type {SvelteVirtualListProps}
@@ -241,25 +241,74 @@
241
241
  calculatedItemHeight = result.newHeight
242
242
  lastMeasuredIndex = result.newLastMeasuredIndex
243
243
  heightCache = result.updatedHeightCache
244
- }
244
+
245
+ // Update running totals for precise height calculation (only when significant changes)
246
+ if (result.clearedDirtyItems.size > 10) {
247
+ const heights = Object.values(heightCache)
248
+ totalMeasuredHeight = heights.reduce((sum, h) => sum + h, 0)
249
+ measuredCount = heights.length
250
+ }
251
+
252
+ // Clear processed dirty items
253
+ result.clearedDirtyItems.forEach((index) => {
254
+ dirtyItems.delete(index)
255
+ })
256
+
257
+ if (INTERNAL_DEBUG && result.clearedDirtyItems.size > 0) {
258
+ console.log(
259
+ `Cleared ${result.clearedDirtyItems.size} dirty items:`,
260
+ Array.from(result.clearedDirtyItems)
261
+ )
262
+ }
263
+ },
264
+ 100, // debounceTime
265
+ dirtyItems // Pass dirty items for processing
245
266
  )
246
267
  }
247
268
 
248
269
  // Add new effect to handle height changes
270
+ // Track if user has scrolled away from bottom to prevent snap-back
271
+ let userHasScrolledAway = $state(false)
272
+ let lastCalculatedHeight = $state(0)
273
+
249
274
  $effect(() => {
250
275
  if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
251
276
  const totalHeight = Math.max(0, items.length * calculatedItemHeight)
252
277
  const targetScrollTop = Math.max(0, totalHeight - height)
278
+ const currentScrollTop = viewportElement.scrollTop
279
+ const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
280
+
281
+ // Only correct scroll if:
282
+ // 1. Item height changed significantly (not just user scrolling)
283
+ // 2. User hasn't intentionally scrolled away from bottom
284
+ // 3. We're significantly off target
285
+ const heightChanged = Math.abs(calculatedItemHeight - lastCalculatedHeight) > 1
286
+ const shouldCorrect =
287
+ heightChanged && !userHasScrolledAway && scrollDifference > calculatedItemHeight * 3
288
+
289
+ if (shouldCorrect) {
290
+ if (INTERNAL_DEBUG) {
291
+ console.log(
292
+ '🔄 Correcting scroll position from',
293
+ currentScrollTop,
294
+ 'to',
295
+ targetScrollTop,
296
+ 'diff:',
297
+ scrollDifference,
298
+ 'heightChanged:',
299
+ heightChanged
300
+ )
301
+ }
302
+ viewportElement.scrollTop = targetScrollTop
303
+ scrollTop = targetScrollTop
304
+ }
253
305
 
254
- // Only update if the difference is significant
255
- if (Math.abs(viewportElement.scrollTop - targetScrollTop) > calculatedItemHeight) {
256
- requestAnimationFrame(() => {
257
- if (viewportElement) {
258
- viewportElement.scrollTop = targetScrollTop
259
- scrollTop = targetScrollTop
260
- }
261
- })
306
+ // Track if user has scrolled significantly away from bottom
307
+ if (scrollDifference > calculatedItemHeight * 5) {
308
+ userHasScrolledAway = true
262
309
  }
310
+
311
+ lastCalculatedHeight = calculatedItemHeight
263
312
  }
264
313
  })
265
314
 
@@ -303,6 +352,23 @@
303
352
  }
304
353
  })
305
354
 
355
+ /**
356
+ * Calculate precise item height based on actual measurements when available
357
+ */
358
+ // Running totals for efficient precise height calculation
359
+ let totalMeasuredHeight = $state(0)
360
+ let measuredCount = $state(0)
361
+ const preciseItemHeight = $derived(() => {
362
+ if (measuredCount > 100) {
363
+ const avgHeight = totalMeasuredHeight / measuredCount
364
+ // Only use if the difference is significant (more than 0.5px)
365
+ if (Math.abs(avgHeight - calculatedItemHeight) > 0.5) {
366
+ return avgHeight
367
+ }
368
+ }
369
+ return calculatedItemHeight
370
+ })
371
+
306
372
  /**
307
373
  * Calculates the range of items that should be rendered based on current scroll position.
308
374
  *
@@ -326,7 +392,27 @@
326
392
  if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
327
393
  const viewportHeight = height || 0
328
394
 
329
- return calculateVisibleRange(
395
+ // For bottomToTop mode, don't calculate visible range until properly initialized
396
+ // This prevents showing wrong items when scrollTop starts at 0
397
+ if (mode === 'bottomToTop' && !initialized && scrollTop === 0 && viewportHeight > 0) {
398
+ // Calculate what the correct scroll position should be
399
+ const totalHeight = items.length * calculatedItemHeight
400
+ const targetScrollTop = Math.max(0, totalHeight - viewportHeight)
401
+
402
+ // Use the target scroll position for visible range calculation
403
+ const result = calculateVisibleRange(
404
+ targetScrollTop,
405
+ viewportHeight,
406
+ calculatedItemHeight,
407
+ items.length,
408
+ bufferSize,
409
+ mode
410
+ )
411
+
412
+ return result
413
+ }
414
+
415
+ const result = calculateVisibleRange(
330
416
  scrollTop,
331
417
  viewportHeight,
332
418
  calculatedItemHeight,
@@ -334,6 +420,8 @@
334
420
  bufferSize,
335
421
  mode
336
422
  )
423
+
424
+ return result
337
425
  })
338
426
 
339
427
  /**
@@ -495,7 +583,7 @@
495
583
  itemResizeObserver = new ResizeObserver((entries) => {
496
584
  let shouldRecalculate = false
497
585
 
498
- if (debug) {
586
+ if (INTERNAL_DEBUG) {
499
587
  console.log(`ResizeObserver fired for ${entries.length} entries`)
500
588
  }
501
589
 
@@ -510,7 +598,7 @@
510
598
  dirtyItems.add(actualIndex)
511
599
  shouldRecalculate = true
512
600
 
513
- if (debug) {
601
+ if (INTERNAL_DEBUG) {
514
602
  console.log(
515
603
  `Item ${actualIndex} marked dirty (resized), queue size: ${dirtyItems.size}`
516
604
  )
@@ -556,7 +644,7 @@
556
644
 
557
645
  // Add the effect in the script section
558
646
  $effect(() => {
559
- if (debug) {
647
+ if (INTERNAL_DEBUG) {
560
648
  prevVisibleRange = visibleItems()
561
649
  prevHeight = calculatedItemHeight
562
650
  }
@@ -811,7 +899,7 @@
811
899
  function autoObserveItemResize(element: HTMLElement) {
812
900
  if (itemResizeObserver) {
813
901
  itemResizeObserver.observe(element)
814
- if (debug) {
902
+ if (INTERNAL_DEBUG) {
815
903
  console.log(
816
904
  'Started observing element:',
817
905
  element,
@@ -819,7 +907,7 @@
819
907
  element.getBoundingClientRect().height
820
908
  )
821
909
  }
822
- } else if (debug) {
910
+ } else if (INTERNAL_DEBUG) {
823
911
  console.log('itemResizeObserver not available for element:', element)
824
912
  }
825
913
 
@@ -827,7 +915,7 @@
827
915
  destroy() {
828
916
  if (itemResizeObserver) {
829
917
  itemResizeObserver.unobserve(element)
830
- if (debug) {
918
+ if (INTERNAL_DEBUG) {
831
919
  console.log('Stopped observing element:', element)
832
920
  }
833
921
  }
@@ -862,24 +950,36 @@
862
950
  id="virtual-list-content"
863
951
  {...testId ? { 'data-testid': `${testId}-content` } : {}}
864
952
  class={contentClass ?? 'virtual-list-content'}
865
- style:height="{Math.max(height, items.length * calculatedItemHeight)}px"
953
+ style:height="{(() => {
954
+ // Use precise height when available for better cross-browser compatibility
955
+ const totalActualHeight = items.length * preciseItemHeight()
956
+ return Math.max(height, totalActualHeight)
957
+ })()}px"
866
958
  >
867
959
  <!-- Items container is translated to show correct items -->
868
960
  <div
869
961
  id="virtual-list-items"
870
962
  {...testId ? { 'data-testid': `${testId}-items` } : {}}
871
963
  class={itemsClass ?? 'virtual-list-items'}
872
- style:transform="translateY({calculateTransformY(
873
- mode,
874
- items.length,
875
- visibleItems().end,
876
- visibleItems().start,
877
- calculatedItemHeight
878
- )}px)"
964
+ style:transform="translateY({(() => {
965
+ const transform = calculateTransformY(
966
+ mode,
967
+ items.length,
968
+ visibleItems().end,
969
+ visibleItems().start,
970
+ calculatedItemHeight
971
+ )
972
+
973
+ return transform
974
+ })()}px)"
879
975
  >
880
- {#each mode === 'bottomToTop' ? items
881
- .slice(visibleItems().start, visibleItems().end)
882
- .reverse() : items.slice(visibleItems().start, visibleItems().end) as currentItem, i (currentItem?.id ?? i)}
976
+ {#each (() => {
977
+ const slice = mode === 'bottomToTop' ? items
978
+ .slice(visibleItems().start, visibleItems().end)
979
+ .reverse() : items.slice(visibleItems().start, visibleItems().end)
980
+
981
+ return slice
982
+ })() as currentItem, i (currentItem?.id ?? i)}
883
983
  <!-- Only debug when visible range or average height changes -->
884
984
  {#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, calculatedItemHeight)}
885
985
  {@const debugInfo = createDebugInfo(
@@ -74,4 +74,5 @@ export declare const calculateAverageHeightDebounced: (isCalculatingHeight: bool
74
74
  newHeight: number;
75
75
  newLastMeasuredIndex: number;
76
76
  updatedHeightCache: Record<number, number>;
77
- }) => void, debounceTime?: number) => NodeJS.Timeout | null;
77
+ clearedDirtyItems: Set<number>;
78
+ }) => void, debounceTime: number, dirtyItems: Set<number>) => NodeJS.Timeout | null;
@@ -71,20 +71,23 @@ import { BROWSER } from 'esm-env';
71
71
  */
72
72
  export const calculateAverageHeightDebounced = (isCalculatingHeight, heightUpdateTimeout, visibleItemsGetter, itemElements, heightCache, lastMeasuredIndex, calculatedItemHeight,
73
73
  /* trunk-ignore(eslint/no-unused-vars) */
74
- onUpdate, debounceTime = 200) => {
75
- if (!BROWSER || isCalculatingHeight || heightUpdateTimeout)
74
+ onUpdate, debounceTime, dirtyItems) => {
75
+ if (!BROWSER || isCalculatingHeight)
76
76
  return null;
77
77
  const visibleRange = visibleItemsGetter();
78
78
  const currentIndex = visibleRange.start;
79
79
  if (currentIndex === lastMeasuredIndex)
80
80
  return null;
81
+ if (heightUpdateTimeout)
82
+ clearTimeout(heightUpdateTimeout);
81
83
  return setTimeout(() => {
82
- const { newHeight, newLastMeasuredIndex, updatedHeightCache } = calculateAverageHeight(itemElements, visibleRange, heightCache, calculatedItemHeight);
83
- if (Math.abs(newHeight - calculatedItemHeight) > 1) {
84
+ const { newHeight, newLastMeasuredIndex, updatedHeightCache, clearedDirtyItems } = calculateAverageHeight(itemElements, visibleRange, heightCache, calculatedItemHeight, dirtyItems);
85
+ if (Math.abs(newHeight - calculatedItemHeight) > 1 || dirtyItems.size > 0) {
84
86
  onUpdate({
85
87
  newHeight,
86
88
  newLastMeasuredIndex,
87
- updatedHeightCache
89
+ updatedHeightCache,
90
+ clearedDirtyItems
88
91
  });
89
92
  }
90
93
  }, debounceTime);
@@ -86,10 +86,11 @@ export declare const updateHeightAndScroll: (state: VirtualListState, setters: V
86
86
  */
87
87
  export declare const calculateAverageHeight: (itemElements: HTMLElement[], visibleRange: {
88
88
  start: number;
89
- }, heightCache: Record<number, number>, currentItemHeight: number) => {
89
+ }, heightCache: Record<number, number>, currentItemHeight: number, dirtyItems: Set<number>) => {
90
90
  newHeight: number;
91
91
  newLastMeasuredIndex: number;
92
92
  updatedHeightCache: Record<number, number>;
93
+ clearedDirtyItems: Set<number>;
93
94
  };
94
95
  /**
95
96
  * Processes large arrays in chunks to prevent UI blocking.
@@ -35,6 +35,17 @@ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, tot
35
35
  if (mode === 'bottomToTop') {
36
36
  const visibleCount = Math.ceil(viewportHeight / itemHeight) + 1;
37
37
  const bottomIndex = totalItems - Math.floor(scrollTop / itemHeight);
38
+ // Safeguard: if bottomIndex is negative, it means scrollTop is too large for current itemHeight
39
+ // This can happen when itemHeight changes but scrollTop hasn't been corrected yet
40
+ if (bottomIndex < 0) {
41
+ // Calculate what scrollTop should be and use that for visible range
42
+ const totalHeight = totalItems * itemHeight;
43
+ const correctedScrollTop = Math.max(0, totalHeight - viewportHeight);
44
+ const correctedBottomIndex = totalItems - Math.floor(correctedScrollTop / itemHeight);
45
+ const start = Math.max(0, correctedBottomIndex - visibleCount - bufferSize);
46
+ const end = Math.min(totalItems, correctedBottomIndex + bufferSize);
47
+ return { start, end };
48
+ }
38
49
  // Add buffer to both ends
39
50
  const start = Math.max(0, bottomIndex - visibleCount - bufferSize);
40
51
  const end = Math.min(totalItems, bottomIndex + bufferSize);
@@ -43,6 +54,23 @@ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, tot
43
54
  else {
44
55
  const start = Math.floor(scrollTop / itemHeight);
45
56
  const end = Math.min(totalItems, start + Math.ceil(viewportHeight / itemHeight) + 1);
57
+ // Safeguard for topToBottom: ensure last item is fully visible when at max scroll
58
+ const totalHeight = totalItems * itemHeight;
59
+ const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
60
+ // Add dynamic tolerance based on item height for browser rendering precision
61
+ const tolerance = Math.max(itemHeight, 10); // At least one full item height or 10px minimum
62
+ const isAtBottom = Math.abs(scrollTop - maxScrollTop) <= tolerance;
63
+ if (isAtBottom) {
64
+ // When at the bottom, ensure we include all items up to the end
65
+ const adjustedEnd = totalItems;
66
+ const visibleItemCount = Math.ceil(viewportHeight / itemHeight) + bufferSize + 1;
67
+ const adjustedStart = Math.max(0, adjustedEnd - visibleItemCount);
68
+ // TopToBottom safeguard is now active
69
+ return {
70
+ start: adjustedStart,
71
+ end: adjustedEnd
72
+ };
73
+ }
46
74
  // Add buffer to both ends
47
75
  return {
48
76
  start: Math.max(0, start - bufferSize),
@@ -65,9 +93,15 @@ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, tot
65
93
  * @returns {number} The calculated transform Y value in pixels
66
94
  */
67
95
  export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart, itemHeight) => {
68
- return mode === 'bottomToTop'
69
- ? (totalItems - visibleEnd) * itemHeight
70
- : visibleStart * itemHeight;
96
+ if (mode === 'bottomToTop') {
97
+ // In bottomToTop mode, we need to position the container so that
98
+ // the first visible item (visibleStart) aligns with its correct position
99
+ // from the bottom of the total content
100
+ return (totalItems - visibleEnd) * itemHeight;
101
+ }
102
+ else {
103
+ return visibleStart * itemHeight;
104
+ }
71
105
  };
72
106
  /**
73
107
  * Updates the virtual list's height and scroll position when necessary.
@@ -124,39 +158,89 @@ export const updateHeightAndScroll = (state, setters, immediate = false) => {
124
158
  * 40
125
159
  * )
126
160
  */
127
- export const calculateAverageHeight = (itemElements, visibleRange, heightCache, currentItemHeight) => {
161
+ export const calculateAverageHeight = (itemElements, visibleRange, heightCache, currentItemHeight, dirtyItems) => {
128
162
  const validElements = itemElements.filter((el) => el);
129
163
  if (validElements.length === 0) {
130
164
  return {
131
165
  newHeight: currentItemHeight,
132
166
  newLastMeasuredIndex: visibleRange.start,
133
- updatedHeightCache: heightCache
167
+ updatedHeightCache: heightCache,
168
+ clearedDirtyItems: new Set()
134
169
  };
135
170
  }
136
171
  const newHeightCache = { ...heightCache };
137
- // Cache heights for new items
138
- validElements.forEach((el, i) => {
139
- const itemIndex = visibleRange.start + i;
140
- if (!newHeightCache[itemIndex]) {
141
- try {
142
- const height = el.getBoundingClientRect().height;
143
- if (Number.isFinite(height) && height > 0) {
144
- newHeightCache[itemIndex] = height;
172
+ const clearedDirtyItems = new Set();
173
+ // Initialize running totals for O(1) average calculation
174
+ let totalValidHeight = 0;
175
+ let validHeightCount = 0;
176
+ // Calculate initial totals from existing cache
177
+ for (const height of Object.values(heightCache)) {
178
+ if (Number.isFinite(height) && height > 0) {
179
+ totalValidHeight += height;
180
+ validHeightCount++;
181
+ }
182
+ }
183
+ // Process only dirty items if they exist, otherwise process all visible items
184
+ if (dirtyItems.size > 0) {
185
+ // Process only dirty items
186
+ dirtyItems.forEach((itemIndex) => {
187
+ const elementIndex = itemIndex - visibleRange.start;
188
+ const element = validElements[elementIndex];
189
+ if (element && elementIndex >= 0 && elementIndex < validElements.length) {
190
+ try {
191
+ const height = element.getBoundingClientRect().height;
192
+ if (Number.isFinite(height) && height > 0) {
193
+ const oldHeight = newHeightCache[itemIndex];
194
+ // Only update if height actually changed (use smaller tolerance for precision)
195
+ if (!oldHeight || Math.abs(oldHeight - height) >= 0.1) {
196
+ // Update running totals
197
+ if (oldHeight && Number.isFinite(oldHeight) && oldHeight > 0) {
198
+ // Replace old height with new height in running total
199
+ totalValidHeight = totalValidHeight - oldHeight + height;
200
+ }
201
+ else {
202
+ // Add new height to running total
203
+ totalValidHeight += height;
204
+ validHeightCount++;
205
+ }
206
+ newHeightCache[itemIndex] = height;
207
+ }
208
+ }
209
+ clearedDirtyItems.add(itemIndex);
210
+ }
211
+ catch {
212
+ // Skip invalid measurements but still clear from dirty
213
+ clearedDirtyItems.add(itemIndex);
145
214
  }
146
215
  }
147
- catch {
148
- // Skip invalid measurements
216
+ });
217
+ }
218
+ else {
219
+ // Original behavior: process all visible items
220
+ validElements.forEach((el, i) => {
221
+ const itemIndex = visibleRange.start + i;
222
+ if (!newHeightCache[itemIndex]) {
223
+ try {
224
+ const height = el.getBoundingClientRect().height;
225
+ if (Number.isFinite(height) && height > 0) {
226
+ // Add new height to running totals
227
+ totalValidHeight += height;
228
+ validHeightCount++;
229
+ newHeightCache[itemIndex] = height;
230
+ }
231
+ }
232
+ catch {
233
+ // Skip invalid measurements
234
+ }
149
235
  }
150
- }
151
- });
152
- // Calculate average from valid cached heights
153
- const validHeights = Object.values(newHeightCache).filter((h) => Number.isFinite(h) && h > 0);
236
+ });
237
+ }
238
+ // O(1) average calculation using running totals!
154
239
  return {
155
- newHeight: validHeights.length > 0
156
- ? validHeights.reduce((sum, h) => sum + h, 0) / validHeights.length
157
- : currentItemHeight,
240
+ newHeight: validHeightCount > 0 ? totalValidHeight / validHeightCount : currentItemHeight,
158
241
  newLastMeasuredIndex: visibleRange.start,
159
- updatedHeightCache: newHeightCache
242
+ updatedHeightCache: newHeightCache,
243
+ clearedDirtyItems
160
244
  };
161
245
  };
162
246
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.2.6-beta.0",
3
+ "version": "0.2.6-beta.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",
@@ -55,6 +55,7 @@
55
55
  "lint": "prettier --check . && eslint .",
56
56
  "lint:fix": "npm run format && eslint . --fix",
57
57
  "package": "svelte-kit sync && svelte-package && publint",
58
+ "prepare": "husky",
58
59
  "prepublishOnly": "npm run package",
59
60
  "preview": "vite preview",
60
61
  "test": "vitest run --coverage",
@@ -97,6 +98,7 @@
97
98
  "eslint-plugin-svelte": "^3.11.0",
98
99
  "eslint-plugin-unused-imports": "^4.1.4",
99
100
  "globals": "^16.3.0",
101
+ "husky": "^9.1.7",
100
102
  "jsdom": "^26.1.0",
101
103
  "prettier": "^3.6.2",
102
104
  "prettier-plugin-organize-imports": "^4.2.0",