@tanstack/virtual-core 3.2.1 → 3.4.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
@@ -1,4 +1,4 @@
1
- import { approxEqual, memo, notUndefined } from './utils'
1
+ import { approxEqual, memo, notUndefined, debounce } from './utils'
2
2
 
3
3
  export * from './utils'
4
4
 
@@ -98,6 +98,10 @@ export const observeElementRect = <T extends Element>(
98
98
  }
99
99
  }
100
100
 
101
+ const addEventListenerOptions = {
102
+ passive: true,
103
+ }
104
+
101
105
  export const observeWindowRect = (
102
106
  instance: Virtualizer<Window, any>,
103
107
  cb: (rect: Rect) => void,
@@ -112,58 +116,81 @@ export const observeWindowRect = (
112
116
  }
113
117
  handler()
114
118
 
115
- element.addEventListener('resize', handler, {
116
- passive: true,
117
- })
119
+ element.addEventListener('resize', handler, addEventListenerOptions)
118
120
 
119
121
  return () => {
120
122
  element.removeEventListener('resize', handler)
121
123
  }
122
124
  }
123
125
 
126
+ const supportsScrollend =
127
+ typeof window == 'undefined' ? true : 'onscrollend' in window
128
+
124
129
  export const observeElementOffset = <T extends Element>(
125
130
  instance: Virtualizer<T, any>,
126
- cb: (offset: number) => void,
131
+ cb: (offset: number, isScrolling: boolean) => void,
127
132
  ) => {
128
133
  const element = instance.scrollElement
129
134
  if (!element) {
130
135
  return
131
136
  }
132
137
 
133
- const handler = () => {
134
- cb(element[instance.options.horizontal ? 'scrollLeft' : 'scrollTop'])
138
+ let offset = 0
139
+ const fallback = supportsScrollend
140
+ ? () => undefined
141
+ : debounce(() => {
142
+ cb(offset, false)
143
+ }, 150)
144
+
145
+ const createHandler = (isScrolling: boolean) => () => {
146
+ offset = element[instance.options.horizontal ? 'scrollLeft' : 'scrollTop']
147
+ fallback()
148
+ cb(offset, isScrolling)
135
149
  }
136
- handler()
150
+ const handler = createHandler(true)
151
+ const endHandler = createHandler(false)
152
+ endHandler()
137
153
 
138
- element.addEventListener('scroll', handler, {
139
- passive: true,
140
- })
154
+ element.addEventListener('scroll', handler, addEventListenerOptions)
155
+ element.addEventListener('scrollend', endHandler, addEventListenerOptions)
141
156
 
142
157
  return () => {
143
158
  element.removeEventListener('scroll', handler)
159
+ element.removeEventListener('scrollend', endHandler)
144
160
  }
145
161
  }
146
162
 
147
163
  export const observeWindowOffset = (
148
164
  instance: Virtualizer<Window, any>,
149
- cb: (offset: number) => void,
165
+ cb: (offset: number, isScrolling: boolean) => void,
150
166
  ) => {
151
167
  const element = instance.scrollElement
152
168
  if (!element) {
153
169
  return
154
170
  }
155
171
 
156
- const handler = () => {
157
- cb(element[instance.options.horizontal ? 'scrollX' : 'scrollY'])
172
+ let offset = 0
173
+ const fallback = supportsScrollend
174
+ ? () => undefined
175
+ : debounce(() => {
176
+ cb(offset, false)
177
+ }, 150)
178
+
179
+ const createHandler = (isScrolling: boolean) => () => {
180
+ offset = element[instance.options.horizontal ? 'scrollX' : 'scrollY']
181
+ fallback()
182
+ cb(offset, isScrolling)
158
183
  }
159
- handler()
184
+ const handler = createHandler(true)
185
+ const endHandler = createHandler(false)
186
+ endHandler()
160
187
 
161
- element.addEventListener('scroll', handler, {
162
- passive: true,
163
- })
188
+ element.addEventListener('scroll', handler, addEventListenerOptions)
189
+ element.addEventListener('scrollend', endHandler, addEventListenerOptions)
164
190
 
165
191
  return () => {
166
192
  element.removeEventListener('scroll', handler)
193
+ element.removeEventListener('scrollend', endHandler)
167
194
  }
168
195
  }
169
196
 
@@ -241,7 +268,7 @@ export interface VirtualizerOptions<
241
268
  ) => void | (() => void)
242
269
  observeElementOffset: (
243
270
  instance: Virtualizer<TScrollElement, TItemElement>,
244
- cb: (offset: number) => void,
271
+ cb: (offset: number, isScrolling: boolean) => void,
245
272
  ) => void | (() => void)
246
273
 
247
274
  // Optional
@@ -267,7 +294,6 @@ export interface VirtualizerOptions<
267
294
  rangeExtractor?: (range: Range) => number[]
268
295
  scrollMargin?: number
269
296
  gap?: number
270
- scrollingDelay?: number
271
297
  indexAttribute?: string
272
298
  initialMeasurementsCache?: VirtualItem[]
273
299
  lanes?: number
@@ -281,7 +307,6 @@ export class Virtualizer<
281
307
  options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>
282
308
  scrollElement: TScrollElement | null = null
283
309
  isScrolling: boolean = false
284
- private isScrollingTimeoutId: ReturnType<typeof setTimeout> | null = null
285
310
  private scrollToIndexTimeoutId: ReturnType<typeof setTimeout> | null = null
286
311
  measurementsCache: VirtualItem[] = []
287
312
  private itemSizeCache = new Map<Key, number>()
@@ -290,6 +315,13 @@ export class Virtualizer<
290
315
  scrollOffset: number
291
316
  scrollDirection: ScrollDirection | null = null
292
317
  private scrollAdjustments: number = 0
318
+ shouldAdjustScrollPositionOnItemSizeChange:
319
+ | undefined
320
+ | ((
321
+ item: VirtualItem,
322
+ delta: number,
323
+ instance: Virtualizer<TScrollElement, TItemElement>,
324
+ ) => boolean)
293
325
  measureElementCache = new Map<Key, TItemElement>()
294
326
  private observer = (() => {
295
327
  let _ro: ResizeObserver | null = null
@@ -329,7 +361,7 @@ export class Virtualizer<
329
361
  this.itemSizeCache.set(item.key, item.size)
330
362
  })
331
363
 
332
- this.maybeNotify()
364
+ this.notify(false, false)
333
365
  }
