@tanstack/virtual-core 3.0.0-beta.6 → 3.0.0-beta.61

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 (36) hide show
  1. package/build/lib/_virtual/_rollupPluginBabelHelpers.esm.js +27 -0
  2. package/build/lib/_virtual/_rollupPluginBabelHelpers.esm.js.map +1 -0
  3. package/build/lib/_virtual/_rollupPluginBabelHelpers.js +31 -0
  4. package/build/lib/_virtual/_rollupPluginBabelHelpers.js.map +1 -0
  5. package/build/lib/_virtual/_rollupPluginBabelHelpers.mjs +27 -0
  6. package/build/lib/_virtual/_rollupPluginBabelHelpers.mjs.map +1 -0
  7. package/build/lib/index.d.ts +126 -0
  8. package/build/lib/index.esm.js +652 -0
  9. package/build/lib/index.esm.js.map +1 -0
  10. package/build/lib/index.js +667 -0
  11. package/build/lib/index.js.map +1 -0
  12. package/build/lib/index.mjs +652 -0
  13. package/build/lib/index.mjs.map +1 -0
  14. package/build/lib/utils.d.ts +10 -0
  15. package/build/lib/utils.esm.js +58 -0
  16. package/build/lib/utils.esm.js.map +1 -0
  17. package/build/{cjs/packages/virtual-core/src → lib}/utils.js +28 -21
  18. package/build/lib/utils.js.map +1 -0
  19. package/build/lib/utils.mjs +58 -0
  20. package/build/lib/utils.mjs.map +1 -0
  21. package/build/umd/index.development.js +604 -452
  22. package/build/umd/index.development.js.map +1 -1
  23. package/build/umd/index.production.js +1 -1
  24. package/build/umd/index.production.js.map +1 -1
  25. package/package.json +15 -13
  26. package/src/index.ts +546 -233
  27. package/src/utils.ts +17 -5
  28. package/build/cjs/packages/virtual-core/src/index.js +0 -465
  29. package/build/cjs/packages/virtual-core/src/index.js.map +0 -1
  30. package/build/cjs/packages/virtual-core/src/utils.js.map +0 -1
  31. package/build/esm/index.js +0 -559
  32. package/build/esm/index.js.map +0 -1
  33. package/build/stats-html.html +0 -2689
  34. package/build/stats.json +0 -101
  35. package/build/types/index.d.ts +0 -88
  36. 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
 
