@tanstack/virtual-core 3.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,602 @@
1
+ import observeRect from '@reach/observe-rect'
2
+ import React from 'react'
3
+ import { memo } from './utils'
4
+
5
+ export * from './utils'
6
+
7
+ //
8
+
9
+ type ScrollAlignment = 'start' | 'center' | 'end' | 'auto'
10
+
11
+ interface ScrollToOptions {
12
+ align: ScrollAlignment
13
+ }
14
+
15
+ type ScrollToOffsetOptions = ScrollToOptions
16
+
17
+ type ScrollToIndexOptions = ScrollToOptions
18
+
19
+ export interface Range {
20
+ startIndex: number
21
+ endIndex: number
22
+ overscan: number
23
+ count: number
24
+ }
25
+
26
+ type Key = number | string
27
+
28
+ interface Item {
29
+ key: Key
30
+ index: number
31
+ start: number
32
+ end: number
33
+ size: number
34
+ }
35
+
36
+ interface Rect {
37
+ width: number
38
+ height: number
39
+ }
40
+
41
+ export interface VirtualItem<TItemElement> extends Item {
42
+ measureElement: (el: TItemElement | null) => void
43
+ }
44
+
45
+ //
46
+
47
+ export const defaultEstimateSize = () => 50
48
+ export const defaultKeyExtractor = (index: number) => index
49
+
50
+ export const defaultRangeExtractor = (range: Range) => {
51
+ const start = Math.max(range.startIndex - range.overscan, 0)
52
+ const end = Math.min(range.endIndex + range.overscan, range.count - 1)
53
+
54
+ const arr = []
55
+
56
+ for (let i = start; i <= end; i++) {
57
+ arr.push(i)
58
+ }
59
+
60
+ return arr
61
+ }
62
+
63
+ export const observeElementRect = (
64
+ instance: Virtualizer<any, any>,
65
+ cb: (rect: Rect) => void,
66
+ ) => {
67
+ const observer = observeRect(instance.scrollElement as Element, (rect) => {
68
+ cb(rect)
69
+ })
70
+
71
+ if (!instance.scrollElement) {
72
+ return
73
+ }
74
+
75
+ cb(instance.scrollElement.getBoundingClientRect())
76
+
77
+ observer.observe()
78
+
79
+ return () => {
80
+ observer.unobserve()
81
+ }
82
+ }
83
+
84
+ export const observeWindowRect = (
85
+ instance: Virtualizer<any, any>,
86
+ cb: (rect: Rect) => void,
87
+ ) => {
88
+ const onResize = () => {
89
+ cb({
90
+ width: instance.scrollElement.innerWidth,
91
+ height: instance.scrollElement.innerHeight,
92
+ })
93
+ }
94
+
95
+ if (!instance.scrollElement) {
96
+ return
97
+ }
98
+
99
+ onResize()
100
+
101
+ instance.scrollElement.addEventListener('resize', onResize, {
102
+ capture: false,
103
+ passive: true,
104
+ })
105
+
106
+ return () => {
107
+ instance.scrollElement.removeEventListener('resize', onResize)
108
+ }
109
+ }
110
+
111
+ export const observeElementOffset = (
112
+ instance: Virtualizer<any, any>,
113
+ cb: (offset: number) => void,
114
+ ) => {
115
+ const onScroll = () =>
116
+ cb(
117
+ instance.scrollElement[
118
+ instance.options.horizontal ? 'scrollLeft' : 'scrollTop'
119
+ ],
120
+ )
121
+
122
+ if (!instance.scrollElement) {
123
+ return
124
+ }
125
+
126
+ onScroll()
127
+
128
+ instance.scrollElement.addEventListener('scroll', onScroll, {
129
+ capture: false,
130
+ passive: true,
131
+ })
132
+
133
+ return () => {
134
+ instance.scrollElement.removeEventListener('scroll', onScroll)
135
+ }
136
+ }
137
+
138
+ export const observeWindowOffset = (
139
+ instance: Virtualizer<any, any>,
140
+ cb: (offset: number) => void,
141
+ ) => {
142
+ const onScroll = () =>
143
+ cb(
144
+ instance.scrollElement[
145
+ instance.options.horizontal ? 'scrollX' : 'scrollY'
146
+ ],
147
+ )
148
+
149
+ if (!instance.scrollElement) {
150
+ return
151
+ }
152
+
153
+ onScroll()
154
+
155
+ instance.scrollElement.addEventListener('scroll', onScroll, {
156
+ capture: false,
157
+ passive: true,
158
+ })
159
+
160
+ return () => {
161
+ instance.scrollElement.removeEventListener('scroll', onScroll)
162
+ }
163
+ }
164
+
165
+ export const defaultMeasureElement = (
166
+ element: unknown,
167
+ instance: Virtualizer<any, any>,
168
+ ) => {
169
+ return (element as Element).getBoundingClientRect()[instance.getSizeKey()]
170
+ }
171
+
172
+ export const windowScroll = (
173
+ offset: number,
174
+ canSmooth: boolean,
175
+ instance: Virtualizer<any, any>,
176
+ ) => {
177
+ ;(instance.scrollElement as Window)?.scrollTo({
178
+ [instance.options.horizontal ? 'left' : 'top']: offset,
179
+ behavior: canSmooth ? 'smooth' : undefined,
180
+ })
181
+ }
182
+
183
+ export const elementScroll = (
184
+ offset: number,
185
+ canSmooth: boolean,
186
+ instance: Virtualizer<any, any>,
187
+ ) => {
188
+ ;(instance.scrollElement as Element)?.scrollTo({
189
+ [instance.options.horizontal ? 'left' : 'top']: offset,
190
+ behavior: canSmooth ? 'smooth' : undefined,
191
+ })
192
+ }
193
+
194
+ export interface VirtualizerOptions<
195
+ TScrollElement = unknown,
196
+ TItemElement = unknown,
197
+ > {
198
+ count: number
199
+ scrollToFn: (
200
+ offset: number,
201
+ canSmooth: boolean,
202
+ instance: Virtualizer<TScrollElement, TItemElement>,
203
+ ) => void
204
+ getScrollElement: () => TScrollElement
205
+ observeElementRect: (
206
+ instance: Virtualizer<TScrollElement, TItemElement>,
207
+ cb: (rect: Rect) => void,
208
+ ) => void | (() => void)
209
+ observeElementOffset: (
210
+ instance: Virtualizer<TScrollElement, TItemElement>,
211
+ cb: (offset: number) => void,
212
+ ) => void | (() => void)
213
+
214
+ //
215
+
216
+ debug?: any
217
+ initialRect?: Rect
218
+ onChange?: (instance: Virtualizer<TScrollElement, TItemElement>) => void
219
+ measureElement?: (
220
+ el: TItemElement,
221
+ instance: Virtualizer<TScrollElement, TItemElement>,
222
+ ) => number
223
+ estimateSize?: (index: number) => number
224
+ overscan?: number
225
+ horizontal?: boolean
226
+ paddingStart?: number
227
+ paddingEnd?: number
228
+ initialOffset?: number
229
+ keyExtractor?: (index: number) => Key
230
+ rangeExtractor?: (range: Range) => number[]
231
+ enableSmoothScroll?: boolean
232
+ }
233
+
234
+ export class Virtualizer<TScrollElement = unknown, TItemElement = unknown> {
235
+ unsubs: (void | (() => void))[] = []
236
+ options!: Required<VirtualizerOptions<TScrollElement, TItemElement>>
237
+ scrollElement: TScrollElement | null = null
238
+ private measurementsCache: Item[] = []
239
+ private itemMeasurementsCache: Record<Key, number> = {}
240
+ private pendingMeasuredCacheIndexes: number[] = []
241
+ private scrollRect: Rect
242
+ private scrollOffset: number
243
+
244
+ //
245
+ // virtualItems: VirtualItem<TItemElement>[]
246
+ // totalSize: number
247
+ // scrollToOffset: (offset: number, options?: ScrollToOffsetOptions) => void
248
+ // scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void
249
+ // measure: (index: number) => void
250
+
251
+ constructor(opts: VirtualizerOptions<TScrollElement, TItemElement>) {
252
+ this.setOptions(opts)
253
+ this.scrollRect = this.options.initialRect
254
+ this.scrollOffset = this.options.initialOffset
255
+ }
256
+
257
+ setOptions = (opts: VirtualizerOptions<TScrollElement, TItemElement>) => {
258
+ Object.entries(opts).forEach(([key, value]) => {
259
+ if (typeof value === 'undefined') delete (opts as any)[key]
260
+ })
261
+
262
+ this.options = {
263
+ debug: false,
264
+ initialOffset: 0,
265
+ estimateSize: defaultEstimateSize,
266
+ overscan: 1,
267
+ paddingStart: 0,
268
+ paddingEnd: 0,
269
+ horizontal: false,
270
+ keyExtractor: defaultKeyExtractor,
271
+ rangeExtractor: defaultRangeExtractor,
272
+ enableSmoothScroll: false,
273
+ onChange: () => {},
274
+ measureElement: defaultMeasureElement,
275
+ initialRect: { width: 0, height: 0 },
276
+ ...opts,
277
+ }
278
+ }
279
+
280
+ private notify = () => {
281
+ this.options.onChange?.(this)
282
+ }
283
+
284
+ private cleanup = () => {
285
+ this.unsubs.filter(Boolean).forEach((d) => d!())
286
+ this.unsubs = []
287
+ }
288
+
289
+ _didMount = () => {
290
+ return () => {
291
+ this.cleanup()
292
+ }
293
+ }
294
+
295
+ _willUpdate = () => {
296
+ const scrollElement = this.options.getScrollElement()
297
+
298
+ if (this.scrollElement !== scrollElement) {
299
+ this.cleanup()
300
+
301
+ this.scrollElement = scrollElement
302
+
303
+ this.unsubs.push(
304
+ this.options.observeElementRect(this, (rect) => {
305
+ this.scrollRect = rect
306
+ this.notify()
307
+ }),
308
+ )
309
+
310
+ this.unsubs.push(
311
+ this.options.observeElementOffset(this, (offset) => {
312
+ this.scrollOffset = offset
313
+ this.notify()
314
+ }),
315
+ )
316
+ }
317
+ }
318
+
319
+ private getSize = () => {
320
+ return this.scrollRect[this.getSizeKey()]
321
+ }
322
+
323
+ private getMeasurements = memo(
324
+ () => [
325
+ this.options.count,
326
+ this.options.paddingStart,
327
+ this.getEstimateSizeFn(),
328
+ this.options.keyExtractor,
329
+ this.itemMeasurementsCache,
330
+ ],
331
+ (count, paddingStart, estimateSize, keyExtractor, measurementsCache) => {
332
+ const min =
333
+ this.pendingMeasuredCacheIndexes.length > 0
334
+ ? Math.min(...this.pendingMeasuredCacheIndexes)
335
+ : 0
336
+ this.pendingMeasuredCacheIndexes = []
337
+
338
+ const measurements = this.measurementsCache.slice(0, min)
339
+
340
+ for (let i = min; i < count; i++) {
341
+ const key = keyExtractor(i)
342
+ const measuredSize = measurementsCache[key]
343
+ const start = measurements[i - 1]
344
+ ? measurements[i - 1]!.end
345
+ : paddingStart
346
+ const size =
347
+ typeof measuredSize === 'number' ? measuredSize : estimateSize(i)
348
+ const end = start + size
349
+ measurements[i] = { index: i, start, size, end, key }
350
+ }
351
+
352
+ this.measurementsCache = measurements
353
+ return measurements
354
+ },
355
+ {
356
+ key: process.env.NODE_ENV === 'development' && 'getMeasurements',
357
+ debug: () => this.options.debug,
358
+ },
359
+ )
360
+
361
+ private calculateRange = memo(
362
+ () => [this.getMeasurements(), this.getSize(), this.scrollOffset],
363
+ (measurements, outerSize, scrollOffset) => {
364
+ return calculateRange({
365
+ measurements,
366
+ outerSize,
367
+ scrollOffset,
368
+ })
369
+ },
370
+ {
371
+ key: process.env.NODE_ENV === 'development' && 'calculateRange',
372
+ debug: () => this.options.debug,
373
+ },
374
+ )
375
+
376
+ private getIndexes = memo(
377
+ () => [
378
+ this.options.rangeExtractor,
379
+ this.calculateRange(),
380
+ this.options.overscan,
381
+ this.options.count,
382
+ ],
383
+ (rangeExtractor, range, overscan, count) => {
384
+ return rangeExtractor({
385
+ ...range,
386
+ overscan,
387
+ count: count,
388
+ })
389
+ },
390
+ {
391
+ key: process.env.NODE_ENV === 'development' && 'getIndexes',
392
+ },
393
+ )
394
+
395
+ getVirtualItems = memo(
396
+ () => [
397
+ this.getIndexes(),
398
+ this.getMeasurements(),
399
+ this.options.measureElement,
400
+ ],
401
+ (indexes, measurements, measureElement) => {
402
+ const virtualItems: VirtualItem<TItemElement>[] = []
403
+
404
+ for (let k = 0, len = indexes.length; k < len; k++) {
405
+ const i = indexes[k]!
406
+ const measurement = measurements[i]!
407
+
408
+ const item = {
409
+ ...measurement,
410
+ measureElement: (measurableItem: TItemElement | null) => {
411
+ if (measurableItem) {
412
+ const measuredItemSize = measureElement(measurableItem, this)
413
+
414
+ if (measuredItemSize !== item.size) {
415
+ if (item.start < this.scrollOffset) {
416
+ if (
417
+ process.env.NODE_ENV === 'development' &&
418
+ this.options.debug
419
+ )
420
+ console.info('correction', measuredItemSize - item.size)
421
+
422
+ this._scrollToOffset(
423
+ this.scrollOffset + (measuredItemSize - item.size),
424
+ false,
425
+ )
426
+ }
427
+
428
+ this.pendingMeasuredCacheIndexes.push(i)
429
+ this.itemMeasurementsCache = {
430
+ ...this.itemMeasurementsCache,
431
+ [item.key]: measuredItemSize,
432
+ }
433
+ this.notify()
434
+ }
435
+ }
436
+ },
437
+ }
438
+
439
+ virtualItems.push(item)
440
+ }
441
+
442
+ return virtualItems
443
+ },
444
+ {
445
+ key: process.env.NODE_ENV === 'development' && 'getIndexes',
446
+ },
447
+ )
448
+
449
+ scrollToOffset = (
450
+ toOffset: number,
451
+ { align }: ScrollToOffsetOptions = { align: 'start' },
452
+ ) => {
453
+ const offset = this.scrollOffset
454
+ const size = this.getSize()
455
+
456
+ if (align === 'auto') {
457
+ if (toOffset <= offset) {
458
+ align = 'start'
459
+ } else if (toOffset >= offset + size) {
460
+ align = 'end'
461
+ } else {
462
+ align = 'start'
463
+ }
464
+ }
465
+
466
+ if (align === 'start') {
467
+ this._scrollToOffset(toOffset, true)
468
+ } else if (align === 'end') {
469
+ this._scrollToOffset(toOffset - size, true)
470
+ } else if (align === 'center') {
471
+ this._scrollToOffset(toOffset - size / 2, true)
472
+ }
473
+ }
474
+
475
+ private tryScrollToIndex = (
476
+ index: number,
477
+ { align, ...rest }: ScrollToIndexOptions = { align: 'auto' },
478
+ ) => {
479
+ const measurements = this.getMeasurements()
480
+ const offset = this.scrollOffset
481
+ const size = this.getSize()
482
+ const { count } = this.options
483
+
484
+ const measurement = measurements[Math.max(0, Math.min(index, count - 1))]
485
+
486
+ if (!measurement) {
487
+ return
488
+ }
489
+
490
+ if (align === 'auto') {
491
+ if (measurement.end >= offset + size) {
492
+ align = 'end'
493
+ } else if (measurement.start <= offset) {
494
+ align = 'start'
495
+ } else {
496
+ return
497
+ }
498
+ }
499
+
500
+ const toOffset =
501
+ align === 'center'
502
+ ? measurement.start + measurement.size / 2
503
+ : align === 'end'
504
+ ? measurement.end
505
+ : measurement.start
506
+
507
+ this.scrollToOffset(toOffset, { align, ...rest })
508
+ }
509
+
510
+ scrollToIndex = (index: number, options?: ScrollToIndexOptions) => {
511
+ // We do a double request here because of
512
+ // dynamic sizes which can cause offset shift
513
+ // and end up in the wrong spot. Unfortunately,
514
+ // we can't know about those dynamic sizes until
515
+ // we try and render them. So double down!
516
+ this.tryScrollToIndex(index, options)
517
+ requestAnimationFrame(() => {
518
+ this.tryScrollToIndex(index, options)
519
+ })
520
+ }
521
+
522
+ getTotalSize = () =>
523
+ (this.getMeasurements()[this.options.count - 1]?.end ||
524
+ this.options.paddingStart) + this.options.paddingEnd
525
+
526
+ getSizeKey = () => (this.options.horizontal ? 'width' : 'height')
527
+
528
+ private _scrollToOffset = (offset: number, canSmooth: boolean) => {
529
+ this.options.scrollToFn(
530
+ offset,
531
+ this.options.enableSmoothScroll && canSmooth,
532
+ this,
533
+ )
534
+ }
535
+
536
+ private getEstimateSizeFn = memo(
537
+ () => [this.options.estimateSize],
538
+ (d) => d,
539
+ {
540
+ key: false,
541
+ onChange: () => {
542
+ this.itemMeasurementsCache = {}
543
+ },
544
+ },
545
+ )
546
+
547
+ measure = () => {
548
+ this.itemMeasurementsCache = {}
549
+ this.notify()
550
+ }
551
+ }
552
+
553
+ const findNearestBinarySearch = (
554
+ low: number,
555
+ high: number,
556
+ getCurrentValue: (i: number) => number,
557
+ value: number,
558
+ ) => {
559
+ while (low <= high) {
560
+ const middle = ((low + high) / 2) | 0
561
+ const currentValue = getCurrentValue(middle)
562
+
563
+ if (currentValue < value) {
564
+ low = middle + 1
565
+ } else if (currentValue > value) {
566
+ high = middle - 1
567
+ } else {
568
+ return middle
569
+ }
570
+ }
571
+
572
+ if (low > 0) {
573
+ return low - 1
574
+ } else {
575
+ return 0
576
+ }
577
+ }
578
+
579
+ function calculateRange({
580
+ measurements,
581
+ outerSize,
582
+ scrollOffset,
583
+ }: {
584
+ measurements: Item[]
585
+ outerSize: number
586
+ scrollOffset: number
587
+ }) {
588
+ const count = measurements.length - 1
589
+ const getOffset = (index: number) => measurements[index]!.start
590
+
591
+ const startIndex = findNearestBinarySearch(0, count, getOffset, scrollOffset)
592
+ let endIndex = startIndex
593
+
594
+ while (
595
+ endIndex < count &&
596
+ measurements[endIndex]!.end < scrollOffset + outerSize
597
+ ) {
598
+ endIndex++
599
+ }
600
+
601
+ return { startIndex, endIndex }
602
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,67 @@
1
+ export type NoInfer<A extends any> = [A][A extends any ? 0 : never]
2
+
3
+ export type PartialKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
4
+
5
+ export function memo<TDeps extends readonly any[], TResult>(
6
+ getDeps: () => [...TDeps],
7
+ fn: (...args: NoInfer<[...TDeps]>) => TResult,
8
+ opts: {
9
+ key: any
10
+ debug?: () => any
11
+ onChange?: (result: TResult) => void
12
+ },
13
+ ): () => TResult {
14
+ let deps: any[] = []
15
+ let result: TResult | undefined
16
+
17
+ return () => {
18
+ let depTime: number
19
+ if (opts.key && opts.debug?.()) depTime = Date.now()
20
+
21
+ const newDeps = getDeps()
22
+
23
+ const depsChanged =
24
+ newDeps.length !== deps.length ||
25
+ newDeps.some((dep: any, index: number) => deps[index] !== dep)
26
+
27
+ if (!depsChanged) {
28
+ return result!
29
+ }
30
+
31
+ deps = newDeps
32
+
33
+ let resultTime: number
34
+ if (opts.key && opts.debug?.()) resultTime = Date.now()
35
+
36
+ result = fn(...newDeps)
37
+ opts?.onChange?.(result)
38
+
39
+ if (opts.key && opts.debug?.()) {
40
+ const depEndTime = Math.round((Date.now() - depTime!) * 100) / 100
41
+ const resultEndTime = Math.round((Date.now() - resultTime!) * 100) / 100
42
+ const resultFpsPercentage = resultEndTime / 16
43
+
44
+ const pad = (str: number | string, num: number) => {
45
+ str = String(str)
46
+ while (str.length < num) {
47
+ str = ' ' + str
48
+ }
49
+ return str
50
+ }
51
+
52
+ console.info(
53
+ `%c⏱ ${pad(resultEndTime, 5)} /${pad(depEndTime, 5)} ms`,
54
+ `
55
+ font-size: .6rem;
56
+ font-weight: bold;
57
+ color: hsl(${Math.max(
58
+ 0,
59
+ Math.min(120 - 120 * resultFpsPercentage, 120),
60
+ )}deg 100% 31%);`,
61
+ opts?.key,
62
+ )
63
+ }
64
+
65
+ return result!
66
+ }
67
+ }