@tanstack/virtual-core 3.13.20 → 3.13.21

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
@@ -348,6 +348,22 @@ export interface VirtualizerOptions<
348
348
  useAnimationFrameWithResizeObserver?: boolean
349
349
  }
350
350
 
351
+ type ScrollState = {
352
+ // what we want
353
+ index: number | null
354
+ align: ScrollAlignment
355
+ behavior: ScrollBehavior
356
+
357
+ // lifecycle
358
+ startedAt: number
359
+
360
+ // target tracking
361
+ lastTargetOffset: number
362
+
363
+ // settling
364
+ stableFrames: number
365
+ }
366
+
351
367
  export class Virtualizer<
352
368
  TScrollElement extends Element | Window,
353
369
  TItemElement extends Element,
@@ -357,7 +373,7 @@ export class Virtualizer<
357
373
  scrollElement: TScrollElement | null = null
358
374
  targetWindow: (Window & typeof globalThis) | null = null
359
375
  isScrolling = false
360
- private currentScrollToIndex: number | null = null
376
+ private scrollState: ScrollState | null = null
361
377
  measurementsCache: Array<VirtualItem> = []
362
378
  private itemSizeCache = new Map<Key, number>()
363
379
  private laneAssignments = new Map<number, number>() // index → lane cache
@@ -377,6 +393,7 @@ export class Virtualizer<
377
393
  instance: Virtualizer<TScrollElement, TItemElement>,
378
394
  ) => boolean)
379
395
  elementsCache = new Map<Key, TItemElement>()
396
+ private now = () => this.targetWindow?.performance?.now?.() ?? Date.now()
380
397
  private observer = (() => {
381
398
  let _ro: ResizeObserver | null = null
382
399
 
@@ -482,6 +499,11 @@ export class Virtualizer<
482
499
  this.unsubs.filter(Boolean).forEach((d) => d!())
483
500
  this.unsubs = []
484
501
  this.observer.disconnect()
502
+ if (this.rafId != null && this.targetWindow) {
503
+ this.targetWindow.cancelAnimationFrame(this.rafId)
504
+ this.rafId = null
505
+ }
506
+ this.scrollState = null
485
507
  this.scrollElement = null
486
508
  this.targetWindow = null
487
509
  }
@@ -535,6 +557,9 @@ export class Virtualizer<
535
557
  this.scrollOffset = offset
536
558
  this.isScrolling = isScrolling
537
559
 
560
+ if (this.scrollState) {
561
+ this.scheduleScrollReconcile()
562
+ }
538
563
  this.maybeNotify()
539
564
  }),
540
565
  )
@@ -546,6 +571,74 @@ export class Virtualizer<
546
571
  }
547
572
  }
548
573
 
574
+ private rafId: number | null = null
575
+ private scheduleScrollReconcile() {
576
+ if (!this.targetWindow) {
577
+ this.scrollState = null
578
+ return
579
+ }
580
+ if (this.rafId != null) return
581
+ this.rafId = this.targetWindow.requestAnimationFrame(() => {
582
+ this.rafId = null
583
+ this.reconcileScroll()
584
+ })
585
+ }
586
+ private reconcileScroll() {
587
+ if (!this.scrollState) return
588
+
589
+ const el = this.scrollElement
590
+ if (!el) return
591
+
592
+ // Safety valve: bail out if reconciliation has been running too long
593
+ const MAX_RECONCILE_MS = 5000
594
+ if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) {
595
+ this.scrollState = null
596
+ return
597
+ }
598
+
599
+ const offsetInfo =
600
+ this.scrollState.index != null
601
+ ? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align)
602
+ : undefined
603
+ const targetOffset = offsetInfo
604
+ ? offsetInfo[0]
605
+ : this.scrollState.lastTargetOffset
606
+
607
+ // Require one stable frame where target matches scroll offset.
608
+ // approxEqual() already tolerates minor fluctuations, so one frame is sufficient
609
+ // to confirm scroll has reached its target without premature cleanup.
610
+ const STABLE_FRAMES = 1
611
+
612
+ const targetChanged = targetOffset !== this.scrollState.lastTargetOffset
613
+
614
+ if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
615
+ this.scrollState.stableFrames++
616
+ if (this.scrollState.stableFrames >= STABLE_FRAMES) {
617
+ this.scrollState = null
618
+ return
619
+ }
620
+ } else {
621
+ this.scrollState.stableFrames = 0
622
+
623
+ if (targetChanged) {
624
+ this.scrollState.lastTargetOffset = targetOffset
625
+ // Switch to 'auto' behavior once measurements cause target to change
626
+ // We want to jump directly to the correct position, not smoothly animate to it
627
+ this.scrollState.behavior = 'auto'
628
+
629
+ this._scrollToOffset(targetOffset, {
630
+ adjustments: undefined,
631
+ behavior: 'auto',
632
+ })
633
+ }
634
+ }
635
+
636
+ // Always reschedule while scrollState is active to guarantee
637
+ // the safety valve timeout runs even if no scroll events fire
638
+ // (e.g. no-op scrollToFn, detached element)
639
+ this.scheduleScrollReconcile()
640
+ }
641
+
549
642
  private getSize = () => {
550
643
  if (!this.options.enabled) {
551
644
  this.scrollRect = null
@@ -859,6 +952,38 @@ export class Virtualizer<
859
952
  return parseInt(indexStr, 10)
860
953
  }
861
954
 
955
+ /**
956
+ * Determines if an item at the given index should be measured during smooth scroll.
957
+ * During smooth scroll, only items within a buffer range around the target are measured
958
+ * to prevent items far from the target from pushing it away.
959
+ */
960
+ private shouldMeasureDuringScroll = (index: number): boolean => {
961
+ // No scroll state or not smooth scroll - always allow measurements
962
+ if (!this.scrollState || this.scrollState.behavior !== 'smooth') {
963
+ return true
964
+ }
965
+
966
+ const scrollIndex =
967
+ this.scrollState.index ??
968
+ this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)?.index
969
+
970
+ if (scrollIndex !== undefined && this.range) {
971
+ // Allow measurements within a buffer range around the scroll target
972
+ const bufferSize = Math.max(
973
+ this.options.overscan,
974
+ Math.ceil((this.range.endIndex - this.range.startIndex) / 2),
975
+ )
976
+ const minIndex = Math.max(0, scrollIndex - bufferSize)
977
+ const maxIndex = Math.min(
978
+ this.options.count - 1,
979
+ scrollIndex + bufferSize,
980
+ )
981
+ return index >= minIndex && index <= maxIndex
982
+ }
983
+
984
+ return true
985
+ }
986
+
862
987
  private _measureElement = (
863
988
  node: TItemElement,
864
989
  entry: ResizeObserverEntry | undefined,
@@ -884,7 +1009,9 @@ export class Virtualizer<
884
1009
  this.elementsCache.set(key, node)
885
1010
  }