10
- interface ScrollToOptions {
11
- align: ScrollAlignment
11
+ type ScrollBehavior = 'auto' | 'smooth'
12
+
13
+ export interface ScrollToOptions {
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: (
@@ -220,7 +245,8 @@ export interface VirtualizerOptions<
220
245
  initialRect?: Rect
221
246
  onChange?: (instance: Virtualizer<TScrollElement, TItemElement>) => void
222
247
  measureElement?: (
223
- el: TItemElement,
248
+ element: TItemElement,
249
+ entry: ResizeObserverEntry | undefined,
224
250
  instance: Virtualizer<TScrollElement, TItemElement>,
225
251
  ) => number
226
252
  overscan?: number
@@ -232,29 +258,70 @@ export interface VirtualizerOptions<
232
258
  initialOffset?: number
233
259
  getItemKey?: (index: number) => Key
234
260
  rangeExtractor?: (range: Range) => number[]
235
- enableSmoothScroll?: boolean
261
+ scrollMargin?: number
262
+ scrollingDelay?: number
263
+ indexAttribute?: string
264
+ initialMeasurementsCache?: VirtualItem[]
265
+ lanes?: number
236
266
  }
237
267
 
238
- export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
268
+ export class Virtualizer<
269
+ TScrollElement extends Element | Window,
270
+ TItemElement extends Element,
271
+ > {
239
272
  private unsubs: (void | (() => void))[] = []
240
273
  options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>
241
274
  scrollElement: TScrollElement | null = null
242
- private measurementsCache: Item[] = []
243
- private itemMeasurementsCache: Record<Key, number> = {}
275
+ isScrolling: boolean = false
276
+ private isScrollingTimeoutId: ReturnType<typeof setTimeout> | null = null
277
+ private scrollToIndexTimeoutId: ReturnType<typeof setTimeout> | null = null
278
+ measurementsCache: VirtualItem[] = []
279
+ private itemSizeCache = new Map<Key, number>()
244
280
  private pendingMeasuredCacheIndexes: number[] = []
245
281
  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
- > = {}
282
+ scrollOffset: number
283
+ scrollDirection: ScrollDirection | null = null
284
+ private scrollAdjustments: number = 0
285
+ measureElementCache = new Map<Key, TItemElement>()
286
+ private observer = (() => {
287
+ let _ro: ResizeObserver | null = null
288
+
289
+ const get = () => {
290
+ if (_ro) {
291
+ return _ro
292
+ } else if (typeof ResizeObserver !== 'undefined') {
293
+ return (_ro = new ResizeObserver((entries) => {
294
+ entries.forEach((entry) => {
295
+ this._measureElement(entry.target as TItemElement, entry)
296
+ })
297
+ }))
298
+ } else {
299
+ return null
300
+ }
301
+ }
302
+
303
+ return {
304
+ disconnect: () => get()?.disconnect(),
305
+ observe: (target: Element) =>
306
+ get()?.observe(target, { box: 'border-box' }),
307
+ unobserve: (target: Element) => get()?.unobserve(target),
308
+ }
309
+ })()
310
+ range: { startIndex: number; endIndex: number } = {
311
+ startIndex: 0,
312
+ endIndex: 0,
313
+ }
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,10 +340,14 @@ 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
  }
@@ -288,10 +359,13 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
288
359
  private cleanup = () => {
289
360
  this.unsubs.filter(Boolean).forEach((d) => d!())
290
361
  this.unsubs = []
362
+ this.scrollElement = null
291
363
  }
292
364
 
293
365
  _didMount = () => {
366
+ this.measureElementCache.forEach(this.observer.observe)
294
367
  return () => {
368
+ this.observer.disconnect()
295
369
  this.cleanup()
296
370
  }
297
371
  }
@@ -304,17 +378,52 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
304
378
 
305
379
  this.scrollElement = scrollElement
306
380
 
381
+ this._scrollToOffset(this.scrollOffset, {
382
+ adjustments: undefined,
383
+ behavior: undefined,
384
+ })
385
+
307
386
  this.unsubs.push(
308
387
  this.options.observeElementRect(this, (rect) => {
388
+ const prev = this.scrollRect
309
389
  this.scrollRect = rect
310
- this.notify()
390
+ if (
391
+ this.options.horizontal
392
+ ? rect.width !== prev.width
393
+ : rect.height !== prev.height
394
+ ) {
395
+ this.maybeNotify()
396
+ }
311
397
  }),
312
398
  )
313
399
 
314
400
  this.unsubs.push(
315
401
  this.options.observeElementOffset(this, (offset) => {
402
+ this.scrollAdjustments = 0
403
+
404
+ if (this.scrollOffset === offset) {
405
+ return
406
+ }
407
+
408
+ if (this.isScrollingTimeoutId !== null) {
409
+ clearTimeout(this.isScrollingTimeoutId)
410
+ this.isScrollingTimeoutId = null
411
+ }
412
+
413
+ this.isScrolling = true
414
+ this.scrollDirection =
415
+ this.scrollOffset < offset ? 'forward' : 'backward'
316
416
  this.scrollOffset = offset
317
- this.notify()
417
+
418
+ this.maybeNotify()
419
+
420
+ this.isScrollingTimeoutId = setTimeout(() => {
421
+ this.isScrollingTimeoutId = null
422
+ this.isScrolling = false
423
+ this.scrollDirection = null
424
+
425
+ this.maybeNotify()
426
+ }, this.options.scrollingDelay)
318
427
  }),
319
428
  )
320
429
  }
@@ -324,14 +433,67 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
324
433
  return this.scrollRect[this.options.horizontal ? 'width' : 'height']
325
434
  }
326
435
 
