@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/dist/cjs/index.cjs +152 -106
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +14 -6
- package/dist/esm/index.d.ts +14 -6
- package/dist/esm/index.js +152 -106
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +218 -126
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
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1067
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
1228
|
+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
|
|
1229
|
+
|
|
1230
|
+
this.scheduleScrollReconcile()
|
|
1147
1231
|
}
|
|
1148
1232
|
|
|
1149
|
-
scrollBy = (
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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.
|
|
1157
|
-
|
|
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 = () => {
|