@humanspeak/svelte-virtual-list 0.2.6-beta.0 → 0.2.6-beta.2

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.
@@ -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.2",
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",
@@ -72,8 +73,7 @@
72
73
  }
73
74
  },
74
75
  "dependencies": {
75
- "esm-env": "^1.2.2",
76
- "runed": "^0.31.1"
76
+ "esm-env": "^1.2.2"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@eslint/compat": "^1.3.1",
@@ -97,6 +97,7 @@
97
97
  "eslint-plugin-svelte": "^3.11.0",
98
98
  "eslint-plugin-unused-imports": "^4.1.4",
99
99
  "globals": "^16.3.0",
100
+ "husky": "^9.1.7",
100
101
  "jsdom": "^26.1.0",
101
102
  "prettier": "^3.6.2",
102
103
  "prettier-plugin-organize-imports": "^4.2.0",