Skip to content

Commit 56b2a63

Browse files
authored
fix(virtual-core): record the committed max as a clamp candidate (#1330)
1 parent d2c7911 commit 56b2a63

6 files changed

Lines changed: 367 additions & 50 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
fix(virtual-core): keep an instant scroll command clamped at a max the sizer committed outside a row resize
6+
7+
Before an instant scroll command's write is reported, the browser can clamp it at a smaller scroll max that the sizer moves to without a row resize: an append or prepend, `measure()`, or a resize the sizer had not committed when the command was written. The virtualizer now also records the max at each commit, so such an event is read as its own clamped write and the command lands. A clamped write reported with `isScrolling` false is now written again too.

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

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -610,15 +610,16 @@ export class Virtualizer<
610610
// further by the time it reports.
611611
// - `smooth`: the write animates, so until it lands the element is
612612
// somewhere between the old offset and `target`.
613-
// - `max`, `lowMaxes`: for a command's instant write, the scroll max as
614-
// rows measured since the write moved it (`resizeItem`), and each value
615-
// it took below `target`. The browser clamps the write to one of them
616-
// at a layout, and the sizer can grow back before the report, so the
617-
// read-back is that max rather than the element's current one.
613+
// - `lowMaxes`: for a command's instant write, each scroll max below
614+
// `target` the element had since the write. A command's target is
615+
// clamped to the max at the write, but the sizer can shrink under it
616+
// before the report (rows measured smaller, a count change) and grow
617+
// back, so the read-back is the max the browser clamped to then rather
618+
// than the element's current one. Read wherever the sizer can have
619+
// been committed (`_noteClampMax`).
618620
private pendingWrite: {
619621
target: number
620622
smooth: boolean
621-
max?: number
622623
lowMaxes?: Array<number>
623624
} | null = null
624625
// A compensation write from `applyScrollAdjustment` whose target exceeded
@@ -673,6 +674,7 @@ export class Virtualizer<
673674
this._resizeBatch = null
674675
if (batch.notify) {
675676
this.notify(batch.sync)
677+
this._noteClampMax()
676678
this._flushHoldIfReady(true)
677679
this._retryClampedAdjustment()
678680
}
@@ -1094,8 +1096,8 @@ export class Virtualizer<
10941096
} else if (
10951097
kind === 'clampedReadBack' &&
10961098
this.scrollState?.index != null &&
1097-
elementMax !== null &&
1098-
offset < elementMax + this._holdOffset - 1.5
1099+
// Not `elementMax`: an adapter can report the read-back at rest.
1100+
offset < this.getMaxScrollOffset() - 1.5
10991101
) {
11001102
// Our write, clamped at a max the sizer has since grown back
11011103
// from: reconcile writes the target again, even when the layout
@@ -1212,6 +1214,9 @@ export class Virtualizer<
12121214
this._scrollToOffset(this.getScrollOffset())
12131215
}
12141216

1217+
// The consumer has committed the sizer.
1218+
this._noteClampMax()
1219+
12151220
const anchor = this.pendingScrollAnchor
12161221
this.pendingScrollAnchor = null
12171222

@@ -2048,30 +2053,6 @@ export class Virtualizer<
20482053
this.itemSizeCache.set(key, size)
20492054
this.itemSizeCacheVersion++
20502055

