@humanspeak/svelte-virtual-list 0.3.9 → 0.3.10

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.
@@ -352,6 +352,8 @@
352
352
  let dirtyItemsCount = $state(0) // Reactive count of dirty items
353
353
  // Fallback measurement used only when height has not been established yet
354
354
  let measuredFallbackHeight = $state(0)
355
+ // Scroll delta threshold optimization - track last scroll position used for range calculation
356
+ let lastProcessedScrollTop = $state(0)
355
357
 
356
358
  let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
357
359
  let prevHeight = $state<number>(0)
@@ -534,16 +536,13 @@
534
536
  Math.abs(contRect.y + contRect.height - (itemRect.y + itemRect.height)) <=
535
537
  tol
536
538
  if (!aligned) {
537
- // Native browser API handles all positioning edge cases perfectly
538
- item0Element.scrollIntoView({
539
- block: 'end', // Align Item 0 to bottom edge of viewport
540
- behavior: 'smooth', // Smooth animation for better UX
541
- inline: 'nearest' // Minimal horizontal adjustment
542
- })
543
- log('[SVL] b2t-correction-native', {
544
- containerBottom: contRect.y + contRect.height,
545
- itemBottom: itemRect.y + itemRect.height
546
- })
539
+ // Use manual scrollTop instead of scrollIntoView to prevent parent scroll
540
+ // (scrollIntoView scrolls all ancestor containers, not just the viewport)
541
+ // Note: `container: 'nearest'` option could replace this once browser support improves
542
+ const currentScrollTop = heightManager.viewport.scrollTop
543
+ const offset = itemRect.bottom - contRect.bottom
544
+ heightManager.viewport.scrollTop = currentScrollTop + offset
545
+ log('[SVL] b2t-correction-manual', { offset })
547
546
  }
548
547
  // Sync our internal scroll state with actual DOM position
549
548
  heightManager.scrollTop = heightManager.viewport.scrollTop
@@ -990,6 +989,21 @@
990
989
  return lastVisibleRange
991
990
  }
992
991
 
992
+ // Scroll delta threshold optimization: skip recalculation if scroll delta is less than
993
+ // half the average item height and we have a cached range. This reduces unnecessary
994
+ // calculations during smooth scrolling.
995
+ // Note: Only applied in topToBottom mode - bottomToTop has complex scroll correction
996
+ // logic that requires precise visible range calculations.
997
+ // Note: We use lastProcessedScrollTop read-only here; it's updated in the scroll handler
998
+ if (mode === 'topToBottom') {
999
+ const scrollDelta = Math.abs(heightManager.scrollTop - lastProcessedScrollTop)
1000
+ const threshold = heightManager.averageHeight * 0.5
1001
+ if (lastVisibleRange && scrollDelta < threshold && scrollDelta > 0) {
1002
+ // Reuse cached range for small scroll movements
1003
+ return lastVisibleRange
1004
+ }
1005
+ }
1006
+
993
1007
  lastVisibleRange = calculateVisibleRange(
994
1008
  heightManager.scrollTop,
995
1009
  viewportHeight,
@@ -1007,6 +1021,40 @@
1007
1021
  return lastVisibleRange
1008
1022
  })
1009
1023
 
