@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/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
- const min =
657
- this.pendingMeasuredCacheIndexes.length > 0
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
- const furthestMeasurement =
668
- this.options.lanes === 1
669
- ? measurements[i - 1]
670
- : this.getFurthestMeasurement(measurements, i)
671
-
672
- const start = furthestMeasurement
673
- ? furthestMeasurement.end + this.options.gap
674
- : paddingStart + scrollMargin
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
- const offsetAndAlign = this.getOffsetForIndex(index, initialAlign)
986
- if (!offsetAndAlign) return
1058
+ index = Math.max(0, Math.min(index, this.options.count - 1))
987
1059
 
988
- const [offset, align] = offsetAndAlign
1060
+ let attempts = 0
1061
+ const maxAttempts = 10
989
1062
 
990
- this._scrollToOffset(offset, { adjustments: undefined, behavior })
1063
+ const tryScroll = (currentAlign: ScrollAlignment) => {
1064
+ if (!this.targetWindow) return
991
1065
 
992
- if (behavior !== 'smooth' && this.isDynamicMode() && this.targetWindow) {
993
- this.scrollToIndexTimeoutId = this.targetWindow.setTimeout(() => {
994
- this.scrollToIndexTimeoutId = null
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
- const elementInDOM = this.elementsCache.has(
997
- this.options.getItemKey(index),
998
- )
1082
+ if (!approxEqual(afterInfo[0], currentOffset)) {
1083
+ scheduleRetry(align)
1084
+ }
1085
+ })
1086
+ }
999
1087
 
1000
- if (elementInDOM) {
1001
- const result = this.getOffsetForIndex(index, align)
1002
- if (!result) return
1003
- const [latestOffset] = result
1088
+ const scheduleRetry = (align: ScrollAlignment) => {
1089
+ if (!this.targetWindow) return
1004
1090
 
1005
- const currentScrollOffset = this.getScrollOffset()
1006
- if (!approxEqual(latestOffset, currentScrollOffset)) {
1007
- this.scrollToIndex(index, { align, behavior })
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?.(result)
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) <= 1
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,