@tanstack/virtual-core 3.0.0-beta.9 → 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 +600 -493
  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 +566 -278
  28. package/src/utils.ts +17 -5
  29. package/build/cjs/packages/virtual-core/src/index.js +0 -497
  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 -591
  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,171 +59,176 @@ export const defaultRangeExtractor = (range: Range) => {
58
59
  return arr
59
60
  }
60
61
 
61
- const memoRectCallback = (
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
- let prev: Rect = { height: -1, width: -1 }
66
-
67
- return (rect: Rect) => {
68
- if (
69
- instance.options.horizontal
70
- ? rect.width !== prev.width
71
- : rect.height !== prev.height
72
- ) {
73
- cb(rect)
74
- }
66
+ const element = instance.scrollElement
67
+ if (!element) {
68
+ return
69
+ }
75
70
 
76
- prev = rect
71
+ const handler = (rect: Rect) => {
72
+ const { width, height } = rect
73
+ cb({ width: Math.round(width), height: Math.round(height) })
77
74
  }
78
- }
79
75
 
80
- export const observeElementRect = (
81
- instance: Virtualizer<any, any>,
82
- cb: (rect: Rect) => void,
83
- ) => {
84
- const onResize = memoRectCallback(instance, cb)
76
+ handler(element.getBoundingClientRect())
85
77
 
86
- const observer = observeRect(instance.scrollElement as Element, (rect) => {
87
- onResize(rect)
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
88
  })
89
89
 
90
- if (!instance.scrollElement) {
91
- return
92
- }
93
-
94
- onResize(instance.scrollElement.getBoundingClientRect())
95
-
96
- observer.observe()
90
+ observer.observe(element, { box: 'border-box' })
97
91
 
98
92
  return () => {
99
- observer.unobserve()
93
+ observer.unobserve(element)
100
94
  }
101
95
  }
102
96
 
103
97
  export const observeWindowRect = (
104
- instance: Virtualizer<any, any>,
98
+ instance: Virtualizer<Window, any>,
105
99
  cb: (rect: Rect) => void,
106
100
  ) => {
107
- const memoizedCallback = memoRectCallback(instance, cb)
108
- const onResize = () =>
109
- memoizedCallback({
110
- width: instance.scrollElement.innerWidth,
111
- height: instance.scrollElement.innerHeight,
112
- })
113
-
114
- if (!instance.scrollElement) {
101
+ const element = instance.scrollElement
102
+ if (!element) {
115
103
  return
116
104
  }
117
105
 
118
- onResize()
106
+ const handler = () => {
107
+ cb({ width: element.innerWidth, height: element.innerHeight })
108
+ }
109
+ handler()
119
110
 
120
- instance.scrollElement.addEventListener('resize', onResize, {
121
- capture: false,
111
+ element.addEventListener('resize', handler, {
122
112
  passive: true,
123
113
  })
124
114
 
125
115
  return () => {
126
- instance.scrollElement.removeEventListener('resize', onResize)
116
+ element.removeEventListener('resize', handler)
127
117
  }
128
118
  }
129
119
 
130
- type ObserverMode = 'element' | 'window'
131
-
132
- const scrollProps = {
133
- element: ['scrollLeft', 'scrollTop'],
134
- window: ['scrollX', 'scrollY'],
135
- } as const
136
-
137
- const createOffsetObserver = (mode: ObserverMode) => {
138
- return (instance: Virtualizer<any, any>, cb: (offset: number) => void) => {
139
- if (!instance.scrollElement) {
140
- return
141
- }
142
-
143
- const propX = scrollProps[mode][0]
144
- const propY = scrollProps[mode][1]
145
-
146
- let prevX: number = instance.scrollElement[propX]
147
- let prevY: number = instance.scrollElement[propY]
120
+ export const observeElementOffset = <T extends Element>(
121
+ instance: Virtualizer<T, any>,
122
+ cb: (offset: number) => void,
123
+ ) => {
124
+ const element = instance.scrollElement
125
+ if (!element) {
126
+ return
127
+ }
148
128
 
149
- const scroll = () => {
150
- cb(instance.scrollElement[instance.options.horizontal ? propX : propY])
151
- }
129
+ const handler = () => {
130
+ cb(element[instance.options.horizontal ? 'scrollLeft' : 'scrollTop'])
131
+ }
132
+ handler()
152
133
 
153
- scroll()
134
+ element.addEventListener('scroll', handler, {
135
+ passive: true,
136
+ })
154
137
 
155
- const onScroll = (e: Event) => {
156
- const target = e.currentTarget as HTMLElement & Window
157
- const scrollX = target[propX]
158
- const scrollY = target[propY]
138
+ return () => {
139
+ element.removeEventListener('scroll', handler)
140
+ }
141
+ }
159
142
 
160
- if (instance.options.horizontal ? prevX - scrollX : prevY - scrollY) {
161
- scroll()
162
- }
143
+ export const observeWindowOffset = (
144
+ instance: Virtualizer<Window, any>,
145
+ cb: (offset: number) => void,
146
+ ) => {
147
+ const element = instance.scrollElement
148
+ if (!element) {
149
+ return
150
+ }
163
151
 
164
- prevX = scrollX
165
- prevY = scrollY
166
- }
152
+ const handler = () => {
153
+ cb(element[instance.options.horizontal ? 'scrollX' : 'scrollY'])
154
+ }
155
+ handler()
167
156
 
168
- instance.scrollElement.addEventListener('scroll', onScroll, {
169
- capture: false,
170
- passive: true,
171
- })
157
+ element.addEventListener('scroll', handler, {
158
+ passive: true,
159
+ })
172
160
 
173
- return () => {
174
- instance.scrollElement.removeEventListener('scroll', onScroll)
175
- }
161
+ return () => {
162
+ element.removeEventListener('scroll', handler)
176
163
  }
177
164
  }
178
165
 
179
- export const observeElementOffset = createOffsetObserver('element')
180
- export const observeWindowOffset = createOffsetObserver('window')
181
-
182
- export const measureElement = (
183
- element: unknown,
184
- instance: Virtualizer<any, any>,
166
+ export const measureElement = <TItemElement extends Element>(
167
+ element: TItemElement,
168
+ entry: ResizeObserverEntry | undefined,
169
+ instance: Virtualizer<any, TItemElement>,
185
170
  ) => {
186
- return (element as Element).getBoundingClientRect()[
187
- instance.options.horizontal ? 'width' : 'height'
188
- ]
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
+ )
189
185
  }
190
186
 
191
- export const windowScroll = (
187
+ export const windowScroll = <T extends Window>(
192
188
  offset: number,
193
- canSmooth: boolean,
194
- instance: Virtualizer<any, any>,
189
+ {
190
+ adjustments = 0,
191
+ behavior,
192
+ }: { adjustments?: number; behavior?: ScrollBehavior },
193
+ instance: Virtualizer<T, any>,
195
194
  ) => {
196
- ;(instance.scrollElement as Window)?.scrollTo({
197
- [instance.options.horizontal ? 'left' : 'top']: offset,
198
- behavior: canSmooth ? 'smooth' : undefined,
195
+ const toOffset = offset + adjustments
196
+
197
+ instance.scrollElement?.scrollTo?.({
198
+ [instance.options.horizontal ? 'left' : 'top']: toOffset,
199
+ behavior,
199
200
  })
200
201
  }
201
202
 
202
- export const elementScroll = (
203
+ export const elementScroll = <T extends Element>(
203
204
  offset: number,
204
- canSmooth: boolean,
205
- instance: Virtualizer<any, any>,
205
+ {
206
+ adjustments = 0,
207
+ behavior,
208
+ }: { adjustments?: number; behavior?: ScrollBehavior },
209
+ instance: Virtualizer<T, any>,
206
210
  ) => {
207
- ;(instance.scrollElement as Element)?.scrollTo({
208
- [instance.options.horizontal ? 'left' : 'top']: offset,
209
- behavior: canSmooth ? 'smooth' : undefined,
211
+ const toOffset = offset + adjustments
212
+
213
+ instance.scrollElement?.scrollTo?.({
214
+ [instance.options.horizontal ? 'left' : 'top']: toOffset,
215
+ behavior,
210
216
  })
211
217
  }
212
218
 
213
219
  export interface VirtualizerOptions<
214
- TScrollElement = unknown,
215
- TItemElement = unknown,
220
+ TScrollElement extends Element | Window,
221
+ TItemElement extends Element,
216
222
  > {
217
223
  // Required from the user
218
224
  count: number
219
- getScrollElement: () => TScrollElement
225
+ getScrollElement: () => TScrollElement | null
220
226
  estimateSize: (index: number) => number
221
227
 
222
228
  // Required from the framework adapter (but can be overridden)
223
229
  scrollToFn: (
224
230
  offset: number,
225
- canSmooth: boolean,
231
+ options: { adjustments?: number; behavior?: ScrollBehavior },
226
232
  instance: Virtualizer<TScrollElement, TItemElement>,
227
233
  ) => void
228
234
  observeElementRect: (
@@ -237,9 +243,13 @@ export interface VirtualizerOptions<
237
243
  // Optional
238
244
  debug?: any
239
245
  initialRect?: Rect
240
- onChange?: (instance: Virtualizer<TScrollElement, TItemElement>) => void
246
+ onChange?: (
247
+ instance: Virtualizer<TScrollElement, TItemElement>,
248
+ sync: boolean,
249
+ ) => void
241
250
  measureElement?: (
242
- el: TItemElement,
251
+ element: TItemElement,
252
+ entry: ResizeObserverEntry | undefined,
243
253
  instance: Virtualizer<TScrollElement, TItemElement>,
244
254
  ) => number
245
255
  overscan?: number
@@ -251,29 +261,67 @@ export interface VirtualizerOptions<
251
261
  initialOffset?: number
252
262
  getItemKey?: (index: number) => Key
253
263
  rangeExtractor?: (range: Range) => number[]
254
- enableSmoothScroll?: boolean
264
+ scrollMargin?: number
265
+ scrollingDelay?: number
266
+ indexAttribute?: string
267
+ initialMeasurementsCache?: VirtualItem[]
268
+ lanes?: number
255
269
  }
256
270
 
257
- export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
271
+ export class Virtualizer<
272
+ TScrollElement extends Element | Window,
273
+ TItemElement extends Element,
274
+ > {
258
275
  private unsubs: (void | (() => void))[] = []
259
276
  options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>
260
277
  scrollElement: TScrollElement | null = null
261
- private measurementsCache: Item[] = []
262
- 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>()
263
283
  private pendingMeasuredCacheIndexes: number[] = []
264
- private scrollRect: Rect
265
- private scrollOffset: number
266
- private destinationOffset: undefined | number
267
- private scrollCheckFrame!: ReturnType<typeof setTimeout>
268
- private measureElementCache: Record<
269
- number,
270
- (measurableItem: TItemElement | null) => void
271
- > = {}
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
272
314
 
273
315
  constructor(opts: VirtualizerOptions<TScrollElement, TItemElement>) {
274
316
  this.setOptions(opts)
275
317
  this.scrollRect = this.options.initialRect
276
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()
277
325
  }
278
326
 
279
327
  setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
@@ -292,18 +340,46 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
292
340
  horizontal: false,
293
341
  getItemKey: defaultKeyExtractor,
294
342
  rangeExtractor: defaultRangeExtractor,
295
- enableSmoothScroll: true,
296
343
  onChange: () => {},
297
344
  measureElement,
298
345
  initialRect: { width: 0, height: 0 },
346
+ scrollMargin: 0,
347
+ scrollingDelay: 150,
348
+ indexAttribute: 'data-index',
349
+ initialMeasurementsCache: [],
350
+ lanes: 1,
299
351
  ...opts,
300
352
  }
301
353
  }
302
354
 
303
- private notify = () => {
304
- this.options.onChange?.(this)
355
+ private notify = (sync: boolean) => {
356
+ this.options.onChange?.(this, sync)
305
357
  }
306
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
+
307
383
  private cleanup = () => {
308
384
  this.unsubs.filter(Boolean).forEach((d) => d!())
309
385
  this.unsubs = []
@@ -311,7 +387,9 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
311
387
  }
312
388
 
313
389
  _didMount = () => {
390
+ this.measureElementCache.forEach(this.observer.observe)
314
391
  return () => {
392
+ this.observer.disconnect()
315
393
  this.cleanup()
316
394
  }
317
395
  }
@@ -324,17 +402,45 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
324
402
 
325
403
  this.scrollElement = scrollElement
326
404
 
405
+ this._scrollToOffset(this.scrollOffset, {
406
+ adjustments: undefined,
407
+ behavior: undefined,
408
+ })
409
+
327
410
  this.unsubs.push(
328
411
  this.options.observeElementRect(this, (rect) => {
329
412
  this.scrollRect = rect
330
- this.notify()
413
+ this.maybeNotify()
331
414
  }),
332
415
  )
333
416
 
334
417
  this.unsubs.push(
335
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'
336
433
  this.scrollOffset = offset
337
- 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)
338
444
  }),
339
445
  )
340
446
  }
@@ -344,14 +450,67 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
344
450
  return this.scrollRect[this.options.horizontal ? 'width' : 'height']
345
451
  }
346
452
 
347
- private getMeasurements = memo(
453
+ private memoOptions = memo(
348
454
  () => [
349
455
  this.options.count,
350
456
  this.options.paddingStart,
457
+ this.options.scrollMargin,
351
458
  this.options.getItemKey,
352
- this.itemMeasurementsCache,
353
459
  ],
354
- (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) => {
355
514
  const min =
356
515
  this.pendingMeasuredCacheIndexes.length > 0
357
516
  ? Math.min(...this.pendingMeasuredCacheIndexes)
@@ -362,38 +521,62 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
362
521
 
363
522
  for (let i = min; i < count; i++) {
364
523
  const key = getItemKey(i)
365
- const measuredSize = measurementsCache[key]
366
- const start = measurements[i - 1]
367
- ? measurements[i - 1]!.end
368
- : 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)
369
535
  const size =
370
536
  typeof measuredSize === 'number'
371
537
  ? measuredSize
372
538
  : this.options.estimateSize(i)
539
+
373
540
  const end = start + size
374
- 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
+ }
375
554
  }
376
555
 
377
556
  this.measurementsCache = measurements
557
+
378
558
  return measurements
379
559
  },
380
560
  {
381
- key: process.env.NODE_ENV === 'development' && 'getMeasurements',
561
+ key: process.env.NODE_ENV !== 'production' && 'getMeasurements',
382
562
  debug: () => this.options.debug,
383
563
  },
384
564
  )
385
565
 
386
- private calculateRange = memo(
566
+ calculateRange = memo(
387
567
  () => [this.getMeasurements(), this.getSize(), this.scrollOffset],
388
568
  (measurements, outerSize, scrollOffset) => {
389
- return calculateRange({
390
- measurements,
391
- outerSize,
392
- scrollOffset,
393
- })
569
+ return (this.range =
570
+ measurements.length > 0 && outerSize > 0
571
+ ? calculateRange({
572
+ measurements,
573
+ outerSize,
574
+ scrollOffset,
575
+ })
576
+ : null)
394
577
  },
395
578
  {
396
- key: process.env.NODE_ENV === 'development' && 'calculateRange',
579
+ key: process.env.NODE_ENV !== 'production' && 'calculateRange',
397
580
  debug: () => this.options.debug,
398
581
  },
399
582
  )
@@ -406,144 +589,184 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
406
589
  this.options.count,
407
590
  ],
408
591
  (rangeExtractor, range, overscan, count) => {
409
- return rangeExtractor({
410
- ...range,
411
- overscan,
412
- count: count,
413
- })
592
+ return range === null
593
+ ? []
594
+ : rangeExtractor({
595
+ ...range,
596
+ overscan,
597
+ count,
598
+ })
414
599
  },
415
600
  {
416
- key: process.env.NODE_ENV === 'development' && 'getIndexes',
601
+ key: process.env.NODE_ENV !== 'production' && 'getIndexes',
602
+ debug: () => this.options.debug,
417
603
  },
418
604
  )
419
605
 
420
- getVirtualItems = memo(
421
- () => [
422
- this.getIndexes(),
423
- this.getMeasurements(),
424
- this.options.measureElement,
425
- ],
426
- (indexes, measurements, measureElement) => {
427
- const makeMeasureElement =
428
- (index: number) => (measurableItem: TItemElement | null) => {
429
- const item = this.measurementsCache[index]!
606
+ indexFromElement = (node: TItemElement) => {
607
+ const attributeName = this.options.indexAttribute
608
+ const indexStr = node.getAttribute(attributeName)
430
609
 
431
- if (!measurableItem) {
432
- return
433
- }
610
+ if (!indexStr) {
611
+ console.warn(
612
+ `Missing attribute name '${attributeName}={index}' on measured element.`,
613
+ )
614
+ return -1
615
+ }
434
616
 
435
- const measuredItemSize = measureElement(measurableItem, this)
436
- const itemSize = this.itemMeasurementsCache[item.key] ?? item.size
437
-
438
- if (measuredItemSize !== itemSize) {
439
- if (item.start < this.scrollOffset) {
440
- if (
441
- process.env.NODE_ENV === 'development' &&
442
- this.options.debug
443
- ) {
444
- console.info('correction', measuredItemSize - itemSize)
445
- }
446
-
447
- if (!this.destinationOffset) {
448
- this._scrollToOffset(
449
- this.scrollOffset + (measuredItemSize - itemSize),
450
- false,
451
- )
452
- }
453
- }
454
-
455
- this.pendingMeasuredCacheIndexes.push(index)
456
- this.itemMeasurementsCache = {
457
- ...this.itemMeasurementsCache,
458
- [item.key]: measuredItemSize,
459
- }
460
- this.notify()
461
- }
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)
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
+ }
650
+
651
+ resizeItem = (item: VirtualItem, size: number) => {
652
+ const itemSize = this.itemSizeCache.get(item.key) ?? item.size
653
+ const delta = size - itemSize
654
+
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)
462
659
  }
463
660
 
464
- const virtualItems: VirtualItem<TItemElement>[] = []
661
+ this._scrollToOffset(this.scrollOffset, {
662
+ adjustments: (this.scrollAdjustments += delta),
663
+ behavior: undefined,
664
+ })
665
+ }
465
666
 
466
- const currentMeasureElements: typeof this.measureElementCache = {}
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[] = []
467
686
 
468
687
  for (let k = 0, len = indexes.length; k < len; k++) {
469
688
  const i = indexes[k]!
470
689
  const measurement = measurements[i]!
471
690
 
472
- const item = {
473
- ...measurement,
474
- measureElement: (currentMeasureElements[i] =
475
- this.measureElementCache[i] ?? makeMeasureElement(i)),
476
- }
477
- virtualItems.push(item)
691
+ virtualItems.push(measurement)
478
692
  }
479
693
 
480
- this.measureElementCache = currentMeasureElements
481
-
482
694
  return virtualItems
483
695
  },
484
696
  {
485
- key: process.env.NODE_ENV === 'development' && 'getIndexes',
697
+ key: process.env.NODE_ENV !== 'production' && 'getIndexes',
698
+ debug: () => this.options.debug,
486
699
  },
487
700
  )
488
701
 
489
- scrollToOffset = (
490
- toOffset: number,
491
- { align }: ScrollToOffsetOptions = { align: 'start' },
492
- ) => {
493
- const attempt = () => {
494
- const offset = this.scrollOffset
495
- const size = this.getSize()
496
-
497
- if (align === 'auto') {
498
- if (toOffset <= offset) {
499
- align = 'start'
500
- } else if (toOffset >= offset + size) {
501
- align = 'end'
502
- } else {
503
- align = 'start'
504
- }
505
- }
506
-
507
- if (align === 'start') {
508
- this._scrollToOffset(toOffset, true)
509
- } else if (align === 'end') {
510
- this._scrollToOffset(toOffset - size, true)
511
- } else if (align === 'center') {
512
- this._scrollToOffset(toOffset - size / 2, true)
513
- }
514
- }
702
+ getVirtualItemForOffset = (offset: number) => {
703
+ const measurements = this.getMeasurements()
515
704
 
516
- attempt()
517
- requestAnimationFrame(() => {
518
- attempt()
519
- })
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
+ )
520
715
  }
521
716
 
522
- scrollToIndex = (
523
- index: number,
524
- { align, ...rest }: ScrollToIndexOptions = { align: 'auto' },
525
- ) => {
526
- const measurements = this.getMeasurements()
527
- const offset = this.scrollOffset
717
+ getOffsetForAlignment = (toOffset: number, align: ScrollAlignment) => {
528
718
  const size = this.getSize()
529
- const { count } = this.options
530
719
 
531
- 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
+ }
532
729
 
533
- if (!measurement) {
534
- 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
535
736
  }
536
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
+
537
757
  if (align === 'auto') {
538
- if (measurement.end >= offset + size - this.options.scrollPaddingEnd) {
758
+ if (
759
+ measurement.end >=
760
+ this.scrollOffset + this.getSize() - this.options.scrollPaddingEnd
761
+ ) {
539
762
  align = 'end'
540
763
  } else if (
541
764
  measurement.start <=
542
- offset + this.options.scrollPaddingStart
765
+ this.scrollOffset + this.options.scrollPaddingStart
543
766
  ) {
544
767
  align = 'start'
545
768
  } else {
546
- return
769
+ return [this.scrollOffset, align] as const
547
770
  }
548
771
  }
549
772
 
@@ -552,47 +775,112 @@ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
552
775
  ? measurement.end + this.options.scrollPaddingEnd
553
776
  : measurement.start - this.options.scrollPaddingStart
554
777
 
555
- this.scrollToOffset(toOffset, { align, ...rest })
778
+ return [this.getOffsetForAlignment(toOffset, align), align] as const
556
779
  }
557
780
 
558
- getTotalSize = () =>
559
- (this.getMeasurements()[this.options.count - 1]?.end ||
560
- this.options.paddingStart) + this.options.paddingEnd
781
+ private isDynamicMode = () => this.measureElementCache.size > 0
561
782
 
562
- private _scrollToOffset = (offset: number, canSmooth: boolean) => {
563
- clearTimeout(this.scrollCheckFrame)
783
+ private cancelScrollToIndex = () => {
784
+ if (this.scrollToIndexTimeoutId !== null) {
785
+ clearTimeout(this.scrollToIndexTimeoutId)
786
+ this.scrollToIndexTimeoutId = null
787
+ }
788
+ }
564
789
 
565
- this.destinationOffset = offset
566
- this.options.scrollToFn(
567
- offset,
568
- this.options.enableSmoothScroll && canSmooth,
569
- this,
570
- )
790
+ scrollToOffset = (
791
+ toOffset: number,
792
+ { align = 'start', behavior }: ScrollToOffsetOptions = {},
793
+ ) => {
794
+ this.cancelScrollToIndex()
571
795
 
572
- 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
+ }
573
801
 
574
- const check = () => {
575
- let lastOffset = this.scrollOffset
576
- this.scrollCheckFrame = scrollCheckFrame = setTimeout(() => {
577
- if (this.scrollCheckFrame !== scrollCheckFrame) {
578
- return
579
- }
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()
815
+
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 })
580
825
 
581
- if (this.scrollOffset === lastOffset) {
582
- this.destinationOffset = undefined
583
- return
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 })
584
842
  }
585
- lastOffset = this.scrollOffset
586
- check()
587
- }, 100)
843
+ })
588
844
  }
845
+ }
589
846
 
590
- check()
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
+ )
854
+ }
855
+
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)
591
879
  }
592
880
 
593
881
  measure = () => {
594
- this.itemMeasurementsCache = {}
595
- this.notify()
882
+ this.itemSizeCache = new Map()
883
+ this.notify(false)
596
884
  }
597
885
  }
598
886
 
@@ -627,7 +915,7 @@ function calculateRange({
627
915
  outerSize,
628
916
  scrollOffset,
629
917
  }: {
630
- measurements: Item[]
918
+ measurements: VirtualItem[]
631
919
  outerSize: number
632
920
  scrollOffset: number
633
921
  }) {