fix(virtual-core): leave resizes measured before a scroll command's r… · TanStack/virtual@e7a0a92 · GitHub
Skip to content

Commit e7a0a92

Browse files
authored
fix(virtual-core): leave resizes measured before a scroll command's read-back to reconcile (#1309)
1 parent 6732e00 commit e7a0a92

4 files changed

Lines changed: 799 additions & 21 deletions

File tree

Lines changed: 5 additions & 0 deletions

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

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,14 @@ type ScrollState = {
406406
stableFrames: number
407407
// Keep arrival across measurement-driven retargets so external scrolls can cancel.
408408
hasReachedTarget: boolean
409+
410+
// Where our last non-smooth write for this command put the element, until
411+
// its scroll event arrives. Until then `scrollOffset` still holds the old
412+
// position, and the rows on screen are the ones rendered for it. A smooth
413+
// write leaves it null: the element is somewhere between the two offsets.
414+
// It goes with the scroll state, so a write the browser ignored (no read-back)
415+
// does not outlive its command.
416+
writtenOffset?: number | null
409417
}
410418

411419
type PendingScrollAnchor = [
@@ -973,6 +981,7 @@ export class Virtualizer<
973981
this.scrollState = null
974982
}
975983
this._intendedScrollOffset = null
984+
if (this.scrollState) this.scrollState.writtenOffset = null
976985

977986
// A pending clamped compensation write (#1258) survives only its
978987
// own read-back, which the browser reports at the scroll max we
@@ -1835,13 +1844,33 @@ export class Virtualizer<
18351844
this.itemSizeCache.set(key, size)
18361845
this.itemSizeCacheVersion++
18371846

1838-
let adjustedSync = false
1839-
if (wasAtEnd) {
1840-
adjustedSync = this.applyScrollAdjustment(
1841-
this.getTotalSize() - prevTotalSize,
1847+
// A scroll command (or reconcile) wrote a new position that neither a
1848+
// scroll event nor an eager update has reflected in `scrollOffset` yet.
1849+
// The rows being measured were rendered for the old position, so
1850+
// "above the viewport" means nothing against either offset. An index
1851+
// or end target is retargeted from fresh measurements by
1852+
// reconcileScroll next frame; an offset target (scrollToOffset,
1853+
// scrollBy) lands where it was written and the delta is not applied,
1854+
// since compensating against the stale offset would overwrite the
1855+
// command. Compensation writes and the prepend anchor sync update
1856+
// `scrollOffset` eagerly, and the iOS flush writes exactly
1857+
// `scrollOffset + scrollAdjustments`, so none of them count.
1858+
const awaitingCommandReadBack =
1859+
this.scrollState?.writtenOffset != null &&
1860+
!approxEqual(
1861+
this.scrollState.writtenOffset,
1862+
this.getScrollOffset() + this.scrollAdjustments,
18421863
)
1843-
} else if (shouldAdjustScroll) {
1844-
adjustedSync = this.applyScrollAdjustment(delta)
1864+
1865+
let adjustedSync = false
1866+
if (!awaitingCommandReadBack) {
1867+
if (wasAtEnd) {
1868+
adjustedSync = this.applyScrollAdjustment(
1869+
this.getTotalSize() - prevTotalSize,
1870+
)
1871+
} else if (shouldAdjustScroll) {
1872+
adjustedSync = this.applyScrollAdjustment(delta)
1873+
}
18451874
}
18461875

18471876
// When we just moved `scrollTop` to compensate for an above-viewport
@@ -2080,7 +2109,13 @@ export class Virtualizer<
20802109
delta: number,
20812110
{ behavior = 'auto' }: ScrollToOffsetOptions = {},
20822111
) => {
2083-
const offset = this.getScrollOffset() + delta
2112+
// Build on a position we just wrote even before its scroll event arrives.
2113+
// Clamp: a write past either edge that leaves the element where it was
2114+
// fires no scroll event, so nothing would clear an unreachable offset.
2115+
const offset = this.getOffsetForAlignment(
2116+
(this.scrollState?.writtenOffset ?? this.getScrollOffset()) + delta,
2117+
'start',
2118+
)
20842119
const now = this.now()
20852120

20862121
this.scrollState = {
@@ -2227,6 +2262,10 @@ export class Virtualizer<
22272262
// Record the intended logical scroll target so the next scroll event
22282263
// can reconcile against subpixel rounding by the browser.
22292264
this._intendedScrollOffset = offset + (adjustments ?? 0)
2265+
if (this.scrollState) {
2266+
this.scrollState.writtenOffset =
2267+
behavior === 'smooth' ? null : this._intendedScrollOffset
2268+
}
22302269
this.options.scrollToFn(offset, { behavior, adjustments }, this)
22312270
}
22322271

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

Lines changed: 238 additions & 14 deletions

0 commit comments

Comments
 (0)