2051-
// Follow the scroll max through the sizes measured before a command's
2052-
// instant write reports back (see `pendingWrite`). The element's max
2053-
// is read once, at the first resize after the write, before that
2054-
// resize reaches the sizer; it may already be below the target. With
2055-
// lanes, a row's delta is not the total's, so nothing is followed.
2056-
const write =
2057-
this.options.lanes === 1 && this._awaitingCommandReadBack()
2058-
? this.pendingWrite!
2059-
: null
2060-
const followMax = (max: number) => {
2061-
write!.max = max
2062-
// The element cannot clamp below its own 0, which is the hold in
2063-
// layout coordinates; the raw max stays for the next delta.
2064-
const reachable = Math.max(max, this._holdOffset)
2065-
if (reachable < write!.target) {
2066-
;(write!.lowMaxes ??= []).push(reachable)
2067-
}
2068-
}
2069-
if (write !== null && write.max === undefined) {
2070-
const elementMax = this._elementMaxScrollOffset()
2071-
if (elementMax !== null) followMax(elementMax + this._holdOffset)
2072-
}
2073-
if (write?.max !== undefined) followMax(write.max + delta)
2074-
20752056
// Before a command's read-back, an index or end target is retargeted
20762057
// from fresh measurements by reconcileScroll next frame; an offset
20772058
// target lands where it was written and the delta is not applied.
@@ -2104,6 +2085,7 @@ export class Virtualizer<
21042085
return
21052086
}
21062087
this.notify(adjustedSync)
2088+
this._noteClampMax()
21072089
// A consumer that grows the sizer synchronously inside `onChange`
21082090
// (direct DOM updates) may never re-render when the range is
21092091
// unchanged, so retry a clamped write here rather than only in
@@ -2164,6 +2146,23 @@ export class Virtualizer<
21642146
return notUndefined(measurements[idx])
21652147
}
21662148

2149+
// Record the element's max, in layout coordinates, as one a command's
2150+
// pending instant write may be clamped to (see `lowMaxes` on
2151+
// `pendingWrite`). Called where the sizer can have been committed: after
2152+
// a notify, for a consumer that sizes it in `onChange`, and in
2153+
// `_willUpdate`. The element cannot clamp below its own 0, which is the
2154+
// hold in layout coordinates.
2155+
private _noteClampMax = () => {
2156+
if (!this._awaitingCommandReadBack()) return
2157+
const elementMax = this._elementMaxScrollOffset()
2158+
if (elementMax === null) return
2159+
const write = this.pendingWrite!
2160+
const max = Math.max(elementMax, 0) + this._holdOffset
2161+
if (max >= write.target) return
2162+
const lowMaxes = (write.lowMaxes ??= [])
2163+
if (lowMaxes[lowMaxes.length - 1] !== max) lowMaxes.push(max)
2164+
}
2165+
21672166
// The element's scroll range in layout coordinates: while a correction is
21682167
// held the sizer is short by the hold, so the element's own max is too.
21692168
private getMaxScrollOffset = () =>