1024
+ /**
1025
+ * Computed content height for the virtual list.
1026
+ * Uses the maximum of container height and total content height to ensure
1027
+ * proper scrolling behavior.
1028
+ */
1029
+ const contentHeight = $derived(() => Math.max(height, totalHeight()))
1030
+
1031
+ /**
1032
+ * Computed transform Y value for positioning the visible items.
1033
+ * Extracted from inline IIFE for better performance and readability.
1034
+ */
1035
+ const transformY = $derived(() => {
1036
+ const viewportHeight = height || measuredFallbackHeight || 0
1037
+ const visibleRange = visibleItems()
1038
+
1039
+ // Avoid synchronous DOM reads here; fall back once if height is 0
1040
+ const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
1041
+
1042
+ // Use precise offset for topToBottom using measured heights when available
1043
+ return Math.round(
1044
+ calculateTransformY(
1045
+ mode,
1046
+ items.length,
1047
+ visibleRange.end,
1048
+ visibleRange.start,
1049
+ heightManager.averageHeight,
1050
+ effectiveHeight,
1051
+ totalHeight(),
1052
+ heightManager.getHeightCache(),
1053
+ measuredFallbackHeight
1054
+ )
1055
+ )
1056
+ })
1057
+
1010
1058
  /**
1011
1059
  * Handles scroll events in the viewport using requestAnimationFrame for performance.
1012
1060
  *
@@ -1057,6 +1105,13 @@
1057
1105
  }
1058
1106
  lastScrollTopSnapshot = current
1059
1107
  heightManager.scrollTop = current
1108
+ // Update last processed scroll position for delta threshold optimization
1109
+ // Only update when we actually process a scroll (i.e., recalculate visible range)
1110
+ const scrollDelta = Math.abs(current - lastProcessedScrollTop)
1111
+ const threshold = heightManager.averageHeight * 0.5
1112
+ if (scrollDelta >= threshold || lastVisibleRange === null) {
1113
+ lastProcessedScrollTop = current
1114
+ }
1060
1115
  updateDebugTailDistance()
1061
1116
  if (anchorModeEnabled) {
1062
1117
  captureAnchor()
@@ -1138,10 +1193,14 @@
1138
1193
  ) as HTMLElement | null
1139
1194
 
1140
1195
  if (el && !userHasScrolled) {
1141
- el.scrollIntoView({
1142
- block: 'end',
1143
- inline: 'nearest'
1144
- })
1196
+ // Use manual scrollTop instead of scrollIntoView to prevent parent scroll
1197
+ // (scrollIntoView scrolls all ancestor containers, not just the viewport)
1198
+ // Note: `container: 'nearest'` option could replace this once browser support improves
1199
+ const viewportRect =
1200
+ heightManager.viewport.getBoundingClientRect()
1201
+ const elRect = el.getBoundingClientRect()
1202
+ const offset = elRect.bottom - viewportRect.bottom
1203
+ heightManager.viewport.scrollTop += offset
1145
1204
  heightManager.scrollTop = heightManager.viewport.scrollTop
1146
1205
  } else if (userHasScrolled) {
1147
1206
  // Sync internal state with current scroll
@@ -1531,37 +1590,14 @@
1531
1590
  id="virtual-list-content"
1532
1591
  {...testId ? { 'data-testid': `${testId}-content` } : {}}
1533
1592
  class={contentClass ?? 'virtual-list-content'}
1534
- style:height="{(() => Math.max(height, totalHeight()))()}px"
1593
+ style:height="{contentHeight()}px"
1535
1594
  >
1536
1595
  <!-- Items container is translated to show correct items -->
1537
1596
  <div
1538
1597
  id="virtual-list-items"
1539
1598
  {...testId ? { 'data-testid': `${testId}-items` } : {}}
1540
1599
  class={itemsClass ?? 'virtual-list-items'}
1541
- style:transform="translateY({(() => {
1542
- const viewportHeight = height || measuredFallbackHeight || 0
1543
- const visibleRange = visibleItems()
1544
-
1545
- // Avoid synchronous DOM reads here; fall back once if height is 0
1546
- const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
1547
-
1548
- // Use precise offset for topToBottom using measured heights when available
1549
- const transform = Math.round(
1550
- calculateTransformY(
1551
- mode,
1552
- items.length,
1553
- visibleRange.end,
1554
- visibleRange.start,
1555
- heightManager.averageHeight,
1556
- effectiveHeight,
1557
- totalHeight(),
1558
- heightManager.getHeightCache(),
1559
- measuredFallbackHeight
1560
- )
1561
- )
1562
-
1563
- return transform
1564
- })()}px)"
1600
+ style:transform="translateY({transformY()}px)"
1565
1601
  >
1566
1602
  {#each displayItems() as currentItemWithIndex, i (currentItemWithIndex.originalIndex)}
1567
1603
  <!-- Only debug when visible range or average height changes -->
package/dist/index.d.ts CHANGED
@@ -3,4 +3,6 @@ import type { SvelteVirtualListDebugInfo, SvelteVirtualListMode, SvelteVirtualLi
3
3
  export type { SvelteVirtualListDebugInfo, SvelteVirtualListMode, SvelteVirtualListProps, SvelteVirtualListScrollAlign, SvelteVirtualListScrollOptions };
4
4
  export { ReactiveListManager } from './reactive-list-manager/index.js';
5
5
  export type { ListManagerConfig } from './reactive-list-manager/index.js';
6
+ export { formatBytes, getCurrentFps, getMemoryUsage, isPerfEnabled, measureAsync, measureSync, perfMetrics, recordDuration, startFpsTracking, startMeasure, stopFpsTracking } from './utils/perfMetrics.js';
7
+ export type { MetricEntry, MetricName, MetricStats, PerfMetrics } from './utils/perfMetrics.js';
6
8
  export default SvelteVirtualList;
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import SvelteVirtualList from './SvelteVirtualList.svelte';
2
2
  // Re-export renamed manager from existing package location to avoid churn
3
3
  export { ReactiveListManager } from './reactive-list-manager/index.js';
4
+ // Re-export performance metrics utilities
5
+ export { formatBytes, getCurrentFps, getMemoryUsage, isPerfEnabled, measureAsync, measureSync, perfMetrics, recordDuration, startFpsTracking, startMeasure, stopFpsTracking } from './utils/perfMetrics.js';
4
6
  export default SvelteVirtualList;
@@ -46,6 +46,9 @@ export declare class ReactiveListManager {
46
46
  private _mutationObserver;
47
47
  private _heightCache;
48
48
  private _scheduler;
49
+ private _blockSums;
50
+ private _blockSumsValid;
51
+ private _blockSize;
49
52
  private recomputeDerivedHeights;
50
53
  private recomputeIsReady;
51
54
  private scheduleRecomputeDerivedHeights;
@@ -157,6 +160,30 @@ export declare class ReactiveListManager {
157
160
  * Read-only view of measured heights cache
158
161
  */
