@humanspeak/svelte-virtual-list 0.3.9 → 0.3.11
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/dist/SvelteVirtualList.svelte +108 -55
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/reactive-list-manager/ReactiveListManager.svelte.d.ts +27 -0
- package/dist/reactive-list-manager/ReactiveListManager.svelte.js +72 -0
- package/dist/utils/perfMetrics.d.ts +114 -0
- package/dist/utils/perfMetrics.js +252 -0
- package/package.json +18 -18
|
@@ -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
|
-
//
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
log('[SVL] b2t-correction-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
|
@@ -1288,6 +1347,38 @@
|
|
|
1288
1347
|
}
|
|
1289
1348
|
})
|
|
1290
1349
|
|
|
1350
|
+
// Call debugFunction in an effect to avoid state_unsafe_mutation when
|
|
1351
|
+
// the callback writes to $state (which is forbidden during render effects)
|
|
1352
|
+
$effect(() => {
|
|
1353
|
+
if (!debug) return
|
|
1354
|
+
const currentVisibleRange = visibleItems()
|
|
1355
|
+
if (
|
|
1356
|
+
!shouldShowDebugInfo(
|
|
1357
|
+
prevVisibleRange,
|
|
1358
|
+
currentVisibleRange,
|
|
1359
|
+
prevHeight,
|
|
1360
|
+
heightManager.averageHeight
|
|
1361
|
+
)
|
|
1362
|
+
)
|
|
1363
|
+
return
|
|
1364
|
+
|
|
1365
|
+
const info = createDebugInfo(
|
|
1366
|
+
currentVisibleRange,
|
|
1367
|
+
items.length,
|
|
1368
|
+
Object.keys(heightManager.getHeightCache()).length,
|
|
1369
|
+
heightManager.averageHeight,
|
|
1370
|
+
heightManager.scrollTop,
|
|
1371
|
+
height || 0,
|
|
1372
|
+
totalHeight()
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
if (debugFunction) {
|
|
1376
|
+
debugFunction(info)
|
|
1377
|
+
} else {
|
|
1378
|
+
console.info('Virtual List Debug:', info)
|
|
1379
|
+
}
|
|
1380
|
+
})
|
|
1381
|
+
|
|
1291
1382
|
/**
|
|
1292
1383
|
* Scrolls the virtual list to the item at the given index.
|
|
1293
1384
|
*
|
|
@@ -1531,54 +1622,16 @@
|
|
|
1531
1622
|
id="virtual-list-content"
|
|
1532
1623
|
{...testId ? { 'data-testid': `${testId}-content` } : {}}
|
|
1533
1624
|
class={contentClass ?? 'virtual-list-content'}
|
|
1534
|
-
style:height="{(
|
|
1625
|
+
style:height="{contentHeight()}px"
|
|
1535
1626
|
>
|
|
1536
1627
|
<!-- Items container is translated to show correct items -->
|
|
1537
1628
|
<div
|
|
1538
1629
|
id="virtual-list-items"
|
|
1539
1630
|
{...testId ? { 'data-testid': `${testId}-items` } : {}}
|
|
1540
1631
|
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)"
|
|
1632
|
+
style:transform="translateY({transformY()}px)"
|
|
1565
1633
|
>
|
|
1566
|
-
{#each displayItems() as currentItemWithIndex,
|
|
1567
|
-
<!-- Only debug when visible range or average height changes -->
|
|
1568
|
-
{#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, heightManager.averageHeight)}
|
|
1569
|
-
{@const debugInfo = createDebugInfo(
|
|
1570
|
-
visibleItems(),
|
|
1571
|
-
items.length,
|
|
1572
|
-
Object.keys(heightManager.getHeightCache()).length,
|
|
1573
|
-
heightManager.averageHeight,
|
|
1574
|
-
heightManager.scrollTop,
|
|
1575
|
-
height || 0,
|
|
1576
|
-
totalHeight()
|
|
1577
|
-
)}
|
|
1578
|
-
{debugFunction
|
|
1579
|
-
? debugFunction(debugInfo)
|
|
1580
|
-
: console.info('Virtual List Debug:', debugInfo)}
|
|
1581
|
-
{/if}
|
|
1634
|
+
{#each displayItems() as currentItemWithIndex, _i (currentItemWithIndex.originalIndex)}
|
|
1582
1635
|
<!-- Render each visible item -->
|
|
1583
1636
|
<div
|
|
1584
1637
|
bind:this={itemElements[currentItemWithIndex.sliceIndex]}
|
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.
|
|
3
|
+
"version": "0.3.11",
|
|
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,43 +59,43 @@
|
|
|
59
59
|
"esm-env": "^1.2.2"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@eslint/compat": "^2.0.
|
|
63
|
-
"@eslint/js": "^
|
|
64
|
-
"@faker-js/faker": "^10.
|
|
65
|
-
"@playwright/test": "^1.58.
|
|
66
|
-
"@sveltejs/adapter-auto": "^7.0.
|
|
67
|
-
"@sveltejs/kit": "^2.
|
|
62
|
+
"@eslint/compat": "^2.0.2",
|
|
63
|
+
"@eslint/js": "^10.0.1",
|
|
64
|
+
"@faker-js/faker": "^10.3.0",
|
|
65
|
+
"@playwright/test": "^1.58.2",
|
|
66
|
+
"@sveltejs/adapter-auto": "^7.0.1",
|
|
67
|
+
"@sveltejs/kit": "^2.52.0",
|
|
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.
|
|
75
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
76
|
-
"@typescript-eslint/parser": "^8.
|
|
74
|
+
"@types/node": "^25.2.3",
|
|
75
|
+
"@typescript-eslint/eslint-plugin": "^8.55.0",
|
|
76
|
+
"@typescript-eslint/parser": "^8.55.0",
|
|
77
77
|
"@vitest/coverage-v8": "^4.0.18",
|
|
78
78
|
"concurrently": "^9.2.1",
|
|
79
|
-
"eslint": "^
|
|
79
|
+
"eslint": "^10.0.0",
|
|
80
80
|
"eslint-config-prettier": "^10.1.8",
|
|
81
81
|
"eslint-plugin-import": "^2.32.0",
|
|
82
|
-
"eslint-plugin-svelte": "^3.
|
|
83
|
-
"eslint-plugin-unused-imports": "^4.
|
|
84
|
-
"globals": "^17.
|
|
82
|
+
"eslint-plugin-svelte": "^3.15.0",
|
|
83
|
+
"eslint-plugin-unused-imports": "^4.4.1",
|
|
84
|
+
"globals": "^17.3.0",
|
|
85
85
|
"husky": "^9.1.7",
|
|
86
|
-
"jsdom": "^
|
|
86
|
+
"jsdom": "^28.1.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.
|
|
94
|
-
"svelte-check": "^4.
|
|
93
|
+
"svelte": "^5.51.2",
|
|
94
|
+
"svelte-check": "^4.4.0",
|
|
95
95
|
"tailwindcss": "^4.1.18",
|
|
96
96
|
"tw-animate-css": "^1.4.0",
|
|
97
97
|
"typescript": "^5.9.3",
|
|
98
|
-
"typescript-eslint": "^8.
|
|
98
|
+
"typescript-eslint": "^8.55.0",
|
|
99
99
|
"vite": "^7.3.1",
|
|
100
100
|
"vitest": "^4.0.18"
|
|
101
101
|
},
|