Skip to content

Commit f14d223

Browse files
author
전예진(양파유닛)
committed
feat(virtual-core): Optimize scroll adjustments with batching and add backward scroll option
1 parent e06e32d commit f14d223

File tree

2 files changed

+77
-6
lines changed

2 files changed

+77
-6
lines changed

.changeset/itchy-moons-push.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': minor
3+
---
4+
5+
Optimize scroll adjustments with batching and add backward scroll option

packages/virtual-core/src/index.ts

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ export interface VirtualizerOptions<
348348
enabled?: boolean
349349
isRtl?: boolean
350350
useAnimationFrameWithResizeObserver?: boolean
351+
disableScrollAdjustmentOnBackwardScroll?: boolean
351352
}
352353

353354
export class Virtualizer<
@@ -370,6 +371,8 @@ export class Virtualizer<
370371
scrollOffset: number | null = null
371372
scrollDirection: ScrollDirection | null = null
372373
private scrollAdjustments = 0
374+
private pendingAdjustmentDeltas: Array<number> = []
375+
private adjustmentBatchTimer: number | null = null
373376
shouldAdjustScrollPositionOnItemSizeChange:
374377
| undefined
375378
| ((
@@ -447,6 +450,7 @@ export class Virtualizer<
447450
isRtl: false,
448451
useScrollendEvent: false,
449452
useAnimationFrameWithResizeObserver: false,
453+
disableScrollAdjustmentOnBackwardScroll: false,
450454
...opts,
451455
}
452456
}
@@ -483,6 +487,14 @@ export class Virtualizer<
483487
this.unsubs.filter(Boolean).forEach((d) => d!())
484488
this.unsubs = []
485489
this.observer.disconnect()
490+
491+
// Clear adjustment batch timer
492+
if (this.adjustmentBatchTimer !== null && this.targetWindow) {
493+
this.targetWindow.cancelAnimationFrame(this.adjustmentBatchTimer)
494+
this.adjustmentBatchTimer = null
495+
}
496+
this.pendingAdjustmentDeltas = []
497+
486498
this.scrollElement = null
487499
this.targetWindow = null
488500
}
@@ -879,6 +891,21 @@ export class Virtualizer<
879891
}
880892

881893
if (node.isConnected) {
894+
// Check if we should skip remeasuring during backward scroll
895+
if (
896+
this.options.disableScrollAdjustmentOnBackwardScroll &&
897+
this.scrollDirection === 'backward' &&
898+
this.isScrolling
899+
) {
900+
const isAlreadyMeasured = this.itemSizeCache.has(key)
901+
if (isAlreadyMeasured) {
902+
// Skip remeasuring to prevent stuttering during backward scroll
903+
// Use cached measurement instead
904+
return
905+
}
906+
}
907+
908+
// Measure and update size
882909
this.resizeItem(index, this.options.measureElement(node, entry, this))
883910
}
884911
}
@@ -892,19 +919,19 @@ export class Virtualizer<
892919
const delta = size - itemSize
893920

894921
if (delta !== 0) {
895-
if (
922+
const shouldAdjust =
896923
this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
897924
? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this)
898925
: item.start < this.getScrollOffset() + this.scrollAdjustments
899-
) {
926+
927+
if (shouldAdjust) {
900928
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
901929
console.info('correction', delta)
902930
}
903931

904-
this._scrollToOffset(this.getScrollOffset(), {
905-
adjustments: (this.scrollAdjustments += delta),
906-
behavior: undefined,
907-
})
932+
// Add to batch instead of immediate adjustment
933+
this.pendingAdjustmentDeltas.push(delta)
934+
this.scheduleAdjustmentBatch()
908935
}
909936

910937
this.pendingMeasuredCacheIndexes.push(item.index)
@@ -914,6 +941,45 @@ export class Virtualizer<
914941
}
915942
}
916943

944+
private scheduleAdjustmentBatch = () => {
945+
if (!this.targetWindow) return
946+
947+
// Cancel existing timer if any
948+
if (this.adjustmentBatchTimer !== null) {
949+
this.targetWindow.cancelAnimationFrame(this.adjustmentBatchTimer)
950+
}
951+
952+
// Schedule batch flush on next animation frame
953+
this.adjustmentBatchTimer = this.targetWindow.requestAnimationFrame(() => {
954+
this.flushAdjustments()
955+
})
956+
}
957+
958+
private flushAdjustments = () => {
959+
if (this.pendingAdjustmentDeltas.length === 0) {
960+
this.adjustmentBatchTimer = null
961+
return
962+
}
963+
964+
// Sum all pending deltas
965+
const totalDelta = this.pendingAdjustmentDeltas.reduce(
966+
(sum, delta) => sum + delta,
967+
0,
968+
)
969+
970+
// Clear batch
971+
this.pendingAdjustmentDeltas = []
972+
this.adjustmentBatchTimer = null
973+
974+
// Apply single adjustment for all batched changes
975+
if (totalDelta !== 0) {
976+
this._scrollToOffset(this.getScrollOffset(), {
977+
adjustments: (this.scrollAdjustments += totalDelta),
978+
behavior: undefined,
979+
})
980+
}
981+
}
982+
917983
measureElement = (node: TItemElement | null | undefined) => {
918984
if (!node) {
919985
this.elementsCache.forEach((cached, key) => {

0 commit comments

Comments
 (0)