Skip to content

Commit 7ae32b5

Browse files
authored
fix(virtual-core): stop viewport-spanning item growth from drifting scroll (#1236)
1 parent aa536e7 commit 7ae32b5

3 files changed

Lines changed: 103 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
Stop the default scroll-adjustment heuristic from drifting the viewport when a viewport-spanning item grows. Previously any item whose top sat above the fold (`itemStart < scrollOffset`) had its size delta compensated on every re-measure — including a streaming chat message that spans the fold and grows at its bottom, dragging `scrollTop` downward token by token (#1218). Re-measurements now only compensate items that are _entirely_ above the fold (`itemStart + itemSize <= scrollOffset`); growth below the anchor point leaves the scroll position untouched. First measurements (estimate→actual) still compensate any above-fold item, and a custom `shouldAdjustScrollPositionOnItemSizeChange` still overrides the default.

packages/virtual-core/src/index.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,6 +1555,25 @@ export class Virtualizer<
15551555
this.scrollState?.behavior !== 'smooth' &&
15561556
this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold
15571557
const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0
1558+
// Default anchoring predicate (used unless the consumer supplies a
1559+
// custom shouldAdjustScrollPositionOnItemSizeChange).
1560+
const scrollOffsetWithAdj =
1561+
this.getScrollOffset() + this.scrollAdjustments
1562+
const isFirstMeasure = !this.itemSizeCache.has(key)
1563+
const defaultShouldAdjust = isFirstMeasure
1564+
? // First measurement: compensate any item whose top sits above the
1565+
// fold — the estimate→actual delta must be corrected regardless of
1566+
// scroll direction, since the whole estimated block was above it.
1567+
itemStart < scrollOffsetWithAdj
1568+
: // Re-measurement: only compensate an item that is ENTIRELY above the
1569+
// fold. An item that merely *spans* the fold (top above, bottom
1570+
// below — e.g. a streaming chat message growing at its bottom)
1571+
// changes size *below* the anchor point, so shifting scrollTop by the
1572+
// delta would drag the viewport downward on every growth (#1218).
1573+
// Also skip during backward scroll to avoid the "items jump while
1574+
// scrolling up" cascade.
1575+
itemStart + itemSize <= scrollOffsetWithAdj &&
1576+
this.scrollDirection !== 'backward'
15581577
const shouldAdjustScroll =
15591578
this.scrollState?.behavior !== 'smooth' &&
15601579
(this.shouldAdjustScrollPositionOnItemSizeChange !== undefined
@@ -1572,15 +1591,7 @@ export class Virtualizer<
15721591
delta,
15731592
this,
15741593
)
1575-
: // Default: adjust when the resize is an above-viewport item.
1576-
// First measurement (!has(key)): always adjust — the item
1577-
// has never been sized, so the estimate→actual delta must
1578-
// be compensated regardless of scroll direction.
1579-
// Re-measurement (has(key)): skip during backward scroll
1580-
// to avoid the "items jump while scrolling up" cascade.
1581-
itemStart < this.getScrollOffset() + this.scrollAdjustments &&
1582-
(!this.itemSizeCache.has(key) ||
1583-
this.scrollDirection !== 'backward'))
1594+
: defaultShouldAdjust)
15841595

15851596
if (this.pendingMin === null || index < this.pendingMin) {
15861597
this.pendingMin = index

packages/virtual-core/tests/index.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3415,3 +3415,81 @@ test('anchorTo end: setOptions re-anchor clamps tracked scrollOffset at 0 (#1229
34153415

34163416
expect(virtualizer.scrollOffset).toBe(0)
34173417
})
3418+
3419+
// ─── #1218: viewport-spanning item growth must not drift the viewport ─────────
3420+
// A streaming chat message whose top is scrolled above the fold but whose
3421+
// bottom extends below it grows at its bottom. That growth happens *below* the
3422+
// anchor point (the fold), so scrollTop must stay put. Only an item that is
3423+
// ENTIRELY above the fold should shift scrollTop on re-measure.
3424+
function makeAdjustmentVirtualizer(scrollTop: number) {
3425+
const scrollToFn = vi.fn(elementScroll)
3426+
let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null =
3427+
null
3428+
const el = {
3429+
scrollTop,
3430+
scrollLeft: 0,
3431+
scrollHeight: 100000,
3432+
clientHeight: 200,
3433+
offsetHeight: 200,
3434+
scrollTo: vi.fn(({ top }: { top: number }) => {
3435+
el.scrollTop = top
3436+
}),
3437+
addEventListener: () => {},
3438+
removeEventListener: () => {},
3439+
}
3440+
const v = new Virtualizer<any, any>({
3441+
count: 50,
3442+
estimateSize: () => 50,
3443+
getScrollElement: () => el as any,
3444+
scrollToFn,
3445+
observeElementRect: (_i, cb) => {
3446+
cb({ width: 300, height: 200 })
3447+
},
3448+
observeElementOffset: (_i, cb) => {
3449+
scrollCallback = cb
3450+
cb(scrollTop, false) // settled, not scrolling
3451+
return () => {}
3452+
},
3453+
})
3454+
v._willUpdate()
3455+
v['getMeasurements']()
3456+
return { v, scrollToFn }
3457+
}
3458+
3459+
test('#1218: re-measuring a viewport-spanning item does not drift scroll', () => {
3460+
// Viewport 175..375, items 50px by estimate. Item 3: start=150, end=200 —
3461+
// spans the fold at 175.
3462+
const { v, scrollToFn } = makeAdjustmentVirtualizer(175)
3463+
v.resizeItem(3, 60) // seed the size cache (item still spans the fold)
3464+
scrollToFn.mockClear()
3465+
3466+
const before = v.scrollOffset
3467+
v.resizeItem(3, 160) // stream: grow at the bottom by 100 (below the fold)
3468+
3469+
expect(v.scrollOffset).toBe(before)
3470+
expect(scrollToFn).not.toHaveBeenCalled()
3471+
})
3472+
3473+
test('#1218: re-measuring an entirely-above item still anchors (no regression)', () => {
3474+
// Item 1: start=50, end=100 — fully above the fold at 175.
3475+
const { v } = makeAdjustmentVirtualizer(175)
3476+
v.resizeItem(1, 60)
3477+
const before = v.scrollOffset!
3478+
3479+
v.resizeItem(1, 160) // grows by 100 entirely above the fold
3480+
3481+
// scrollTop shifts by the delta so content below the fold stays put.
3482+
expect(v.scrollOffset).toBe(before + 100)
3483+
})
3484+
3485+
test('#1218: first measurement of a spanning item still compensates', () => {
3486+
// First measurement (estimate→actual) always compensates an above-fold top,
3487+
// regardless of spanning — the estimated block sat above the fold. Item 3
3488+
// starts at 150 (above fold 175) and has never been measured.
3489+
const { v } = makeAdjustmentVirtualizer(175)
3490+
const before = v.scrollOffset!
3491+
3492+
v.resizeItem(3, 120) // first measure: 50 -> 120, delta +70
3493+
3494+
expect(v.scrollOffset).toBe(before + 70)
3495+
})

0 commit comments

Comments
 (0)