@tanstack/virtual-core 3.13.20 → 3.13.22

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
@@ -8,7 +8,7 @@ type ScrollDirection = 'forward' | 'backward'
8
8
 
9
9
  type ScrollAlignment = 'start' | 'center' | 'end' | 'auto'
10
10
 
11
- type ScrollBehavior = 'auto' | 'smooth'
11
+ type ScrollBehavior = 'auto' | 'smooth' | 'instant'
12
12
 
13
13
  export interface ScrollToOptions {
14
14
  align?: ScrollAlignment
@@ -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
 
@@ -392,7 +409,21 @@ export class Virtualizer<
392
409
  return (_ro = new this.targetWindow.ResizeObserver((entries) => {
393
410
  entries.forEach((entry) => {
394
411
  const run = () => {
395
- this._measureElement(entry.target as TItemElement, entry)
412
+ const node = entry.target as TItemElement
413
+ const index = this.indexFromElement(node)
414
+
415
+ if (!node.isConnected) {
416
+ this.observer.unobserve(node)
417
+ this.elementsCache.delete(this.options.getItemKey(index))
418
+ return
419
+ }
420
+
421
+ if (this.shouldMeasureDuringScroll(index)) {
422
+ this.resizeItem(
423
+ index,
424
+ this.options.measureElement(node, entry, this),
425
+ )
426
+ }
396
427
  }
397
428
  this.options.useAnimationFrameWithResizeObserver
398
429
  ? requestAnimationFrame(run)
@@ -482,6 +513,11 @@ export class Virtualizer<
482
513
  this.unsubs.filter(Boolean).forEach((d) => d!())
483
514
  this.unsubs = []
484
515
  this.observer.disconnect()
516
+ if (this.rafId != null && this.targetWindow) {
517
+ this.targetWindow.cancelAnimationFrame(this.rafId)
518
+ this.rafId = null
519
+ }
520
+ this.scrollState = null
485
521
  this.scrollElement = null
486
522
  this.targetWindow = null
487
523
  }
@@ -535,6 +571,9 @@ export class Virtualizer<
535
571
  this.scrollOffset = offset
536
572
  this.isScrolling = isScrolling
537
573
 
574
+ if (this.scrollState) {
575
+ this.scheduleScrollReconcile()
576
+ }
538
577
  this.maybeNotify()
539
578
  }),
540
579
  )
@@ -546,6 +585,74 @@ export class Virtualizer<
546
585
  }
547
586
  }
548
587
 