159
162
  getHeightCache(): Readonly<Record<number, number>>;
163
+ /**
164
+ * Invalidate block sums from a given index onwards.
165
+ * Call this when item heights change to ensure block sums are recalculated.
166
+ *
167
+ * @param index - The index from which to invalidate block sums
168
+ */
169
+ invalidateBlockSumsFrom(index: number): void;
170
+ /**
171
+ * Get the block sums array, rebuilding if necessary.
172
+ * Block sums enable O(blockSize) offset calculations instead of O(n).
173
+ *
174
+ * Each entry contains the cumulative height sum up to and including that block.
175
+ * For example, with blockSize=1000:
176
+ * - Entry 0: sum of heights for items 0-999
177
+ * - Entry 1: sum of heights for items 0-1999
178
+ *
179
+ * @returns Array of cumulative block sums
180
+ */
181
+ getBlockSums(): number[];
182
+ /**
183
+ * Build block prefix sums for efficient offset calculations.
184
+ * Uses the same algorithm as the utility function but leverages internal state.
185
+ */
186
+ private buildBlockSums;
160
187
  /**
161
188
  * Create a new ReactiveListManager instance
162
189
  *
@@ -49,6 +49,10 @@ export class ReactiveListManager {
49
49
  _heightCache = {};
50
50
  // Recompute scheduling
51
51
  _scheduler = new RecomputeScheduler(() => this.recomputeDerivedHeights());
52
+ // Block sum caching for O(blockSize) offset calculations instead of O(n)
53
+ _blockSums = [];
54
+ _blockSumsValid = false;
55
+ _blockSize = 1000;
52
56
  recomputeDerivedHeights() {
53
57
  const average = this._measuredCount > 0
54
58
  ? this._totalMeasuredHeight / this._measuredCount
@@ -343,6 +347,57 @@ export class ReactiveListManager {
343
347
  getHeightCache() {
344
348
  return this._heightCache;
345
349
  }
350
+ /**
351
+ * Invalidate block sums from a given index onwards.
352
+ * Call this when item heights change to ensure block sums are recalculated.
353
+ *
354
+ * @param index - The index from which to invalidate block sums
355
+ */
356
+ invalidateBlockSumsFrom(index) {
357
+ const blockIndex = Math.floor(index / this._blockSize);
358
+ // Truncate to remove invalidated blocks
359
+ if (blockIndex < this._blockSums.length) {
360
+ this._blockSums.length = blockIndex;
361
+ }
362
+ this._blockSumsValid = false;
363
+ }
364
+ /**
365
+ * Get the block sums array, rebuilding if necessary.
366
+ * Block sums enable O(blockSize) offset calculations instead of O(n).
367
+ *
368
+ * Each entry contains the cumulative height sum up to and including that block.
369
+ * For example, with blockSize=1000:
370
+ * - Entry 0: sum of heights for items 0-999
371
+ * - Entry 1: sum of heights for items 0-1999
372
+ *
373
+ * @returns Array of cumulative block sums
374
+ */
375
+ getBlockSums() {
376
+ if (!this._blockSumsValid || this._blockSums.length === 0) {
377
+ this._blockSums = this.buildBlockSums();
378
+ this._blockSumsValid = true;
379
+ }
380
+ return this._blockSums;
381
+ }
382
+ /**
383
+ * Build block prefix sums for efficient offset calculations.
384
+ * Uses the same algorithm as the utility function but leverages internal state.
385
+ */
386
+ buildBlockSums() {
387
+ const blocks = Math.ceil(this._itemLength / this._blockSize);
388
+ const sums = new Array(Math.max(0, blocks - 1));
389
+ let running = 0;
390
+ for (let b = 0; b < blocks - 1; b++) {
391
+ const start = b * this._blockSize;
392
+ const end = start + this._blockSize;
393
+ for (let i = start; i < end; i++) {
394
+ const height = this._heightCache[i];
395
+ running += Number.isFinite(height) && height > 0 ? height : this._averageHeight;
396
+ }
397
+ sums[b] = running;
398
+ }
399
+ return sums;
400
+ }
346
401
  /**
347
402
  * Create a new ReactiveListManager instance
348
403
  *
@@ -372,8 +427,13 @@ export class ReactiveListManager {
372
427
  // Batch calculate changes to trigger reactivity only once
373
428
  let heightDelta = 0;
374
429
  let countDelta = 0;
430
+ let minChangedIndex = Infinity;
375
431
  for (const change of dirtyResults) {
376
432
  const { index, oldHeight, newHeight } = change;
433
+ // Track minimum changed index for block sum invalidation
434
+ if (index < minChangedIndex) {
435
+ minChangedIndex = index;
436
+ }
377
437
  // Remove old contribution if it existed
378
438
  if (oldHeight !== undefined) {
379
439
  heightDelta -= oldHeight;
@@ -394,6 +454,10 @@ export class ReactiveListManager {
394
454
  this._measuredFlags[index] = 1;
395
455
  }
396
456
  }
457
+ // Invalidate block sums from the minimum changed index
458
+ if (minChangedIndex < Infinity) {
459
+ this.invalidateBlockSumsFrom(minChangedIndex);
460
+ }
397
461
  // IDK... no one can explain it to me,.. but its here like this... it cannot be:
398
462
  // if (heightDelta === 0 && countDelta === 0) return
399
463
  const isJsdom = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
@@ -421,6 +485,9 @@ export class ReactiveListManager {
421
485
  updateItemLength(newLength) {
422
486
  this._itemLength = newLength;
423
487
  this._measuredFlags = new Uint8Array(Math.max(0, newLength));
488
+ // Reset block sums since length changed
489
+ this._blockSums = [];
490
+ this._blockSumsValid = false;
424
491
  // Immediate recompute so new items become visible without delay
425
492
  this.recomputeDerivedHeights();
426
493
  }
@@ -450,6 +517,8 @@ export class ReactiveListManager {
450
517
  if (Number.isFinite(height) && height > 0) {
451
518
  this._heightCache[index] = height;
452
519
  this._totalMeasuredHeight += height;
520
+ // Invalidate block sums from this index
521
+ this.invalidateBlockSumsFrom(index);
453
522
  this.scheduleRecomputeDerivedHeights();
454
523
  }
455
524
  }
@@ -462,6 +531,9 @@ export class ReactiveListManager {
462
531
  this._totalMeasuredHeight = 0;
463
532
  this._measuredCount = 0;
464
533
  this._measuredFlags = this._itemLength > 0 ? new Uint8Array(this._itemLength) : null;
534
+ // Reset block sums
535
+ this._blockSums = [];
536
+ this._blockSumsValid = false;
465
537
  // Note: Don't reset _itemLength, _itemHeight as they represent configuration, not measured state
466
538
  this.scheduleRecomputeDerivedHeights();
467
539
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Performance metrics utility for profiling virtual list operations.
3
+ *
4
+ * This lightweight profiling wrapper measures execution times for critical operations
5
+ * in the virtual list component. Enable by setting the environment variable:
6
+ * PUBLIC_SVELTE_VIRTUAL_LIST_PERF=true
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { perfMetrics, measureSync, measureAsync, isPerfEnabled } from './perfMetrics.js'
11
+ *
12
+ * // Measure synchronous operation
13
+ * const result = measureSync('visibleRange', () => calculateVisibleRange(...))
14
+ *
15
+ * // Get aggregated stats
16
+ * const stats = perfMetrics.getStats()
17
+ * console.log(stats.visibleRange.avg, stats.visibleRange.max)
18
+ * ```
19
+ */
20
+ export type MetricName = 'scrollHandler' | 'visibleRange' | 'transformY' | 'heightBatch' | 'frameTime' | 'displayItems' | 'resizeObserver' | 'initialRender';
21
+ export interface MetricEntry {
22
+ timestamp: number;
23
+ duration: number;
24
+ }
25
+ export interface MetricStats {
26
+ count: number;
27
+ total: number;
28
+ avg: number;
29
+ min: number;
30
+ max: number;
31
+ recent: number[];
32
+ }
33
+ export interface PerfMetrics {
34
+ scrollHandler: MetricEntry[];
35
+ visibleRange: MetricEntry[];
36
+ transformY: MetricEntry[];
37
+ heightBatch: MetricEntry[];
38
+ frameTime: MetricEntry[];
39
+ displayItems: MetricEntry[];
40
+ resizeObserver: MetricEntry[];
41
+ initialRender: MetricEntry[];
42
+ }
43
+ /**
44
+ * Check if performance profiling is enabled via environment variable
45
+ */
46
+ export declare const isPerfEnabled: () => boolean;
47
+ /**
48
+ * Measure a synchronous function's execution time
49
+ */
50
+ export declare const measureSync: <T>(name: MetricName, fn: () => T) => T;
51
+ /**
52
+ * Measure an async function's execution time
53
+ */
54
+ export declare const measureAsync: <T>(name: MetricName, fn: () => Promise<T>) => Promise<T>;
55
+ /**
56
+ * Start a manual timing measurement (for operations that span callbacks)
57
+ */
58
+ export declare const startMeasure: () => (() => number);
59
+ /**
60
+ * Record a pre-calculated duration
61
+ */
62
+ export declare const recordDuration: (name: MetricName, duration: number) => void;
63
+ /**
64
+ * Start FPS tracking during scroll
65
+ */
66
+ export declare const startFpsTracking: () => void;
67
+ /**
68
+ * Stop FPS tracking and return average FPS
69
+ */
70
+ export declare const stopFpsTracking: () => number;
71
+ /**
72
+ * Get current FPS (call during active tracking)
73
+ */
74
+ export declare const getCurrentFps: () => number;
75
+ /**
76
+ * Performance metrics interface with stats aggregation
77
+ */
78
+ export declare const perfMetrics: {
79
+ /**
80
+ * Get aggregated stats for all metrics
81
+ */
82
+ getStats: () => Record<MetricName, MetricStats>;
83
+ /**
84
+ * Get stats for a single metric
85
+ */
86
+ getMetricStats: (name: MetricName) => MetricStats;
87
+ /**
88
+ * Get raw metric entries
89
+ */
90
+ getRawMetrics: () => PerfMetrics;
91
+ /**
92
+ * Reset all metrics
93
+ */
94
+ reset: () => void;
95
+ /**
96
+ * Get a summary report suitable for console output
97
+ */
98
+ getSummary: () => string;
99
+ /**
100
+ * Log summary to console
101
+ */
102
+ logSummary: () => void;
103
+ };
104
+ /**
105
+ * Memory tracking utilities
106
+ */
107
+ export declare const getMemoryUsage: () => {
108
+ usedJSHeapSize: number;
109
+ totalJSHeapSize: number;
110
+ } | null;
111
+ /**
112
+ * Helper to format bytes to human-readable size
113
+ */
114
+ export declare const formatBytes: (bytes: number) => string;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Performance metrics utility for profiling virtual list operations.
3
+ *
4
+ * This lightweight profiling wrapper measures execution times for critical operations
5
+ * in the virtual list component. Enable by setting the environment variable:
6
+ * PUBLIC_SVELTE_VIRTUAL_LIST_PERF=true
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { perfMetrics, measureSync, measureAsync, isPerfEnabled } from './perfMetrics.js'
11
+ *
12
+ * // Measure synchronous operation
13
+ * const result = measureSync('visibleRange', () => calculateVisibleRange(...))
14
+ *
15
+ * // Get aggregated stats
16
+ * const stats = perfMetrics.getStats()
17
+ * console.log(stats.visibleRange.avg, stats.visibleRange.max)
18
+ * ```
19
+ */
20
+ const MAX_SAMPLES = 1000; // Keep last N samples per metric
21
+ const RECENT_WINDOW = 100; // Number of recent samples for stats
22
+ /**
23
+ * Check if performance profiling is enabled via environment variable
24
+ */
25
+ export const isPerfEnabled = () => {
26
+ if (typeof process === 'undefined')
27
+ return false;
28
+ return (process?.env?.PUBLIC_SVELTE_VIRTUAL_LIST_PERF === 'true' ||
29
+ process?.env?.SVELTE_VIRTUAL_LIST_PERF === 'true');
30
+ };
31
+ const createEmptyMetrics = () => ({
32
+ scrollHandler: [],
33
+ visibleRange: [],
34
+ transformY: [],
35
+ heightBatch: [],
36
+ frameTime: [],
37
+ displayItems: [],
38
+ resizeObserver: [],
39
+ initialRender: []
40
+ });
41
+ let metrics = createEmptyMetrics();
42
+ /**
43
+ * Record a metric measurement
44
+ */
45
+ const record = (name, duration) => {
46
+ if (!isPerfEnabled())
47
+ return;
48
+ const entry = {
49
+ timestamp: performance.now(),
50
+ duration
51
+ };
52
+ metrics[name].push(entry);
53
+ // Keep array bounded
54
+ if (metrics[name].length > MAX_SAMPLES) {
55
+ metrics[name] = metrics[name].slice(-MAX_SAMPLES);
56
+ }
57
+ };
58
+ /**
59
+ * Measure a synchronous function's execution time
60
+ */
61
+ export const measureSync = (name, fn) => {
62
+ if (!isPerfEnabled()) {
63
+ return fn();
64
+ }
65
+ const start = performance.now();
66
+ try {
67
+ return fn();
68
+ }
69
+ finally {
70
+ const duration = performance.now() - start;
71
+ record(name, duration);
72
+ }
73
+ };
74
+ /**
75
+ * Measure an async function's execution time
76
+ */
77
+ export const measureAsync = async (name, fn) => {
78
+ if (!isPerfEnabled()) {
79
+ return fn();
80
+ }
81
+ const start = performance.now();
82
+ try {
83
+ return await fn();
84
+ }
85
+ finally {
86
+ const duration = performance.now() - start;
87
+ record(name, duration);
88
+ }
89
+ };
90
+ /**
91
+ * Start a manual timing measurement (for operations that span callbacks)
92
+ */
93
+ export const startMeasure = () => {
94
+ const start = performance.now();
95
+ return () => performance.now() - start;
96
+ };
97
+ /**
98
+ * Record a pre-calculated duration
99
+ */
100
+ export const recordDuration = (name, duration) => {
101
+ record(name, duration);
102
+ };
103
+ /**
104
+ * Calculate stats for a single metric
105
+ */
106
+ const calculateStats = (entries) => {
107
+ if (entries.length === 0) {
108
+ return { count: 0, total: 0, avg: 0, min: 0, max: 0, recent: [] };
109
+ }
110
+ const durations = entries.map((e) => e.duration);
111
+ const total = durations.reduce((a, b) => a + b, 0);
112
+ const recent = durations.slice(-RECENT_WINDOW);
113
+ return {
114
+ count: entries.length,
115
+ total,
116
+ avg: total / entries.length,
117
+ min: Math.min(...durations),
118
+ max: Math.max(...durations),
119
+ recent
120
+ };
121
+ };
122
+ /**
123
+ * Frame rate tracking for scroll performance
124
+ */
125
+ let frameTimestamps = [];
126
+ let rafId = null;
127
+ /**
128
+ * Start FPS tracking during scroll
129
+ */
130
+ export const startFpsTracking = () => {
131
+ if (!isPerfEnabled())
132
+ return;
133
+ if (rafId !== null)
134
+ return;
135
+ frameTimestamps = [];
136
+ const trackFrame = () => {
137
+ frameTimestamps.push(performance.now());
138
+ // Keep only last 2 seconds of frames
139
+ const cutoff = performance.now() - 2000;
140
+ frameTimestamps = frameTimestamps.filter((t) => t > cutoff);
141
+ rafId = requestAnimationFrame(trackFrame);
142
+ };
143
+ rafId = requestAnimationFrame(trackFrame);
144
+ };
145
+ /**
146
+ * Stop FPS tracking and return average FPS
147
+ */
148
+ export const stopFpsTracking = () => {
149
+ if (rafId !== null) {
150
+ cancelAnimationFrame(rafId);
151
+ rafId = null;
152
+ }
153
+ if (frameTimestamps.length < 2)
154
+ return 0;
155
+ const duration = frameTimestamps[frameTimestamps.length - 1] - frameTimestamps[0];
156
+ if (duration <= 0)
157
+ return 0;
158
+ return (frameTimestamps.length - 1) / (duration / 1000);
159
+ };
160
+ /**
161
+ * Get current FPS (call during active tracking)
162
+ */
163
+ export const getCurrentFps = () => {
164
+ if (frameTimestamps.length < 2)
165
+ return 0;
166
+ const now = performance.now();
167
+ const recentFrames = frameTimestamps.filter((t) => now - t < 1000);
168
+ if (recentFrames.length < 2)
169
+ return 0;
170
+ const duration = recentFrames[recentFrames.length - 1] - recentFrames[0];
171
+ if (duration <= 0)
172
+ return 0;
173
+ return (recentFrames.length - 1) / (duration / 1000);
174
+ };
175
+ /**
176
+ * Performance metrics interface with stats aggregation
177
+ */
178
+ export const perfMetrics = {
179
+ /**
180
+ * Get aggregated stats for all metrics
181
+ */
182
+ getStats: () => ({
183
+ scrollHandler: calculateStats(metrics.scrollHandler),
184
+ visibleRange: calculateStats(metrics.visibleRange),
185
+ transformY: calculateStats(metrics.transformY),
186
+ heightBatch: calculateStats(metrics.heightBatch),
187
+ frameTime: calculateStats(metrics.frameTime),
188
+ displayItems: calculateStats(metrics.displayItems),
189
+ resizeObserver: calculateStats(metrics.resizeObserver),
190
+ initialRender: calculateStats(metrics.initialRender)
191
+ }),
192
+ /**
193
+ * Get stats for a single metric
194
+ */
195
+ getMetricStats: (name) => calculateStats(metrics[name]),
196
+ /**
197
+ * Get raw metric entries
198
+ */
199
+ getRawMetrics: () => ({ ...metrics }),
200
+ /**
201
+ * Reset all metrics
202
+ */
203
+ reset: () => {
204
+ metrics = createEmptyMetrics();
205
+ },
206
+ /**
207
+ * Get a summary report suitable for console output
208
+ */
209
+ getSummary: () => {
210
+ const stats = perfMetrics.getStats();
211
+ const lines = ['=== Virtual List Performance Summary ==='];
212
+ for (const [name, stat] of Object.entries(stats)) {
213
+ if (stat.count > 0) {
214
+ lines.push(`${name}: avg=${stat.avg.toFixed(2)}ms, max=${stat.max.toFixed(2)}ms, count=${stat.count}`);
215
+ }
216
+ }
217
+ return lines.join('\n');
218
+ },
219
+ /**
220
+ * Log summary to console
221
+ */
222
+ logSummary: () => {
223
+ if (isPerfEnabled()) {
224
+ console.info(perfMetrics.getSummary());
225
+ }
226
+ }
227
+ };
228
+ /**
229
+ * Memory tracking utilities
230
+ */
231
+ export const getMemoryUsage = () => {
232
+ if (typeof performance !== 'undefined' &&
233
+ 'memory' in performance &&
234
+ performance.memory) {
235
+ const memory = performance.memory;
236
+ return {
237
+ usedJSHeapSize: memory.usedJSHeapSize,
238
+ totalJSHeapSize: memory.totalJSHeapSize
239
+ };
240
+ }
241
+ return null;
242
+ };
243
+ /**
244
+ * Helper to format bytes to human-readable size
245
+ */
246
+ export const formatBytes = (bytes) => {
247
+ if (bytes < 1024)
248
+ return `${bytes} B`;
249
+ if (bytes < 1024 * 1024)
250
+ return `${(bytes / 1024).toFixed(1)} KB`;
251
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
252
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
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,19 +59,19 @@
59
59
  "esm-env": "^1.2.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@eslint/compat": "^2.0.1",
62
+ "@eslint/compat": "^2.0.2",
63
63
  "@eslint/js": "^9.39.2",
64
64
  "@faker-js/faker": "^10.2.0",
65
- "@playwright/test": "^1.58.0",
65
+ "@playwright/test": "^1.58.1",
66
66
  "@sveltejs/adapter-auto": "^7.0.0",
67
- "@sveltejs/kit": "^2.50.1",
67
+ "@sveltejs/kit": "^2.50.2",
68
68
  "@sveltejs/package": "^2.5.7",
69
69
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
70
70
  "@tailwindcss/vite": "^4.1.18",
71
71
  "@testing-library/jest-dom": "^6.9.1",
72
72
  "@testing-library/svelte": "^5.3.1",
73
73
  "@testing-library/user-event": "^14.6.1",
74
- "@types/node": "^25.1.0",
74
+ "@types/node": "^25.2.0",
75
75
  "@typescript-eslint/eslint-plugin": "^8.54.0",
76
76
  "@typescript-eslint/parser": "^8.54.0",
77
77
  "@vitest/coverage-v8": "^4.0.18",
@@ -81,17 +81,17 @@
81
81
  "eslint-plugin-import": "^2.32.0",
82
82
  "eslint-plugin-svelte": "^3.14.0",
83
83
  "eslint-plugin-unused-imports": "^4.3.0",
84
- "globals": "^17.2.0",
84
+ "globals": "^17.3.0",
85
85
  "husky": "^9.1.7",
86
- "jsdom": "^27.4.0",
86
+ "jsdom": "^28.0.0",
87
87
  "prettier": "^3.8.1",
88
88
  "prettier-plugin-organize-imports": "^4.3.0",
89
89
  "prettier-plugin-sort-json": "^4.2.0",
90
90
  "prettier-plugin-svelte": "^3.4.1",
91
91
  "prettier-plugin-tailwindcss": "^0.7.2",
92
92
  "publint": "^0.3.17",
93
- "svelte": "^5.48.5",
94
- "svelte-check": "^4.3.5",
93
+ "svelte": "^5.49.1",
94
+ "svelte-check": "^4.3.6",
95
95
  "tailwindcss": "^4.1.18",
96
96
  "tw-animate-css": "^1.4.0",
97
97
  "typescript": "^5.9.3",