@humanspeak/svelte-virtual-list 0.4.3 → 0.4.5

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.
@@ -178,8 +178,11 @@
178
178
  import { onMount, tick, untrack } from 'svelte'
179
179
 
180
180
  const rafSchedule = createRafScheduler()
181
- // Per-instance correction guard to avoid same-frame tug-of-war per viewport
182
- const GLOBAL_CORRECTION_COOLDOWN = 16
181
+ // Timing constants
182
+ const GLOBAL_CORRECTION_COOLDOWN_MS = 16
183
+ const SCROLL_IDLE_DELAY_MS = 250
184
+ const SUPPRESSION_WINDOW_MS = 450
185
+ const HEIGHT_DEBOUNCE_MS = 100
183
186
  const lastCorrectionTimestampByViewport = new WeakMap<HTMLElement, number>()
184
187
  // Package-specific debug flag - safe for library distribution
185
188
  // Enable with: PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG=true (preferred) or SVELTE_VIRTUAL_LIST_DEBUG=true
@@ -494,7 +497,7 @@
494
497
  const now = performance.now()
495
498
  const viewportEl = heightManager.viewport
496
499
  const lastCorrectionMs = lastCorrectionTimestampByViewport.get(viewportEl) ?? 0
497
- if (now - lastCorrectionMs < GLOBAL_CORRECTION_COOLDOWN) {
500
+ if (now - lastCorrectionMs < GLOBAL_CORRECTION_COOLDOWN_MS) {
498
501
  suppressBottomAnchoringUntilMs = now + 50
499
502
  return
500
503
  }
@@ -524,11 +527,12 @@
524
527
  // Note: `container: 'nearest'` option could replace this once browser support improves
525
528
  const currentScrollTop = heightManager.viewport.scrollTop
526
529
  const offset = itemRect.bottom - contRect.bottom
527
- heightManager.viewport.scrollTop = currentScrollTop + offset
530
+ syncScrollTop(currentScrollTop + offset)
528
531
  log('[SVL] b2t-correction-manual', { offset })
532
+ } else {
533
+ // Sync our internal scroll state with actual DOM position
534
+ heightManager.scrollTop = heightManager.viewport.scrollTop
529
535
  }
530
- // Sync our internal scroll state with actual DOM position
531
- heightManager.scrollTop = heightManager.viewport.scrollTop
532
536
  // After peer correction, delay further corrections briefly
533
537
  suppressBottomAnchoringUntilMs = performance.now() + 200
534
538
  }
@@ -688,7 +692,7 @@
688
692
  })
689
693
  heightManager.endDynamicUpdate()
690
694
  },
691
- lastMeasuredIndex < 0 || dirtyItems.size > 0 ? 0 : 100, // debounceTime (no debounce on first pass or when dirty items exist)
695
+ lastMeasuredIndex < 0 || dirtyItems.size > 0 ? 0 : HEIGHT_DEBOUNCE_MS,
692
696
  dirtyItems, // Pass dirty items for processing
693
697
  0, // Don't pass ReactiveListManager state - let each system manage its own totals
694
698
  0, // Don't pass ReactiveListManager state - let each system manage its own totals
@@ -1124,7 +1128,7 @@
1124
1128
  if (idleCorrectionsOnly || anchorModeEnabled) {
1125
1129
  reconcileToAnchorIfEnabled()
1126
1130
  }
1127
- }, 250)
1131
+ }, SCROLL_IDLE_DELAY_MS)
1128
1132
 
