fix(virtual-core): notify synchronously after above-viewport resize c… · TanStack/virtual@a8a35b1 · GitHub
Skip to content

Commit a8a35b1

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 a8a35b1

3 files changed

Lines changed: 84 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions

‎packages/virtual-core/src/index.ts‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ export class Virtualizer<
420420
scrollRect: Rect | null = null
421421
scrollOffset: number | null = null
422422
scrollDirection: ScrollDirection | null = null
423-
private scrollAdjustments = 0
423+
scrollAdjustments = 0
424424
// Sum of size-change deltas above-viewport that were skipped during
425425
// iOS momentum scroll (writing scrollTop mid-momentum cancels it).
426426
// Flushed in a single scrollTo when iOS is fully settled.
@@ -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

0 commit comments

Comments
 (0)