@tanstack/virtual-core 3.5.0 → 3.6.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/src/index.ts CHANGED
@@ -67,6 +67,10 @@ export const observeElementRect = <T extends Element>(
67
67
  if (!element) {
68
68
  return
69
69
  }
70
+ const targetWindow = instance.targetWindow
71
+ if (!targetWindow) {
72
+ return
73
+ }
70
74
 
71
75
  const handler = (rect: Rect) => {
72
76
  const { width, height } = rect
@@ -75,11 +79,11 @@ export const observeElementRect = <T extends Element>(
75
79
 
76
80
  handler(element.getBoundingClientRect())
77
81
 
78
- if (typeof ResizeObserver === 'undefined') {
82
+ if (!targetWindow.ResizeObserver) {
79
83
  return () => {}
80
84
  }
81
85
 
82
- const observer = new ResizeObserver((entries) => {
86
+ const observer = new targetWindow.ResizeObserver((entries) => {
83
87
  const entry = entries[0]
84
88
  if (entry?.borderBoxSize) {
85
89
  const box = entry.borderBoxSize[0]
@@ -134,13 +138,21 @@ export const observeElementOffset = <T extends Element>(
134
138
  if (!element) {
135
139
  return
136
140
  }
141
+ const targetWindow = instance.targetWindow
142
+ if (!targetWindow) {
143
+ return
144
+ }
137
145
 
138
146
  let offset = 0
139
147
  const fallback = supportsScrollend
140
148
  ? () => undefined
141
- : debounce(() => {
142
- cb(offset, false)
143
- }, instance.options.isScrollingResetDelay)
149
+ : debounce(
150
+ targetWindow,
151
+ () => {
152
+ cb(offset, false)
153
+ },
154
+ instance.options.isScrollingResetDelay,
155
+ )
144
156
 
145
157
  const createHandler = (isScrolling: boolean) => () => {
146
158
  offset = element[instance.options.horizontal ? 'scrollLeft' : 'scrollTop']
@@ -168,13 +180,21 @@ export const observeWindowOffset = (
168
180
  if (!element) {
169
181
  return
170
182
  }
183
+ const targetWindow = instance.targetWindow
184
+ if (!targetWindow) {
185
+ return
186
+ }
171
187
 
172
188
  let offset = 0
173
189
  const fallback = supportsScrollend
174
190
  ? () => undefined
175
- : debounce(() => {
176
- cb(offset, false)
177
- }, instance.options.isScrollingResetDelay)
191
+ : debounce(
192
+ targetWindow,
193
+ () => {
194
+ cb(offset, false)
195
+ },
196
+ instance.options.isScrollingResetDelay,
197
+ )
178
198
 
179
199
  const createHandler = (isScrolling: boolean) => () => {
180
200
  offset = element[instance.options.horizontal ? 'scrollX' : 'scrollY']
@@ -298,6 +318,7 @@ export interface VirtualizerOptions<
298
318
  initialMeasurementsCache?: VirtualItem[]
299
319
  lanes?: number
300
320
  isScrollingResetDelay?: number
321
+ enabled?: boolean
301
322
  }
302
323
 
303
324
  export class Virtualizer<
@@ -307,13 +328,14 @@ export class Virtualizer<
307
328
  private unsubs: (void | (() => void))[] = []
308
329
  options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>
309
330
  scrollElement: TScrollElement | null = null
331
+ targetWindow: (Window & typeof globalThis) | null = null
310
332
  isScrolling: boolean = false
311
- private scrollToIndexTimeoutId: ReturnType<typeof setTimeout> | null = null
333
+ private scrollToIndexTimeoutId: number | null = null
312
334
  measurementsCache: VirtualItem[] = []
313
335
  private itemSizeCache = new Map<Key, number>()
314
336
  private pendingMeasuredCacheIndexes: number[] = []
315
- scrollRect: Rect
316
- scrollOffset: number
337
+ scrollRect: Rect | null = null
338
+ scrollOffset: number | null = null
317
339
  scrollDirection: ScrollDirection | null = null
318
340
  private scrollAdjustments: number = 0
319
341
  shouldAdjustScrollPositionOnItemSizeChange:
@@ -330,15 +352,17 @@ export class Virtualizer<
330
352
  const get = () => {
331
353
  if (_ro) {
332
354
  return _ro
333
- } else if (typeof ResizeObserver !== 'undefined') {
334
- return (_ro = new ResizeObserver((entries) => {
335
- entries.forEach((entry) => {
336
- this._measureElement(entry.target as TItemElement, entry)
337
- })
338
- }))
339
- } else {
355
+ }
356
+
357
+ if (!this.targetWindow || !this.targetWindow.ResizeObserver) {
340
358
  return null
341
359
  }
360
+
361
+ return (_ro = new this.targetWindow.ResizeObserver((entries) => {
362
+ entries.forEach((entry) => {
363
+ this._measureElement(entry.target as TItemElement, entry)
364
+ })
365
+ }))
342
366
  }
343
367
 
344
368
  return {
@@ -352,17 +376,6 @@ export class Virtualizer<
352
376
 
353
377
  constructor(opts: VirtualizerOptions<TScrollElement, TItemElement>) {
354
378
  this.setOptions(opts)
355
- this.scrollRect = this.options.initialRect
356
- this.scrollOffset =
357
- typeof this.options.initialOffset === 'function'
358
- ? this.options.initialOffset()
359
- : this.options.initialOffset
360
- this.measurementsCache = this.options.initialMeasurementsCache
361
- this.measurementsCache.forEach((item) => {
362
- this.itemSizeCache.set(item.key, item.size)
363
- })
364
-
365
- this.notify(false, false)
366
379
  }
367
380
 
368
381
  setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
@@ -390,6 +403,7 @@ export class Virtualizer<
390
403
  initialMeasurementsCache: [],
391
404
  lanes: 1,
392
405
  isScrollingResetDelay: 150,
406
+ enabled: true,
393
407
  ...opts,
394
408
  }
395
409
  }
@@ -414,25 +428,39 @@ export class Virtualizer<
414
428
  this.unsubs.filter(Boolean).forEach((d) => d!())
415
429
  this.unsubs = []
416
430
  this.scrollElement = null
431
+ this.targetWindow = null
432
+ this.observer.disconnect()
433
+ this.measureElementCache.clear()
417
434
  }
418
435
 
419
436
  _didMount = () => {
420
- this.measureElementCache.forEach(this.observer.observe)
421
437
  return () => {
422
- this.observer.disconnect()
423
438
  this.cleanup()
424
439
  }
425
440
  }
426
441
 
427
442
  _willUpdate = () => {
428
- const scrollElement = this.options.getScrollElement()
443
+ const scrollElement = this.options.enabled
444
+ ? this.options.getScrollElement()
445
+ : null
429
446
 
430
447
  if (this.scrollElement !== scrollElement) {
431
448
  this.cleanup()
432
449
 
450
+ if (!scrollElement) {
451
+ this.notify(false, false)
452
+ return
453
+ }
454
+
433
455
  this.scrollElement = scrollElement
434
456
 
435
- this._scrollToOffset(this.scrollOffset, {
457
+ if (this.scrollElement && 'ownerDocument' in this.scrollElement) {
458
+ this.targetWindow = this.scrollElement.ownerDocument.defaultView
459
+ } else {
460
+ this.targetWindow = this.scrollElement?.window ?? null
461
+ }
462
+
463
+ this._scrollToOffset(this.getScrollOffset(), {
436
464
  adjustments: undefined,
437
465
  behavior: undefined,
438
466
  })
@@ -448,7 +476,7 @@ export class Virtualizer<
448
476
  this.options.observeElementOffset(this, (offset, isScrolling) => {
449
477
  this.scrollAdjustments = 0
450
478
  this.scrollDirection = isScrolling
451
- ? this.scrollOffset < offset
479
+ ? this.getScrollOffset() < offset
452
480
  ? 'forward'
453
481
  : 'backward'
454
482
  : null
@@ -464,29 +492,30 @@ export class Virtualizer<
464
492
  }
465
493
 
466
494
  private getSize = () => {
495
+ if (!this.options.enabled) {
496
+ this.scrollRect = null
497
+ return 0
498
+ }
499
+
500
+ this.scrollRect = this.scrollRect ?? this.options.initialRect
501
+
467
502
  return this.scrollRect[this.options.horizontal ? 'width' : 'height']
468
503
  }
469
504
 
470
- private getMeasurementOptions = memo(
471
- () => [
472
- this.options.count,
473
- this.options.paddingStart,
474
- this.options.scrollMargin,
475
- this.options.getItemKey,
476
- ],
477
- (count, paddingStart, scrollMargin, getItemKey) => {
478
- this.pendingMeasuredCacheIndexes = []
479
- return {
480
- count,
481
- paddingStart,
482
- scrollMargin,
483
- getItemKey,
484
- }
485
- },
486
- {
487
- key: false,
488
- },
489
- )
505
+ private getScrollOffset = () => {
506
+ if (!this.options.enabled) {
507
+ this.scrollOffset = null
508
+ return 0
509
+ }
510
+
511
+ this.scrollOffset =
512
+ this.scrollOffset ??
513
+ (typeof this.options.initialOffset === 'function'
514
+ ? this.options.initialOffset()
515
+ : this.options.initialOffset)
516
+
517
+ return this.scrollOffset
518
+ }
490
519
 
491
520
  private getFurthestMeasurement = (
492
521
  measurements: VirtualItem[],
@@ -529,9 +558,48 @@ export class Virtualizer<
529
558
  : undefined
530
559
  }
531
560
 
561
+ private getMeasurementOptions = memo(
562
+ () => [
563
+ this.options.count,
564
+ this.options.paddingStart,
565
+ this.options.scrollMargin,
566
+ this.options.getItemKey,
567
+ this.options.enabled,
568
+ ],
569
+ (count, paddingStart, scrollMargin, getItemKey, enabled) => {
570
+ this.pendingMeasuredCacheIndexes = []
571
+ return {
572
+ count,
573
+ paddingStart,
574
+ scrollMargin,
575
+ getItemKey,
576
+ enabled,
577
+ }
578
+ },
579
+ {
580
+ key: false,
581
+ },
582
+ )
583
+
532
584
  private getMeasurements = memo(
533
585
  () => [this.getMeasurementOptions(), this.itemSizeCache],
534
- ({ count, paddingStart, scrollMargin, getItemKey }, itemSizeCache) => {
586
+ (
587
+ { count, paddingStart, scrollMargin, getItemKey, enabled },
588
+ itemSizeCache,
589
+ ) => {
590
+ if (!enabled) {
591
+ this.measurementsCache = []
592
+ this.itemSizeCache.clear()
593
+ return []
594
+ }
595
+
596
+ if (this.measurementsCache.length === 0) {
597
+ this.measurementsCache = this.options.initialMeasurementsCache
598
+ this.measurementsCache.forEach((item) => {
599
+ this.itemSizeCache.set(item.key, item.size)
600
+ })
601
+ }
602
+
535
603
  const min =
536
604
  this.pendingMeasuredCacheIndexes.length > 0
537
605
  ? Math.min(...this.pendingMeasuredCacheIndexes)
@@ -585,7 +653,7 @@ export class Virtualizer<
585
653
  )
586
654
 
587
655
  calculateRange = memo(
588
- () => [this.getMeasurements(), this.getSize(), this.scrollOffset],
656
+ () => [this.getMeasurements(), this.getSize(), this.getScrollOffset()],
589
657
  (measurements, outerSize, scrollOffset) => {
590
658
  return (this.range =
591
659
  measurements.length > 0 && outerSize > 0
@@ -643,7 +711,7 @@ export class Virtualizer<
643
711
  node: TItemElement,
644
712
  entry: ResizeObserverEntry | undefined,
645
713
  ) => {
646
- const item = this.measurementsCache[this.indexFromElement(node)]
714
+ const item = this.getMeasurements()[this.indexFromElement(node)]
647
715
 
648
716
  if (!item || !node.isConnected) {
649
717
  this.measureElementCache.forEach((cached, key) => {
@@ -678,13 +746,13 @@ export class Virtualizer<
678
746
  if (
679
747
  this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
680
748
  ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
681
- : item.start < this.scrollOffset + this.scrollAdjustments
749
+ : item.start < this.getScrollOffset() + this.scrollAdjustments
682
750
  ) {
683
751
  if (process.env.NODE_ENV !== 'production' && this.options.debug) {
684
752
  console.info('correction', delta)
685
753
  }
686
754
 
687
- this._scrollToOffset(this.scrollOffset, {
755
+ this._scrollToOffset(this.getScrollOffset(), {
688
756
  adjustments: (this.scrollAdjustments += delta),
689
757
  behavior: undefined,
690
758
  })
@@ -727,7 +795,9 @@ export class Virtualizer<
727
795
 
728
796
  getVirtualItemForOffset = (offset: number) => {
729
797
  const measurements = this.getMeasurements()
730
-
798
+ if (measurements.length === 0) {
799
+ return undefined
800
+ }
731
801
  return notUndefined(
732
802
  measurements[
733
803
  findNearestBinarySearch(
@@ -742,11 +812,12 @@ export class Virtualizer<
742
812
 
743
813
  getOffsetForAlignment = (toOffset: number, align: ScrollAlignment) => {
744
814
  const size = this.getSize()
815
+ const scrollOffset = this.getScrollOffset()
745
816
 
746
817
  if (align === 'auto') {
747
- if (toOffset <= this.scrollOffset) {
818
+ if (toOffset <= scrollOffset) {
748
819
  align = 'start'
749
- } else if (toOffset >= this.scrollOffset + size) {
820
+ } else if (toOffset >= scrollOffset + size) {
750
821
  align = 'end'
751
822
  } else {
752
823
  align = 'start'
@@ -770,7 +841,7 @@ export class Virtualizer<
770
841
  : this.scrollElement[scrollSizeProp]
771
842
  : 0
772
843
 
773
- const maxOffset = scrollSize - this.getSize()
844
+ const maxOffset = scrollSize - size
774
845
 
775
846
  return Math.max(Math.min(maxOffset, toOffset), 0)
776
847
  }
@@ -778,28 +849,28 @@ export class Virtualizer<
778
849
  getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
779
850
  index = Math.max(0, Math.min(index, this.options.count - 1))
780
851
 
781
- const measurement = notUndefined(this.getMeasurements()[index])
852
+ const item = this.getMeasurements()[index]
853
+ if (!item) {
854
+ return undefined
855
+ }
856
+
857
+ const size = this.getSize()
858
+ const scrollOffset = this.getScrollOffset()
782
859
 
783
860
  if (align === 'auto') {
784
- if (
785
- measurement.end >=
786
- this.scrollOffset + this.getSize() - this.options.scrollPaddingEnd
787
- ) {
861
+ if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) {
788
862
  align = 'end'
789
- } else if (
790
- measurement.start <=
791
- this.scrollOffset + this.options.scrollPaddingStart
792
- ) {
863
+ } else if (item.start <= scrollOffset + this.options.scrollPaddingStart) {
793
864
  align = 'start'
794
865
  } else {
795
- return [this.scrollOffset, align] as const
866
+ return [scrollOffset, align] as const
796
867
  }
797
868
  }
798
869
 
799
870
  const toOffset =
800
871
  align === 'end'
801
- ? measurement.end + this.options.scrollPaddingEnd
802
- : measurement.start - this.options.scrollPaddingStart
872
+ ? item.end + this.options.scrollPaddingEnd
873
+ : item.start - this.options.scrollPaddingStart
803
874
 
804
875
  return [this.getOffsetForAlignment(toOffset, align), align] as const
805
876
  }
@@ -807,8 +878,8 @@ export class Virtualizer<
807
878
  private isDynamicMode = () => this.measureElementCache.size > 0
808
879
 
809
880
  private cancelScrollToIndex = () => {
810
- if (this.scrollToIndexTimeoutId !== null) {
811
- clearTimeout(this.scrollToIndexTimeoutId)
881
+ if (this.scrollToIndexTimeoutId !== null && this.targetWindow) {
882
+ this.targetWindow.clearTimeout(this.scrollToIndexTimeoutId)
812
883
  this.scrollToIndexTimeoutId = null
813
884
  }
814
885
  }
@@ -845,12 +916,15 @@ export class Virtualizer<
845
916
  )
846
917
  }
847
918
 
848
- const [toOffset, align] = this.getOffsetForIndex(index, initialAlign)
919
+ const offsetAndAlign = this.getOffsetForIndex(index, initialAlign)
920
+ if (!offsetAndAlign) return
921
+
922
+ const [offset, align] = offsetAndAlign
849
923
 
850
- this._scrollToOffset(toOffset, { adjustments: undefined, behavior })
924
+ this._scrollToOffset(offset, { adjustments: undefined, behavior })
851
925
 
852
- if (behavior !== 'smooth' && this.isDynamicMode()) {
853
- this.scrollToIndexTimeoutId = setTimeout(() => {
926
+ if (behavior !== 'smooth' && this.isDynamicMode() && this.targetWindow) {
927
+ this.scrollToIndexTimeoutId = this.targetWindow.setTimeout(() => {
854
928
  this.scrollToIndexTimeoutId = null
855
929
 
856
930
  const elementInDOM = this.measureElementCache.has(
@@ -858,9 +932,11 @@ export class Virtualizer<
858
932
  )
859
933
 
860
934
  if (elementInDOM) {
861
- const [toOffset] = this.getOffsetForIndex(index, align)
935
+ const [latestOffset] = notUndefined(
936
+ this.getOffsetForIndex(index, align),
937
+ )
862
938
 
863
- if (!approxEqual(toOffset, this.scrollOffset)) {
939
+ if (!approxEqual(latestOffset, this.getScrollOffset())) {
864
940
  this.scrollToIndex(index, { align, behavior })
865
941
  }
866
942
  } else {
@@ -879,7 +955,7 @@ export class Virtualizer<
879
955
  )
880
956
  }
881
957
 
882
- this._scrollToOffset(this.scrollOffset + delta, {
958
+ this._scrollToOffset(this.getScrollOffset() + delta, {
883
959
  adjustments: undefined,
884
960
  behavior,
885
961
  })
package/src/utils.ts CHANGED
@@ -78,10 +78,14 @@ export function notUndefined<T>(value: T | undefined, msg?: string): T {
78
78
 
79
79
  export const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1
80
80
 
81
- export const debounce = (fn: Function, ms: number) => {
82
- let timeoutId: ReturnType<typeof setTimeout>
81
+ export const debounce = (
82
+ targetWindow: Window & typeof globalThis,
83
+ fn: Function,
84
+ ms: number,
85
+ ) => {
86
+ let timeoutId: number
83
87
  return function (this: any, ...args: any[]) {
84
- clearTimeout(timeoutId)
85
- timeoutId = setTimeout(() => fn.apply(this, args), ms)
88
+ targetWindow.clearTimeout(timeoutId)
89
+ timeoutId = targetWindow.setTimeout(() => fn.apply(this, args), ms)
86
90
  }
87
91
  }