@tanstack/virtual-core 3.0.0-beta.8 → 3.0.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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/build/lib/_virtual/_rollupPluginBabelHelpers.esm.js +27 -0
  3. package/build/lib/_virtual/_rollupPluginBabelHelpers.esm.js.map +1 -0
  4. package/build/lib/_virtual/_rollupPluginBabelHelpers.js +31 -0
  5. package/build/lib/_virtual/_rollupPluginBabelHelpers.js.map +1 -0
  6. package/build/lib/_virtual/_rollupPluginBabelHelpers.mjs +27 -0
  7. package/build/lib/_virtual/_rollupPluginBabelHelpers.mjs.map +1 -0
  8. package/build/lib/index.d.ts +126 -0
  9. package/build/lib/index.esm.js +639 -0
  10. package/build/lib/index.esm.js.map +1 -0
  11. package/build/lib/index.js +654 -0
  12. package/build/lib/index.js.map +1 -0
  13. package/build/lib/index.mjs +639 -0
  14. package/build/lib/index.mjs.map +1 -0
  15. package/build/lib/utils.d.ts +10 -0
  16. package/build/lib/utils.esm.js +58 -0
  17. package/build/lib/utils.esm.js.map +1 -0
  18. package/build/{cjs/packages/virtual-core/src → lib}/utils.js +28 -21
  19. package/build/lib/utils.js.map +1 -0
  20. package/build/lib/utils.mjs +58 -0
  21. package/build/lib/utils.mjs.map +1 -0
  22. package/build/umd/index.development.js +592 -454
  23. package/build/umd/index.development.js.map +1 -1
  24. package/build/umd/index.production.js +1 -1
  25. package/build/umd/index.production.js.map +1 -1
  26. package/package.json +16 -14
  27. package/src/index.ts +551 -244
  28. package/src/utils.ts +17 -5
  29. package/build/cjs/packages/virtual-core/src/index.js +0 -466
  30. package/build/cjs/packages/virtual-core/src/index.js.map +0 -1
  31. package/build/cjs/packages/virtual-core/src/utils.js.map +0 -1
  32. package/build/esm/index.js +0 -560
  33. package/build/esm/index.js.map +0 -1
  34. package/build/stats-html.html +0 -2689
  35. package/build/stats.json +0 -101
  36. package/build/types/index.d.ts +0 -88
  37. package/build/types/utils.d.ts +0 -7
package/src/index.ts CHANGED
@@ -1,14 +1,18 @@
1
- import observeRect from '@reach/observe-rect'
2
- import { memo } from './utils'
1
+ import { approxEqual, memo, notUndefined } from './utils'
3
2
 
4
3
  export * from './utils'
5
4
 
6
5
  //
7
6
 
7
+ type ScrollDirection = 'forward' | 'backward'
8
+
8
9
  type ScrollAlignment = 'start' | 'center' | 'end' | 'auto'
9
10
 
11
+ type ScrollBehavior = 'auto' | 'smooth'
12
+
10
13
  export interface ScrollToOptions {
11
- align: ScrollAlignment
14
+ align?: ScrollAlignment
15
+ behavior?: ScrollBehavior
12
16
  }
13
17
 
14
18
  type ScrollToOffsetOptions = ScrollToOptions