327
- private getMeasurements = memo(
436
+ private memoOptions = memo(
328
437
  () => [
329
438
  this.options.count,
330
439
  this.options.paddingStart,
440
+ this.options.scrollMargin,
331
441
  this.options.getItemKey,
332
- this.itemMeasurementsCache,
333
442
  ],
334
- (count, paddingStart, getItemKey, measurementsCache) => {
443
+ (count, paddingStart, scrollMargin, getItemKey) => {
444
+ this.pendingMeasuredCacheIndexes = []
445
+ return {
446
+ count,
447
+ paddingStart,
448
+ scrollMargin,
449
+ getItemKey,
450
+ }
451
+ },
452
+ {
453
+ key: false,
454
+ },
455
+ )
456
+
457
+ private getFurthestMeasurement = (
458
+ measurements: VirtualItem[],
459
+ index: number,
460
+ ) => {
461
+ const furthestMeasurementsFound = new Map<number, true>()
462
+ const furthestMeasurements = new Map<number, VirtualItem>()
463
+ for (let m = index - 1; m >= 0; m--) {
464
+ const measurement = measurements[m]!
465
+
466
+ if (furthestMeasurementsFound.has(measurement.lane)) {
467
+ continue
468
+ }
469
+
470
+ const previousFurthestMeasurement = furthestMeasurements.get(
471
+ measurement.lane,
472
+ )
473
+ if (
474
+ previousFurthestMeasurement == null ||
475
+ measurement.end > previousFurthestMeasurement.end
476
+ ) {
477
+ furthestMeasurements.set(measurement.lane, measurement)
478
+ } else if (measurement.end < previousFurthestMeasurement.end) {
479
+ furthestMeasurementsFound.set(measurement.lane, true)
480
+ }
481
+
482
+ if (furthestMeasurementsFound.size === this.options.lanes) {
483
+ break
484
+ }
485
+ }
486
+
487
+ return furthestMeasurements.size === this.options.lanes
488
+ ? Array.from(furthestMeasurements.values()).sort(
489
+ (a, b) => a.end - b.end,
490
+ )[0]
491
+ : undefined
492
+ }
493
+
494
+ private getMeasurements = memo(
495
+ () => [this.memoOptions(), this.itemSizeCache],
496
+ ({ count, paddingStart, scrollMargin, getItemKey }, itemSizeCache) => {
335
497
  const min =
336
498
  this.pendingMeasuredCacheIndexes.length > 0
337
499
  ? Math.min(...this.pendingMeasuredCacheIndexes)
@@ -342,42 +504,83 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
342
504
 
343
505
  for (let i = min; i < count; i++) {
344
506
  const key = getItemKey(i)
345
- const measuredSize = measurementsCache[key]
346
- const start = measurements[i - 1]
347
- ? measurements[i - 1]!.end
348
- : paddingStart
507
+
508
+ const furthestMeasurement =
509
+ this.options.lanes === 1
510
+ ? measurements[i - 1]
511
+ : this.getFurthestMeasurement(measurements, i)
512
+
513
+ const start = furthestMeasurement
514
+ ? furthestMeasurement.end
515
+ : paddingStart + scrollMargin
516
+
517
+ const measuredSize = itemSizeCache.get(key)
349
518
  const size =
350
519
  typeof measuredSize === 'number'
351
520
  ? measuredSize
352
521
  : this.options.estimateSize(i)
522
+
353
523
  const end = start + size
354
- measurements[i] = { index: i, start, size, end, key }
524
+
525
+ const lane = furthestMeasurement
526
+ ? furthestMeasurement.lane
527
+ : i % this.options.lanes
528
+
529
+ measurements[i] = {
530
+ index: i,
531
+ start,
532
+ size,
533
+ end,
534
+ key,
535
+ lane,
536
+ }
355
537
  }
356
538
 
357
539
  this.measurementsCache = measurements
540
+
358
541
  return measurements
359
542
  },
360
543
  {
361
- key: process.env.NODE_ENV === 'development' && 'getMeasurements',
544
+ key: process.env.NODE_ENV !== 'production' && 'getMeasurements',
362
545
  debug: () => this.options.debug,
363
546
  },
364
547
  )
365
548
 
366
- private calculateRange = memo(
549
+ calculateRange = memo(
367
550
  () => [this.getMeasurements(), this.getSize(), this.scrollOffset],
368
551
  (measurements, outerSize, scrollOffset) => {
369
- return calculateRange({
552
+ return (this.range = calculateRange({
370
553
  measurements,
371
554
  outerSize,
372
555
  scrollOffset,
373
- })
556
+ }))
374
557
  },
375
558
  {
376
- key: process.env.NODE_ENV === 'development' && 'calculateRange',
559
+ key: process.env.NODE_ENV !== 'production' && 'calculateRange',
377
560
  debug: () => this.options.debug,
378
561
  },
379
562
  )
380
563
 
564
+ private maybeNotify = memo(
565
+ () => {
566
+ const range = this.calculateRange()
567
+
568
+ return [range.startIndex, range.endIndex, this.isScrolling]
569
+ },
570
+ () => {
571
+ this.notify()
572
+ },
573
+ {
574
+ key: process.env.NODE_ENV !== 'production' && 'maybeNotify',
575
+ debug: () => this.options.debug,
576
+ initialDeps: [
577
+ this.range.startIndex,
578
+ this.range.endIndex,
579
+ this.isScrolling,
580
+ ],
581
+ },
582
+ )
583
+
381
584
  private getIndexes = memo(
382
585
  () => [
383
586
  this.options.rangeExtractor,
@@ -389,141 +592,186 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
389
592
  return rangeExtractor({
390
593
  ...range,
391
594
  overscan,
392
- count: count,
595
+ count,
393
596
  })
394
597
  },
395
598
  {
396
- key: process.env.NODE_ENV === 'development' && 'getIndexes',
599
+ key: process.env.NODE_ENV !== 'production' && 'getIndexes',
600
+ debug: () => this.options.debug,
397
601
  },
398
602
  )
399
603
 
400
- getVirtualItems = memo(
401
- () => [
402
- this.getIndexes(),
403
- this.getMeasurements(),
404
- this.options.measureElement,
405
- ],
406
- (indexes, measurements, measureElement) => {
407
- const makeMeasureElement =
408
- (index: number) => (measurableItem: TItemElement | null) => {
409
- const item = this.measurementsCache[index]!
604
+ indexFromElement = (node: TItemElement) => {
605
+ const attributeName = this.options.indexAttribute
606
+ const indexStr = node.getAttribute(attributeName)
410
607
 
411
- if (!measurableItem) {
412
- return
413
- }
608
+ if (!indexStr) {
609
+ console.warn(
610
+ `Missing attribute name '${attributeName}={index}' on measured element.`,
611
+ )
612
+ return -1
613
+ }
414
614
 
415
- const measuredItemSize = measureElement(measurableItem, this)
416
- const itemSize = this.itemMeasurementsCache[item.key] ?? item.size
417
-
418
- if (measuredItemSize !== itemSize) {
419
- if (item.start < this.scrollOffset) {
420
- if (
421
- process.env.NODE_ENV === 'development' &&
422
- this.options.debug
423
- ) {
424
- console.info('correction', measuredItemSize - itemSize)
425
- }
426
-
427
- if (!this.destinationOffset) {
428
- this._scrollToOffset(
429
- this.scrollOffset + (measuredItemSize - itemSize),
430
- false,
431
- )
432
- }
433
- }
434
-
435
- this.pendingMeasuredCacheIndexes.push(index)
436
- this.itemMeasurementsCache = {
437
- ...this.itemMeasurementsCache,
438
- [item.key]: measuredItemSize,
439
- }
440
- this.notify()
441
- }
615
+ return parseInt(indexStr, 10)
616
+ }
617
+
618
+ private _measureElement = (
619
+ node: TItemElement,
620
+ entry: ResizeObserverEntry | undefined,
621
+ ) => {
622
+ const item = this.measurementsCache[this.indexFromElement(node)]
623
+ if (!item) {
624
+ this.measureElementCache.forEach((cached, key) => {
625
+ if (cached === node) {
626
+ this.observer.unobserve(node)
627
+ this.measureElementCache.delete(key)
628
+ }
629
+ })
630
+ return
631
+ }
632
+
633
+ const prevNode = this.measureElementCache.get(item.key)
634
+
635
+ if (!node.isConnected) {
636
+ if (prevNode) {
637
+ this.observer.unobserve(prevNode)
638
+ this.measureElementCache.delete(item.key)
639
+ }
640
+ return
641
+ }
642
+
643
+ if (prevNode !== node) {
644
+ if (prevNode) {
645
+ this.observer.unobserve(prevNode)
646
+ }
647
+ this.observer.observe(node)
648
+ this.measureElementCache.set(item.key, node)
649
+ }
650
+
651
+ const measuredItemSize = this.options.measureElement(node, entry, this)
652
+
653
+ this.resizeItem(item, measuredItemSize)
654
+ }
655
+
656
+ resizeItem = (item: VirtualItem, size: number) => {
657
+ const itemSize = this.itemSizeCache.get(item.key) ?? item.size
658
+ const delta = size - itemSize
659
+
660
+ if (delta !== 0) {
661
+ if (item.start < this.scrollOffset) {
662
+ if (process.env.NODE_ENV !== 'production' && this.options.debug) {
663
+ console.info('correction', delta)
442
664
  }
443
665
 
444
- const virtualItems: VirtualItem<TItemElement>[] = []
666
+ this._scrollToOffset(this.scrollOffset, {
667
+ adjustments: (this.scrollAdjustments += delta),
668
+ behavior: undefined,
669
+ })
670
+ }
671
+
672
+ this.pendingMeasuredCacheIndexes.push(item.index)
673
+ this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size))
674
+
675
+ this.notify()
676
+ }
677
+ }
445
678
 
446
- const currentMeasureElements: typeof this.measureElementCache = {}
679
+ measureElement = (node: TItemElement | null) => {
680
+ if (!node) {
681
+ return
682
+ }
683
+
684
+ this._measureElement(node, undefined)
685
+ }
686
+
687
+ getVirtualItems = memo(
688
+ () => [this.getIndexes(), this.getMeasurements()],
689
+ (indexes, measurements) => {
690
+ const virtualItems: VirtualItem[] = []
447
691
 
448
692
  for (let k = 0, len = indexes.length; k < len; k++) {
449
693
  const i = indexes[k]!
450
694
  const measurement = measurements[i]!
451
695
 
452
- const item = {
453
- ...measurement,
454
- measureElement: (currentMeasureElements[i] =
455
- this.measureElementCache[i] ?? makeMeasureElement(i)),
456
- }
457
- virtualItems.push(item)
696
+ virtualItems.push(measurement)
458
697
  }
459
698
 
460
- this.measureElementCache = currentMeasureElements
461
-
462
699
  return virtualItems
463
700
  },
464
701
  {
465
- key: process.env.NODE_ENV === 'development' && 'getIndexes',
702
+ key: process.env.NODE_ENV !== 'production' && 'getIndexes',
703
+ debug: () => this.options.debug,
466
704
  },
467
705
  )
468
706
 
469
- scrollToOffset = (
470
- toOffset: number,
471
- { align }: ScrollToOffsetOptions = { align: 'start' },
472
- ) => {
473
- const attempt = () => {
474
- const offset = this.scrollOffset
475
- const size = this.getSize()
476
-
477
- if (align === 'auto') {
478
- if (toOffset <= offset) {
479
- align = 'start'
480
- } else if (toOffset >= offset + size) {
481
- align = 'end'
482
- } else {
483
- align = 'start'
484
- }
485
- }
486
-
487
- if (align === 'start') {
488
- this._scrollToOffset(toOffset, true)
489
- } else if (align === 'end') {
490
- this._scrollToOffset(toOffset - size, true)
491
- } else if (align === 'center') {
492
- this._scrollToOffset(toOffset - size / 2, true)
493
- }
494
- }
707
+ getVirtualItemForOffset = (offset: number) => {
708
+ const measurements = this.getMeasurements()
495
709
 
496
- attempt()
497
- requestAnimationFrame(() => {
498
- attempt()
499
- })
710
+ return notUndefined(
711
+ measurements[
712
+ findNearestBinarySearch(
713
+ 0,
714
+ measurements.length - 1,
715
+ (index: number) => notUndefined(measurements[index]).start,
716
+ offset,
717
+ )
718
+ ],
719
+ )
500
720
  }
501
721
 
502
- scrollToIndex = (
503
- index: number,
504
- { align, ...rest }: ScrollToIndexOptions = { align: 'auto' },
505
- ) => {
506
- const measurements = this.getMeasurements()
507
- const offset = this.scrollOffset
722
+ getOffsetForAlignment = (toOffset: number, align: ScrollAlignment) => {
508
723
  const size = this.getSize()
509
- const { count } = this.options
510
724
 
511
- const measurement = measurements[Math.max(0, Math.min(index, count - 1))]
725
+ if (align === 'auto') {
726
+ if (toOffset <= this.scrollOffset) {
727
+ align = 'start'
728
+ } else if (toOffset >= this.scrollOffset + size) {
729
+ align = 'end'
730
+ } else {
731
+ align = 'start'
732
+ }
733
+ }
512
734
 
513
- if (!measurement) {
514
- return
735
+ if (align === 'start') {
736
+ toOffset = toOffset
737
+ } else if (align === 'end') {
738
+ toOffset = toOffset - size
739
+ } else if (align === 'center') {
740
+ toOffset = toOffset - size / 2
515
741
  }
516
742
 
743
+ const scrollSizeProp = this.options.horizontal
744
+ ? 'scrollWidth'
745
+ : 'scrollHeight'
746
+ const scrollSize = this.scrollElement
747
+ ? 'document' in this.scrollElement
748
+ ? this.scrollElement.document.documentElement[scrollSizeProp]
749
+ : this.scrollElement[scrollSizeProp]
750
+ : 0
751
+
752
+ const maxOffset = scrollSize - this.getSize()
753
+
754
+ return Math.max(Math.min(maxOffset, toOffset), 0)
755
+ }
756
+
757
+ getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
758
+ index = Math.max(0, Math.min(index, this.options.count - 1))
759
+
760
+ const measurement = notUndefined(this.getMeasurements()[index])
761
+
517
762
  if (align === 'auto') {
518
- if (measurement.end >= offset + size - this.options.scrollPaddingEnd) {
763
+ if (
764
+ measurement.end >=
765
+ this.scrollOffset + this.getSize() - this.options.scrollPaddingEnd
766
+ ) {
519
767
  align = 'end'
520
768
  } else if (
521
769
  measurement.start <=
522
- offset + this.options.scrollPaddingStart
770
+ this.scrollOffset + this.options.scrollPaddingStart
523
771
  ) {
524
772
  align = 'start'
525
773
  } else {
526
- return
774
+ return [this.scrollOffset, align] as const
527
775
  }
528
776
  }
529
777
 
@@ -532,46 +780,111 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
532
780
  ? measurement.end + this.options.scrollPaddingEnd
533
781
  : measurement.start - this.options.scrollPaddingStart
534
782
 
535
- this.scrollToOffset(toOffset, { align, ...rest })
783
+ return [this.getOffsetForAlignment(toOffset, align), align] as const
536
784
  }
537
785
 
538
- getTotalSize = () =>
539
- (this.getMeasurements()[this.options.count - 1]?.end ||
540
- this.options.paddingStart) + this.options.paddingEnd
786
+ private isDynamicMode = () => this.measureElementCache.size > 0
541
787
 
542
- private _scrollToOffset = (offset: number, canSmooth: boolean) => {
543
- clearTimeout(this.scrollCheckFrame)
788
+ private cancelScrollToIndex = () => {
789
+ if (this.scrollToIndexTimeoutId !== null) {
790
+ clearTimeout(this.scrollToIndexTimeoutId)
791
+ this.scrollToIndexTimeoutId = null
792
+ }
793
+ }
544
794
 
545
- this.destinationOffset = offset
546
- this.options.scrollToFn(
547
- offset,
548
- this.options.enableSmoothScroll && canSmooth,
549
- this,
550
- )
795
+ scrollToOffset = (
796
+ toOffset: number,
797
+ { align = 'start', behavior }: ScrollToOffsetOptions = {},
798
+ ) => {
799
+ this.cancelScrollToIndex()
551
800
 
552
- let scrollCheckFrame: ReturnType<typeof setTimeout>
801
+ if (behavior === 'smooth' && this.isDynamicMode()) {
802
+ console.warn(
803
+ 'The `smooth` scroll behavior is not fully supported with dynamic size.',
804
+ )
805
+ }
553
806
 
554
- const check = () => {
555
- let lastOffset = this.scrollOffset
556
- this.scrollCheckFrame = scrollCheckFrame = setTimeout(() => {
557
- if (this.scrollCheckFrame !== scrollCheckFrame) {
558
- return
559
- }
807
+ this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
808
+ adjustments: undefined,
809
+ behavior,
810
+ })
811
+ }
812
+
813
+ scrollToIndex = (
814
+ index: number,
815
+ { align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {},
816
+ ) => {
817
+ index = Math.max(0, Math.min(index, this.options.count - 1))
818
+
819
+ this.cancelScrollToIndex()
560
820
 
561
- if (this.scrollOffset === lastOffset) {
562
- this.destinationOffset = undefined
563
- return
821
+ if (behavior === 'smooth' && this.isDynamicMode()) {
822
+ console.warn(
823
+ 'The `smooth` scroll behavior is not fully supported with dynamic size.',
824
+ )
825
+ }
826
+
827
+ const [toOffset, align] = this.getOffsetForIndex(index, initialAlign)
828
+
829
+ this._scrollToOffset(toOffset, { adjustments: undefined, behavior })
830
+
831
+ if (behavior !== 'smooth' && this.isDynamicMode()) {
832
+ this.scrollToIndexTimeoutId = setTimeout(() => {
833
+ this.scrollToIndexTimeoutId = null
834
+
835
+ const elementInDOM = this.measureElementCache.has(
836
+ this.options.getItemKey(index),
837
+ )
838
+
839
+ if (elementInDOM) {
840
+ const [toOffset] = this.getOffsetForIndex(index, align)
841
+
842
+ if (!approxEqual(toOffset, this.scrollOffset)) {
843
+ this.scrollToIndex(index, { align, behavior })
844
+ }
845
+ } else {
846
+ this.scrollToIndex(index, { align, behavior })
564
847
  }
565
- lastOffset = this.scrollOffset
566
- check()
567
- }, 100)
848
+ })
849
+ }
850
+ }
851
+
852
+ scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => {
853
+ this.cancelScrollToIndex()
854
+
855
+ if (behavior === 'smooth' && this.isDynamicMode()) {
856
+ console.warn(
857
+ 'The `smooth` scroll behavior is not fully supported with dynamic size.',
858
+ )
568
859
  }
569
860
 
570
- check()
861
+ this._scrollToOffset(this.scrollOffset + delta, {
862
+ adjustments: undefined,
863
+ behavior,
864
+ })
865
+ }
866
+
867
+ getTotalSize = () =>
868
+ (this.getMeasurements()[this.options.count - 1]?.end ||
869
+ this.options.paddingStart) -
870
+ this.options.scrollMargin +
871
+ this.options.paddingEnd
872
+
873
+ private _scrollToOffset = (
874
+ offset: number,
875
+ {
876
+ adjustments,
877
+ behavior,
878
+ }: {
879
+ adjustments: number | undefined
880
+ behavior: ScrollBehavior | undefined
881
+ },
882
+ ) => {
883
+ this.options.scrollToFn(offset, { behavior, adjustments }, this)
571
884
  }
572
885
 
573
886
  measure = () => {
574
- this.itemMeasurementsCache = {}
887
+ this.itemSizeCache = new Map()
575
888
  this.notify()
576
889
  }
577
890
  }
@@ -607,7 +920,7 @@ function calculateRange({
607
920
  outerSize,
608
921
  scrollOffset,
609
922
  }: {
610
- measurements: Item[]
923
+ measurements: VirtualItem[]
611
924
  outerSize: number
612
925
  scrollOffset: number
613
926
  }) {