886
1011
 
887
- this.resizeItem(index, this.options.measureElement(node, entry, this))
1012
+ if (this.shouldMeasureDuringScroll(index)) {
1013
+ this.resizeItem(index, this.options.measureElement(node, entry, this))
1014
+ }
888
1015
  }
889
1016
 
890
1017
  resizeItem = (index: number, size: number) => {
@@ -897,14 +1024,14 @@ export class Virtualizer<
897
1024
 
898
1025
  if (delta !== 0) {
899
1026
  if (
900
- this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
1027
+ this.scrollState?.behavior !== 'smooth' &&
1028
+ (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
901
1029
  ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
902
- : item.start < this.getScrollOffset() + this.scrollAdjustments
1030
+ : item.start < this.getScrollOffset() + this.scrollAdjustments)
903
1031
  ) {
904
1032
  if (process.env.NODE_ENV !== 'production' && this.options.debug) {
905
1033
  console.info('correction', delta)
906
1034
  }
907
-
908
1035
  this._scrollToOffset(this.getScrollOffset(), {
909
1036
  adjustments: (this.scrollAdjustments += delta),
910
1037
  behavior: undefined,
@@ -1016,14 +1143,12 @@ export class Virtualizer<
1016
1143
  getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
1017
1144
  index = Math.max(0, Math.min(index, this.options.count - 1))
1018
1145
 
1019
- const item = this.measurementsCache[index]
1020
- if (!item) {
1021
- return undefined
1022
- }
1023
-
1024
1146
  const size = this.getSize()
1025
1147
  const scrollOffset = this.getScrollOffset()
1026
1148
 
1149
+ const item = this.measurementsCache[index]
1150
+ if (!item) return
1151
+
1027
1152
  if (align === 'auto') {
1028
1153
  if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
1029
1154
  align = 'end'
@@ -1051,112 +1176,76 @@ export class Virtualizer<
1051
1176
  ] as const
1052
1177
  }
1053
1178
 
1054
- private isDynamicMode = () => this.elementsCache.size > 0
1055
-
1056
1179
  scrollToOffset = (
1057
1180
  toOffset: number,
1058
- { align = 'start', behavior }: ScrollToOffsetOptions = {},
1181
+ { align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
1059
1182
  ) => {
1060
- if (behavior === 'smooth' && this.isDynamicMode()) {
1061
- console.warn(
1062
- 'The `smooth` scroll behavior is not fully supported with dynamic size.',
1063
- )
1064
- }
1183
+ const offset = this.getOffsetForAlignment(toOffset, align)
1065
1184
 
1066
- this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
1067
- adjustments: undefined,
1185
+ const now = this.now()
1186
+ this.scrollState = {
1187
+ index: null,
1188
+ align,
1068
1189
  behavior,
1069
- })
1190
+ startedAt: now,
1191
+ lastTargetOffset: offset,
1192
+ stableFrames: 0,
1193
+ }
1194
+
1195
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
1196
+
1197
+ this.scheduleScrollReconcile()
1070
1198
  }
1071
1199
 
1072
1200
  scrollToIndex = (
1073
1201
  index: number,
1074
- { align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
1202
+ {
1203
+ align: initialAlign = 'auto',
1204
+ behavior = 'auto',
1205
+ }: ScrollToIndexOptions = {},
1075
1206
  ) => {
1076
- if (behavior === 'smooth' && this.isDynamicMode()) {
1077
- console.warn(
1078
- 'The `smooth` scroll behavior is not fully supported with dynamic size.',
1079
- )
1080
- }
1081
-
1082
1207
  index = Math.max(0, Math.min(index, this.options.count - 1))
1083
- this.currentScrollToIndex = index
1084
-
1085
- let attempts = 0
1086
- const maxAttempts = 10
1087
-
1088
- const tryScroll = (currentAlign: ScrollAlignment) => {
1089
- if (!this.targetWindow) return
1090
-
1091
- const offsetInfo = this.getOffsetForIndex(index, currentAlign)
1092
- if (!offsetInfo) {
1093
- console.warn('Failed to get offset for index:', index)
1094
- return
1095
- }
1096
- const [offset, align] = offsetInfo
1097
- this._scrollToOffset(offset, { adjustments: undefined, behavior })
1098
1208
 
1099
- this.targetWindow.requestAnimationFrame(() => {
1100
- if (!this.targetWindow) return
1101
-
1102
- const verify = () => {
1103
- // Abort if a new scrollToIndex was called with a different index
1104
- if (this.currentScrollToIndex !== index) return
1105
-
1106
- const currentOffset = this.getScrollOffset()
1107
- const afterInfo = this.getOffsetForIndex(index, align)
1108
- if (!afterInfo) {
1109
- console.warn('Failed to get offset for index:', index)
1110
- return
1111
- }
1112
-
1113
- if (!approxEqual(afterInfo[0], currentOffset)) {
1114
- scheduleRetry(align)
1115
- }
1116
- }
1117
-
1118
- // In dynamic mode, wait an extra frame for ResizeObserver to measure newly visible elements
1119
- if (this.isDynamicMode()) {
1120
- this.targetWindow.requestAnimationFrame(verify)
1121
- } else {
1122
- verify()
1123
- }
1124
- })
1209
+ const offsetInfo = this.getOffsetForIndex(index, initialAlign)
1210
+ if (!offsetInfo) {
1211
+ return
1125
1212
  }
1213
+ const [offset, align] = offsetInfo
1126
1214
 
1127
- const scheduleRetry = (align: ScrollAlignment) => {
1128
- if (!this.targetWindow) return
1129
-
1130
- // Abort if a new scrollToIndex was called with a different index
1131
- if (this.currentScrollToIndex !== index) return
1132
-
1133
- attempts++
1134
- if (attempts < maxAttempts) {
1135
- if (process.env.NODE_ENV !== 'production' && this.options.debug) {
1136
- console.info('Schedule retry', attempts, maxAttempts)
1137
- }
1138
- this.targetWindow.requestAnimationFrame(() => tryScroll(align))
1139
- } else {
1140
- console.warn(
1141
- `Failed to scroll to index ${index} after ${maxAttempts} attempts.`,
1142
- )
1143
- }
1215
+ const now = this.now()
1216
+ this.scrollState = {
1217
+ index,
1218
+ align,
1219
+ behavior,
1220
+ startedAt: now,
1221
+ lastTargetOffset: offset,
1222
+ stableFrames: 0,
1144
1223
  }
1145
1224
 
1146
- tryScroll(initialAlign)
1225
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
1226
+
1227
+ this.scheduleScrollReconcile()
1147
1228
  }
1148
1229
 
1149
- scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => {
1150
- if (behavior === 'smooth' && this.isDynamicMode()) {
1151
- console.warn(
1152
- 'The `smooth` scroll behavior is not fully supported with dynamic size.',
1153
- )
1154
- }
1230
+ scrollBy = (
1231
+ delta: number,
1232
+ { behavior = 'auto' }: ScrollToOffsetOptions = {},
1233
+ ) => {
1234
+ const offset = this.getScrollOffset() + delta
1235
+ const now = this.now()
1155
1236
 
1156
- this._scrollToOffset(this.getScrollOffset() + delta, {
1157
- adjustments: undefined,
1237
+ this.scrollState = {
1238
+ index: null,
1239
+ align: 'start',
1158
1240
  behavior,
1159
- })
1241
+ startedAt: now,
1242
+ lastTargetOffset: offset,
1243
+ stableFrames: 0,
1244
+ }
1245
+
1246
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
1247
+
1248
+ this.scheduleScrollReconcile()
1160
1249
  }
1161
1250
 
1162
1251
  getTotalSize = () => {