@tanstack/virtual-core 3.5.1 → 3.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/virtual-core",
3
- "version": "3.5.1",
3
+ "version": "3.7.0",
4
4
  "description": "Headless UI for virtualizing scrollable elements in TS/JS + Frameworks",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -28,13 +28,14 @@ export interface Range {
28
28
 
29
29
  type Key = number | string
30
30
 
31
- export interface VirtualItem {
31
+ export interface VirtualItem<TItemElement extends Element> {
32
32
  key: Key
33
33
  index: number
34
34
  start: number
35
35
  end: number
36
36
  size: number
37
37
  lane: number
38
+ measureElement: (node: TItemElement | null | undefined) => void
38
39
  }
39
40
 
40
41
  export interface Rect {
@@ -315,9 +316,10 @@ export interface VirtualizerOptions<
315
316
  scrollMargin?: number
316
317
  gap?: number
317
318
  indexAttribute?: string
318
- initialMeasurementsCache?: VirtualItem[]
319
+ initialMeasurementsCache?: VirtualItem<TItemElement>[]
319
320
  lanes?: number
320
321
  isScrollingResetDelay?: number
322
+ enabled?: boolean
321
323
  }
322
324
 
323
325
  export class Virtualizer<
@@ -330,21 +332,21 @@ export class Virtualizer<
330
332
  targetWindow: (Window & typeof globalThis) | null = null
331
333
  isScrolling: boolean = false
332
334
  private scrollToIndexTimeoutId: number | null = null
333
- measurementsCache: VirtualItem[] = []
335
+ measurementsCache: VirtualItem<TItemElement>[] = []
334
336
  private itemSizeCache = new Map<Key, number>()
335
337
  private pendingMeasuredCacheIndexes: number[] = []
336
- scrollRect: Rect
337
- scrollOffset: number
338
+ scrollRect: Rect | null = null
339
+ scrollOffset: number | null = null
338
340
  scrollDirection: ScrollDirection | null = null
339
341
  private scrollAdjustments: number = 0
340
342
  shouldAdjustScrollPositionOnItemSizeChange:
341
343
  | undefined
342
344
  | ((
343
- item: VirtualItem,
345
+ item: VirtualItem<TItemElement>,
344
346
  delta: number,
345
347
  instance: Virtualizer<TScrollElement, TItemElement>,
346
348
  ) => boolean)
347
- measureElementCache = new Map<Key, TItemElement>()
349
+ elementsCache = new Map<Key, TItemElement>()
348
350
  private observer = (() => {
349
351
  let _ro: ResizeObserver | null = null
350
352
 
@@ -375,17 +377,6 @@ export class Virtualizer<
375
377
 
376
378
  constructor(opts: VirtualizerOptions<TScrollElement, TItemElement>) {
377
379
  this.setOptions(opts)
378
- this.scrollRect = this.options.initialRect
379
- this.scrollOffset =
380
- typeof this.options.initialOffset === 'function'
381
- ? this.options.initialOffset()
382
- : this.options.initialOffset
383
- this.measurementsCache = this.options.initialMeasurementsCache
384
- this.measurementsCache.forEach((item) => {
385
- this.itemSizeCache.set(item.key, item.size)
386
- })
387
-
388
- this.notify(false, false)
389
380
  }
390
381
 
391
382
  setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
@@ -413,6 +404,7 @@ export class Virtualizer<
413
404
  initialMeasurementsCache: [],
414
405
  lanes: 1,
415
406
  isScrollingResetDelay: 150,
407
+ enabled: true,
416
408
  ...opts,
417
409
  }
418
410
  }
@@ -437,22 +429,30 @@ export class Virtualizer<
437
429
  this.unsubs.filter(Boolean).forEach((d) => d!())
438
430
  this.unsubs = []
439
431
  this.scrollElement = null
432
+ this.targetWindow = null
433
+ this.observer.disconnect()
434
+ this.elementsCache.clear()
440
435
  }
441
436
 
442
437
  _didMount = () => {
443
- this.measureElementCache.forEach(this.observer.observe)
444
438
  return () => {
445
- this.observer.disconnect()
446
439
  this.cleanup()
447
440
  }
448
441
  }
449
442
 
450
443
  _willUpdate = () => {
451
- const scrollElement = this.options.getScrollElement()
444
+ const scrollElement = this.options.enabled
445
+ ? this.options.getScrollElement()
446
+ : null
452
447
 
453
448
  if (this.scrollElement !== scrollElement) {
454
449
  this.cleanup()
455
450
 
451
+ if (!scrollElement) {
452
+ this.notify(false, false)
453
+ return
454
+ }
455
+
456
456
  this.scrollElement = scrollElement
457
457
 
458
458
  if (this.scrollElement && 'ownerDocument' in this.scrollElement) {
@@ -461,7 +461,7 @@ export class Virtualizer<
461
461
  this.targetWindow = this.scrollElement?.window ?? null
462
462
  }
463
463
 
464
- this._scrollToOffset(this.scrollOffset, {
464
+ this._scrollToOffset(this.getScrollOffset(), {
465
465
  adjustments: undefined,
466
466
  behavior: undefined,
467
467
  })
@@ -477,7 +477,7 @@ export class Virtualizer<
477
477
  this.options.observeElementOffset(this, (offset, isScrolling) => {
478
478
  this.scrollAdjustments = 0
479
479
  this.scrollDirection = isScrolling
480
- ? this.scrollOffset < offset
480
+ ? this.getScrollOffset() < offset
481
481
  ? 'forward'
482
482
  : 'backward'
483
483
  : null
@@ -493,36 +493,37 @@ export class Virtualizer<
493
493
  }
494
494
 
495
495
  private getSize = () => {
496
+ if (!this.options.enabled) {
497
+ this.scrollRect = null
498
+ return 0
499
+ }
500
+
501
+ this.scrollRect = this.scrollRect ?? this.options.initialRect
502
+
496
503
  return this.scrollRect[this.options.horizontal ? 'width' : 'height']
497
504
  }
498
505
 
499
- private getMeasurementOptions = memo(
500
- () => [
501
- this.options.count,
502
- this.options.paddingStart,
503
- this.options.scrollMargin,
504
- this.options.getItemKey,
505
- ],
506
- (count, paddingStart, scrollMargin, getItemKey) => {
507
- this.pendingMeasuredCacheIndexes = []
508
- return {
509
- count,
510
- paddingStart,
511
- scrollMargin,
512
- getItemKey,
513
- }
514
- },
515
- {
516
- key: false,
517
- },
518
- )
506
+ private getScrollOffset = () => {
507
+ if (!this.options.enabled) {
508
+ this.scrollOffset = null
509
+ return 0
510
+ }
511
+
512
+ this.scrollOffset =
513
+ this.scrollOffset ??
514
+ (typeof this.options.initialOffset === 'function'
515
+ ? this.options.initialOffset()
516
+ : this.options.initialOffset)
517
+
518
+ return this.scrollOffset
519
+ }
519
520
 
520
521
  private getFurthestMeasurement = (
521
- measurements: VirtualItem[],
522
+ measurements: VirtualItem<TItemElement>[],
522
523
  index: number,
523
524
  ) => {
524
525
  const furthestMeasurementsFound = new Map<number, true>()
525
- const furthestMeasurements = new Map<number, VirtualItem>()
526
+ const furthestMeasurements = new Map<number, VirtualItem<TItemElement>>()
526
527
  for (let m = index - 1; m >= 0; m--) {
527
528
  const measurement = measurements[m]!
528
529
 
@@ -558,9 +559,48 @@ export class Virtualizer<
558
559
  : undefined
559
560
  }
560
561
 
562
+ private getMeasurementOptions = memo(
563
+ () => [
564
+ this.options.count,
565
+ this.options.paddingStart,
566
+ this.options.scrollMargin,
567
+ this.options.getItemKey,
568
+ this.options.enabled,
569
+ ],
570
+ (count, paddingStart, scrollMargin, getItemKey, enabled) => {
571
+ this.pendingMeasuredCacheIndexes = []
572
+ return {
573
+ count,
574
+ paddingStart,
575
+ scrollMargin,
576
+ getItemKey,
577
+ enabled,
578
+ }
579
+ },
580
+ {
581
+ key: false,
582
+ },
583
+ )
584
+
561
585
  private getMeasurements = memo(
562
586
  () => [this.getMeasurementOptions(), this.itemSizeCache],
563
- ({ count, paddingStart, scrollMargin, getItemKey }, itemSizeCache) => {
587
+ (
588
+ { count, paddingStart, scrollMargin, getItemKey, enabled },
589
+ itemSizeCache,
590
+ ) => {
591
+ if (!enabled) {
592
+ this.measurementsCache = []
593
+ this.itemSizeCache.clear()
594
+ return []
595
+ }
596
+
597
+ if (this.measurementsCache.length === 0) {
598
+ this.measurementsCache = this.options.initialMeasurementsCache
599
+ this.measurementsCache.forEach((item) => {
600
+ this.itemSizeCache.set(item.key, item.size)
601
+ })
602
+ }
603
+
564
604
  const min =
565
605
  this.pendingMeasuredCacheIndexes.length > 0
566
606
  ? Math.min(...this.pendingMeasuredCacheIndexes)
@@ -570,6 +610,38 @@ export class Virtualizer<
570
610
  const measurements = this.measurementsCache.slice(0, min)
571
611
 
572
612
  for (let i = min; i < count; i++) {
613
+ let measureElement = this.measurementsCache[i]?.measureElement
614
+
615
+ if (!measureElement) {
616
+ measureElement = (node: TItemElement | null | undefined) => {
617
+ const key = getItemKey(i)
618
+ const prevNode = this.elementsCache.get(key)
619
+
620
+ if (!node) {
621
+ if (prevNode) {
622
+ this.observer.unobserve(prevNode)
623
+ this.elementsCache.delete(key)
624
+ }
625
+ return
626
+ }
627
+
628
+ if (prevNode !== node) {
629
+ if (prevNode) {
630
+ this.observer.unobserve(prevNode)
631
+ }
632
+ this.observer.observe(node)
633
+ this.elementsCache.set(key, node)
634
+ }
635
+
636
+ if (node.isConnected) {
637
+ this.resizeItem(
638
+ i,
639
+ this.options.measureElement(node, undefined, this),
640
+ )
641
+ }
642
+ }
643
+ }
644
+
573
645
  const key = getItemKey(i)
574
646
 
575
647
  const furthestMeasurement =
@@ -600,6 +672,7 @@ export class Virtualizer<
600
672
  end,
601
673
  key,
602
674
  lane,
675
+ measureElement,
603
676
  }
604
677
  }
605
678
 
@@ -614,7 +687,7 @@ export class Virtualizer<
614
687
  )
615
688
 
616
689
  calculateRange = memo(
617
- () => [this.getMeasurements(), this.getSize(), this.scrollOffset],
690
+ () => [this.getMeasurements(), this.getSize(), this.getScrollOffset()],
618
691
  (measurements, outerSize, scrollOffset) => {
619
692
  return (this.range =
620
693
  measurements.length > 0 && outerSize > 0
@@ -672,34 +745,37 @@ export class Virtualizer<
672
745
  node: TItemElement,
673
746
  entry: ResizeObserverEntry | undefined,
674
747
  ) => {
675
- const item = this.measurementsCache[this.indexFromElement(node)]
748
+ const i = this.indexFromElement(node)
749
+ const item = this.getMeasurements()[i]
676
750
 
677
751
  if (!item || !node.isConnected) {
678
- this.measureElementCache.forEach((cached, key) => {
752
+ this.elementsCache.forEach((cached, key) => {
679
753
  if (cached === node) {
680
754
  this.observer.unobserve(node)
681
- this.measureElementCache.delete(key)
755
+ this.elementsCache.delete(key)
682
756
  }
683
757
  })
684
758
  return
685
759
  }
686
760
 
687
- const prevNode = this.measureElementCache.get(item.key)
761
+ const prevNode = this.elementsCache.get(item.key)
688
762
 
689
763
  if (prevNode !== node) {
690
764
  if (prevNode) {
691
765
  this.observer.unobserve(prevNode)
692
766
  }
693
767
  this.observer.observe(node)
694
- this.measureElementCache.set(item.key, node)
768
+ this.elementsCache.set(item.key, node)
695
769
  }
696
770
 
697
- const measuredItemSize = this.options.measureElement(node, entry, this)
698
-
699
- this.resizeItem(item, measuredItemSize)
771
+ this.resizeItem(i, this.options.measureElement(node, entry, this))
700
772
  }
701
773
 
702
- resizeItem = (item: VirtualItem, size: number) => {
774
+ resizeItem = (index: number, size: number) => {
775
+ const item = this.getMeasurements()[index]
776
+ if (!item) {
777
+ return
778
+ }
703
779
  const itemSize = this.itemSizeCache.get(item.key) ?? item.size
704
780
  const delta = size - itemSize
705
781
 
@@ -707,13 +783,13 @@ export class Virtualizer<
707
783
  if (
708
784
  this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
709
785
  ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
710
- : item.start < this.scrollOffset + this.scrollAdjustments
786
+ : item.start < this.getScrollOffset() + this.scrollAdjustments
711
787
  ) {
712
788
  if (process.env.NODE_ENV !== 'production' && this.options.debug) {
713
789
  console.info('correction', delta)
714
790
  }
715
791
 
716
- this._scrollToOffset(this.scrollOffset, {
792
+ this._scrollToOffset(this.getScrollOffset(), {
717
793
  adjustments: (this.scrollAdjustments += delta),
718
794
  behavior: undefined,
719
795
  })
@@ -726,7 +802,7 @@ export class Virtualizer<
726
802
  }
727
803
  }
728
804
 
729
- measureElement = (node: TItemElement | null) => {
805
+ measureElement = (node: TItemElement | null | undefined) => {
730
806
  if (!node) {
731
807
  return
732
808
  }
@@ -737,7 +813,7 @@ export class Virtualizer<
737
813
  getVirtualItems = memo(
738
814
  () => [this.getIndexes(), this.getMeasurements()],
739
815
  (indexes, measurements) => {
740
- const virtualItems: VirtualItem[] = []
816
+ const virtualItems: VirtualItem<TItemElement>[] = []
741
817
 
742
818
  for (let k = 0, len = indexes.length; k < len; k++) {
743
819
  const i = indexes[k]!
@@ -756,7 +832,9 @@ export class Virtualizer<
756
832
 
757
833
  getVirtualItemForOffset = (offset: number) => {
758
834
  const measurements = this.getMeasurements()
759
-
835
+ if (measurements.length === 0) {
836
+ return undefined
837
+ }
760
838
  return notUndefined(
761
839
  measurements[
762
840
  findNearestBinarySearch(
@@ -771,11 +849,12 @@ export class Virtualizer<
771
849
 
772
850
  getOffsetForAlignment = (toOffset: number, align: ScrollAlignment) => {
773
851
  const size = this.getSize()
852
+ const scrollOffset = this.getScrollOffset()
774
853
 
775
854
  if (align === 'auto') {
776
- if (toOffset <= this.scrollOffset) {
855
+ if (toOffset <= scrollOffset) {
777
856
  align = 'start'
778
- } else if (toOffset >= this.scrollOffset + size) {
857
+ } else if (toOffset >= scrollOffset + size) {
779
858
  align = 'end'
780
859
  } else {
781
860
  align = 'start'
@@ -799,7 +878,7 @@ export class Virtualizer<
799
878
  : this.scrollElement[scrollSizeProp]
800
879
  : 0
801
880
 
802
- const maxOffset = scrollSize - this.getSize()
881
+ const maxOffset = scrollSize - size
803
882
 
804
883
  return Math.max(Math.min(maxOffset, toOffset), 0)
805
884
  }
@@ -807,33 +886,33 @@ export class Virtualizer<
807
886
  getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
808
887
  index = Math.max(0, Math.min(index, this.options.count - 1))
809
888
 
810
- const measurement = notUndefined(this.getMeasurements()[index])
889
+ const item = this.getMeasurements()[index]
890
+ if (!item) {
891
+ return undefined
892
+ }
893
+
894
+ const size = this.getSize()
895
+ const scrollOffset = this.getScrollOffset()
811
896
 
812
897
  if (align === 'auto') {
813
- if (
814
- measurement.end >=
815
- this.scrollOffset + this.getSize() - this.options.scrollPaddingEnd
816
- ) {
898
+ if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
817
899
  align = 'end'
818
- } else if (
819
- measurement.start <=
820
- this.scrollOffset + this.options.scrollPaddingStart
821
- ) {
900
+ } else if (item.start <= scrollOffset + this.options.scrollPaddingStart) {
822
901
  align = 'start'
823
902
  } else {
824
- return [this.scrollOffset, align] as const
903
+ return [scrollOffset, align] as const
825
904
  }
826
905
  }
827
906
 
828
907
  const toOffset =
829
908
  align === 'end'
830
- ? measurement.end + this.options.scrollPaddingEnd
831
- : measurement.start - this.options.scrollPaddingStart
909
+ ? item.end + this.options.scrollPaddingEnd
910
+ : item.start - this.options.scrollPaddingStart
832
911
 
833
912
  return [this.getOffsetForAlignment(toOffset, align), align] as const
834
913
  }
835
914
 
836
- private isDynamicMode = () => this.measureElementCache.size > 0
915
+ private isDynamicMode = () => this.elementsCache.size > 0
837
916
 
838
917
  private cancelScrollToIndex = () => {
839
918
  if (this.scrollToIndexTimeoutId !== null && this.targetWindow) {
@@ -874,22 +953,27 @@ export class Virtualizer<
874
953
  )
875
954
  }
876
955
 
877
- const [toOffset, align] = this.getOffsetForIndex(index, initialAlign)
956
+ const offsetAndAlign = this.getOffsetForIndex(index, initialAlign)
957
+ if (!offsetAndAlign) return
958
+
959
+ const [offset, align] = offsetAndAlign
878
960
 
879
- this._scrollToOffset(toOffset, { adjustments: undefined, behavior })
961
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
880
962
 
881
963
  if (behavior !== 'smooth' && this.isDynamicMode() && this.targetWindow) {
882
964
  this.scrollToIndexTimeoutId = this.targetWindow.setTimeout(() => {
883
965
  this.scrollToIndexTimeoutId = null
884
966
 
885
- const elementInDOM = this.measureElementCache.has(
967
+ const elementInDOM = this.elementsCache.has(
886
968
  this.options.getItemKey(index),
887
969
  )
888
970
 
889
971
  if (elementInDOM) {
890
- const [toOffset] = this.getOffsetForIndex(index, align)
972
+ const [latestOffset] = notUndefined(
973
+ this.getOffsetForIndex(index, align),
974
+ )
891
975
 
892
- if (!approxEqual(toOffset, this.scrollOffset)) {
976
+ if (!approxEqual(latestOffset, this.getScrollOffset())) {
893
977
  this.scrollToIndex(index, { align, behavior })
894
978
  }
895
979
  } else {
@@ -908,7 +992,7 @@ export class Virtualizer<
908
992
  )
909
993
  }
910
994
 
911
- this._scrollToOffset(this.scrollOffset + delta, {
995
+ this._scrollToOffset(this.getScrollOffset() + delta, {
912
996
  adjustments: undefined,
913
997
  behavior,
914
998
  })
@@ -979,12 +1063,12 @@ const findNearestBinarySearch = (
979
1063
  }
980
1064
  }
981
1065
 
982
- function calculateRange({
1066
+ function calculateRange<TItemElement extends Element>({
983
1067
  measurements,
984
1068
  outerSize,
985
1069
  scrollOffset,
986
1070
  }: {
987
- measurements: VirtualItem[]
1071
+ measurements: VirtualItem<TItemElement>[]
988
1072
  outerSize: number
989
1073
  scrollOffset: number
990
1074
  }) {