@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/dist/cjs/index.cjs +131 -82
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +12 -3
- package/dist/esm/index.d.ts +12 -3
- package/dist/esm/index.js +131 -82
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +188 -96
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
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1064
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
1225
|
+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
|
|
1226
|
+
|
|
1227
|
+
this.scheduleScrollReconcile()
|
|
1144
1228
|
}
|
|
1145
1229
|
|
|
1146
|
-
scrollBy = (
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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.
|
|
1154
|
-
|
|
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 = () => {
|