@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/dist/cjs/index.cjs +129 -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 +129 -82
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +185 -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,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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1067
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1100
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
1225
|
+
this._scrollToOffset(offset, { adjustments: undefined, behavior })
|
|
1226
|
+
|
|
1227
|
+
this.scheduleScrollReconcile()
|
|
1147
1228
|
}
|
|
1148
1229
|
|
|
1149
|
-
scrollBy = (
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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.
|
|
1157
|
-
|
|
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 = () => {
|