1129
1133
  rafSchedule(() => {
1130
1134
  const current = heightManager.viewport.scrollTop
@@ -1132,7 +1136,7 @@
1132
1136
  const delta = lastScrollTopSnapshot - current
1133
1137
  if (delta > 0.5) {
1134
1138
  // Widen suppression to avoid fighting peer instance corrections
1135
- suppressBottomAnchoringUntilMs = performance.now() + 450
1139
+ suppressBottomAnchoringUntilMs = performance.now() + SUPPRESSION_WINDOW_MS
1136
1140
  userHasScrolledAway = true
1137
1141
  }
1138
1142
  }
@@ -183,11 +183,6 @@ export declare class ReactiveListManager {
183
183
  * @returns Array of cumulative block sums
184
184
  */
185
185
  getBlockSums(): number[];
186
- /**
187
- * Build block prefix sums for efficient offset calculations.
188
- * Uses the same algorithm as the utility function but leverages internal state.
189
- */
190
- private buildBlockSums;
191
186
  /**
192
187
  * Create a new ReactiveListManager instance
193
188
  *
@@ -1,3 +1,4 @@
1
+ import { buildBlockSums } from '../utils/virtualList.js';
1
2
  import { RecomputeScheduler } from './RecomputeScheduler.js';
2
3
  /**
3
4
  * ReactiveListManager - A standalone reactive height calculation system
@@ -378,30 +379,11 @@ export class ReactiveListManager {
378
379
  */
379
380
  getBlockSums() {
380
381
  if (!this._blockSumsValid || this._blockSums.length === 0) {
381
- this._blockSums = this.buildBlockSums();
382
+ this._blockSums = buildBlockSums(this._heightCache, this._averageHeight, this._itemLength, this._blockSize);
382
383
  this._blockSumsValid = true;
383
384
  }
384
385
  return this._blockSums;
385
386
  }
386
- /**
387
- * Build block prefix sums for efficient offset calculations.
388
- * Uses the same algorithm as the utility function but leverages internal state.
389
- */
390
- buildBlockSums() {
391
- const blocks = Math.ceil(this._itemLength / this._blockSize);
392
- const sums = new Array(Math.max(0, blocks - 1));
393
- let running = 0;
394
- for (let b = 0; b < blocks - 1; b++) {
395
- const start = b * this._blockSize;
396
- const end = start + this._blockSize;
397
- for (let i = start; i < end; i++) {
398
- const height = this._heightCache[i];
399
- running += Number.isFinite(height) && height > 0 ? height : this._averageHeight;
400
- }
401
- sums[b] = running;
402
- }
403
- return sums;
404
- }
405
387
  /**
406
388
  * Create a new ReactiveListManager instance
407
389
  *
@@ -62,6 +62,11 @@ export declare const calculateScrollPosition: (totalItems: number, itemHeight: n
62
62
  * @param {number} totalItems - Total number of items in the list
63
63
  * @param {number} bufferSize - Number of items to render outside the visible area
64
64
  * @param {SvelteVirtualListMode} mode - Scroll direction mode
65
+ * @param {boolean} atBottom - Whether the list is scrolled to the bottom (unused, legacy parameter)
66
+ * @param {boolean} wasAtBottomBeforeHeightChange - Whether the list was at bottom before a height change (unused, legacy parameter)
67
+ * @param {SvelteVirtualListPreviousVisibleRange | null} lastVisibleRange - Previous visible range (unused, legacy parameter)
68
+ * @param {number} [totalContentHeight] - Pre-calculated total content height; defaults to totalItems * itemHeight
69
+ * @param {Record<number, number>} [heightCache] - Cache of measured item heights keyed by index, used in topToBottom mode to walk actual heights instead of dividing by average
65
70
  * @returns {SvelteVirtualListPreviousVisibleRange} Range of indices to render
66
71
  */
67
72
  export declare const calculateVisibleRange: (scrollTop: number, viewportHeight: number, itemHeight: number, totalItems: number, bufferSize: number, mode: SvelteVirtualListMode, atBottom: boolean, wasAtBottomBeforeHeightChange: boolean, lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null, totalContentHeight?: number, heightCache?: Record<number, number>) => SvelteVirtualListPreviousVisibleRange;
@@ -138,33 +143,6 @@ export declare const calculateAverageHeight: (itemElements: HTMLElement[], visib
138
143
  delta: number;
139
144
  }>;
140
145
  };
141
- /**
142
- * Processes large arrays in chunks to prevent UI blocking.
143
- *
144
- * This function implements a progressive processing strategy that:
145
- * 1. Breaks down large arrays into manageable chunks
146
- * 2. Processes each chunk asynchronously
147
- * 3. Reports progress after each chunk
148
- * 4. Yields to the main thread between chunks
149
- *
150
- * @param {any[]} items - Array of items to process
151
- * @param {number} chunkSize - Number of items to process in each chunk
152
- * @param {(processed: number) => void} onProgress - Callback for progress updates
153
- * @param {() => void} onComplete - Callback when all processing is complete
154
- *
155
- * @returns {Promise<void>} Resolves when all chunks have been processed
156
- *
157
- * @example
158
- * await processChunked(
159
- * largeArray,
160
- * 50,
161
- * (processed) => console.log(`Processed ${processed} items`),
162
- * () => console.log('All items processed')
163
- * )
164
- */
165
- export declare const processChunked: (items: any[], // eslint-disable-line @typescript-eslint/no-explicit-any
166
- chunkSize: number, onProgress: (processed: number) => void, // eslint-disable-line no-unused-vars
167
- onComplete: () => void) => Promise<void>;
168
146
  /**
169
147
  * Calculates the scroll offset (in pixels) needed to bring a specific item into view in a virtual list.
170
148
  *
@@ -16,6 +16,7 @@
16
16
  * ```
17
17
  */
18
18
  export const getValidHeight = (height, fallback) => Number.isFinite(height) && height > 0 ? height : fallback;
19
+ const BOTTOM_TOLERANCE_FACTOR = 0.25;
19
20
  /**
20
21
  * Clamps a numeric value to be within a specified range.
21
22
  *
@@ -65,6 +66,11 @@ export const calculateScrollPosition = (totalItems, itemHeight, containerHeight)
65
66
  * @param {number} totalItems - Total number of items in the list
66
67
  * @param {number} bufferSize - Number of items to render outside the visible area
67
68
  * @param {SvelteVirtualListMode} mode - Scroll direction mode
69
+ * @param {boolean} atBottom - Whether the list is scrolled to the bottom (unused, legacy parameter)
70
+ * @param {boolean} wasAtBottomBeforeHeightChange - Whether the list was at bottom before a height change (unused, legacy parameter)
71
+ * @param {SvelteVirtualListPreviousVisibleRange | null} lastVisibleRange - Previous visible range (unused, legacy parameter)
72
+ * @param {number} [totalContentHeight] - Pre-calculated total content height; defaults to totalItems * itemHeight
73
+ * @param {Record<number, number>} [heightCache] - Cache of measured item heights keyed by index, used in topToBottom mode to walk actual heights instead of dividing by average
68
74
  * @returns {SvelteVirtualListPreviousVisibleRange} Range of indices to render
69
75
  */
70
76
  export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, totalItems, bufferSize, mode, atBottom, wasAtBottomBeforeHeightChange, lastVisibleRange, totalContentHeight, heightCache) => {
@@ -91,23 +97,38 @@ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, tot
91
97
  return { start, end };
92
98
  }