588
+ private rafId: number | null = null
589
+ private scheduleScrollReconcile() {
590
+ if (!this.targetWindow) {
591
+ this.scrollState = null
592
+ return
593
+ }
594
+ if (this.rafId != null) return
595
+ this.rafId = this.targetWindow.requestAnimationFrame(() => {
596
+ this.rafId = null
597
+ this.reconcileScroll()
598
+ })
599
+ }
600
+ private reconcileScroll() {
601
+ if (!this.scrollState) return
602
+
603
+ const el = this.scrollElement
604
+ if (!el) return
605
+
606
+ // Safety valve: bail out if reconciliation has been running too long
607
+ const MAX_RECONCILE_MS = 5000
608
+ if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) {
609
+ this.scrollState = null
610
+ return
611
+ }
612
+
613
+ const offsetInfo =
614
+ this.scrollState.index != null
615
+ ? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align)
616
+ : undefined
617
+ const targetOffset = offsetInfo
618
+ ? offsetInfo[0]
619
+ : this.scrollState.lastTargetOffset
620
+
621
+ // Require one stable frame where target matches scroll offset.
622
+ // approxEqual() already tolerates minor fluctuations, so one frame is sufficient
623
+ // to confirm scroll has reached its target without premature cleanup.
624
+ const STABLE_FRAMES = 1
625
+
626
+ const targetChanged = targetOffset !== this.scrollState.lastTargetOffset
627
+
628
+ if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
629
+ this.scrollState.stableFrames++
630
+ if (this.scrollState.stableFrames >= STABLE_FRAMES) {
631
+ this.scrollState = null
632
+ return
633
+ }
634
+ } else {
635
+ this.scrollState.stableFrames = 0
636
+
637
+ if (targetChanged) {
638
+ this.scrollState.lastTargetOffset = targetOffset
639
+ // Switch to 'auto' behavior once measurements cause target to change
640
+ // We want to jump directly to the correct position, not smoothly animate to it
641
+ this.scrollState.behavior = 'auto'
642
+
643
+ this._scrollToOffset(targetOffset, {
644
+ adjustments: undefined,
645
+ behavior: 'auto',
646
+ })
647
+ }
648
+ }
649
+
650
+ // Always reschedule while scrollState is active to guarantee
651
+ // the safety valve timeout runs even if no scroll events fire
652
+ // (e.g. no-op scrollToFn, detached element)
653
+ this.scheduleScrollReconcile()
654
+ }
655
+
549
656
  private getSize = () => {
550
657
  if (!this.options.enabled) {
551
658
  this.scrollRect = null
@@ -859,21 +966,51 @@ export class Virtualizer<
859
966
  return parseInt(indexStr, 10)
860
967
  }
861
968
 
862
- private _measureElement = (
863
- node: TItemElement,
864
- entry: ResizeObserverEntry | undefined,
865
- ) => {
866
- if (!node.isConnected) {
867
- this.observer.unobserve(node)
868
- return
969
+ /**
970
+ * Determines if an item at the given index should be measured during smooth scroll.
971
+ * During smooth scroll, only items within a buffer range around the target are measured
972
+ * to prevent items far from the target from pushing it away.
973
+ */
974
+ private shouldMeasureDuringScroll = (index: number): boolean => {
975
+ // No scroll state or not smooth scroll - always allow measurements
976
+ if (!this.scrollState || this.scrollState.behavior !== 'smooth') {
977
+ return true
869
978
  }
870
979
 
871
- const index = this.indexFromElement(node)
872
- const item = this.measurementsCache[index]
873
- if (!item) {
980
+ const scrollIndex =
981
+ this.scrollState.index ??
982
+ this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)?.index
983
+
984
+ if (scrollIndex !== undefined && this.range) {
985
+ // Allow measurements within a buffer range around the scroll target
986
+ const bufferSize = Math.max(
987
+ this.options.overscan,
988
+ Math.ceil((this.range.endIndex - this.range.startIndex) / 2),
989
+ )
990
+ const minIndex = Math.max(0, scrollIndex - bufferSize)
991
+ const maxIndex = Math.min(
992
+ this.options.count - 1,
993
+ scrollIndex + bufferSize,
994
+ )
995
+ return index >= minIndex && index <= maxIndex
996
+ }
997
+
998
+ return true
999
+ }
1000
+
1001
+ measureElement = (node: TItemElement | null) => {
1002
+ if (!node) {
1003
+ this.elementsCache.forEach((cached, key) => {
1004
+ if (!cached.isConnected) {
1005
+ this.observer.unobserve(cached)
1006
+ this.elementsCache.delete(key)
1007
+ }
1008
+ })
874
1009
  return
875
1010
  }
876
- const key = item.key
1011
+
1012
+ const index = this.indexFromElement(node)
1013
+ const key = this.options.getItemKey(index)
877
1014
  const prevNode = this.elementsCache.get(key)
878
1015
 
879
1016
  if (prevNode !== node) {
@@ -884,27 +1021,34 @@ export class Virtualizer<
884
1021
  this.elementsCache.set(key, node)
885
1022
  }
886
1023
 
887
- this.resizeItem(index, this.options.measureElement(node, entry, this))
1024
+ // Sync-measure when idle (initial render) or during programmatic scrolling
1025
+ // (scrollToIndex/scrollToOffset) where reconcileScroll needs sizes in the same frame.
1026
+ // During normal user scrolling, skip sync measurement — the RO callback handles it async.
1027
+ if (
1028
+ (!this.isScrolling || this.scrollState) &&
1029
+ this.shouldMeasureDuringScroll(index)
1030
+ ) {
1031
+ this.resizeItem(index, this.options.measureElement(node, undefined, this))
1032
+ }
888
1033
  }
889
1034
 
890
1035
  resizeItem = (index: number, size: number) => {
891
1036
  const item = this.measurementsCache[index]
892
- if (!item) {
893
- return
894
- }
1037
+ if (!item) return
1038
+
895
1039
  const itemSize = this.itemSizeCache.get(item.key) ?? item.size
896
1040
  const delta = size - itemSize
897
1041
 
898
1042
  if (delta !== 0) {
899
1043
  if (
900
- this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
1044
+ this.scrollState?.behavior !== 'smooth' &&
1045
+ (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
901
1046
  ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
902
- : item.start < this.getScrollOffset() + this.scrollAdjustments
1047
+ : item.start < this.getScrollOffset() + this.scrollAdjustments)
903
1048
  ) {
904
1049
  if (process.env.NODE_ENV !== 'production' && this.options.debug) {
905
1050
  console.info('correction', delta)
906
1051
  }
907
-
908
1052
  this._scrollToOffset(this.getScrollOffset(), {
909
1053
  adjustments: (this.scrollAdjustments += delta),
910
1054
  behavior: undefined,
@@ -918,20 +1062,6 @@ export class Virtualizer<
918
1062
  }
919
1063
  }
920
1064
 
921
- measureElement = (node: TItemElement | null | undefined) => {
922
- if (!node) {
923
- this.elementsCache.forEach((cached, key) => {
924
- if (!cached.isConnected) {
925
- this.observer.unobserve(cached)
926
- this.elementsCache.delete(key)
927
- }
928
- })
929
- return
930
- }
931
-
932
- this._measureElement(node, undefined)
933
- }
934
-
935
1065
  getVirtualItems = memo(
936
1066
  () => [this.getVirtualIndexes(), this.getMeasurements()],
937
1067
  (indexes, measurements) => {
@@ -1016,14 +1146,12 @@ export class Virtualizer<
1016
1146
  getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
1017
1147
  index = Math.max(0, Math.min(index, this.options.count - 1))
1018
1148
 
1019
- const item = this.measurementsCache[index]
1020
- if (!item) {
1021
- return undefined
1022
- }
1023
-
1024
1149
  const size = this.getSize()
1025
1150
  const scrollOffset = this.getScrollOffset()
1026
1151
 
1152
+ const item = this.measurementsCache[index]
1153
+ if (!item) return
1154
+
1027
1155
  if (align === 'auto') {
1028
1156
  if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
1029
1157
  align = 'end'
@@ -1051,112 +1179,76 @@ export class Virtualizer<
1051
1179
  ] as const
1052
1180
  }
1053
1181
 
1054
- private isDynamicMode = () => this.elementsCache.size > 0
1055
-
1056
1182
  scrollToOffset = (
1057
1183
  toOffset: number,
1058
- { align = 'start', behavior }: ScrollToOffsetOptions = {},
1184
+ { align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
1059
1185
  ) => {
1060
- if (behavior === 'smooth' && this.isDynamicMode()) {
1061
- console.warn(
1062
- 'The `smooth` scroll behavior is not fully supported with dynamic size.',
1063
- )
1064
- }
1186
+ const offset = this.getOffsetForAlignment(toOffset, align)
1065
1187
 
1066
- this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
1067
- adjustments: undefined,
1188
+ const now = this.now()
1189
+ this.scrollState = {
1190
+ index: null,
1191
+ align,
1068
1192
  behavior,
1069
- })
1193
+ startedAt: now,
1194
+ lastTargetOffset: offset,
1195
+ stableFrames: 0,
1196
+ }
1197
+
1198
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
1199
+
1200
+ this.scheduleScrollReconcile()
1070
1201
  }
1071
1202
 
1072
1203
  scrollToIndex = (
1073
1204
  index: number,
1074
- { align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
1205
+ {
1206
+ align: initialAlign = 'auto',
1207
+ behavior = 'auto',
1208
+ }: ScrollToIndexOptions = {},
1075
1209
  ) => {
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
1210
  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
-
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
1211
 
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
- })
1212
+ const offsetInfo = this.getOffsetForIndex(index, initialAlign)
1213
+ if (!offsetInfo) {
1214
+ return
1125
1215
  }
1216
+ const [offset, align] = offsetInfo
1126
1217
 
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
- }
1218
+ const now = this.now()
1219
+ this.scrollState = {
1220
+ index,
1221
+ align,
1222
+ behavior,
1223
+ startedAt: now,
1224
+ lastTargetOffset: offset,
1225
+ stableFrames: 0,
1144
1226
  }
1145
1227
 
1146
- tryScroll(initialAlign)
1228
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
1229
+
1230
+ this.scheduleScrollReconcile()
1147
1231
  }
1148
1232
 
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
- }
1233
+ scrollBy = (
1234
+ delta: number,
1235
+ { behavior = 'auto' }: ScrollToOffsetOptions = {},
1236
+ ) => {
1237
+ const offset = this.getScrollOffset() + delta
1238
+ const now = this.now()
1155
1239
 
1156
- this._scrollToOffset(this.getScrollOffset() + delta, {
1157
- adjustments: undefined,
1240
+ this.scrollState = {
1241
+ index: null,
1242
+ align: 'start',
1158
1243
  behavior,
1159
- })
1244
+ startedAt: now,
1245
+ lastTargetOffset: offset,
1246
+ stableFrames: 0,
1247
+ }
1248
+
1249
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
1250
+
1251
+ this.scheduleScrollReconcile()
1160
1252
  }
1161
1253
 
1162
1254
  getTotalSize = () => {