Skip to content

Commit 54bb501

Browse files
piecykclaude
andcommitted
fix(virtual-core): notify synchronously after above-viewport resize compensation
`resizeItem` writes `scrollTop` synchronously inside the ResizeObserver callback to compensate for an above-viewport size change, but then notified asynchronously. The browser could paint one frame with the new `scrollTop` and the old item transforms, so the viewport visibly jumped by the resize delta and snapped back on the next commit (#1227) — the scrollbar stayed anchored (skew=0) but the content flashed. When a compensation actually moves the scroll position, notify synchronously so the transform commit lands in the same paint as the scroll write. Resizes that move nothing (below-fold measurements) and iOS-deferred adjustments keep the cheaper async notify. `applyScrollAdjustment` now reports whether it wrote `scrollTop` this tick to drive that decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef6e92a commit 54bb501

3 files changed

Lines changed: 83 additions & 5 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+
Fix a one-frame viewport jump when above-viewport rows resize while scrolling up (#1227). `resizeItem` writes `scrollTop` synchronously inside the ResizeObserver callback to compensate for the size change, but then notified asynchronously — so the browser could paint a frame with the new `scrollTop` and the old item transforms, making the content jerk by the resize delta and snap back. When a compensation actually moves the scroll position, `resizeItem` now notifies synchronously so the transform commit lands in the same paint as the scroll write. Resizes that don't move the scroll position (below-fold measurements, iOS-deferred adjustments) keep the cheaper async notify.

packages/virtual-core/src/index.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -679,8 +679,15 @@ export class Virtualizer<
679679
this.options.onChange?.(this, sync)
680680
}
681681

682-
private applyScrollAdjustment(delta: number, behavior?: ScrollBehavior) {
683-
if (delta === 0) return
682+
// Returns `true` when it performed a synchronous `scrollTop` write this
683+
// tick, `false` when the delta was zero or the write was deferred (iOS).
684+
// `resizeItem` uses that to decide whether the follow-up `notify` must be
685+
// synchronous so the grown transforms commit in the same paint (#1227).
686+
private applyScrollAdjustment(
687+
delta: number,
688+
behavior?: ScrollBehavior,
689+
): boolean {
690+
if (delta === 0) return false
684691

685692
if (process.env.NODE_ENV !== 'production' && this.options.debug) {
686693
console.info('correction', delta)
@@ -691,6 +698,7 @@ export class Virtualizer<
691698
(this.isScrolling || this._iosTouching || this._iosJustTouchEnded)
692699
) {
693700
this._iosDeferredAdjustment += delta
701+
return false
694702
} else {
695703
this._scrollToOffset(this.getScrollOffset(), {
696704
adjustments: (this.scrollAdjustments += delta),
@@ -716,6 +724,7 @@ export class Virtualizer<
716724
if (this.scrollOffset < 0) this.scrollOffset = 0
717725
this.scrollAdjustments = 0
718726
}
727+
return true
719728
}
720729
}
721730

@@ -1599,13 +1608,26 @@ export class Virtualizer<
15991608
this.itemSizeCache.set(key, size)
16001609
this.itemSizeCacheVersion++
16011610

1611+
let adjustedSync = false
16021612
if (wasAtEnd) {
1603-
this.applyScrollAdjustment(this.getTotalSize() - prevTotalSize)
1613+
adjustedSync = this.applyScrollAdjustment(
1614+
this.getTotalSize() - prevTotalSize,
1615+
)
16041616
} else if (shouldAdjustScroll) {
1605-
this.applyScrollAdjustment(delta)
1617+
adjustedSync = this.applyScrollAdjustment(delta)
16061618
}
16071619

1608-
this.notify(false)
1620+
// When we just moved `scrollTop` to compensate for an above-viewport
1621+
// resize, the grown item transforms must commit in the SAME frame as
1622+
// that write. `applyScrollAdjustment` writes `scrollTop` synchronously
1623+
// inside this ResizeObserver callback, but an async `notify` schedules
1624+
// the transform render for a later commit — so the browser can paint
1625+
// one frame with the new `scrollTop` and the old positions, and the
1626+
// viewport visibly jumps by `delta` before snapping back (#1227). A
1627+
// synchronous notify flushes the render in this same callback, so both
1628+
// land in one paint. When nothing moved (or the write was deferred on
1629+
// iOS), keep the cheaper async notify.
1630+
this.notify(adjustedSync)
16091631
}
16101632
}
16111633

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2553,9 +2553,11 @@ test('scroll-up jank: idle (scrollDirection=null) still applies adjustment', ()
25532553
function createAdjustmentVirtualizer({
25542554
count = 30,
25552555
offset,
2556+
onChange,
25562557
}: {
25572558
count?: number
25582559
offset: number
2560+
onChange?: (instance: any, sync: boolean) => void
25592561
}) {
25602562
const scrollToFn = vi.fn()
25612563
let scrollCb: ((o: number, s: boolean) => void) | null = null
@@ -2571,6 +2573,7 @@ function createAdjustmentVirtualizer({
25712573
offsetHeight: 200,
25722574
}) as any,
25732575
scrollToFn,
2576+
onChange,
25742577
observeElementRect: (_inst: any, cb: any) => {
25752578
cb({ width: 400, height: 200 })
25762579
return () => {}
@@ -2735,6 +2738,54 @@ test('multi-frame reflow: above-viewport re-measure compensation is never skippe
27352738
expect(v.scrollOffset).toBe(725)
27362739
})
27372740

2741+
test('above-viewport compensation notifies synchronously so the scroll write and transforms commit in one paint (#1227)', () => {
2742+
// applyScrollAdjustment writes scrollTop synchronously inside the resize
2743+
// (ResizeObserver) callback. If the follow-up notify were async, the
2744+
// browser could paint one frame with the new scrollTop but the old item
2745+
// transforms — the viewport visibly jumps by `delta` and snaps back. The
2746+
// compensating resize must notify synchronously (sync=true) so the render
2747+
// flushes in the same callback.
2748+
const onChange = vi.fn()
2749+
const { v, scrollToFn } = createAdjustmentVirtualizer({
2750+
offset: 600,
2751+
onChange,
2752+
})
2753+
v['getMeasurements']()
2754+
onChange.mockClear()
2755+
scrollToFn.mockClear()
2756+
2757+
// Item 0 sits entirely above the fold (offset 600) — a first measurement
2758+
// there compensates regardless of scroll direction, moving scrollTop.
2759+
v.resizeItem(0, 90)
2760+
2761+
// The compensation write happened...
2762+
expect(scrollToFn).toHaveBeenCalledTimes(1)
2763+
// ...and its notify was synchronous so transforms flush in this same frame.
2764+
expect(onChange).toHaveBeenCalled()
2765+
expect(onChange.mock.calls.at(-1)![1]).toBe(true)
2766+
})
2767+
2768+
test('a resize that moves no scroll position keeps the cheaper async notify (#1227)', () => {
2769+
// No scroll write → no same-frame constraint → the notify stays async so we
2770+
// don't force a synchronous render on every below-fold measurement.
2771+
const onChange = vi.fn()
2772+
const { v, scrollToFn } = createAdjustmentVirtualizer({
2773+
offset: 0,
2774+
onChange,
2775+
})
2776+
v['getMeasurements']()
2777+
onChange.mockClear()
2778+
scrollToFn.mockClear()
2779+
2780+
// Item 10 (start 500) sits below the fold at offset 0 — measuring it does
2781+
// not compensate, so scrollTop is untouched.
2782+
v.resizeItem(10, 90)
2783+
2784+
expect(scrollToFn).not.toHaveBeenCalled()
2785+
expect(onChange).toHaveBeenCalled()
2786+
expect(onChange.mock.calls.at(-1)![1]).toBe(false)
2787+
})
2788+
27382789
// ─── end anchoring / chat-style reverse virtualization ──────────────────────
27392790

27402791
function createChatVirtualizer({

0 commit comments

Comments
 (0)