@tanstack/virtual-core 3.13.19 → 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,10 +952,47 @@ 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,
865
990
  ) => {
991
+ if (!node.isConnected) {
992
+ this.observer.unobserve(node)
993
+ return
994
+ }
995
+
866
996
  const index = this.indexFromElement(node)
867
997
  const item = this.measurementsCache[index]
868
998
  if (!item) {
@@ -879,7 +1009,7 @@ export class Virtualizer<
879
1009
  this.elementsCache.set(key, node)
880
1010
  }
881
1011
 
882
- if (node.isConnected) {
1012
+ if (this.shouldMeasureDuringScroll(index)) {
883
1013
  this.resizeItem(index, this.options.measureElement(node, entry, this))
884
1014
  }
885
1015
  }
@@ -894,14 +1024,14 @@ export class Virtualizer<
894
1024
 
895
1025
  if (delta !== 0) {
896
1026
  if (
897
- this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
1027
+ this.scrollState?.behavior !== 'smooth' &&
1028
+ (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
898
1029
  ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
899
- : item.start < this.getScrollOffset() + this.scrollAdjustments
1030
+ : item.start < this.getScrollOffset() + this.scrollAdjustments)
900
1031
  ) {
901
1032
  if (process.env.NODE_ENV !== 'production' && this.options.debug) {
902
1033
  console.info('correction', delta)
903
1034
  }
904
-
905
1035
  this._scrollToOffset(this.getScrollOffset(), {
906
1036
  adjustments: (this.scrollAdjustments += delta),
907
1037
  behavior: undefined,
@@ -1013,14 +1143,12 @@ export class Virtualizer<
1013
1143
  getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
1014
1144
  index = Math.max(0, Math.min(index, this.options.count - 1))
1015
1145
 
1016
- const item = this.measurementsCache[index]
1017
- if (!item) {
1018
- return undefined
1019
- }
1020
-
1021
1146
  const size = this.getSize()
1022
1147
  const scrollOffset = this.getScrollOffset()
1023
1148
 
1149
+ const item = this.measurementsCache[index]
1150
+ if (!item) return
1151
+
1024
1152
  if (align === 'auto') {
1025
1153
  if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
1026
1154
  align = 'end'
@@ -1048,112 +1176,76 @@ export class Virtualizer<
1048
1176
  ] as const
1049
1177
  }
1050
1178
 
1051
- private isDynamicMode = () => this.elementsCache.size > 0
1052
-
1053
1179
  scrollToOffset = (
1054
1180
  toOffset: number,
1055
- { align = 'start', behavior }: ScrollToOffsetOptions = {},
1181
+ { align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
1056
1182
  ) => {
1057
- if (behavior === 'smooth' && this.isDynamicMode()) {
1058
- console.warn(
1059
- 'The `smooth` scroll behavior is not fully supported with dynamic size.',
1060
- )
1061
- }
1183
+ const offset = this.getOffsetForAlignment(toOffset, align)
1062
1184
 
1063
- this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
1064
- adjustments: undefined,
1185
+ const now = this.now()
1186
+ this.scrollState = {
1187
+ index: null,
1188
+ align,
1065
1189
  behavior,
1066
- })
1190
+ startedAt: now,
1191
+ lastTargetOffset: offset,
1192
+ stableFrames: 0,
1193
+ }
1194
+
1195
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
1196
+
1197
+ this.scheduleScrollReconcile()
1067
1198
  }
1068
1199
 
1069
1200
  scrollToIndex = (
1070
1201
  index: number,
1071
- { align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
1202
+ {
1203
+ align: initialAlign = 'auto',
1204
+ behavior = 'auto',
1205
+ }: ScrollToIndexOptions = {},
1072
1206
  ) => {
1073
- if (behavior === 'smooth' && this.isDynamicMode()) {
1074
- console.warn(
1075
- 'The `smooth` scroll behavior is not fully supported with dynamic size.',
1076
- )
1077
- }
1078
-
1079
1207
  index = Math.max(0, Math.min(index, this.options.count - 1))
1080
- this.currentScrollToIndex = index
1081
-
1082
- let attempts = 0
1083
- const maxAttempts = 10
1084
-
1085
- const tryScroll = (currentAlign: ScrollAlignment) => {
1086
- if (!this.targetWindow) return
1087
-
1088
- const offsetInfo = this.getOffsetForIndex(index, currentAlign)
1089
- if (!offsetInfo) {
1090
- console.warn('Failed to get offset for index:', index)
1091
- return
1092
- }
1093
- const [offset, align] = offsetInfo
1094
- this._scrollToOffset(offset, { adjustments: undefined, behavior })
1095
-
1096
- this.targetWindow.requestAnimationFrame(() => {
1097
- if (!this.targetWindow) return
1098
-
1099
- const verify = () => {
1100
- // Abort if a new scrollToIndex was called with a different index
1101
- if (this.currentScrollToIndex !== index) return
1102
-
1103
- const currentOffset = this.getScrollOffset()
1104
- const afterInfo = this.getOffsetForIndex(index, align)
1105
- if (!afterInfo) {
1106
- console.warn('Failed to get offset for index:', index)
1107
- return
1108
- }
1109
-
1110
- if (!approxEqual(afterInfo[0], currentOffset)) {
1111
- scheduleRetry(align)
1112
- }
1113
- }
1114
1208
 
1115
- // In dynamic mode, wait an extra frame for ResizeObserver to measure newly visible elements
1116
- if (this.isDynamicMode()) {
1117
- this.targetWindow.requestAnimationFrame(verify)
1118
- } else {
1119
- verify()
1120
- }
1121
- })
1209
+ const offsetInfo = this.getOffsetForIndex(index, initialAlign)
1210
+ if (!offsetInfo) {
1211
+ return
1122
1212
  }
1213
+ const [offset, align] = offsetInfo
1123
1214
 
1124
- const scheduleRetry = (align: ScrollAlignment) => {
1125
- if (!this.targetWindow) return
1126
-
1127
- // Abort if a new scrollToIndex was called with a different index
1128
- if (this.currentScrollToIndex !== index) return
1129
-
1130
- attempts++
1131
- if (attempts < maxAttempts) {
1132
- if (process.env.NODE_ENV !== 'production' && this.options.debug) {
1133
- console.info('Schedule retry', attempts, maxAttempts)
1134
- }
1135
- this.targetWindow.requestAnimationFrame(() => tryScroll(align))
1136
- } else {
1137
- console.warn(
1138
- `Failed to scroll to index ${index} after ${maxAttempts} attempts.`,
1139
- )
1140
- }
1215
+ const now = this.now()
1216
+ this.scrollState = {
1217
+ index,
1218
+ align,
1219
+ behavior,
1220
+ startedAt: now,
1221
+ lastTargetOffset: offset,
1222
+ stableFrames: 0,
1141
1223
  }
1142
1224
 
1143
- tryScroll(initialAlign)
1225
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
1226
+
1227
+ this.scheduleScrollReconcile()
1144
1228
  }
1145
1229
 
1146
- scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => {
1147
- if (behavior === 'smooth' && this.isDynamicMode()) {
1148
- console.warn(
1149
- 'The `smooth` scroll behavior is not fully supported with dynamic size.',
1150
- )
1151
- }
1230
+ scrollBy = (
1231
+ delta: number,
1232
+ { behavior = 'auto' }: ScrollToOffsetOptions = {},
1233
+ ) => {
1234
+ const offset = this.getScrollOffset() + delta
1235
+ const now = this.now()
1152
1236
 
1153
- this._scrollToOffset(this.getScrollOffset() + delta, {
1154
- adjustments: undefined,
1237
+ this.scrollState = {
1238
+ index: null,
1239
+ align: 'start',
1155
1240
  behavior,
1156
- })
1241
+ startedAt: now,
1242
+ lastTargetOffset: offset,
1243
+ stableFrames: 0,
1244
+ }
1245
+
1246
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
1247
+
1248
+ this.scheduleScrollReconcile()
1157
1249
  }
1158
1250
 
1159
1251
  getTotalSize = () => {