@humanspeak/svelte-virtual-list 0.3.4 → 0.3.6
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/README.md +1 -1
- package/dist/SvelteVirtualList.svelte +213 -65
- package/dist/reactive-list-manager/RecomputeScheduler.d.ts +1 -0
- package/dist/reactive-list-manager/RecomputeScheduler.js +39 -6
- package/dist/utils/raf.d.ts +1 -1
- package/dist/utils/raf.js +4 -4
- package/dist/utils/virtualListDebug.d.ts +4 -4
- package/dist/utils/virtualListDebug.js +4 -4
- package/package.json +30 -28
package/README.md
CHANGED
|
@@ -176,6 +176,9 @@
|
|
|
176
176
|
import { onMount, tick, untrack } from 'svelte'
|
|
177
177
|
|
|
178
178
|
const rafSchedule = createRafScheduler()
|
|
179
|
+
// Per-instance correction guard to avoid same-frame tug-of-war per viewport
|
|
180
|
+
const GLOBAL_CORRECTION_COOLDOWN = 16
|
|
181
|
+
const lastCorrectionTimestampByViewport = new WeakMap<HTMLElement, number>()
|
|
179
182
|
// Package-specific debug flag - safe for library distribution
|
|
180
183
|
// Enable with: PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG=true (preferred) or SVELTE_VIRTUAL_LIST_DEBUG=true
|
|
181
184
|
// Avoid SvelteKit-only $env imports so library works in non-Kit/Vitest contexts
|
|
@@ -251,6 +254,18 @@
|
|
|
251
254
|
itemHeight: defaultEstimatedItemHeight,
|
|
252
255
|
internalDebug: INTERNAL_DEBUG
|
|
253
256
|
})
|
|
257
|
+
const instanceId = Math.random().toString(36).slice(2, 7)
|
|
258
|
+
|
|
259
|
+
// Centralized debug logger gated by flags
|
|
260
|
+
const log = (tag: string, payload?: unknown) => {
|
|
261
|
+
if (!debug && !INTERNAL_DEBUG) return
|
|
262
|
+
try {
|
|
263
|
+
const ts = new Date().toISOString().split('T')[1]?.replace('Z', '')
|
|
264
|
+
console.info(`[SVL][${instanceId}] ${ts} ${tag}`, payload ?? '')
|
|
265
|
+
} catch {
|
|
266
|
+
// no-op
|
|
267
|
+
}
|
|
268
|
+
}
|
|
254
269
|
|
|
255
270
|
// Dynamic update coordination to avoid UA scroll anchoring interference
|
|
256
271
|
let suppressBottomAnchoringUntilMs = $state(0)
|
|
@@ -339,11 +354,21 @@
|
|
|
339
354
|
mode === 'bottomToTop' &&
|
|
340
355
|
wasAtBottomBeforeHeightChange &&
|
|
341
356
|
!programmaticScrollInProgress &&
|
|
342
|
-
performance.now() >= suppressBottomAnchoringUntilMs
|
|
343
|
-
!heightManager.isDynamicUpdateInProgress
|
|
357
|
+
performance.now() >= suppressBottomAnchoringUntilMs
|
|
344
358
|
) {
|
|
359
|
+
// Prevent same-frame corrections; defer if this viewport just corrected
|
|
360
|
+
const now = performance.now()
|
|
361
|
+
const viewportEl = heightManager.viewport
|
|
362
|
+
const lastCorrectionMs = lastCorrectionTimestampByViewport.get(viewportEl) ?? 0
|
|
363
|
+
if (now - lastCorrectionMs < GLOBAL_CORRECTION_COOLDOWN) {
|
|
364
|
+
suppressBottomAnchoringUntilMs = now + 50
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
lastCorrectionTimestampByViewport.set(viewportEl, now)
|
|
368
|
+
|
|
345
369
|
// Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
|
|
346
370
|
const approximateScrollTop = Math.max(0, totalHeight() - height)
|
|
371
|
+
log('b2t-correction-approx', { approximateScrollTop })
|
|
347
372
|
heightManager.viewport.scrollTop = approximateScrollTop
|
|
348
373
|
heightManager.scrollTop = approximateScrollTop
|
|
349
374
|
|
|
@@ -353,15 +378,29 @@
|
|
|
353
378
|
'[data-original-index="0"]'
|
|
354
379
|
)
|
|
355
380
|
if (item0Element) {
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
381
|
+
// Verify alignment via rects; if off, perform one-time scrollIntoView
|
|
382
|
+
const contRect = heightManager.viewport.getBoundingClientRect()
|
|
383
|
+
const itemRect = (item0Element as HTMLElement).getBoundingClientRect()
|
|
384
|
+
const tol = 4
|
|
385
|
+
const aligned =
|
|
386
|
+
Math.abs(contRect.y + contRect.height - (itemRect.y + itemRect.height)) <=
|
|
387
|
+
tol
|
|
388
|
+
if (!aligned) {
|
|
389
|
+
// Native browser API handles all positioning edge cases perfectly
|
|
390
|
+
item0Element.scrollIntoView({
|
|
391
|
+
block: 'end', // Align Item 0 to bottom edge of viewport
|
|
392
|
+
behavior: 'smooth', // Smooth animation for better UX
|
|
393
|
+
inline: 'nearest' // Minimal horizontal adjustment
|
|
394
|
+
})
|
|
395
|
+
log('b2t-correction-native', {
|
|
396
|
+
containerBottom: contRect.y + contRect.height,
|
|
397
|
+
itemBottom: itemRect.y + itemRect.height
|
|
398
|
+
})
|
|
399
|
+
}
|
|
363
400
|
// Sync our internal scroll state with actual DOM position
|
|
364
401
|
heightManager.scrollTop = heightManager.viewport.scrollTop
|
|
402
|
+
// After peer correction, delay further corrections briefly
|
|
403
|
+
suppressBottomAnchoringUntilMs = performance.now() + 200
|
|
365
404
|
}
|
|
366
405
|
})
|
|
367
406
|
|
|
@@ -445,7 +484,9 @@
|
|
|
445
484
|
|
|
446
485
|
// Handle height changes for scroll correction (manager totals already updated)
|
|
447
486
|
if (result.heightChanges.length > 0 && mode === 'bottomToTop') {
|
|
448
|
-
|
|
487
|
+
// Run correction after dynamic update finishes to avoid blocking conditions
|
|
488
|
+
const changes = result.heightChanges
|
|
489
|
+
queueMicrotask(() => handleHeightChangesScrollCorrection(changes))
|
|
449
490
|
}
|
|
450
491
|
|
|
451
492
|
// TopToBottom: maintain bottom anchoring when total height changes
|
|
@@ -496,6 +537,8 @@
|
|
|
496
537
|
let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
|
|
497
538
|
let lastCalculatedHeight = $state(0)
|
|
498
539
|
let lastItemsLength = $state(0)
|
|
540
|
+
// Track last observed total height to compute precise deltas on item count changes
|
|
541
|
+
let lastTotalHeightObserved = $state(0)
|
|
499
542
|
|
|
500
543
|
/**
|
|
501
544
|
* CRITICAL: O(1) Reactive Total Height Calculation
|
|
@@ -620,34 +663,96 @@
|
|
|
620
663
|
const currentCalculatedItemHeight = heightManager.averageHeight
|
|
621
664
|
const currentHeight = height
|
|
622
665
|
const currentTotalHeight = totalHeight()
|
|
623
|
-
const
|
|
666
|
+
const prevTotalHeight =
|
|
667
|
+
lastTotalHeightObserved ||
|
|
668
|
+
currentTotalHeight - itemsAdded * currentCalculatedItemHeight
|
|
669
|
+
const prevMaxScrollTop = Math.max(0, prevTotalHeight - currentHeight)
|
|
670
|
+
const nextMaxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
|
|
671
|
+
const deltaMax = nextMaxScrollTop - prevMaxScrollTop
|
|
672
|
+
log('[SVL] items-length-change:before', {
|
|
673
|
+
instanceId,
|
|
674
|
+
itemsAdded,
|
|
675
|
+
lastItemsLength,
|
|
676
|
+
currentItemsLength,
|
|
677
|
+
currentScrollTop,
|
|
678
|
+
prevTotalHeight,
|
|
679
|
+
currentTotalHeight,
|
|
680
|
+
prevMaxScrollTop,
|
|
681
|
+
nextMaxScrollTop,
|
|
682
|
+
deltaMax,
|
|
683
|
+
averageItemHeight: currentCalculatedItemHeight
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
// Maintain visual position for ALL cases by advancing scrollTop by deltaMax.
|
|
687
|
+
// If near the bottom, this naturally pins to the new max; otherwise it preserves the current content.
|
|
688
|
+
programmaticScrollInProgress = true
|
|
689
|
+
void heightManager.runDynamicUpdate(() => {
|
|
690
|
+
const unclamped = currentScrollTop + deltaMax
|
|
691
|
+
const newScrollTop = Math.max(0, Math.min(nextMaxScrollTop, unclamped))
|
|
692
|
+
heightManager.viewport.scrollTop = newScrollTop
|
|
693
|
+
heightManager.scrollTop = newScrollTop
|
|
694
|
+
log('[SVL] items-length-change:applied', {
|
|
695
|
+
instanceId,
|
|
696
|
+
previousScrollTop: currentScrollTop,
|
|
697
|
+
appliedScrollTop: newScrollTop,
|
|
698
|
+
prevMaxScrollTop,
|
|
699
|
+
nextMaxScrollTop,
|
|
700
|
+
deltaMax
|
|
701
|
+
})
|
|
624
702
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
703
|
+
// We are explicitly managing position; consider this a programmatic action.
|
|
704
|
+
// Do not flip userHasScrolledAway here; it should reflect user intent only.
|
|
705
|
+
|
|
706
|
+
// Reconcile on next frame in case measured heights adjust totals
|
|
707
|
+
requestAnimationFrame(() => {
|
|
708
|
+
const beforeReconcileScrollTop = heightManager.viewport.scrollTop
|
|
709
|
+
const reconciledNextMax = Math.max(0, totalHeight() - height)
|
|
710
|
+
const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
|
|
711
|
+
// Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
|
|
712
|
+
const desiredScrollTop = Math.max(
|
|
713
|
+
0,
|
|
714
|
+
Math.min(reconciledNextMax, newScrollTop + reconciledDeltaMaxChange)
|
|
715
|
+
)
|
|
716
|
+
// Snap to integer pixels to prevent oscillation due to subpixel rounding
|
|
717
|
+
const desiredRounded = Math.round(desiredScrollTop)
|
|
718
|
+
const diffToDesired = desiredRounded - heightManager.viewport.scrollTop
|
|
719
|
+
if (Math.abs(diffToDesired) >= 1) {
|
|
720
|
+
const adjusted = Math.max(
|
|
630
721
|
0,
|
|
631
|
-
|
|
722
|
+
Math.min(reconciledNextMax, desiredRounded)
|
|
632
723
|
)
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
724
|
+
heightManager.viewport.scrollTop = adjusted
|
|
725
|
+
heightManager.scrollTop = adjusted
|
|
726
|
+
log('[SVL] items-length-change:reconciled', {
|
|
727
|
+
instanceId,
|
|
728
|
+
beforeReconcileScrollTop,
|
|
729
|
+
adjustedScrollTop: adjusted,
|
|
730
|
+
reconciledNextMax,
|
|
731
|
+
reconciledDeltaMaxChange,
|
|
732
|
+
desiredScrollTop,
|
|
733
|
+
desiredRounded,
|
|
734
|
+
diffToDesired
|
|
735
|
+
})
|
|
736
|
+
} else {
|
|
737
|
+
log('[SVL] items-length-change:reconciled-skip', {
|
|
738
|
+
instanceId,
|
|
739
|
+
beforeReconcileScrollTop,
|
|
740
|
+
reconciledNextMax,
|
|
741
|
+
reconciledDeltaMaxChange,
|
|
742
|
+
desiredScrollTop,
|
|
743
|
+
desiredRounded,
|
|
744
|
+
diffToDesired
|
|
745
|
+
})
|
|
746
|
+
}
|
|
747
|
+
programmaticScrollInProgress = false
|
|
645
748
|
})
|
|
646
|
-
}
|
|
749
|
+
})
|
|
647
750
|
}
|
|
648
751
|
}
|
|
649
752
|
|
|
650
753
|
lastItemsLength = currentItemsLength
|
|
754
|
+
// Update last observed total height at the end of the effect
|
|
755
|
+
lastTotalHeightObserved = totalHeight()
|
|
651
756
|
})
|
|
652
757
|
|
|
653
758
|
// Update container height continuously to reflect layout changes that
|
|
@@ -770,7 +875,8 @@
|
|
|
770
875
|
if (mode === 'bottomToTop') {
|
|
771
876
|
const delta = lastScrollTopSnapshot - current
|
|
772
877
|
if (delta > 0.5) {
|
|
773
|
-
|
|
878
|
+
// Widen suppression to avoid fighting peer instance corrections
|
|
879
|
+
suppressBottomAnchoringUntilMs = performance.now() + 450
|
|
774
880
|
userHasScrolledAway = true
|
|
775
881
|
}
|
|
776
882
|
}
|
|
@@ -779,7 +885,7 @@
|
|
|
779
885
|
updateDebugTailDistance()
|
|
780
886
|
if (INTERNAL_DEBUG) {
|
|
781
887
|
const vr = visibleItems()
|
|
782
|
-
|
|
888
|
+
log('scroll', {
|
|
783
889
|
mode,
|
|
784
890
|
scrollTop: heightManager.scrollTop,
|
|
785
891
|
height,
|
|
@@ -808,40 +914,65 @@
|
|
|
808
914
|
* @param immediate - Whether to skip the delay (used for resize events)
|
|
809
915
|
*/
|
|
810
916
|
const updateHeightAndScroll = (immediate = false) => {
|
|
917
|
+
log('updateHeightAndScroll-enter', {
|
|
918
|
+
immediate,
|
|
919
|
+
initialized: heightManager.initialized,
|
|
920
|
+
mode
|
|
921
|
+
})
|
|
811
922
|
if (!heightManager.initialized && mode === 'bottomToTop') {
|
|
923
|
+
// Deterministic init order: double RAF + microtask, then apply bottom anchoring
|
|
812
924
|
tick().then(() => {
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
)
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
925
|
+
requestAnimationFrame(() => {
|
|
926
|
+
requestAnimationFrame(() => {
|
|
927
|
+
if (!heightManager.isReady) return
|
|
928
|
+
const measuredHeight =
|
|
929
|
+
heightManager.container.getBoundingClientRect().height
|
|
930
|
+
height = measuredHeight
|
|
931
|
+
const targetScrollTop = calculateScrollPosition(
|
|
932
|
+
items.length,
|
|
933
|
+
heightManager.averageHeight,
|
|
934
|
+
measuredHeight
|
|
935
|
+
)
|
|
936
|
+
// Instance jitter to avoid same-frame collisions when two lists init together
|
|
937
|
+
const cleanedId = String(instanceId)
|
|
938
|
+
.toLowerCase()
|
|
939
|
+
.replace(/[^a-z0-9]/g, '')
|
|
940
|
+
const suffix = cleanedId.slice(-4)
|
|
941
|
+
const parsed = parseInt(suffix, 36)
|
|
942
|
+
const jitterMs = Number.isNaN(parsed)
|
|
943
|
+
? Math.floor(Math.random() * 3)
|
|
944
|
+
: parsed % 3
|
|
945
|
+
log('b2t-init', { measuredHeight, targetScrollTop, jitterMs })
|
|
946
|
+
setTimeout(() => {
|
|
831
947
|
heightManager.viewport.scrollTop = targetScrollTop
|
|
832
948
|
heightManager.scrollTop = targetScrollTop
|
|
833
|
-
|
|
834
949
|
requestAnimationFrame(() => {
|
|
835
|
-
|
|
836
|
-
if (
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
950
|
+
// Guard: only transition false -> true to avoid invariant error
|
|
951
|
+
if (!heightManager.initialized) heightManager.initialized = true
|
|
952
|
+
// Post-init verification: ensure item 0 bottom aligns; fallback to native
|
|
953
|
+
tick().then(() => {
|
|
954
|
+
const el = heightManager.viewport.querySelector(
|
|
955
|
+
'[data-original-index="0"]'
|
|
956
|
+
) as HTMLElement | null
|
|
957
|
+
if (!el) return
|
|
958
|
+
const cont = heightManager.viewport.getBoundingClientRect()
|
|
959
|
+
const r = el.getBoundingClientRect()
|
|
960
|
+
const tol = 4
|
|
961
|
+
const aligned =
|
|
962
|
+
Math.abs(cont.y + cont.height - (r.y + r.height)) <= tol
|
|
963
|
+
if (!aligned) {
|
|
964
|
+
el.scrollIntoView({ block: 'end', inline: 'nearest' })
|
|
965
|
+
heightManager.scrollTop = heightManager.viewport.scrollTop
|
|
966
|
+
log('b2t-init-native-fallback', {
|
|
967
|
+
containerBottom: cont.y + cont.height,
|
|
968
|
+
itemBottom: r.y + r.height
|
|
969
|
+
})
|
|
970
|
+
}
|
|
971
|
+
})
|
|
841
972
|
})
|
|
842
|
-
}
|
|
973
|
+
}, jitterMs)
|
|
843
974
|
})
|
|
844
|
-
}
|
|
975
|
+
})
|
|
845
976
|
})
|
|
846
977
|
return
|
|
847
978
|
}
|
|
@@ -859,17 +990,24 @@
|
|
|
859
990
|
{
|
|
860
991
|
setHeight: (h) => (height = h),
|
|
861
992
|
setScrollTop: (st) => (heightManager.scrollTop = st),
|
|
862
|
-
|
|
993
|
+
// Guard: respect invariant in ReactiveListManager; avoid re-setting true
|
|
994
|
+
setInitialized: (i) => {
|
|
995
|
+
if (i && heightManager.initialized) return
|
|
996
|
+
heightManager.initialized = i
|
|
997
|
+
}
|
|
863
998
|
},
|
|
864
999
|
immediate
|
|
865
1000
|
)
|
|
1001
|
+
log('updateHeightAndScroll-exit', { immediate })
|
|
866
1002
|
}
|
|
867
1003
|
|
|
868
1004
|
// Create itemResizeObserver immediately when in browser
|
|
869
1005
|
if (BROWSER) {
|
|
870
1006
|
// Watch for individual item size changes
|
|
871
1007
|
itemResizeObserver = new ResizeObserver((entries) => {
|
|
872
|
-
|
|
1008
|
+
// Batch via RAF to avoid thrash across instances
|
|
1009
|
+
rafSchedule(() => {
|
|
1010
|
+
log('item-resize-observer', { entries: entries.length })
|
|
873
1011
|
let shouldRecalculate = false
|
|
874
1012
|
void visibleItems() // Cache once to avoid reactive loops
|
|
875
1013
|
|
|
@@ -903,9 +1041,8 @@
|
|
|
903
1041
|
}
|
|
904
1042
|
|
|
905
1043
|
if (shouldRecalculate) {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
})
|
|
1044
|
+
log('item-resize-recalc')
|
|
1045
|
+
updateHeight()
|
|
909
1046
|
}
|
|
910
1047
|
})
|
|
911
1048
|
})
|
|
@@ -915,14 +1052,25 @@
|
|
|
915
1052
|
onMount(() => {
|
|
916
1053
|
if (BROWSER) {
|
|
917
1054
|
// Initial setup of heights and scroll position
|
|
1055
|
+
log('onMount-enter', { mode, items: items.length })
|
|
918
1056
|
updateHeightAndScroll()
|
|
919
1057
|
// Ensure one initial measurement pass even if no ResizeObserver fires
|
|
920
1058
|
tick().then(() =>
|
|
921
|
-
requestAnimationFrame(() =>
|
|
1059
|
+
requestAnimationFrame(() =>
|
|
1060
|
+
requestAnimationFrame(() => {
|
|
1061
|
+
log('post-hydration-measure')
|
|
1062
|
+
updateHeight()
|
|
1063
|
+
})
|
|
1064
|
+
)
|
|
922
1065
|
)
|
|
923
1066
|
|
|
924
1067
|
// Watch for container size changes
|
|
925
1068
|
resizeObserver = new ResizeObserver(() => {
|
|
1069
|
+
if (!heightManager.initialized) {
|
|
1070
|
+
log('container-resize-ignored', 'not-initialized')
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
1073
|
+
log('container-resize')
|
|
926
1074
|
updateHeightAndScroll(true)
|
|
927
1075
|
})
|
|
928
1076
|
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
+
// RecomputeScheduler
|
|
2
|
+
// -------------------
|
|
3
|
+
// Coalesces recompute requests to the next animation frame in the browser.
|
|
4
|
+
// Falls back to setTimeout(0) in non-browser/jsdom to preserve deterministic tests.
|
|
5
|
+
// Supports temporary blocking to delay recomputation during critical sections.
|
|
1
6
|
export class RecomputeScheduler {
|
|
2
7
|
onRecompute;
|
|
3
8
|
isScheduled = false;
|
|
4
9
|
isPending = false;
|
|
5
10
|
blockDepth = 0;
|
|
6
11
|
timeoutId = null;
|
|
12
|
+
rafId = null;
|
|
7
13
|
constructor(onRecompute) {
|
|
8
14
|
this.onRecompute = onRecompute;
|
|
9
15
|
}
|
|
16
|
+
// Request a recompute. If blocked, mark as pending; otherwise schedule for next frame.
|
|
10
17
|
schedule = () => {
|
|
11
18
|
if (this.blockDepth > 0) {
|
|
12
19
|
this.isPending = true;
|
|
@@ -15,16 +22,30 @@ export class RecomputeScheduler {
|
|
|
15
22
|
if (this.isScheduled)
|
|
16
23
|
return;
|
|
17
24
|
this.isScheduled = true;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
// In jsdom or non-browser, fall back to immediate execution for determinism
|
|
26
|
+
const isBrowser = typeof window !== 'undefined' && typeof requestAnimationFrame === 'function';
|
|
27
|
+
if (!isBrowser) {
|
|
28
|
+
if (this.timeoutId) {
|
|
29
|
+
clearTimeout(this.timeoutId);
|
|
30
|
+
this.timeoutId = null;
|
|
31
|
+
}
|
|
32
|
+
this.timeoutId = setTimeout(() => {
|
|
33
|
+
this.timeoutId = null;
|
|
34
|
+
this.isScheduled = false;
|
|
35
|
+
this.onRecompute();
|
|
36
|
+
}, 0);
|
|
37
|
+
return;
|
|
21
38
|
}
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
// Browser path: coalesce with RAF for visual stability across instances
|
|
40
|
+
if (this.rafId !== null)
|
|
41
|
+
cancelAnimationFrame(this.rafId);
|
|
42
|
+
this.rafId = requestAnimationFrame(() => {
|
|
43
|
+
this.rafId = null;
|
|
24
44
|
this.isScheduled = false;
|
|
25
45
|
this.onRecompute();
|
|
26
|
-
}
|
|
46
|
+
});
|
|
27
47
|
};
|
|
48
|
+
// Temporarily block recomputes; any in-flight timers are canceled and a recompute is marked pending.
|
|
28
49
|
block = () => {
|
|
29
50
|
this.blockDepth += 1;
|
|
30
51
|
if (this.timeoutId) {
|
|
@@ -33,7 +54,14 @@ export class RecomputeScheduler {
|
|
|
33
54
|
this.isScheduled = false;
|
|
34
55
|
this.isPending = true;
|
|
35
56
|
}
|
|
57
|
+
if (this.rafId !== null) {
|
|
58
|
+
cancelAnimationFrame(this.rafId);
|
|
59
|
+
this.rafId = null;
|
|
60
|
+
this.isScheduled = false;
|
|
61
|
+
this.isPending = true;
|
|
62
|
+
}
|
|
36
63
|
};
|
|
64
|
+
// Unblock and run recompute immediately if one was pending.
|
|
37
65
|
unblock = () => {
|
|
38
66
|
if (this.blockDepth === 0)
|
|
39
67
|
return;
|
|
@@ -43,11 +71,16 @@ export class RecomputeScheduler {
|
|
|
43
71
|
this.onRecompute();
|
|
44
72
|
}
|
|
45
73
|
};
|
|
74
|
+
// Cancel any scheduled recompute and clear pending state.
|
|
46
75
|
cancel = () => {
|
|
47
76
|
if (this.timeoutId) {
|
|
48
77
|
clearTimeout(this.timeoutId);
|
|
49
78
|
this.timeoutId = null;
|
|
50
79
|
}
|
|
80
|
+
if (this.rafId !== null) {
|
|
81
|
+
cancelAnimationFrame(this.rafId);
|
|
82
|
+
this.rafId = null;
|
|
83
|
+
}
|
|
51
84
|
this.isScheduled = false;
|
|
52
85
|
this.isPending = false;
|
|
53
86
|
};
|
package/dist/utils/raf.d.ts
CHANGED
package/dist/utils/raf.js
CHANGED
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
*
|
|
30
30
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
|
|
31
31
|
*/
|
|
32
|
-
export
|
|
32
|
+
export const createRafScheduler = () => {
|
|
33
33
|
let scheduled = false;
|
|
34
34
|
let callback = null;
|
|
35
|
-
return (
|
|
36
|
-
callback =
|
|
35
|
+
return (_fn) => {
|
|
36
|
+
callback = _fn;
|
|
37
37
|
if (!scheduled) {
|
|
38
38
|
scheduled = true;
|
|
39
39
|
requestAnimationFrame(() => {
|
|
@@ -45,4 +45,4 @@ export function createRafScheduler() {
|
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
};
|
|
48
|
-
}
|
|
48
|
+
};
|
|
@@ -28,13 +28,13 @@ import type { SvelteVirtualListDebugInfo } from '../types.js';
|
|
|
28
28
|
* 120
|
|
29
29
|
* );
|
|
30
30
|
*/
|
|
31
|
-
export declare
|
|
31
|
+
export declare const shouldShowDebugInfo: (prevRange: {
|
|
32
32
|
start: number;
|
|
33
33
|
end: number;
|
|
34
34
|
} | null, currentRange: {
|
|
35
35
|
start: number;
|
|
36
36
|
end: number;
|
|
37
|
-
}, prevHeight: number, currentHeight: number)
|
|
37
|
+
}, prevHeight: number, currentHeight: number) => boolean;
|
|
38
38
|
/**
|
|
39
39
|
* Creates a comprehensive debug information object for virtual list state analysis.
|
|
40
40
|
*
|
|
@@ -71,7 +71,7 @@ export declare function shouldShowDebugInfo(prevRange: {
|
|
|
71
71
|
*
|
|
72
72
|
* @throws {Error} Will throw if end index is less than start index in visibleRange
|
|
73
73
|
*/
|
|
74
|
-
export declare
|
|
74
|
+
export declare const createDebugInfo: (visibleRange: {
|
|
75
75
|
start: number;
|
|
76
76
|
end: number;
|
|
77
|
-
}, totalItems: number, processedItems: number, averageItemHeight: number, scrollTop: number, viewportHeight: number, totalHeight: number)
|
|
77
|
+
}, totalItems: number, processedItems: number, averageItemHeight: number, scrollTop: number, viewportHeight: number, totalHeight: number) => SvelteVirtualListDebugInfo;
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
* 120
|
|
28
28
|
* );
|
|
29
29
|
*/
|
|
30
|
-
export
|
|
30
|
+
export const shouldShowDebugInfo = (prevRange, currentRange, prevHeight, currentHeight) => {
|
|
31
31
|
if (!prevRange)
|
|
32
32
|
return true;
|
|
33
33
|
return (prevRange.start !== currentRange.start ||
|
|
34
34
|
prevRange.end !== currentRange.end ||
|
|
35
35
|
prevHeight !== currentHeight);
|
|
36
|
-
}
|
|
36
|
+
};
|
|
37
37
|
/**
|
|
38
38
|
* Creates a comprehensive debug information object for virtual list state analysis.
|
|
39
39
|
*
|
|
@@ -70,7 +70,7 @@ export function shouldShowDebugInfo(prevRange, currentRange, prevHeight, current
|
|
|
70
70
|
*
|
|
71
71
|
* @throws {Error} Will throw if end index is less than start index in visibleRange
|
|
72
72
|
*/
|
|
73
|
-
export
|
|
73
|
+
export const createDebugInfo = (visibleRange, totalItems, processedItems, averageItemHeight, scrollTop, viewportHeight, totalHeight) => {
|
|
74
74
|
const atTop = scrollTop <= 1; // Small tolerance for floating point precision
|
|
75
75
|
const atBottom = scrollTop >= totalHeight - viewportHeight - 1; // Small tolerance
|
|
76
76
|
return {
|
|
@@ -84,4 +84,4 @@ export function createDebugInfo(visibleRange, totalItems, processedItems, averag
|
|
|
84
84
|
atBottom,
|
|
85
85
|
totalHeight
|
|
86
86
|
};
|
|
87
|
-
}
|
|
87
|
+
};
|
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.6",
|
|
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": "^1.4.
|
|
63
|
-
"@eslint/js": "^9.
|
|
64
|
-
"@faker-js/faker": "^10.
|
|
65
|
-
"@playwright/test": "^1.
|
|
66
|
-
"@sveltejs/adapter-auto": "^
|
|
67
|
-
"@sveltejs/kit": "^2.
|
|
62
|
+
"@eslint/compat": "^1.4.1",
|
|
63
|
+
"@eslint/js": "^9.39.1",
|
|
64
|
+
"@faker-js/faker": "^10.1.0",
|
|
65
|
+
"@playwright/test": "^1.56.1",
|
|
66
|
+
"@sveltejs/adapter-auto": "^7.0.0",
|
|
67
|
+
"@sveltejs/kit": "^2.48.4",
|
|
68
68
|
"@sveltejs/package": "^2.5.4",
|
|
69
69
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
|
70
|
-
"@tailwindcss/vite": "^4.1.
|
|
71
|
-
"@testing-library/jest-dom": "^6.
|
|
70
|
+
"@tailwindcss/vite": "^4.1.17",
|
|
71
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
72
72
|
"@testing-library/svelte": "^5.2.8",
|
|
73
73
|
"@testing-library/user-event": "^14.6.1",
|
|
74
|
-
"@types/node": "^24.
|
|
75
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
76
|
-
"@typescript-eslint/parser": "^8.
|
|
77
|
-
"@vitest/coverage-v8": "^
|
|
74
|
+
"@types/node": "^24.10.1",
|
|
75
|
+
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
|
76
|
+
"@typescript-eslint/parser": "^8.46.4",
|
|
77
|
+
"@vitest/coverage-v8": "^4.0.8",
|
|
78
78
|
"concurrently": "^9.2.1",
|
|
79
|
-
"eslint": "^9.
|
|
79
|
+
"eslint": "^9.39.1",
|
|
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": "^16.
|
|
82
|
+
"eslint-plugin-svelte": "^3.13.0",
|
|
83
|
+
"eslint-plugin-unused-imports": "^4.3.0",
|
|
84
|
+
"globals": "^16.5.0",
|
|
85
85
|
"husky": "^9.1.7",
|
|
86
|
-
"jsdom": "^27.
|
|
86
|
+
"jsdom": "^27.2.0",
|
|
87
87
|
"prettier": "^3.6.2",
|
|
88
88
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
89
89
|
"prettier-plugin-sort-json": "^4.1.1",
|
|
90
90
|
"prettier-plugin-svelte": "^3.4.0",
|
|
91
|
-
"prettier-plugin-tailwindcss": "^0.
|
|
92
|
-
"publint": "^0.3.
|
|
93
|
-
"svelte": "^5.
|
|
94
|
-
"svelte-check": "^4.3.
|
|
95
|
-
"tailwindcss": "^4.1.
|
|
91
|
+
"prettier-plugin-tailwindcss": "^0.7.1",
|
|
92
|
+
"publint": "^0.3.15",
|
|
93
|
+
"svelte": "^5.43.6",
|
|
94
|
+
"svelte-check": "^4.3.4",
|
|
95
|
+
"tailwindcss": "^4.1.17",
|
|
96
96
|
"tw-animate-css": "^1.4.0",
|
|
97
|
-
"typescript": "^5.9.
|
|
98
|
-
"typescript-eslint": "^8.
|
|
99
|
-
"vite": "^7.
|
|
100
|
-
"vitest": "^
|
|
97
|
+
"typescript": "^5.9.3",
|
|
98
|
+
"typescript-eslint": "^8.46.4",
|
|
99
|
+
"vite": "^7.2.2",
|
|
100
|
+
"vitest": "^4.0.8"
|
|
101
101
|
},
|
|
102
102
|
"peerDependencies": {
|
|
103
103
|
"svelte": "^5.0.0"
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
124
124
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
125
125
|
"dev": "vite dev",
|
|
126
|
-
"dev:all": "concurrently -k -n pkg,docs -c green,cyan \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev
|
|
126
|
+
"dev:all": "concurrently -k -n pkg,docs,sitemap -c green,cyan,magenta \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev\" \"pnpm --filter docs run dev\" \"pnpm --filter docs run sitemap:watch\"",
|
|
127
127
|
"dev:pkg": "svelte-kit sync && svelte-package --watch",
|
|
128
128
|
"format": "prettier --write .",
|
|
129
129
|
"lint": "prettier --check . && eslint .",
|
|
@@ -134,9 +134,11 @@
|
|
|
134
134
|
"test:all": "pnpm run test && pnpm run test:e2e",
|
|
135
135
|
"test:e2e": "playwright test",
|
|
136
136
|
"test:e2e:debug": "playwright test --debug",
|
|
137
|
+
"test:e2e:ff": "playwright test --project=firefox",
|
|
137
138
|
"test:e2e:report": "playwright show-report",
|
|
138
139
|
"test:e2e:ui": "playwright test --ui",
|
|
139
140
|
"test:only": "vitest run --",
|
|
141
|
+
"test:unit": "vitest run --coverage",
|
|
140
142
|
"test:watch": "vitest --"
|
|
141
143
|
}
|
|
142
144
|
}
|