@tanstack/virtual-core 3.13.11 → 3.13.13
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/cjs/index.cjs +105 -42
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +8 -6
- package/dist/cjs/utils.cjs +7 -3
- package/dist/cjs/utils.cjs.map +1 -1
- package/dist/cjs/utils.d.cts +1 -0
- package/dist/esm/index.d.ts +8 -6
- package/dist/esm/index.js +105 -42
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/utils.d.ts +1 -0
- package/dist/esm/utils.js +7 -3
- package/dist/esm/utils.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +144 -54
- package/src/utils.ts +8 -2
package/src/index.ts
CHANGED
|
@@ -359,10 +359,13 @@ export class Virtualizer<
|
|
|
359
359
|
scrollElement: TScrollElement | null = null
|
|
360
360
|
targetWindow: (Window & typeof globalThis) | null = null
|
|
361
361
|
isScrolling = false
|
|
362
|
-
private scrollToIndexTimeoutId: number | null = null
|
|
363
362
|
measurementsCache: Array<VirtualItem> = []
|
|
364
363
|
private itemSizeCache = new Map<Key, number>()
|
|
364
|
+
private laneAssignments = new Map<number, number>() // index → lane cache
|
|
365
365
|
private pendingMeasuredCacheIndexes: Array<number> = []
|
|
366
|
+
private prevLanes: number | undefined = undefined
|
|
367
|
+
private lanesChangedFlag = false
|
|
368
|
+
private lanesSettling = false
|
|
366
369
|
scrollRect: Rect | null = null
|
|
367
370
|
scrollOffset: number | null = null
|
|
368
371
|
scrollDirection: ScrollDirection | null = null
|
|
@@ -618,34 +621,72 @@ export class Virtualizer<
|
|
|
618
621
|
this.options.scrollMargin,
|
|
619
622
|
this.options.getItemKey,
|
|
620
623
|
this.options.enabled,
|
|
624
|
+
this.options.lanes,
|
|
621
625
|
],
|
|
622
|
-
(count, paddingStart, scrollMargin, getItemKey, enabled) => {
|
|
626
|
+
(count, paddingStart, scrollMargin, getItemKey, enabled, lanes) => {
|
|
627
|
+
const lanesChanged =
|
|
628
|
+
this.prevLanes !== undefined && this.prevLanes !== lanes
|
|
629
|
+
|
|
630
|
+
if (lanesChanged) {
|
|
631
|
+
// Set flag for getMeasurements to handle
|
|
632
|
+
this.lanesChangedFlag = true
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
this.prevLanes = lanes
|
|
623
636
|
this.pendingMeasuredCacheIndexes = []
|
|
637
|
+
|
|
624
638
|
return {
|
|
625
639
|
count,
|
|
626
640
|
paddingStart,
|
|
627
641
|
scrollMargin,
|
|
628
642
|
getItemKey,
|
|
629
643
|
enabled,
|
|
644
|
+
lanes,
|
|
630
645
|
}
|
|
631
646
|
},
|
|
632
647
|
{
|
|
633
648
|
key: false,
|
|
649
|
+
skipInitialOnChange: true,
|
|
650
|
+
onChange: () => {
|
|
651
|
+
// Notify when measurement options change as they affect total size
|
|
652
|
+
this.notify(this.isScrolling)
|
|
653
|
+
},
|
|
634
654
|
},
|
|
635
655
|
)
|
|
636
656
|
|
|
637
657
|
private getMeasurements = memo(
|
|
638
658
|
() => [this.getMeasurementOptions(), this.itemSizeCache],
|
|
639
659
|
(
|
|
640
|
-
{ count, paddingStart, scrollMargin, getItemKey, enabled },
|
|
660
|
+
{ count, paddingStart, scrollMargin, getItemKey, enabled, lanes },
|
|
641
661
|
itemSizeCache,
|
|
642
662
|
) => {
|
|
643
663
|
if (!enabled) {
|
|
644
664
|
this.measurementsCache = []
|
|
645
665
|
this.itemSizeCache.clear()
|
|
666
|
+
this.laneAssignments.clear()
|
|
646
667
|
return []
|
|
647
668
|
}
|
|
648
669
|
|
|
670
|
+
// Clean up stale lane cache entries when count decreases
|
|
671
|
+
if (this.laneAssignments.size > count) {
|
|
672
|
+
for (const index of this.laneAssignments.keys()) {
|
|
673
|
+
if (index >= count) {
|
|
674
|
+
this.laneAssignments.delete(index)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ✅ Force complete recalculation when lanes change
|
|
680
|
+
if (this.lanesChangedFlag) {
|
|
681
|
+
this.lanesChangedFlag = false // Reset immediately
|
|
682
|
+
this.lanesSettling = true // Start settling period
|
|
683
|
+
this.measurementsCache = []
|
|
684
|
+
this.itemSizeCache.clear()
|
|
685
|
+
this.laneAssignments.clear() // Clear lane cache for new lane count
|
|
686
|
+
// Clear pending indexes to force min = 0
|
|
687
|
+
this.pendingMeasuredCacheIndexes = []
|
|
688
|
+
}
|
|
689
|
+
|
|
649
690
|
if (this.measurementsCache.length === 0) {
|
|
650
691
|
this.measurementsCache = this.options.initialMeasurementsCache
|
|
651
692
|
this.measurementsCache.forEach((item) => {
|
|
@@ -653,25 +694,71 @@ export class Virtualizer<
|
|
|
653
694
|
})
|
|
654
695
|
}
|
|
655
696
|
|
|
656
|
-
|
|
657
|
-
|
|
697
|
+
// ✅ During lanes settling, ignore pendingMeasuredCacheIndexes to prevent repositioning
|
|
698
|
+
const min = this.lanesSettling
|
|
699
|
+
? 0
|
|
700
|
+
: this.pendingMeasuredCacheIndexes.length > 0
|
|
658
701
|
? Math.min(...this.pendingMeasuredCacheIndexes)
|
|
659
702
|
: 0
|
|
660
703
|
this.pendingMeasuredCacheIndexes = []
|
|
661
704
|
|
|
705
|
+
// ✅ End settling period when cache is fully built
|
|
706
|
+
if (this.lanesSettling && this.measurementsCache.length === count) {
|
|
707
|
+
this.lanesSettling = false
|
|
708
|
+
}
|
|
709
|
+
|
|
662
710
|
const measurements = this.measurementsCache.slice(0, min)
|
|
663
711
|
|
|
712
|
+
// ✅ Performance: Track last item index per lane for O(1) lookup
|
|
713
|
+
const laneLastIndex: Array<number | undefined> = new Array(lanes).fill(
|
|
714
|
+
undefined,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
// Initialize from existing measurements (before min)
|
|
718
|
+
for (let m = 0; m < min; m++) {
|
|
719
|
+
const item = measurements[m]
|
|
720
|
+
if (item) {
|
|
721
|
+
laneLastIndex[item.lane] = m
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
664
725
|
for (let i = min; i < count; i++) {
|
|
665
726
|
const key = getItemKey(i)
|
|
666
727
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
728
|
+
// Check for cached lane assignment
|
|
729
|
+
const cachedLane = this.laneAssignments.get(i)
|
|
730
|
+
let lane: number
|
|
731
|
+
let start: number
|
|
732
|
+
|
|
733
|
+
if (cachedLane !== undefined && this.options.lanes > 1) {
|
|
734
|
+
// Use cached lane - O(1) lookup for previous item in same lane
|
|
735
|
+
lane = cachedLane
|
|
736
|
+
const prevIndex = laneLastIndex[lane]
|
|
737
|
+
const prevInLane =
|
|
738
|
+
prevIndex !== undefined ? measurements[prevIndex] : undefined
|
|
739
|
+
start = prevInLane
|
|
740
|
+
? prevInLane.end + this.options.gap
|
|
741
|
+
: paddingStart + scrollMargin
|
|
742
|
+
} else {
|
|
743
|
+
// No cache - use original logic (find shortest lane)
|
|
744
|
+
const furthestMeasurement =
|
|
745
|
+
this.options.lanes === 1
|
|
746
|
+
? measurements[i - 1]
|
|
747
|
+
: this.getFurthestMeasurement(measurements, i)
|
|
748
|
+
|
|
749
|
+
start = furthestMeasurement
|
|
750
|
+
? furthestMeasurement.end + this.options.gap
|
|
751
|
+
: paddingStart + scrollMargin
|
|
752
|
+
|
|
753
|
+
lane = furthestMeasurement
|
|
754
|
+
? furthestMeasurement.lane
|
|
755
|
+
: i % this.options.lanes
|
|
756
|
+
|
|
757
|
+
// Cache the lane assignment
|
|
758
|
+
if (this.options.lanes > 1) {
|
|
759
|
+
this.laneAssignments.set(i, lane)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
675
762
|
|
|
676
763
|
const measuredSize = itemSizeCache.get(key)
|
|
677
764
|
const size =
|
|
@@ -681,10 +768,6 @@ export class Virtualizer<
|
|
|
681
768
|
|
|
682
769
|
const end = start + size
|
|
683
770
|
|
|
684
|
-
const lane = furthestMeasurement
|
|
685
|
-
? furthestMeasurement.lane
|
|
686
|
-
: i % this.options.lanes
|
|
687
|
-
|
|
688
771
|
measurements[i] = {
|
|
689
772
|
index: i,
|
|
690
773
|
start,
|
|
@@ -693,6 +776,9 @@ export class Virtualizer<
|
|
|
693
776
|
key,
|
|
694
777
|
lane,
|
|
695
778
|
}
|
|
779
|
+
|
|
780
|
+
// ✅ Performance: Update lane's last item index
|
|
781
|
+
laneLastIndex[lane] = i
|
|
696
782
|
}
|
|
697
783
|
|
|
698
784
|
this.measurementsCache = measurements
|
|
@@ -904,7 +990,7 @@ export class Virtualizer<
|
|
|
904
990
|
toOffset -= size
|
|
905
991
|
}
|
|
906
992
|
|
|
907
|
-
const maxOffset = this.getTotalSize() - size
|
|
993
|
+
const maxOffset = this.getTotalSize() + this.options.scrollMargin - size
|
|
908
994
|
|
|
909
995
|
return Math.max(Math.min(maxOffset, toOffset), 0)
|
|
910
996
|
}
|
|
@@ -943,19 +1029,10 @@ export class Virtualizer<
|
|
|
943
1029
|
|
|
944
1030
|
private isDynamicMode = () => this.elementsCache.size > 0
|
|
945
1031
|
|
|
946
|
-
private cancelScrollToIndex = () => {
|
|
947
|
-
if (this.scrollToIndexTimeoutId !== null && this.targetWindow) {
|
|
948
|
-
this.targetWindow.clearTimeout(this.scrollToIndexTimeoutId)
|
|
949
|
-
this.scrollToIndexTimeoutId = null
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
1032
|
scrollToOffset = (
|
|
954
1033
|
toOffset: number,
|
|
955
1034
|
{ align = 'start', behavior }: ScrollToOffsetOptions = {},
|
|
956
1035
|
) => {
|
|
957
|
-
this.cancelScrollToIndex()
|
|
958
|
-
|
|
959
1036
|
if (behavior === 'smooth' && this.isDynamicMode()) {
|
|
960
1037
|
console.warn(
|
|
961
1038
|
'The `smooth` scroll behavior is not fully supported with dynamic size.',
|
|
@@ -972,50 +1049,62 @@ export class Virtualizer<
|
|
|
972
1049
|
index: number,
|
|
973
1050
|
{ align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
|
|
974
1051
|
) => {
|
|
975
|
-
index = Math.max(0, Math.min(index, this.options.count - 1))
|
|
976
|
-
|
|
977
|
-
this.cancelScrollToIndex()
|
|
978
|
-
|
|
979
1052
|
if (behavior === 'smooth' && this.isDynamicMode()) {
|
|
980
1053
|
console.warn(
|
|
981
1054
|
'The `smooth` scroll behavior is not fully supported with dynamic size.',
|
|
982
1055
|
)
|
|
983
1056
|
}
|
|
984
1057
|
|
|
985
|
-
|
|
986
|
-
if (!offsetAndAlign) return
|
|
1058
|
+
index = Math.max(0, Math.min(index, this.options.count - 1))
|
|
987
1059
|
|
|
988
|
-
|
|
1060
|
+
let attempts = 0
|
|
1061
|
+
const maxAttempts = 10
|
|
989
1062
|
|
|
990
|
-
|
|
1063
|
+
const tryScroll = (currentAlign: ScrollAlignment) => {
|
|
1064
|
+
if (!this.targetWindow) return
|
|
991
1065
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1066
|
+
const offsetInfo = this.getOffsetForIndex(index, currentAlign)
|
|
1067
|
+
if (!offsetInfo) {
|
|
1068
|
+
console.warn('Failed to get offset for index:', index)
|
|
1069
|
+
return
|
|
1070
|
+
}
|
|
1071
|
+
const [offset, align] = offsetInfo
|
|
1072
|
+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
|
|
1073
|
+
|
|
1074
|
+
this.targetWindow.requestAnimationFrame(() => {
|
|
1075
|
+
const currentOffset = this.getScrollOffset()
|
|
1076
|
+
const afterInfo = this.getOffsetForIndex(index, align)
|
|
1077
|
+
if (!afterInfo) {
|
|
1078
|
+
console.warn('Failed to get offset for index:', index)
|
|
1079
|
+
return
|
|
1080
|
+
}
|
|
995
1081
|
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1082
|
+
if (!approxEqual(afterInfo[0], currentOffset)) {
|
|
1083
|
+
scheduleRetry(align)
|
|
1084
|
+
}
|
|
1085
|
+
})
|
|
1086
|
+
}
|
|
999
1087
|
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
if (!result) return
|
|
1003
|
-
const [latestOffset] = result
|
|
1088
|
+
const scheduleRetry = (align: ScrollAlignment) => {
|
|
1089
|
+
if (!this.targetWindow) return
|
|
1004
1090
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
} else {
|
|
1010
|
-
this.scrollToIndex(index, { align, behavior })
|
|
1091
|
+
attempts++
|
|
1092
|
+
if (attempts < maxAttempts) {
|
|
1093
|
+
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
|
|
1094
|
+
console.info('Schedule retry', attempts, maxAttempts)
|
|
1011
1095
|
}
|
|
1012
|
-
|
|
1096
|
+
this.targetWindow.requestAnimationFrame(() => tryScroll(align))
|
|
1097
|
+
} else {
|
|
1098
|
+
console.warn(
|
|
1099
|
+
`Failed to scroll to index ${index} after ${maxAttempts} attempts.`,
|
|
1100
|
+
)
|
|
1101
|
+
}
|
|
1013
1102
|
}
|
|
1103
|
+
|
|
1104
|
+
tryScroll(initialAlign)
|
|
1014
1105
|
}
|
|
1015
1106
|
|
|
1016
1107
|
scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => {
|
|
1017
|
-
this.cancelScrollToIndex()
|
|
1018
|
-
|
|
1019
1108
|
if (behavior === 'smooth' && this.isDynamicMode()) {
|
|
1020
1109
|
console.warn(
|
|
1021
1110
|
'The `smooth` scroll behavior is not fully supported with dynamic size.',
|
|
@@ -1075,6 +1164,7 @@ export class Virtualizer<
|
|
|
1075
1164
|
|
|
1076
1165
|
measure = () => {
|
|
1077
1166
|
this.itemSizeCache = new Map()
|
|
1167
|
+
this.laneAssignments = new Map() // Clear lane cache for full re-layout
|
|
1078
1168
|
this.notify(false)
|
|
1079
1169
|
}
|
|
1080
1170
|
}
|
package/src/utils.ts
CHANGED
|
@@ -10,10 +10,12 @@ export function memo<TDeps extends ReadonlyArray<any>, TResult>(
|
|
|
10
10
|
debug?: () => boolean
|
|
11
11
|
onChange?: (result: TResult) => void
|
|
12
12
|
initialDeps?: TDeps
|
|
13
|
+
skipInitialOnChange?: boolean
|
|
13
14
|
},
|
|
14
15
|
) {
|
|
15
16
|
let deps = opts.initialDeps ?? []
|
|
16
17
|
let result: TResult | undefined
|
|
18
|
+
let isInitial = true
|
|
17
19
|
|
|
18
20
|
function memoizedFunction(): TResult {
|
|
19
21
|
let depTime: number
|
|
@@ -62,7 +64,11 @@ export function memo<TDeps extends ReadonlyArray<any>, TResult>(
|
|
|
62
64
|
)
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
opts?.onChange
|
|
67
|
+
if (opts?.onChange && !(isInitial && opts.skipInitialOnChange)) {
|
|
68
|
+
opts.onChange(result)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
isInitial = false
|
|
66
72
|
|
|
67
73
|
return result
|
|
68
74
|
}
|
|
@@ -83,7 +89,7 @@ export function notUndefined<T>(value: T | undefined, msg?: string): T {
|
|
|
83
89
|
}
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
export const approxEqual = (a: number, b: number) => Math.abs(a - b)
|
|
92
|
+
export const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1.01
|
|
87
93
|
|
|
88
94
|
export const debounce = (
|
|
89
95
|
targetWindow: Window & typeof globalThis,
|