334
366
 
335
367
  setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
@@ -353,7 +385,6 @@ export class Virtualizer<
353
385
  initialRect: { width: 0, height: 0 },
354
386
  scrollMargin: 0,
355
387
  gap: 0,
356
- scrollingDelay: 150,
357
388
  indexAttribute: 'data-index',
358
389
  initialMeasurementsCache: [],
359
390
  lanes: 1,
@@ -361,34 +392,22 @@ export class Virtualizer<
361
392
  }
362
393
  }
363
394
 
364
- private notify = (sync: boolean) => {
365
- this.options.onChange?.(this, sync)
395
+ private notify = (force: boolean, sync: boolean) => {
396
+ const { startIndex, endIndex } = this.range ?? {
397
+ startIndex: undefined,
398
+ endIndex: undefined,
399
+ }
400
+ const range = this.calculateRange()
401
+
402
+ if (
403
+ force ||
404
+ startIndex !== range?.startIndex ||
405
+ endIndex !== range?.endIndex
406
+ ) {
407
+ this.options.onChange?.(this, sync)
408
+ }
366
409
  }
367
410
 
368
- private maybeNotify = memo(
369
- () => {
370
- this.calculateRange()
371
-
372
- return [
373
- this.isScrolling,
374
- this.range ? this.range.startIndex : null,
375
- this.range ? this.range.endIndex : null,
376
- ]
377
- },
378
- (isScrolling) => {
379
- this.notify(isScrolling)
380
- },
381
- {
382
- key: process.env.NODE_ENV !== 'production' && 'maybeNotify',
383
- debug: () => this.options.debug,
384
- initialDeps: [
385
- this.isScrolling,
386
- this.range ? this.range.startIndex : null,
387
- this.range ? this.range.endIndex : null,
388
- ] as [boolean, number | null, number | null],
389
- },
390
- )
391
-
392
411
  private cleanup = () => {
393
412
  this.unsubs.filter(Boolean).forEach((d) => d!())
394
413
  this.unsubs = []
@@ -419,37 +438,24 @@ export class Virtualizer<
419
438
  this.unsubs.push(
420
439
  this.options.observeElementRect(this, (rect) => {
421
440
  this.scrollRect = rect
422
- this.maybeNotify()
441
+ this.notify(false, false)
423
442
  }),
424
443
  )
425
444
 
426
445
  this.unsubs.push(
427
- this.options.observeElementOffset(this, (offset) => {
446
+ this.options.observeElementOffset(this, (offset, isScrolling) => {
428
447
  this.scrollAdjustments = 0
429
-
430
- if (this.scrollOffset === offset) {
431
- return
432
- }
433
-
434
- if (this.isScrollingTimeoutId !== null) {
435
- clearTimeout(this.isScrollingTimeoutId)
436
- this.isScrollingTimeoutId = null
437
- }
438
-
439
- this.isScrolling = true
440
- this.scrollDirection =
441
- this.scrollOffset < offset ? 'forward' : 'backward'
448
+ this.scrollDirection = isScrolling
449
+ ? this.scrollOffset < offset
450
+ ? 'forward'
451
+ : 'backward'
452
+ : null
442
453
  this.scrollOffset = offset
443
454
 
444
- this.maybeNotify()
455
+ const prevIsScrolling = this.isScrolling
456
+ this.isScrolling = isScrolling
445
457
 
446
- this.isScrollingTimeoutId = setTimeout(() => {
447
- this.isScrollingTimeoutId = null
448
- this.isScrolling = false
449
- this.scrollDirection = null
450
-
451
- this.maybeNotify()
452
- }, this.options.scrollingDelay)
458
+ this.notify(prevIsScrolling !== isScrolling, isScrolling)
453
459
  }),
454
460
  )
455
461
  }
@@ -459,7 +465,7 @@ export class Virtualizer<
459
465
  return this.scrollRect[this.options.horizontal ? 'width' : 'height']
460
466
  }
461
467
 
462
- private memoOptions = memo(
468
+ private getMeasurementOptions = memo(
463
469
  () => [
464
470
  this.options.count,
465
471
  this.options.paddingStart,
@@ -522,7 +528,7 @@ export class Virtualizer<
522
528
  }
523
529
 
524
530
  private getMeasurements = memo(
525
- () => [this.memoOptions(), this.itemSizeCache],
531
+ () => [this.getMeasurementOptions(), this.itemSizeCache],
526
532
  ({ count, paddingStart, scrollMargin, getItemKey }, itemSizeCache) => {
527
533
  const min =
528
534
  this.pendingMeasuredCacheIndexes.length > 0
@@ -605,7 +611,8 @@ export class Virtualizer<
605
611
  return range === null
606
612
  ? []
607
613
  : rangeExtractor({
608
- ...range,
614
+ startIndex: range.startIndex,
615
+ endIndex: range.endIndex,
609
616
  overscan,
610
617
  count,
611
618
  })
@@ -666,7 +673,11 @@ export class Virtualizer<
666
673
  const delta = size - itemSize
667
674
 
668
675
  if (delta !== 0) {
669
- if (item.start < this.scrollOffset + this.scrollAdjustments) {
676
+ if (
677
+ this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
678
+ ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
679
+ : item.start < this.scrollOffset + this.scrollAdjustments
680
+ ) {
670
681
  if (process.env.NODE_ENV !== 'production' && this.options.debug) {
671
682
  console.info('correction', delta)
672
683
  }
@@ -680,7 +691,7 @@ export class Virtualizer<
680
691
  this.pendingMeasuredCacheIndexes.push(item.index)
681
692
  this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size))
682
693
 
683
- this.notify(false)
694
+ this.notify(true, false)
684
695
  }
685
696
  }
686
697
 
@@ -907,7 +918,7 @@ export class Virtualizer<
907
918
 
908
919
  measure = () => {
909
920
  this.itemSizeCache = new Map()
910
- this.notify(false)
921
+ this.options.onChange?.(this, false)
911
922
  }
912
923
  }
913
924
 
package/src/utils.ts CHANGED
@@ -77,3 +77,11 @@ export function notUndefined<T>(value: T | undefined, msg?: string): T {
77
77
  }
78
78
 
79
79
  export const approxEqual = (a: number, b: number) => Math.abs(a - b) < 1
80
+
81
+ export const debounce = (fn: Function, ms: number) => {
82
+ let timeoutId: ReturnType<typeof setTimeout>
83
+ return function (this: any, ...args: any[]) {
84
+ clearTimeout(timeoutId)
85
+ timeoutId = setTimeout(() => fn.apply(this, args), ms)
86
+ }
87
+ }