Skip to content

Commit 42eba73

Browse files
committed
Stop the fling on iOS too, and name its upstream fix
The touch-fling stop skipped iOS through a copy of virtual-core's private isIOSWebKit() user-agent check: before iOS 27 the write stops the fling itself, and from 27 virtual-core stops it. That copy could drift from virtual-core's own check without anyone noticing, and nothing tested the iOS branch. It is gone: the stop now runs after every touch fling, iOS included, where it is not needed and costs its two frames. On an iPhone 11 (iOS 26.2) a jump issued 150ms into a fling still lands three times out of three, and the list holds still through the two hidden frames. The fix belongs in virtual-core, which already stops a fling before a command on iOS 27 but not on Android. It is proposed in TanStack/virtual#1347 (issue TanStack/virtual#1346); the README now lists the stop as a stopgap that goes away with that release. Matching that PR, hiding the overflow is undone in the same task when it changes the element's client width: a classic scrollbar on a touchscreen laptop would vanish and reflow the list for two frames. The write then goes out at once, as before the stop existed. Pinned by a third case in the touch-fling browser test, a scrollbar that takes layout space, which fails without the check.
1 parent a7bc81d commit 42eba73

3 files changed

Lines changed: 54 additions & 22 deletions

File tree

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,16 @@ The interop calls three underscore-prefixed virtual-core members: `_willUpdate()
180180

181181
This is a real coupling: a virtual-core release could change them without it being a documented breaking change. It is why the package pins an exact version rather than floating, and why every version bump re-verifies those three members before shipping.
182182

183+
## Stopgaps for upstream fixes
184+
185+
Behaviour that belongs in virtual-core, carried here only until the upstream fix ships:
186+
187+
| Behaviour | Upstream | Stopgap |
188+
|---|---|---|
189+
| A `ScrollToIndex`, `ScrollToOffset`, `ScrollToEnd` or `ScrollBy` issued by code while a touch fling is still moving the list overshoots its target on Android: Chrome carries the fling on from the written position. virtual-core stops the fling first only on iOS 27. | https://github.com/TanStack/virtual/pull/1347 (issue https://github.com/TanStack/virtual/issues/1346) | `runScrollCommand` in `wwwroot/adaptive-virtualize-interop.js` hides the scroll axis's overflow for two frames before the write. Pinned by `ScrollToIndex_WhileATouchFlingIsMovingTheList_StopsTheMomentumBeforeWriting`. |
190+
191+
⚠️ **Remove the stopgap together with the bump to the virtual-core release that contains the fix.** Until then it also runs on iOS, where it is not needed and costs its two frames, because telling the platforms apart would mean copying virtual-core's private user-agent checks.
192+
183193
## Vendored library provenance
184194

185195
| | |

‎src/Blazor.TanStack.VirtualCore/wwwroot/adaptive-virtualize-interop.js‎

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,6 @@ function compact(source) {
4949

5050
const nextFrame = () => new Promise(resolve => requestAnimationFrame(() => resolve()));
5151

52-
/** virtual-core's own iOS WebKit test, iPadOS desktop-class Safari included. */
53-
function isIOSWebKit() {
54-
return /iP(hone|od|ad)/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 0);
55-
}
56-
5752
class AdaptiveVirtualizeInstance {
5853
// Keys and sizes arrive as two parallel arrays rather than one array of objects: this covers the
5954
// WHOLE item set, not just the rendered window, and it crosses the interop boundary as JSON on
@@ -485,41 +480,54 @@ class AdaptiveVirtualizeInstance {
485480
/**
486481
* Runs a scroll command, first stopping a touch fling that is still moving the list. Android
487482
* Chrome carries a fling on after a programmatic scroll write, so a jump issued mid-fling lands
488-
* wherever the momentum takes it instead of on its target. iOS WebKit stops the fling on the
489-
* write before iOS 27, and from 27 on virtual-core stops it itself, so iOS is left alone.
483+
* wherever the momentum takes it instead of on its target. virtual-core stops a fling before a
484+
* command only on iOS 27; this stands in for the rest until
485+
* https://github.com/TanStack/virtual/pull/1347 ships, and goes away with it.
490486
*
491487
* Hiding the scroll axis's overflow for two frames is what stops the momentum - the same trick
492-
* virtual-core uses on iOS 27. Its own _stopMomentum is not reused: it is private, and it drops
493-
* the callback of a command superseded mid-stop, which would leave that command's caller
494-
* awaiting forever. Without a touch there is no fling to stop, and on a desktop hiding the
495-
* overflow would also hide a classic scrollbar and reflow the list, so the write goes out at once.
488+
* virtual-core uses on iOS 27. It runs on iOS too, where it is not needed (before 27 the write
489+
* stops the fling itself, and on 27 virtual-core does) and costs those two frames: telling the
490+
* platforms apart would take copying virtual-core's private user-agent checks. Its own
491+
* _stopMomentum is not reused: it is private, and it drops the callback of a command superseded
492+
* mid-stop, which would leave that command's caller awaiting forever. Without a touch there is
493+
* no fling to stop, and on a desktop hiding the overflow would also hide a classic scrollbar and
494+
* reflow the list, so the write goes out at once.
496495
*/
497496
async runScrollCommand(command) {
498497
const isTouchFling = this.lastInputWasTouch && !this.touchActive
499-
&& this.virtualizer.isScrolling === true && !isIOSWebKit();
498+
&& this.virtualizer.isScrolling === true;
500499
if (isTouchFling) {
501500
// Shared by every command issued during the same stop, so none of them reads back the
502501
// "hidden" another one set as the value to restore.
503-
this.momentumStop ??= this.stopMomentum().finally(() => { this.momentumStop = null; });
504-
await this.momentumStop;
505-
if (!this.cleanupMount) {
506-
return;
502+
this.momentumStop ??= this.stopMomentum()?.finally(() => { this.momentumStop = null; }) ?? null;
503+
if (this.momentumStop) {
504+
await this.momentumStop;
505+
if (!this.cleanupMount) {
506+
return;
507+
}
507508
}
508509
}
509510

510511
command();
511512
}
512513

513-
async stopMomentum() {
514+
/** Hides the scroll axis's overflow for two frames; null when it had to be put back at once. */
515+
stopMomentum() {
514516
const element = this.isWindowScroller ? this.findScrollContainer(this.rootElement) : this.scrollContainer;
515517
const property = this.options.horizontal ? 'overflow-x' : 'overflow-y';
516518
const { style } = element;
517519
const value = style.getPropertyValue(property);
518520
const priority = style.getPropertyPriority(property);
521+
const crossSize = () => this.options.horizontal ? element.clientHeight : element.clientWidth;
522+
const crossSizeBefore = crossSize();
519523
style.setProperty(property, 'hidden', 'important');
520-
await nextFrame();
521-
await nextFrame();
522-
style.setProperty(property, value, priority);
524+
// A classic scrollbar takes layout space, so hiding it would reflow the list for two frames
525+
// (a touchscreen laptop). Put it back before anything paints and write at once, as before.
526+
if (crossSize() !== crossSizeBefore) {
527+
style.setProperty(property, value, priority);
528+
return null;
529+
}
530+
return nextFrame().then(nextFrame).then(() => style.setProperty(property, value, priority));
523531
}
524532

525533
cancelScroll() {

‎tests/Blazor.TanStack.VirtualCore.E2E/InteropModuleTests.cs‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,16 @@ public async Task ScrollToIndex_WhileATouchFlingIsMovingTheList_StopsTheMomentum
221221
var runs = await _fixture.Page.EvaluateAsync<CommandSample[]>(
222222
"async () => {" + _mountScript + @"
223223
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
224-
const jumpWhileMoving = async byTouch => {
224+
const jumpWhileMoving = async (byTouch, classicScrollbar) => {
225225
const list = mount(200, {}, (totalSize, root) =>
226226
setTimeout(() => { root.style.height = `${totalSize}px`; }, 30));
227+
if (classicScrollbar) {
228+
// A scrollbar that takes 15px of the width while it shows; the browser
229+
// under test hides its scrollbars.
230+
Object.defineProperty(list.viewport, 'clientWidth', {
231+
get: () => list.viewport.style.overflowY === 'hidden' ? 300 : 285,
232+
});
233+
}
227234
await delay(100);
228235
229236
// The input that set the list moving: a touch that has already lifted - a fling -
@@ -254,7 +261,7 @@ public async Task ScrollToIndex_WhileATouchFlingIsMovingTheList_StopsTheMomentum
254261
list.viewport.remove();
255262
return sample;
256263
};
257-
return [await jumpWhileMoving(true), await jumpWhileMoving(false)];
264+
return [await jumpWhileMoving(true, false), await jumpWhileMoving(false, false), await jumpWhileMoving(true, true)];
258265
}");
259266

260267
// Assert
@@ -271,6 +278,13 @@ public async Task ScrollToIndex_WhileATouchFlingIsMovingTheList_StopsTheMomentum
271278
var wheel = runs[1];
272279
Assert.Equal("auto", wheel.OverflowWhenIssued);
273280
Assert.Equal(5000, wheel.ScrollTopWhenIssued);
281+
282+
// A scrollbar that takes layout space would vanish for two frames and reflow the list (a
283+
// touchscreen laptop), so the overflow is put back before anything paints and the write
284+
// goes out at once.
285+
var classicScrollbar = runs[2];
286+
Assert.Equal("auto", classicScrollbar.OverflowWhenIssued);
287+
Assert.Equal(5000, classicScrollbar.ScrollTopWhenIssued);
274288
_fixture.AssertNoJsErrors();
275289
}
276290

0 commit comments

Comments
 (0)