‎packages/virtual-core/src/scroll-classifier.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export interface ScrollEventInput {
3434
// The layout position the virtualizer holds.
3535
scrollOffset: number | null
3636
// Our last write to the element, until a scroll event reports it, with
37-
// the scroll maxima below its target the layout took since (see
37+
// the scroll maxima below its target the element had since (see
3838
// `lowMaxes` on `pendingWrite` in the virtualizer).
3939
pendingWrite: {
4040
target: number
@@ -81,11 +81,11 @@ export function classifyScrollEvent({
8181
) {
8282
return 'clampedReadBack'
8383
}
84-
// The same, clamped to a smaller max than the element's current one: a row
85-
// measured smaller shrank the sizer below the target and the browser
86-
// clamped there, then a row measured larger grew it back, all before the
87-
// report. The clamp lands on one of those maxima; a position between them
88-
// is not one.
84+
// The same, clamped to a smaller max than the element's current one: the
85+
// sizer shrank below the target after the write (rows measured smaller,
86+
// a count change) and the browser clamped there, then it grew back, all
87+
// before the report. The clamp lands on one of those maxima; a position
88+
// between them is not one.
8989
if (
9090
pendingWrite !== null &&
9191
!pendingWrite.smooth &&

‎packages/virtual-core/tests/__snapshots__/scroll-trace.test.ts.snap‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,39 @@ exports[`scroll behaviour snapshot > base web 1`] = `
6666
]
6767
`;
6868

69+
exports[`scroll behaviour snapshot > commitPerRow ios 1`] = `
70+
[
71+
"0: 188b7eb6 cbb399d1 0c68f8d8 52d44e85 030fdf66 f89bae5e e33eb3b7 4b11cbb6 bc3f4dfa 0d8fe7ec",
72+
"10: 06bda0c7 fbdcf4b6 92b9b7d7 ac880e66 9880cb46 5ff7a292 10a7d3a0 78eed177 e0b1c0ac e25c37ec",
73+
"20: 7d64b6f6 8ddb843c 01a561c0 4f8fccf5 06aa4f85 0c141569 cfbd97d3 aa353506 4634aede 5052c204",
74+
"30: b6a919af 84dc4560 7e492fad 1852617f b06693e3 124a7157 f88d78a2 a89b2b4f 2d610d8a 155761e2",
75+
"40: 0d274a15 5b099780 9dcf3736 54322109 bf57934a 6041f019 b28f19d9 e5e20e3b cfb551ab 2c1bbec2",
76+
"50: ad0cbc1d 1f82fb9b 8ab02f8b 23c2e2fb 8cb40f92 ec29f0fd 91528992 14e26564 884a1fff 622e658e",
77+
]
78+
`;
79+
80+
exports[`scroll behaviour snapshot > commitPerRow ios27 1`] = `
81+
[
82+
"0: e9f2ce2c cbb399d1 0c68f8d8 2c24151f 030fdf66 43163cec e33eb3b7 0c7f462d bc3f4dfa 0d8fe7ec",
83+
"10: 9ee9150b fbdcf4b6 92b9b7d7 ec0a38f6 9880cb46 60d448ce 10a7d3a0 78eed177 e0b1c0ac a5e63aaf",
84+
"20: 7d64b6f6 8ddb843c 01a561c0 6c999c46 3dcaee6a b2e63a21 cfbd97d3 b6a51eb6 e50f28ad 5052c204",
85+
"30: b6a919af 84dc4560 42933fed 1852617f b06693e3 124a7157 13948bf0 a89b2b4f 2d610d8a 155761e2",
86+
"40: 019380ed 5b099780 9dcf3736 c80d856e bf57934a 6041f019 c20f0271 e5e20e3b cfb551ab 2c1bbec2",
87+
"50: ad0cbc1d 1f82fb9b 8ab02f8b 23c2e2fb 8cb40f92 d54527a4 7989082d 14e26564 884a1fff 98ba9a64",
88+
]
89+
`;
90+
91+
exports[`scroll behaviour snapshot > commitPerRow web 1`] = `
92+
[
93+
"0: ceb7f0e7 cbb399d1 f4a63add a132747c 030fdf66 643d2755 e33eb3b7 4b11cbb6 bc3f4dfa 0d8fe7ec",
94+
"10: 06bda0c7 fbdcf4b6 166319fd ac880e66 9880cb46 5ff7a292 31061954 dae8df99 e2cc19b7 e25c37ec",
95+
"20: 7e86e2b6 fe1ff262 01a561c0 f91f4bae 6e38d09b 4ee9d5dd 7135be90 4ae76f75 b13f2c93 5052c204",
96+
"30: b6a919af 2109d992 7e492fad 1852617f b06693e3 124a7157 117f3ed1 a89b2b4f 2d610d8a 155761e2",
97+
"40: 0d274a15 5b099780 2c1ceef6 f813aca3 bf57934a 8e53c5c4 de439e3e e5e20e3b 5e08526b ef4b633c",
98+
"50: 26d62359 e308f594 a0ad4db6 9d7ab115 509fe1e1 53afc076 83a11448 14e26564 884a1fff 622e658e",
99+
]
100+
`;
101+
69102
exports[`scroll behaviour snapshot > horizontal ios 1`] = `
70103
[
71104
"0: a9fcb9d4 0d2cec68 ed35961e 0a69fa03 0be21da6 3ec8eb85 869fa2d1 27716a81 f885c716 44f33e92",

0 commit comments

Comments
 (0)