@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.
@@ -1,7 +1,7 @@
1
1
  export * from './utils.cjs';
2
2
  type ScrollDirection = 'forward' | 'backward';
3
3
  type ScrollAlignment = 'start' | 'center' | 'end' | 'auto';
4
- type ScrollBehavior = 'auto' | 'smooth';
4
+ type ScrollBehavior = 'auto' | 'smooth' | 'instant';
5
5
  export interface ScrollToOptions {
6
6
  align?: ScrollAlignment;
7
7
  behavior?: ScrollBehavior;
@@ -83,7 +83,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
83
83
  scrollElement: TScrollElement | null;
84
84
  targetWindow: (Window & typeof globalThis) | null;
85
85
  isScrolling: boolean;
86
- private currentScrollToIndex;
86
+ private scrollState;
87
87
  measurementsCache: Array<VirtualItem>;
88
88
  private itemSizeCache;
89
89
  private laneAssignments;
@@ -97,6 +97,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
97
97
  private scrollAdjustments;
98
98
  shouldAdjustScrollPositionOnItemSizeChange: undefined | ((item: VirtualItem, delta: number, instance: Virtualizer<TScrollElement, TItemElement>) => boolean);
99
99
  elementsCache: Map<Key, TItemElement>;
100
+ private now;
100
101
  private observer;
101
102
  range: {
102
103
  startIndex: number;
@@ -109,6 +110,9 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
109
110
  private cleanup;
110
111
  _didMount: () => () => void;
111
112
  _willUpdate: () => void;
113
+ private rafId;
114
+ private scheduleScrollReconcile;
115
+ private reconcileScroll;
112
116
  private getSize;
113
117
  private getScrollOffset;
114
118
  private getFurthestMeasurement;
@@ -126,9 +130,14 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
126
130
  updateDeps(newDeps: [(range: Range) => number[], number, number, number | null, number | null]): void;
127
131
  };
128
132
  indexFromElement: (node: TItemElement) => number;
129
- private _measureElement;
133
+ /**
134
+ * Determines if an item at the given index should be measured during smooth scroll.
135
+ * During smooth scroll, only items within a buffer range around the target are measured
136
+ * to prevent items far from the target from pushing it away.
137
+ */
138
+ private shouldMeasureDuringScroll;
139
+ measureElement: (node: TItemElement | null) => void;
130
140
  resizeItem: (index: number, size: number) => void;
131
- measureElement: (node: TItemElement | null | undefined) => void;
132
141
  getVirtualItems: {
133
142
  (): VirtualItem[];
134
143
  updateDeps(newDeps: [number[], VirtualItem[]]): void;
@@ -137,9 +146,8 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
137
146
  private getMaxScrollOffset;
138
147
  getOffsetForAlignment: (toOffset: number, align: ScrollAlignment, itemSize?: number) => number;
139
148
  getOffsetForIndex: (index: number, align?: ScrollAlignment) => readonly [number, "auto"] | readonly [number, "start" | "center" | "end"] | undefined;
140
- private isDynamicMode;
141
149
  scrollToOffset: (toOffset: number, { align, behavior }?: ScrollToOffsetOptions) => void;
142
- scrollToIndex: (index: number, { align: initialAlign, behavior }?: ScrollToIndexOptions) => void;
150
+ scrollToIndex: (index: number, { align: initialAlign, behavior, }?: ScrollToIndexOptions) => void;
143
151
  scrollBy: (delta: number, { behavior }?: ScrollToOffsetOptions) => void;
144
152
  getTotalSize: () => number;
145
153
  private _scrollToOffset;
@@ -1,7 +1,7 @@
1
1
  export * from './utils.js';
2
2
  type ScrollDirection = 'forward' | 'backward';
3
3
  type ScrollAlignment = 'start' | 'center' | 'end' | 'auto';
4
- type ScrollBehavior = 'auto' | 'smooth';
4
+ type ScrollBehavior = 'auto' | 'smooth' | 'instant';
5
5
  export interface ScrollToOptions {
6
6
  align?: ScrollAlignment;
7
7
  behavior?: ScrollBehavior;
@@ -83,7 +83,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
83
83
  scrollElement: TScrollElement | null;
84
84
  targetWindow: (Window & typeof globalThis) | null;
85
85
  isScrolling: boolean;
86
- private currentScrollToIndex;
86
+ private scrollState;
87
87
  measurementsCache: Array<VirtualItem>;
88
88
  private itemSizeCache;
89
89
  private laneAssignments;
@@ -97,6 +97,7 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
97
97
  private scrollAdjustments;
98
98
  shouldAdjustScrollPositionOnItemSizeChange: undefined | ((item: VirtualItem, delta: number, instance: Virtualizer<TScrollElement, TItemElement>) => boolean);
99
99
  elementsCache: Map<Key, TItemElement>;
100
+ private now;
100
101
  private observer;
101
102
  range: {
102
103
  startIndex: number;
@@ -109,6 +110,9 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
109
110
  private cleanup;
110
111
  _didMount: () => () => void;
111
112
  _willUpdate: () => void;
113
+ private rafId;
114
+ private scheduleScrollReconcile;
115
+ private reconcileScroll;
112
116
  private getSize;
113
117
  private getScrollOffset;
114
118
  private getFurthestMeasurement;
@@ -126,9 +130,14 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
126
130
  updateDeps(newDeps: [(range: Range) => number[], number, number, number | null, number | null]): void;
127
131
  };
128
132
  indexFromElement: (node: TItemElement) => number;
129
- private _measureElement;
133
+ /**
134
+ * Determines if an item at the given index should be measured during smooth scroll.
135
+ * During smooth scroll, only items within a buffer range around the target are measured
136
+ * to prevent items far from the target from pushing it away.
137
+ */
138
+ private shouldMeasureDuringScroll;
139
+ measureElement: (node: TItemElement | null) => void;
130
140
  resizeItem: (index: number, size: number) => void;
131
- measureElement: (node: TItemElement | null | undefined) => void;
132
141
  getVirtualItems: {
133
142
  (): VirtualItem[];
134
143
  updateDeps(newDeps: [number[], VirtualItem[]]): void;
@@ -137,9 +146,8 @@ export declare class Virtualizer<TScrollElement extends Element | Window, TItemE
137
146
  private getMaxScrollOffset;
138
147
  getOffsetForAlignment: (toOffset: number, align: ScrollAlignment, itemSize?: number) => number;
139
148
  getOffsetForIndex: (index: number, align?: ScrollAlignment) => readonly [number, "auto"] | readonly [number, "start" | "center" | "end"] | undefined;
140
- private isDynamicMode;
141
149
  scrollToOffset: (toOffset: number, { align, behavior }?: ScrollToOffsetOptions) => void;
142
- scrollToIndex: (index: number, { align: initialAlign, behavior }?: ScrollToIndexOptions) => void;
150
+ scrollToIndex: (index: number, { align: initialAlign, behavior, }?: ScrollToIndexOptions) => void;
143
151
  scrollBy: (delta: number, { behavior }?: ScrollToOffsetOptions) => void;
144
152
  getTotalSize: () => number;
145
153
  private _scrollToOffset;
package/dist/esm/index.js CHANGED
@@ -181,7 +181,7 @@ class Virtualizer {
181
181
  this.scrollElement = null;
182
182
  this.targetWindow = null;
183
183
  this.isScrolling = false;
184
- this.currentScrollToIndex = null;
184
+ this.scrollState = null;
185
185
  this.measurementsCache = [];
186
186
  this.itemSizeCache = /* @__PURE__ */ new Map();
187
187
  this.laneAssignments = /* @__PURE__ */ new Map();
@@ -194,6 +194,10 @@ class Virtualizer {
194
194
  this.scrollDirection = null;
195
195
  this.scrollAdjustments = 0;
196
196
  this.elementsCache = /* @__PURE__ */ new Map();
197
+ this.now = () => {
198
+ var _a, _b, _c;
199
+ return ((_c = (_b = (_a = this.targetWindow) == null ? void 0 : _a.performance) == null ? void 0 : _b.now) == null ? void 0 : _c.call(_b)) ?? Date.now();
200
+ };
197
201
  this.observer = /* @__PURE__ */ (() => {
198
202
  let _ro = null;
199
203
  const get = () => {
@@ -206,7 +210,19 @@ class Virtualizer {
206
210
  return _ro = new this.targetWindow.ResizeObserver((entries) => {
207
211
  entries.forEach((entry) => {
208
212
  const run = () => {
209
- this._measureElement(entry.target, entry);
213
+ const node = entry.target;
214
+ const index = this.indexFromElement(node);
215
+ if (!node.isConnected) {
216
+ this.observer.unobserve(node);
217
+ this.elementsCache.delete(this.options.getItemKey(index));
218
+ return;
219
+ }
220
+ if (this.shouldMeasureDuringScroll(index)) {
221
+ this.resizeItem(
222
+ index,
223
+ this.options.measureElement(node, entry, this)
224
+ );
225
+ }
210
226
  };
211
227
  this.options.useAnimationFrameWithResizeObserver ? requestAnimationFrame(run) : run();
212
228
  });
@@ -291,6 +307,11 @@ class Virtualizer {
291
307
  this.unsubs.filter(Boolean).forEach((d) => d());
292
308
  this.unsubs = [];
293
309
  this.observer.disconnect();
310
+ if (this.rafId != null && this.targetWindow) {
311
+ this.targetWindow.cancelAnimationFrame(this.rafId);
312
+ this.rafId = null;
313
+ }
314
+ this.scrollState = null;
294
315
  this.scrollElement = null;
295
316
  this.targetWindow = null;
296
317
  };
@@ -329,6 +350,9 @@ class Virtualizer {
329
350
  this.scrollDirection = isScrolling ? this.getScrollOffset() < offset ? "forward" : "backward" : null;
330
351
  this.scrollOffset = offset;
331
352
  this.isScrolling = isScrolling;
353
+ if (this.scrollState) {
354
+ this.scheduleScrollReconcile();
355
+ }
332
356
  this.maybeNotify();
333
357
  })
334
358
  );
@@ -338,6 +362,7 @@ class Virtualizer {
338
362
  });
339
363
  }
340
364
  };
365
+ this.rafId = null;
341
366
  this.getSize = () => {
342
367
  if (!this.options.enabled) {
343
368
  this.scrollRect = null;
@@ -556,17 +581,38 @@ class Virtualizer {
556
581
  }
557
582
  return parseInt(indexStr, 10);
558
583
  };
559
- this._measureElement = (node, entry) => {
560
- if (!node.isConnected) {
561
- this.observer.unobserve(node);
562
- return;
584
+ this.shouldMeasureDuringScroll = (index) => {
585
+ var _a;
586
+ if (!this.scrollState || this.scrollState.behavior !== "smooth") {
587
+ return true;
563
588
  }
564
- const index = this.indexFromElement(node);
565
- const item = this.measurementsCache[index];
566
- if (!item) {
589
+ const scrollIndex = this.scrollState.index ?? ((_a = this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)) == null ? void 0 : _a.index);
590
+ if (scrollIndex !== void 0 && this.range) {
591
+ const bufferSize = Math.max(
592
+ this.options.overscan,
593
+ Math.ceil((this.range.endIndex - this.range.startIndex) / 2)
594
+ );
595
+ const minIndex = Math.max(0, scrollIndex - bufferSize);
596
+ const maxIndex = Math.min(
597
+ this.options.count - 1,
598
+ scrollIndex + bufferSize
599
+ );
600
+ return index >= minIndex && index <= maxIndex;
601
+ }
602
+ return true;
603
+ };
604
+ this.measureElement = (node) => {
605
+ if (!node) {
606
+ this.elementsCache.forEach((cached, key2) => {
607
+ if (!cached.isConnected) {
608
+ this.observer.unobserve(cached);
609
+ this.elementsCache.delete(key2);
610
+ }
611
+ });
567
612
  return;
568
613
  }
569
- const key = item.key;
614
+ const index = this.indexFromElement(node);
615
+ const key = this.options.getItemKey(index);
570
616
  const prevNode = this.elementsCache.get(key);
571
617
  if (prevNode !== node) {
572
618
  if (prevNode) {
@@ -575,17 +621,18 @@ class Virtualizer {
575
621
  this.observer.observe(node);
576
622
  this.elementsCache.set(key, node);
577
623
  }
578
- this.resizeItem(index, this.options.measureElement(node, entry, this));
624
+ if ((!this.isScrolling || this.scrollState) && this.shouldMeasureDuringScroll(index)) {
625
+ this.resizeItem(index, this.options.measureElement(node, void 0, this));
626
+ }
579
627
  };
580
628
  this.resizeItem = (index, size) => {
629
+ var _a;
581
630
  const item = this.measurementsCache[index];
582
- if (!item) {
583
- return;
584
- }
631
+ if (!item) return;
585
632
  const itemSize = this.itemSizeCache.get(item.key) ?? item.size;
586
633
  const delta = size - itemSize;
587
634
  if (delta !== 0) {
588
- if (this.shouldAdjustScrollPositionOnItemSizeChange !== void 0 ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this) : item.start < this.getScrollOffset() + this.scrollAdjustments) {
635
+ if (((_a = this.scrollState) == null ? void 0 : _a.behavior) !== "smooth" && (this.shouldAdjustScrollPositionOnItemSizeChange !== void 0 ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this) : item.start < this.getScrollOffset() + this.scrollAdjustments)) {
589
636
  if (process.env.NODE_ENV !== "production" && this.options.debug) {
590
637
  console.info("correction", delta);
591
638
  }
@@ -599,18 +646,6 @@ class Virtualizer {
599
646
  this.notify(false);
600
647
  }
601
648
  };
602
- this.measureElement = (node) => {
603
- if (!node) {
604
- this.elementsCache.forEach((cached, key) => {
605
- if (!cached.isConnected) {
606
- this.observer.unobserve(cached);
607
- this.elementsCache.delete(key);
608
- }
609
- });
610
- return;
611
- }
612
- this._measureElement(node, void 0);
613
- };
614
649
  this.getVirtualItems = memo(
615
650
  () => [this.getVirtualIndexes(), this.getMeasurements()],
616
651
  (indexes, measurements) => {
@@ -667,12 +702,10 @@ class Virtualizer {
667
702
  };
668
703
  this.getOffsetForIndex = (index, align = "auto") => {
669
704
  index = Math.max(0, Math.min(index, this.options.count - 1));
670
- const item = this.measurementsCache[index];
671
- if (!item) {
672
- return void 0;
673
- }
674
705
  const size = this.getSize();
675
706
  const scrollOffset = this.getScrollOffset();
707
+ const item = this.measurementsCache[index];
708
+ if (!item) return;
676
709
  if (align === "auto") {
677
710
  if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
678
711
  align = "end";
@@ -691,85 +724,55 @@ class Virtualizer {
691
724
  align
692
725
  ];
693
726
  };
694
- this.isDynamicMode = () => this.elementsCache.size > 0;
695
- this.scrollToOffset = (toOffset, { align = "start", behavior } = {}) => {
696
- if (behavior === "smooth" && this.isDynamicMode()) {
697
- console.warn(
698
- "The `smooth` scroll behavior is not fully supported with dynamic size."
699
- );
700
- }
701
- this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
702
- adjustments: void 0,
703
- behavior
704
- });
727
+ this.scrollToOffset = (toOffset, { align = "start", behavior = "auto" } = {}) => {
728
+ const offset = this.getOffsetForAlignment(toOffset, align);
729
+ const now = this.now();
730
+ this.scrollState = {
731
+ index: null,
732
+ align,
733
+ behavior,
734
+ startedAt: now,
735
+ lastTargetOffset: offset,
736
+ stableFrames: 0
737
+ };
738
+ this._scrollToOffset(offset, { adjustments: void 0, behavior });
739
+ this.scheduleScrollReconcile();
705
740
  };
706
- this.scrollToIndex = (index, { align: initialAlign = "auto", behavior } = {}) => {
707
- if (behavior === "smooth" && this.isDynamicMode()) {
708
- console.warn(
709
- "The `smooth` scroll behavior is not fully supported with dynamic size."
710
- );
711
- }
741
+ this.scrollToIndex = (index, {
742
+ align: initialAlign = "auto",
743
+ behavior = "auto"
744
+ } = {}) => {
712
745
  index = Math.max(0, Math.min(index, this.options.count - 1));
713
- this.currentScrollToIndex = index;
714
- let attempts = 0;
715
- const maxAttempts = 10;
716
- const tryScroll = (currentAlign) => {
717
- if (!this.targetWindow) return;
718
- const offsetInfo = this.getOffsetForIndex(index, currentAlign);
719
- if (!offsetInfo) {
720
- console.warn("Failed to get offset for index:", index);
721
- return;
722
- }
723
- const [offset, align] = offsetInfo;
724
- this._scrollToOffset(offset, { adjustments: void 0, behavior });
725
- this.targetWindow.requestAnimationFrame(() => {
726
- if (!this.targetWindow) return;
727
- const verify = () => {
728
- if (this.currentScrollToIndex !== index) return;
729
- const currentOffset = this.getScrollOffset();
730
- const afterInfo = this.getOffsetForIndex(index, align);
731
- if (!afterInfo) {
732
- console.warn("Failed to get offset for index:", index);
733
- return;
734
- }
735
- if (!approxEqual(afterInfo[0], currentOffset)) {
736
- scheduleRetry(align);
737
- }
738
- };
739
- if (this.isDynamicMode()) {
740
- this.targetWindow.requestAnimationFrame(verify);
741
- } else {
742
- verify();
743
- }
744
- });
745
- };
746
- const scheduleRetry = (align) => {
747
- if (!this.targetWindow) return;
748
- if (this.currentScrollToIndex !== index) return;
749
- attempts++;
750
- if (attempts < maxAttempts) {
751
- if (process.env.NODE_ENV !== "production" && this.options.debug) {
752
- console.info("Schedule retry", attempts, maxAttempts);
753
- }
754
- this.targetWindow.requestAnimationFrame(() => tryScroll(align));
755
- } else {
756
- console.warn(
757
- `Failed to scroll to index ${index} after ${maxAttempts} attempts.`
758
- );
759
- }
746
+ const offsetInfo = this.getOffsetForIndex(index, initialAlign);
747
+ if (!offsetInfo) {
748
+ return;
749
+ }
750
+ const [offset, align] = offsetInfo;
751
+ const now = this.now();
752
+ this.scrollState = {
753
+ index,
754
+ align,
755
+ behavior,
756
+ startedAt: now,
757
+ lastTargetOffset: offset,
758
+ stableFrames: 0
760
759
  };
761
- tryScroll(initialAlign);
760
+ this._scrollToOffset(offset, { adjustments: void 0, behavior });
761
+ this.scheduleScrollReconcile();
762
762
  };
763
- this.scrollBy = (delta, { behavior } = {}) => {
764
- if (behavior === "smooth" && this.isDynamicMode()) {
765
- console.warn(
766
- "The `smooth` scroll behavior is not fully supported with dynamic size."
767
- );
768
- }
769
- this._scrollToOffset(this.getScrollOffset() + delta, {
770
- adjustments: void 0,
771
- behavior
772
- });
763
+ this.scrollBy = (delta, { behavior = "auto" } = {}) => {
764
+ const offset = this.getScrollOffset() + delta;
765
+ const now = this.now();
766
+ this.scrollState = {
767
+ index: null,
768
+ align: "start",
769
+ behavior,
770
+ startedAt: now,
771
+ lastTargetOffset: offset,
772
+ stableFrames: 0
773
+ };
774
+ this._scrollToOffset(offset, { adjustments: void 0, behavior });
775
+ this.scheduleScrollReconcile();
773
776
  };
774
777
  this.getTotalSize = () => {
775
778
  var _a;
@@ -809,6 +812,49 @@ class Virtualizer {
809
812
  };
810
813
  this.setOptions(opts);
811
814
  }
815
+ scheduleScrollReconcile() {
816
+ if (!this.targetWindow) {
817
+ this.scrollState = null;
818
+ return;
819
+ }
820
+ if (this.rafId != null) return;
821
+ this.rafId = this.targetWindow.requestAnimationFrame(() => {
822
+ this.rafId = null;
823
+ this.reconcileScroll();
824
+ });
825
+ }
826
+ reconcileScroll() {
827
+ if (!this.scrollState) return;
828
+ const el = this.scrollElement;
829
+ if (!el) return;
830
+ const MAX_RECONCILE_MS = 5e3;
831
+ if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) {
832
+ this.scrollState = null;
833
+ return;
834
+ }
835
+ const offsetInfo = this.scrollState.index != null ? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align) : void 0;
836
+ const targetOffset = offsetInfo ? offsetInfo[0] : this.scrollState.lastTargetOffset;
837
+ const STABLE_FRAMES = 1;
838
+ const targetChanged = targetOffset !== this.scrollState.lastTargetOffset;
839
+ if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
840
+ this.scrollState.stableFrames++;
841
+ if (this.scrollState.stableFrames >= STABLE_FRAMES) {
842
+ this.scrollState = null;
843
+ return;
844
+ }
845
+ } else {
846
+ this.scrollState.stableFrames = 0;
847
+ if (targetChanged) {
848
+ this.scrollState.lastTargetOffset = targetOffset;
849
+ this.scrollState.behavior = "auto";
850
+ this._scrollToOffset(targetOffset, {
851
+ adjustments: void 0,
852
+ behavior: "auto"
853
+ });
854
+ }
855
+ }
856
+ this.scheduleScrollReconcile();
857
+ }
812
858
  }
813
859
  const findNearestBinarySearch = (low, high, getCurrentValue, value) => {
814
860
  while (low <= high) {