@@ -24,12 +28,13 @@ export interface Range {
24
28
 
25
29
  type Key = number | string
26
30
 
27
- interface Item {
31
+ export interface VirtualItem {
28
32
  key: Key
29
33
  index: number
30
34
  start: number
31
35
  end: number
32
36
  size: number
37
+ lane: number
33
38
  }
34
39
 
35
40
  interface Rect {
@@ -37,10 +42,6 @@ interface Rect {
37
42
  height: number
38
43
  }
39
44
 
40
- export interface VirtualItem<TItemElement> extends Item {
41
- measureElement: (el: TItemElement | null) => void
42
- }
43
-
44
45
  //
45
46
 
46
47
  export const defaultKeyExtractor = (index: number) => index
@@ -58,152 +59,176 @@ export const defaultRangeExtractor = (range: Range) => {
58
59
  return arr
59
60
  }
60
61
 
61
- export const observeElementRect = (
62
- instance: Virtualizer<any, any>,
62
+ export const observeElementRect = <T extends Element>(
63
+ instance: Virtualizer<T, any>,
63
64
  cb: (rect: Rect) => void,
64
65
  ) => {
65
- const observer = observeRect(instance.scrollElement as Element, (rect) => {
66
- cb(rect)
67
- })
68
-
69
- if (!instance.scrollElement) {
66
+ const element = instance.scrollElement
67
+ if (!element) {
70
68
  return
71
69
  }
72
70
 
73
- cb(instance.scrollElement.getBoundingClientRect())
71
+ const handler = (rect: Rect) => {
72
+ const { width, height } = rect
73
+ cb({ width: Math.round(width), height: Math.round(height) })
74
+ }
75
+
76
+ handler(element.getBoundingClientRect())
74
77
 
75
- observer.observe()
78
+ const observer = new ResizeObserver((entries) => {
79
+ const entry = entries[0]
80
+ if (entry?.borderBoxSize) {
81
+ const box = entry.borderBoxSize[0]
82
+ if (box) {
83
+ handler({ width: box.inlineSize, height: box.blockSize })
84
+ return
85
+ }
86
+ }
87
+ handler(element.getBoundingClientRect())
88
+ })
89
+
90
+ observer.observe(element, { box: 'border-box' })
76
91
 
77
92
  return () => {
78
- observer.unobserve()
93
+ observer.unobserve(element)
79
94
  }
80
95
  }
81
96
 
82
97
  export const observeWindowRect = (
83
- instance: Virtualizer<any, any>,
98
+ instance: Virtualizer<Window, any>,
84
99
  cb: (rect: Rect) => void,
85
100
  ) => {
86
- const onResize = () => {
87
- cb({
88
- width: instance.scrollElement.innerWidth,
89
- height: instance.scrollElement.innerHeight,
90
- })
91
- }
92
-
93
- if (!instance.scrollElement) {
101
+ const element = instance.scrollElement
102
+ if (!element) {
94
103
  return
95
104
  }
96
105
 
97
- onResize()
106
+ const handler = () => {
107
+ cb({ width: element.innerWidth, height: element.innerHeight })
108
+ }
109
+ handler()
98
110
 
99
- instance.scrollElement.addEventListener('resize', onResize, {
100
- capture: false,
111
+ element.addEventListener('resize', handler, {
101
112
  passive: true,
102
113
  })
103
114
 
104
115
  return () => {
105
- instance.scrollElement.removeEventListener('resize', onResize)
116
+ element.removeEventListener('resize', handler)
106
117
  }
107
118
  }
108
119
 
109
- export const observeElementOffset = (
110
- instance: Virtualizer<any, any>,
120
+ export const observeElementOffset = <T extends Element>(
121
+ instance: Virtualizer<T, any>,
111
122
  cb: (offset: number) => void,
112
123
  ) => {
113
- const onScroll = () =>
114
- cb(
115
- instance.scrollElement[
116
- instance.options.horizontal ? 'scrollLeft' : 'scrollTop'
117
- ],
118
- )
119
-
120
- if (!instance.scrollElement) {
124
+ const element = instance.scrollElement
125
+ if (!element) {
121
126
  return
122
127
  }
123
128
 
124
- onScroll()
129
+ const handler = () => {
130
+ cb(element[instance.options.horizontal ? 'scrollLeft' : 'scrollTop'])
131
+ }
132
+ handler()
125
133
 
126
- instance.scrollElement.addEventListener('scroll', onScroll, {
127
- capture: false,
134
+ element.addEventListener('scroll', handler, {
128
135
  passive: true,
129
136
  })
130
137
 
131
138
  return () => {
132
- instance.scrollElement.removeEventListener('scroll', onScroll)
139
+ element.removeEventListener('scroll', handler)
133
140
  }
134
141
  }
135
142
 
136
143
  export const observeWindowOffset = (
137
- instance: Virtualizer<any, any>,
144
+ instance: Virtualizer<Window, any>,
138
145
  cb: (offset: number) => void,
139
146
  ) => {
140
- const onScroll = () =>
141
- cb(
142
- instance.scrollElement[
143
- instance.options.horizontal ? 'scrollX' : 'scrollY'
144
- ],
145
- )
146
-
147
- if (!instance.scrollElement) {
147
+ const element = instance.scrollElement
148
+ if (!element) {
148
149
  return
149
150
  }
150
151
 
151
- onScroll()
152
+ const handler = () => {
153
+ cb(element[instance.options.horizontal ? 'scrollX' : 'scrollY'])
154
+ }
155
+ handler()
152
156
 
153
- instance.scrollElement.addEventListener('scroll', onScroll, {
154
- capture: false,
157
+ element.addEventListener('scroll', handler, {
155
158
  passive: true,
156
159
  })
157
160
 
158
161
  return () => {
159
- instance.scrollElement.removeEventListener('scroll', onScroll)
162
+ element.removeEventListener('scroll', handler)
160
163
  }
161
164
  }
162
165
 
163
- export const measureElement = (
164
- element: unknown,
165
- instance: Virtualizer<any, any>,
166
+ export const measureElement = <TItemElement extends Element>(
167
+ element: TItemElement,
168
+ entry: ResizeObserverEntry | undefined,
169
+ instance: Virtualizer<any, TItemElement>,
166
170
  ) => {
167
- return (element as Element).getBoundingClientRect()[
168
- instance.options.horizontal ? 'width' : 'height'
169
- ]
171
+ if (entry?.borderBoxSize) {
172
+ const box = entry.borderBoxSize[0]
173
+ if (box) {
174
+ const size = Math.round(
175
+ box[instance.options.horizontal ? 'inlineSize' : 'blockSize'],
176
+ )
177
+ return size
178
+ }
179
+ }
180
+ return Math.round(
181
+ element.getBoundingClientRect()[
182
+ instance.options.horizontal ? 'width' : 'height'
183
+ ],
184
+ )
170
185
  }
171
186
 
172
- export const windowScroll = (
187
+ export const windowScroll = <T extends Window>(
173
188
  offset: number,
174
- canSmooth: boolean,
175
- instance: Virtualizer<any, any>,
189
+ {
190
+ adjustments = 0,
191
+ behavior,
192
+ }: { adjustments?: number; behavior?: ScrollBehavior },
193
+ instance: Virtualizer<T, any>,
176
194
  ) => {
177
- ;(instance.scrollElement as Window)?.scrollTo({
178
- [instance.options.horizontal ? 'left' : 'top']: offset,
179
- behavior: canSmooth ? 'smooth' : undefined,
195
+ const toOffset = offset + adjustments
196
+
197
+ instance.scrollElement?.scrollTo?.({
198
+ [instance.options.horizontal ? 'left' : 'top']: toOffset,
199
+ behavior,
180
200
  })
181
201
  }
182
202
 
183
- export const elementScroll = (
203
+ export const elementScroll = <T extends Element>(
184
204
  offset: number,
185
- canSmooth: boolean,
186
- instance: Virtualizer<any, any>,
205
+ {
206
+ adjustments = 0,
207
+ behavior,
208
+ }: { adjustments?: number; behavior?: ScrollBehavior },
209
+ instance: Virtualizer<T, any>,
187
210
  ) => {
188
- ;(instance.scrollElement as Element)?.scrollTo({
189
- [instance.options.horizontal ? 'left' : 'top']: offset,
190
- behavior: canSmooth ? 'smooth' : undefined,
211
+ const toOffset = offset + adjustments
212
+
213
+ instance.scrollElement?.scrollTo?.({
214
+ [instance.options.horizontal ? 'left' : 'top']: toOffset,
215
+ behavior,
191
216
  })
192
217
  }
193
218
 
194
219
  export interface VirtualizerOptions<
195
- TScrollElement = unknown,
196
- TItemElement = unknown,
220
+ TScrollElement extends Element | Window,
221
+ TItemElement extends Element,
197
222
  > {
198
223
  // Required from the user
199
224
  count: number
200
- getScrollElement: () => TScrollElement
225
+ getScrollElement: () => TScrollElement | null
201
226
  estimateSize: (index: number) => number
202
227
 
203
228
  // Required from the framework adapter (but can be overridden)
204
229
  scrollToFn: (
205
230
  offset: number,
206
- canSmooth: boolean,
231
+ options: { adjustments?: number; behavior?: ScrollBehavior },
207
232
  instance: Virtualizer<TScrollElement, TItemElement>,
208
233
  ) => void
209
234
  observeElementRect: (
@@ -218,9 +243,13 @@ export interface VirtualizerOptions<
218
243
  // Optional
219
244
  debug?: any
220
245
  initialRect?: Rect
221
- onChange?: (instance: Virtualizer<TScrollElement, TItemElement>) => void
246
+ onChange?: (
247
+ instance: Virtualizer<TScrollElement, TItemElement>,
248
+ sync: boolean,
249
+ ) => void
222
250
  measureElement?: (
223
- el: TItemElement,
251
+ element: TItemElement,
252
+ entry: ResizeObserverEntry | undefined,
224
253
  instance: Virtualizer<TScrollElement, TItemElement>,
225
254
  ) => number
226
255
  overscan?: number
@@ -232,29 +261,67 @@ export interface VirtualizerOptions<
232
261
  initialOffset?: number
233
262
  getItemKey?: (index: number) => Key
234
263
  rangeExtractor?: (range: Range) => number[]
235
- enableSmoothScroll?: boolean
264
+ scrollMargin?: number
265
+ scrollingDelay?: number
266
+ indexAttribute?: string
267
+ initialMeasurementsCache?: VirtualItem[]
268
+ lanes?: number
236
269
  }
237
270
 
238
- export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
271
+ export class Virtualizer<
272
+ TScrollElement extends Element | Window,
273
+ TItemElement extends Element,
274
+ > {
239
275
  private unsubs: (void | (() => void))[] = []
240
276
  options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>
241
277
  scrollElement: TScrollElement | null = null
242
- private measurementsCache: Item[] = []
243
- private itemMeasurementsCache: Record<Key, number> = {}
278
+ isScrolling: boolean = false
279
+ private isScrollingTimeoutId: ReturnType<typeof setTimeout> | null = null
280
+ private scrollToIndexTimeoutId: ReturnType<typeof setTimeout> | null = null
281
+ measurementsCache: VirtualItem[] = []
282
+ private itemSizeCache = new Map<Key, number>()
244
283
  private pendingMeasuredCacheIndexes: number[] = []
245
- private scrollRect: Rect
246
- private scrollOffset: number
247
- private destinationOffset: undefined | number
248
- private scrollCheckFrame!: ReturnType<typeof setTimeout>
249
- private measureElementCache: Record<
250
- number,
251
- (measurableItem: TItemElement | null) => void
252
- > = {}
284
+ scrollRect: Rect
285
+ scrollOffset: number
286
+ scrollDirection: ScrollDirection | null = null
287
+ private scrollAdjustments: number = 0
288
+ measureElementCache = new Map<Key, TItemElement>()
289
+ private observer = (() => {
290
+ let _ro: ResizeObserver | null = null
291
+
292
+ const get = () => {
293
+ if (_ro) {
294
+ return _ro
295
+ } else if (typeof ResizeObserver !== 'undefined') {
296
+ return (_ro = new ResizeObserver((entries) => {
297
+ entries.forEach((entry) => {
298
+ this._measureElement(entry.target as TItemElement, entry)
299
+ })
300
+ }))
301
+ } else {
302
+ return null
303
+ }
304
+ }
305
+
306
+ return {
307
+ disconnect: () => get()?.disconnect(),
308
+ observe: (target: Element) =>
309
+ get()?.observe(target, { box: 'border-box' }),
310
+ unobserve: (target: Element) => get()?.unobserve(target),
311
+ }
312
+ })()
313
+ range: { startIndex: number; endIndex: number } | null = null
253
314
 
254
315
  constructor(opts: VirtualizerOptions<TScrollElement, TItemElement>) {
255
316
  this.setOptions(opts)
256
317
  this.scrollRect = this.options.initialRect
257
318
  this.scrollOffset = this.options.initialOffset
319
+ this.measurementsCache = this.options.initialMeasurementsCache
320
+ this.measurementsCache.forEach((item) => {
321
+ this.itemSizeCache.set(item.key, item.size)
322
+ })
323
+
324
+ this.maybeNotify()
258
325
  }
259
326
 
260
327
  setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
@@ -273,18 +340,46 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
273
340
  horizontal: false,
274
341
  getItemKey: defaultKeyExtractor,
275
342
  rangeExtractor: defaultRangeExtractor,
276
- enableSmoothScroll: true,
277
343
  onChange: () => {},
278
344
  measureElement,
279
345
  initialRect: { width: 0, height: 0 },
346
+ scrollMargin: 0,
347
+ scrollingDelay: 150,
348
+ indexAttribute: 'data-index',
349
+ initialMeasurementsCache: [],
350
+ lanes: 1,
280
351
  ...opts,
281
352
  }
282
353
  }
283
354
 
284
- private notify = () => {
285
- this.options.onChange?.(this)
355
+ private notify = (sync: boolean) => {
356
+ this.options.onChange?.(this, sync)
286
357
  }
287
358
 
359
+ private maybeNotify = memo(
360
+ () => {
361
+ this.calculateRange()
362
+
363
+ return [
364
+ this.isScrolling,
365
+ this.range ? this.range.startIndex : null,
366
+ this.range ? this.range.endIndex : null,
367
+ ]
368
+ },
369
+ (isScrolling) => {
370
+ this.notify(isScrolling)
371
+ },
372
+ {
373
+ key: process.env.NODE_ENV !== 'production' && 'maybeNotify',
374
+ debug: () => this.options.debug,
375
+ initialDeps: [
376
+ this.isScrolling,
377
+ this.range ? this.range.startIndex : null,
378
+ this.range ? this.range.endIndex : null,
379
+ ] as [boolean, number | null, number | null],
380
+ },
381
+ )
382
+
288
383
  private cleanup = () => {
289
384
  this.unsubs.filter(Boolean).forEach((d) => d!())
290
385
  this.unsubs = []
@@ -292,7 +387,9 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
292
387
  }
293
388
 
294
389
  _didMount = () => {
390
+ this.measureElementCache.forEach(this.observer.observe)
295
391
  return () => {
392
+ this.observer.disconnect()
296
393
  this.cleanup()
297
394
  }
298
395
  }
@@ -305,17 +402,45 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
305
402
 
306
403
  this.scrollElement = scrollElement
307
404
 
405
+ this._scrollToOffset(this.scrollOffset, {
406
+ adjustments: undefined,
407
+ behavior: undefined,
408
+ })
409
+
308
410
  this.unsubs.push(
309
411
  this.options.observeElementRect(this, (rect) => {
310
412
  this.scrollRect = rect
311
- this.notify()
413
+ this.maybeNotify()
312
414
  }),
313
415
  )
314
416
 
315
417
  this.unsubs.push(
316
418
  this.options.observeElementOffset(this, (offset) => {
419
+ this.scrollAdjustments = 0
420
+
421
+ if (this.scrollOffset === offset) {
422
+ return
423
+ }
424
+
425
+ if (this.isScrollingTimeoutId !== null) {
426
+ clearTimeout(this.isScrollingTimeoutId)
427
+ this.isScrollingTimeoutId = null
428
+ }
429
+
430
+ this.isScrolling = true
431
+ this.scrollDirection =
432
+ this.scrollOffset < offset ? 'forward' : 'backward'
317
433
  this.scrollOffset = offset
318
- this.notify()
434
+
435
+ this.maybeNotify()
436
+
437
+ this.isScrollingTimeoutId = setTimeout(() => {
438
+ this.isScrollingTimeoutId = null
439
+ this.isScrolling = false
440
+ this.scrollDirection = null
441
+
442
+ this.maybeNotify()
443
+ }, this.options.scrollingDelay)
319
444
  }),
320
445
  )
321
446
  }
@@ -325,14 +450,67 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
325
450
  return this.scrollRect[this.options.horizontal ? 'width' : 'height']
326
451
  }
327
452
 
328
- private getMeasurements = memo(
453
+ private memoOptions = memo(
329
454
  () => [
330
455
  this.options.count,
331
456
  this.options.paddingStart,
457
+ this.options.scrollMargin,
332
458
  this.options.getItemKey,
333
- this.itemMeasurementsCache,
334
459
  ],
335
- (count, paddingStart, getItemKey, measurementsCache) => {
460
+ (count, paddingStart, scrollMargin, getItemKey) => {
461
+ this.pendingMeasuredCacheIndexes = []
462
+ return {
463
+ count,
464
+ paddingStart,
465
+ scrollMargin,
466
+ getItemKey,
467
+ }
468
+ },
469
+ {
470
+ key: false,
471
+ },
472
+ )
473
+
474
+ private getFurthestMeasurement = (
475
+ measurements: VirtualItem[],
476
+ index: number,
477
+ ) => {
478
+ const furthestMeasurementsFound = new Map<number, true>()
479
+ const furthestMeasurements = new Map<number, VirtualItem>()
480
+ for (let m = index - 1; m >= 0; m--) {
481
+ const measurement = measurements[m]!
482
+
483
+ if (furthestMeasurementsFound.has(measurement.lane)) {
484
+ continue
485
+ }
486
+
487
+ const previousFurthestMeasurement = furthestMeasurements.get(
488
+ measurement.lane,
489
+ )
490
+ if (
491
+ previousFurthestMeasurement == null ||
492
+ measurement.end > previousFurthestMeasurement.end
493
+ ) {
494
+ furthestMeasurements.set(measurement.lane, measurement)
495
+ } else if (measurement.end < previousFurthestMeasurement.end) {
496
+ furthestMeasurementsFound.set(measurement.lane, true)
497
+ }
498
+
499
+ if (furthestMeasurementsFound.size === this.options.lanes) {
500
+ break
501
+ }
502
+ }
503
+
504
+ return furthestMeasurements.size === this.options.lanes
505
+ ? Array.from(furthestMeasurements.values()).sort(
506
+ (a, b) => a.end - b.end,
507
+ )[0]
508
+ : undefined
509
+ }
510
+
511
+ private getMeasurements = memo(
512
+ () => [this.memoOptions(), this.itemSizeCache],
513
+ ({ count, paddingStart, scrollMargin, getItemKey }, itemSizeCache) => {
336
514
  const min =
337
515
  this.pendingMeasuredCacheIndexes.length > 0
338
516
  ? Math.min(...this.pendingMeasuredCacheIndexes)
@@ -343,38 +521,62 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
343
521
 
344
522
  for (let i = min; i < count; i++) {
345
523
  const key = getItemKey(i)
346
- const measuredSize = measurementsCache[key]
347
- const start = measurements[i - 1]
348
- ? measurements[i - 1]!.end
349
- : paddingStart
524
+
525
+ const furthestMeasurement =
526
+ this.options.lanes === 1
527
+ ? measurements[i - 1]
528
+ : this.getFurthestMeasurement(measurements, i)
529
+
530
+ const start = furthestMeasurement
531
+ ? furthestMeasurement.end
532
+ : paddingStart + scrollMargin
533
+
534
+ const measuredSize = itemSizeCache.get(key)
350
535
  const size =
351
536
  typeof measuredSize === 'number'
352
537
  ? measuredSize
353
538
  : this.options.estimateSize(i)
539
+
354
540
  const end = start + size
355
- measurements[i] = { index: i, start, size, end, key }
541
+
542
+ const lane = furthestMeasurement
543
+ ? furthestMeasurement.lane
544
+ : i % this.options.lanes
545
+
546
+ measurements[i] = {
547
+ index: i,
548
+ start,
549
+ size,
550
+ end,
551
+ key,
552
+ lane,
553
+ }
356
554
  }
357
555
 
358
556
  this.measurementsCache = measurements
557
+
359
558
  return measurements
360
559
  },
361
560
  {
362
- key: process.env.NODE_ENV === 'development' && 'getMeasurements',
561
+ key: process.env.NODE_ENV !== 'production' && 'getMeasurements',
363
562
  debug: () => this.options.debug,
364
563
  },
365
564
  )
366
565
 
367
- private calculateRange = memo(
566
+ calculateRange = memo(
368
567
  () => [this.getMeasurements(), this.getSize(), this.scrollOffset],
369
568
  (measurements, outerSize, scrollOffset) => {
370
- return calculateRange({
371
- measurements,
372
- outerSize,
373
- scrollOffset,
374
- })
569
+ return (this.range =
570
+ measurements.length > 0 && outerSize > 0
571
+ ? calculateRange({
572
+ measurements,
573
+ outerSize,
574
+ scrollOffset,
575
+ })
576
+ : null)
375
577
  },
376
578
  {
377
- key: process.env.NODE_ENV === 'development' && 'calculateRange',
579
+ key: process.env.NODE_ENV !== 'production' && 'calculateRange',
378
580
  debug: () => this.options.debug,
379
581
  },
380
582
  )
@@ -387,144 +589,184 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
387
589
  this.options.count,
388
590
  ],
389
591
  (rangeExtractor, range, overscan, count) => {
390
- return rangeExtractor({
391
- ...range,
392
- overscan,
393
- count: count,
394
- })
592
+ return range === null
593
+ ? []
594
+ : rangeExtractor({
595
+ ...range,
596
+ overscan,
597
+ count,
598
+ })
395
599
  },
396
600
  {
397
- key: process.env.NODE_ENV === 'development' && 'getIndexes',
601
+ key: process.env.NODE_ENV !== 'production' && 'getIndexes',
602
+ debug: () => this.options.debug,
398
603
  },
399
604
  )
400
605
 
401
- getVirtualItems = memo(
402
- () => [
403
- this.getIndexes(),
404
- this.getMeasurements(),
405
- this.options.measureElement,
406
- ],
407
- (indexes, measurements, measureElement) => {
408
- const makeMeasureElement =
409
- (index: number) => (measurableItem: TItemElement | null) => {
410
- const item = this.measurementsCache[index]!
606
+ indexFromElement = (node: TItemElement) => {
607
+ const attributeName = this.options.indexAttribute
608
+ const indexStr = node.getAttribute(attributeName)
411
609
 
412
- if (!measurableItem) {
413
- return
414
- }
610
+ if (!indexStr) {
611
+ console.warn(
612
+ `Missing attribute name '${attributeName}={index}' on measured element.`,
613
+ )
614
+ return -1
615
+ }
415
616
 
416
- const measuredItemSize = measureElement(measurableItem, this)
417
- const itemSize = this.itemMeasurementsCache[item.key] ?? item.size
418
-
419
- if (measuredItemSize !== itemSize) {
420
- if (item.start < this.scrollOffset) {
421
- if (
422
- process.env.NODE_ENV === 'development' &&
423
- this.options.debug
424
- ) {
425
- console.info('correction', measuredItemSize - itemSize)
426
- }
427
-
428
- if (!this.destinationOffset) {
429
- this._scrollToOffset(
430
- this.scrollOffset + (measuredItemSize - itemSize),
431
- false,
432
- )
433
- }
434
- }
435
-
436
- this.pendingMeasuredCacheIndexes.push(index)
437
- this.itemMeasurementsCache = {
438
- ...this.itemMeasurementsCache,
439
- [item.key]: measuredItemSize,
440
- }
441
- this.notify()
442
- }
617
+ return parseInt(indexStr, 10)
618
+ }
619
+
620
+ private _measureElement = (
621
+ node: TItemElement,
622
+ entry: ResizeObserverEntry | undefined,
623
+ ) => {
624
+ const item = this.measurementsCache[this.indexFromElement(node)]
625
+
626
+ if (!item || !node.isConnected) {
627
+ this.measureElementCache.forEach((cached, key) => {
628
+ if (cached === node) {
629
+ this.observer.unobserve(node)
630
+ this.measureElementCache.delete(key)
443
631
  }
632
+ })
633
+ return
634
+ }
635
+
636
+ const prevNode = this.measureElementCache.get(item.key)
637
+
638
+ if (prevNode !== node) {
639
+ if (prevNode) {
640
+ this.observer.unobserve(prevNode)
641
+ }
642
+ this.observer.observe(node)
643
+ this.measureElementCache.set(item.key, node)
644
+ }
645
+
646
+ const measuredItemSize = this.options.measureElement(node, entry, this)
647
+
648
+ this.resizeItem(item, measuredItemSize)
649
+ }
444
650
 
445
- const virtualItems: VirtualItem<TItemElement>[] = []
651
+ resizeItem = (item: VirtualItem, size: number) => {
652
+ const itemSize = this.itemSizeCache.get(item.key) ?? item.size
653
+ const delta = size - itemSize
446
654
 
447
- const currentMeasureElements: typeof this.measureElementCache = {}
655
+ if (delta !== 0) {
656
+ if (item.start < this.scrollOffset) {
657
+ if (process.env.NODE_ENV !== 'production' && this.options.debug) {
658
+ console.info('correction', delta)
659
+ }
660
+
661
+ this._scrollToOffset(this.scrollOffset, {
662
+ adjustments: (this.scrollAdjustments += delta),
663
+ behavior: undefined,
664
+ })
665
+ }
666
+
667
+ this.pendingMeasuredCacheIndexes.push(item.index)
668
+ this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size))
669
+
670
+ this.notify(false)
671
+ }
672
+ }
673
+
674
+ measureElement = (node: TItemElement | null) => {
675
+ if (!node) {
676
+ return
677
+ }
678
+
679
+ this._measureElement(node, undefined)
680
+ }
681
+
682
+ getVirtualItems = memo(
683
+ () => [this.getIndexes(), this.getMeasurements()],
684
+ (indexes, measurements) => {
685
+ const virtualItems: VirtualItem[] = []
448
686
 
449
687
  for (let k = 0, len = indexes.length; k < len; k++) {
450
688
  const i = indexes[k]!
451
689
  const measurement = measurements[i]!
452
690
 
453
- const item = {
454
- ...measurement,
455
- measureElement: (currentMeasureElements[i] =
456
- this.measureElementCache[i] ?? makeMeasureElement(i)),
457
- }
458
- virtualItems.push(item)
691
+ virtualItems.push(measurement)
459
692
  }
460
693
 
461
- this.measureElementCache = currentMeasureElements
462
-
463
694
  return virtualItems
464
695
  },
465
696
  {
466
- key: process.env.NODE_ENV === 'development' && 'getIndexes',
697
+ key: process.env.NODE_ENV !== 'production' && 'getIndexes',
698
+ debug: () => this.options.debug,
467
699
  },
468
700
  )
469
701
 
470
- scrollToOffset = (
471
- toOffset: number,
472
- { align }: ScrollToOffsetOptions = { align: 'start' },
473
- ) => {
474
- const attempt = () => {
475
- const offset = this.scrollOffset
476
- const size = this.getSize()
477
-
478
- if (align === 'auto') {
479
- if (toOffset <= offset) {
480
- align = 'start'
481
- } else if (toOffset >= offset + size) {
482
- align = 'end'
483
- } else {
484
- align = 'start'
485
- }
486
- }
487
-
488
- if (align === 'start') {
489
- this._scrollToOffset(toOffset, true)
490
- } else if (align === 'end') {
491
- this._scrollToOffset(toOffset - size, true)
492
- } else if (align === 'center') {
493
- this._scrollToOffset(toOffset - size / 2, true)
494
- }
495
- }
702
+ getVirtualItemForOffset = (offset: number) => {
703
+ const measurements = this.getMeasurements()
496
704
 
497
- attempt()
498
- requestAnimationFrame(() => {
499
- attempt()
500
- })
705
+ return notUndefined(
706
+ measurements[
707
+ findNearestBinarySearch(
708
+ 0,
709
+ measurements.length - 1,
710
+ (index: number) => notUndefined(measurements[index]).start,
711
+ offset,
712
+ )
713
+ ],
714
+ )
501
715
  }
502
716
 
503
- scrollToIndex = (
504
- index: number,
505
- { align, ...rest }: ScrollToIndexOptions = { align: 'auto' },
506
- ) => {
507
- const measurements = this.getMeasurements()
508
- const offset = this.scrollOffset
717
+ getOffsetForAlignment = (toOffset: number, align: ScrollAlignment) => {
509
718
  const size = this.getSize()
510
- const { count } = this.options
511
719
 
512
- const measurement = measurements[Math.max(0, Math.min(index, count - 1))]
720
+ if (align === 'auto') {
721
+ if (toOffset <= this.scrollOffset) {
722
+ align = 'start'
723
+ } else if (toOffset >= this.scrollOffset + size) {
724
+ align = 'end'
725
+ } else {
726
+ align = 'start'
727
+ }
728
+ }
513
729
 
514
- if (!measurement) {
515
- return
730
+ if (align === 'start') {
731
+ toOffset = toOffset
732
+ } else if (align === 'end') {
733
+ toOffset = toOffset - size
734
+ } else if (align === 'center') {
735
+ toOffset = toOffset - size / 2
516
736
  }
517
737
 
738
+ const scrollSizeProp = this.options.horizontal
739
+ ? 'scrollWidth'
740
+ : 'scrollHeight'
741
+ const scrollSize = this.scrollElement
742
+ ? 'document' in this.scrollElement
743
+ ? this.scrollElement.document.documentElement[scrollSizeProp]
744
+ : this.scrollElement[scrollSizeProp]
745
+ : 0
746
+
747
+ const maxOffset = scrollSize - this.getSize()
748
+
749
+ return Math.max(Math.min(maxOffset, toOffset), 0)
750
+ }
751
+
752
+ getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
753
+ index = Math.max(0, Math.min(index, this.options.count - 1))
754
+
755
+ const measurement = notUndefined(this.getMeasurements()[index])
756
+
518
757
  if (align === 'auto') {
519
- if (measurement.end >= offset + size - this.options.scrollPaddingEnd) {
758
+ if (
759
+ measurement.end >=
760
+ this.scrollOffset + this.getSize() - this.options.scrollPaddingEnd
761
+ ) {
520
762
  align = 'end'
521
763
  } else if (
522
764
  measurement.start <=
523
- offset + this.options.scrollPaddingStart
765
+ this.scrollOffset + this.options.scrollPaddingStart
524
766
  ) {
525
767
  align = 'start'
526
768
  } else {
527
- return
769
+ return [this.scrollOffset, align] as const
528
770
  }
529
771
  }
530
772
 
@@ -533,47 +775,112 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
533
775
  ? measurement.end + this.options.scrollPaddingEnd
534
776
  : measurement.start - this.options.scrollPaddingStart
535
777
 
536
- this.scrollToOffset(toOffset, { align, ...rest })
778
+ return [this.getOffsetForAlignment(toOffset, align), align] as const
537
779
  }
538
780
 
539
- getTotalSize = () =>
540
- (this.getMeasurements()[this.options.count - 1]?.end ||
541
- this.options.paddingStart) + this.options.paddingEnd
781
+ private isDynamicMode = () => this.measureElementCache.size > 0
542
782
 
543
- private _scrollToOffset = (offset: number, canSmooth: boolean) => {
544
- clearTimeout(this.scrollCheckFrame)
783
+ private cancelScrollToIndex = () => {
784
+ if (this.scrollToIndexTimeoutId !== null) {
785
+ clearTimeout(this.scrollToIndexTimeoutId)
786
+ this.scrollToIndexTimeoutId = null
787
+ }
788
+ }
545
789
 
546
- this.destinationOffset = offset
547
- this.options.scrollToFn(
548
- offset,
549
- this.options.enableSmoothScroll && canSmooth,
550
- this,
551
- )
790
+ scrollToOffset = (
791
+ toOffset: number,
792
+ { align = 'start', behavior }: ScrollToOffsetOptions = {},
793
+ ) => {
794
+ this.cancelScrollToIndex()
552
795
 
553
- let scrollCheckFrame: ReturnType<typeof setTimeout>
796
+ if (behavior === 'smooth' && this.isDynamicMode()) {
797
+ console.warn(
798
+ 'The `smooth` scroll behavior is not fully supported with dynamic size.',
799
+ )
800
+ }
554
801
 
555
- const check = () => {
556
- let lastOffset = this.scrollOffset
557
- this.scrollCheckFrame = scrollCheckFrame = setTimeout(() => {
558
- if (this.scrollCheckFrame !== scrollCheckFrame) {
559
- return
560
- }
802
+ this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
803
+ adjustments: undefined,
804
+ behavior,
805
+ })
806
+ }
807
+
808
+ scrollToIndex = (
809
+ index: number,
810
+ { align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
811
+ ) => {
812
+ index = Math.max(0, Math.min(index, this.options.count - 1))
813
+
814
+ this.cancelScrollToIndex()
561
815
 
562
- if (this.scrollOffset === lastOffset) {
563
- this.destinationOffset = undefined
564
- return
816
+ if (behavior === 'smooth' && this.isDynamicMode()) {
817
+ console.warn(
818
+ 'The `smooth` scroll behavior is not fully supported with dynamic size.',
819
+ )
820
+ }
821
+
822
+ const [toOffset, align] = this.getOffsetForIndex(index, initialAlign)
823
+
824
+ this._scrollToOffset(toOffset, { adjustments: undefined, behavior })
825
+
826
+ if (behavior !== 'smooth' && this.isDynamicMode()) {
827
+ this.scrollToIndexTimeoutId = setTimeout(() => {
828
+ this.scrollToIndexTimeoutId = null
829
+
830
+ const elementInDOM = this.measureElementCache.has(
831
+ this.options.getItemKey(index),
832
+ )
833
+
834
+ if (elementInDOM) {
835
+ const [toOffset] = this.getOffsetForIndex(index, align)
836
+
837
+ if (!approxEqual(toOffset, this.scrollOffset)) {
838
+ this.scrollToIndex(index, { align, behavior })
839
+ }
840
+ } else {
841
+ this.scrollToIndex(index, { align, behavior })
565
842
  }
566
- lastOffset = this.scrollOffset
567
- check()
568
- }, 100)
843
+ })
844
+ }
845
+ }
846
+
847
+ scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => {
848
+ this.cancelScrollToIndex()
849
+
850
+ if (behavior === 'smooth' && this.isDynamicMode()) {
851
+ console.warn(
852
+ 'The `smooth` scroll behavior is not fully supported with dynamic size.',
853
+ )
569
854
  }
570
855
 
571
- check()
856
+ this._scrollToOffset(this.scrollOffset + delta, {
857
+ adjustments: undefined,
858
+ behavior,
859
+ })
860
+ }
861
+
862
+ getTotalSize = () =>
863
+ (this.getMeasurements()[this.options.count - 1]?.end ||
864
+ this.options.paddingStart) -
865
+ this.options.scrollMargin +
866
+ this.options.paddingEnd
867
+
868
+ private _scrollToOffset = (
869
+ offset: number,
870
+ {
871
+ adjustments,
872
+ behavior,
873
+ }: {
874
+ adjustments: number | undefined
875
+ behavior: ScrollBehavior | undefined
876
+ },
877
+ ) => {
878
+ this.options.scrollToFn(offset, { behavior, adjustments }, this)
572
879
  }
573
880
 
574
881
  measure = () => {
575
- this.itemMeasurementsCache = {}
576
- this.notify()
882
+ this.itemSizeCache = new Map()
883
+ this.notify(false)
577
884
  }
578
885
  }
579
886
 
@@ -608,7 +915,7 @@ function calculateRange({
608
915
  outerSize,
609
916
  scrollOffset,
610
917
  }: {
611
- measurements: Item[]
918
+ measurements: VirtualItem[]
612
919
  outerSize: number
613
920
  scrollOffset: number
614
921
  }) {