93
99
  else {
94
- const start = Math.floor(scrollTop / itemHeight);
95
- const end = Math.min(totalItems, start + Math.ceil(viewportHeight / itemHeight) + 1);
100
+ // Walk forward through measured heights to find the correct start index
101
+ // instead of dividing by average height (which is wrong for variable-height items).
102
+ let start = 0;
103
+ let acc = 0;
104
+ while (start < totalItems) {
105
+ const h = getValidHeight(heightCache?.[start], itemHeight);
106
+ if (acc + h > scrollTop)
107
+ break;
108
+ acc += h;
109
+ start++;
110
+ }
111
+ // Walk forward from start to find end
112
+ let end = start;
113
+ let viewAcc = 0;
114
+ while (end < totalItems && viewAcc < viewportHeight) {
115
+ viewAcc += getValidHeight(heightCache?.[end], itemHeight);
116
+ end++;
117
+ }
118
+ end = Math.min(totalItems, end + 1); // +1 to ensure partial items are visible
96
119
  // Safeguard for topToBottom: ensure last item is fully visible when at max scroll
97
120
  const totalHeight = totalContentHeight ?? totalItems * itemHeight;
98
121
  const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
99
122
  // Use strict tolerance to avoid premature bottom anchoring that leaves a visible gap
100
- const tolerance = Math.max(1, Math.floor(itemHeight * 0.25)); // pixels, adaptive for wrong initial sizes
123
+ const tolerance = Math.max(1, Math.floor(itemHeight * BOTTOM_TOLERANCE_FACTOR));
101
124
  const isAtBottom = Math.abs(scrollTop - maxScrollTop) <= tolerance;
102
125
  if (isAtBottom) {
103
126
  // Pack from the end using measured heights when available: walk backward until viewport filled
104
127
  const adjustedEnd = totalItems;
105
128
  let startCore = adjustedEnd;
106
- let acc = 0;
107
- const getH = (i) => getValidHeight(heightCache ? heightCache[i] : undefined, itemHeight);
108
- while (startCore > 0 && acc < viewportHeight) {
109
- const h = getH(startCore - 1);
110
- acc += h;
129
+ let backAcc = 0;
130
+ while (startCore > 0 && backAcc < viewportHeight) {
131
+ backAcc += getValidHeight(heightCache?.[startCore - 1], itemHeight);
111
132
  startCore -= 1;
112
133
  }
113
134
  return {
@@ -335,49 +356,6 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
335
356
  heightChanges
336
357
  };
337
358
  };
338
- /**
339
- * Processes large arrays in chunks to prevent UI blocking.
340
- *
341
- * This function implements a progressive processing strategy that:
342
- * 1. Breaks down large arrays into manageable chunks
343
- * 2. Processes each chunk asynchronously
344
- * 3. Reports progress after each chunk
345
- * 4. Yields to the main thread between chunks
346
- *
347
- * @param {any[]} items - Array of items to process
348
- * @param {number} chunkSize - Number of items to process in each chunk
349
- * @param {(processed: number) => void} onProgress - Callback for progress updates
350
- * @param {() => void} onComplete - Callback when all processing is complete
351
- *
352
- * @returns {Promise<void>} Resolves when all chunks have been processed
353
- *
354
- * @example
355
- * await processChunked(
356
- * largeArray,
357
- * 50,
358
- * (processed) => console.log(`Processed ${processed} items`),
359
- * () => console.log('All items processed')
360
- * )
361
- */
362
- export const processChunked = async (items, // eslint-disable-line @typescript-eslint/no-explicit-any
363
- chunkSize, onProgress, // eslint-disable-line no-unused-vars
364
- onComplete) => {
365
- if (!items.length) {
366
- onComplete();
367
- return;
368
- }
369
- const processChunk = async (startIdx) => {
370
- const endIdx = Math.min(startIdx + chunkSize, items.length);
371
- onProgress(endIdx);
372
- if (endIdx < items.length) {
373
- setTimeout(() => processChunk(endIdx), 0);
374
- }
375
- else {
376
- onComplete();
377
- }
378
- };
379
- await processChunk(0);
380
- };
381
359
  /**
382
360
  * Calculates the scroll offset (in pixels) needed to bring a specific item into view in a virtual list.
383
361
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",
@@ -59,45 +59,45 @@
59
59
  "esm-env": "^1.2.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@eslint/compat": "^2.0.2",
62
+ "@eslint/compat": "^2.0.3",
63
63
  "@eslint/js": "^10.0.1",
64
- "@faker-js/faker": "^10.3.0",
64
+ "@faker-js/faker": "^10.4.0",
65
65
  "@playwright/cli": "^0.1.1",
66
66
  "@playwright/test": "^1.58.2",
67
67
  "@sveltejs/adapter-auto": "^7.0.1",
68
- "@sveltejs/kit": "^2.53.4",
68
+ "@sveltejs/kit": "^2.55.0",
69
69
  "@sveltejs/package": "^2.5.7",
70
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
71
- "@tailwindcss/vite": "^4.2.1",
70
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
71
+ "@tailwindcss/vite": "^4.2.2",
72
72
  "@testing-library/jest-dom": "^6.9.1",
73
73
  "@testing-library/svelte": "^5.3.1",
74
74
  "@testing-library/user-event": "^14.6.1",
75
- "@types/node": "^25.3.3",
76
- "@typescript-eslint/eslint-plugin": "^8.56.1",
77
- "@typescript-eslint/parser": "^8.56.1",
78
- "@vitest/coverage-v8": "^4.0.18",
79
- "eslint": "^10.0.2",
75
+ "@types/node": "^25.5.0",
76
+ "@typescript-eslint/eslint-plugin": "^8.57.2",
77
+ "@typescript-eslint/parser": "^8.57.2",
78
+ "@vitest/coverage-v8": "^4.1.2",
79
+ "eslint": "^10.1.0",
80
80
  "eslint-config-prettier": "^10.1.8",
81
81
  "eslint-plugin-import": "^2.32.0",
82
- "eslint-plugin-svelte": "^3.15.0",
82
+ "eslint-plugin-svelte": "^3.16.0",
83
83
  "eslint-plugin-unused-imports": "^4.4.1",
84
84
  "globals": "^17.4.0",
85
85
  "husky": "^9.1.7",
86
- "jsdom": "^28.1.0",
87
- "mprocs": "^0.8.3",
86
+ "jsdom": "^29.0.1",
87
+ "mprocs": "^0.9.2",
88
88
  "prettier": "^3.8.1",
89
89
  "prettier-plugin-organize-imports": "^4.3.0",
90
90
  "prettier-plugin-svelte": "^3.5.1",
91
91
  "prettier-plugin-tailwindcss": "^0.7.2",
92
92
  "publint": "^0.3.18",
93
- "svelte": "^5.53.7",
94
- "svelte-check": "^4.4.4",
95
- "tailwindcss": "^4.2.1",
93
+ "svelte": "^5.55.0",
94
+ "svelte-check": "^4.4.5",
95
+ "tailwindcss": "^4.2.2",
96
96
  "tw-animate-css": "^1.4.0",
97
97
  "typescript": "^5.9.3",
98
- "typescript-eslint": "^8.56.1",
99
- "vite": "^7.3.1",
100
- "vitest": "^4.0.18"
98
+ "typescript-eslint": "^8.57.2",
99
+ "vite": "^8.0.3",
100
+ "vitest": "^4.1.2"
101
101
  },
102
102
  "peerDependencies": {
103
103
  "svelte": "^5.0.0"
@@ -120,6 +120,7 @@
120
120
  ],
121
121
  "scripts": {
122
122
  "build": "vite build && pnpm run package",
123
+ "cf-typegen": "pnpm --filter docs cf-typegen",
123
124
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
124
125
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
125
126
  "dev": "vite dev",