Skip to content

fix(virtual-core): add touch provenance flag to iOS deferral gate - #1254

Open
waterWang wants to merge 1 commit into
TanStack:mainfrom
waterWang:fix/virtual-core-ios-scroll-touch-provenance
Open

fix(virtual-core): add touch provenance flag to iOS deferral gate#1254
waterWang wants to merge 1 commit into
TanStack:mainfrom
waterWang:fix/virtual-core-ios-scroll-touch-provenance

Conversation

@waterWang

@waterWang waterWang commented Aug 12, 2026

Copy link
Copy Markdown

Problem

On iOS WebKit, the iOS scroll-adjustment deferral engages for programmatic scrolls, not just touch-driven ones. The deferral gate is isScrolling, and isScrolling carries no provenance — observeOffset sets it from any scroll event, including the ones a programmatic scroll write generates itself.

The result is that dynamic-measurement compensation which would normally apply pre-paint is deferred past a paint, so a scrollToIndex/scrollToOffset landing paints at a visibly wrong offset and then snaps into place a beat later.

Root Cause

The gate in applyScrollAdjustment:

if (isIOSWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)) {

The this.isScrolling term over-captures: it's true for ALL scroll events, including the echo of the app's own scrollTop write. The existing touch-provenance flags (_iosTouching, _iosJustTouchEnded) are already correct — they only fire for real touch events — but they only cover the active-touch and 150ms-post-touchend windows. Momentum continues well past that, and isScrolling is the only flag that stays true through the rest of the fling.

Fix

Introduce _isUserScrolling — a touch-provenance flag that covers the entire user-initiated scroll sequence:

  • touchstart: set _isUserScrolling = true
  • isScrolling transitions to false: clear _isUserScrolling (scroll fully settled)
  • Deferral gates: use _isUserScrolling instead of bare isScrolling

Touch-initiated sequences defer exactly as before (fixing #884). Programmatic scrolls never set _isUserScrolling, so their adjustments stay on the synchronous pre-paint path — landing compensated on the first painted frame.

Fixes #1250

Summary by CodeRabbit

  • Bug Fixes
    • Improved scrolling behavior on iOS by ensuring deferred adjustments occur only during user-initiated touch scrolling.
    • Prevented programmatic scroll actions from being delayed or incorrectly synchronized.
    • Improved anchor positioning during active touch-based scrolling.

`applyScrollAdjustment` and the anchor-deferral gate guard on
`isScrolling`, but `isScrolling` is set by *any* scroll event,
including the echo of a programmatic scrollTop write. On iOS this
makes every programmatic scrollToIndex/scrollToOffset landing defer
its size-change compensation past a paint, producing a visible
sag-then-snap (TanStack#1250).

Introduce `_isUserScrolling` — set on `touchstart`, kept true while
`isScrolling` remains true (covering the whole momentum phase), and
cleared when the scroll fully settles. The deferral gates now use
`_isUserScrolling` instead of bare `isScrolling`, so touch-initiated
sequences defer exactly as before (fixing TanStack#884) while app-initiated
scrolls land compensated on their first painted frame.

Closes TanStack#1250
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The virtual core now tracks whether scrolling began with a touch gesture. iOS adjustment deferral and anchor synchronization use this state, while programmatic scrolls remain eligible for immediate adjustments.

Changes

iOS scroll provenance

Layer / File(s) Summary
Track touch-driven scroll state
packages/virtual-core/src/index.ts
The virtualizer records touch-start provenance and clears it when scrolling settles or cleanup runs.
Gate iOS adjustments by provenance
packages/virtual-core/src/index.ts
iOS adjustment deferral and anchor synchronization use _isUserScrolling instead of isScrolling.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: piecyk, 2wheeh, leolb-wang

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the virtual-core fix and the iOS deferral gate change.
Description check ✅ Passed The description clearly explains the problem, root cause, fix, expected behavior, and linked issue, but omits the template checklist and release-impact sections.
Linked Issues check ✅ Passed The changes satisfy issue #1250 by separating touch-initiated scrolling from programmatic scrolling while preserving momentum deferral.
Out of Scope Changes check ✅ Passed The changes are limited to the virtual-core scrolling behavior described in issue #1250 and contain no unrelated scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/virtual-core/src/index.ts (1)

712-712: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression coverage for both provenance paths.

The supplied packages/virtual-core/tests/index.test.ts:1922-1939 test covers an active touch. _iosTouching already defers that case. Add tests that verify scrollToIndex and scrollToOffset compensation remains synchronous before paint, and that momentum remains deferred after touchend.

Also applies to: 976-976

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/virtual-core/src/index.ts` at line 712, Add regression tests in the
existing virtual-core test suite covering both compensation provenance paths:
verify scrollToIndex and scrollToOffset remain synchronous before paint during
active touch, while momentum compensation remains deferred after touchend. Reuse
the existing touch/scroll test setup and assert timing separately for
_iosTouching and _iosJustTouchEnded behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/virtual-core/src/index.ts`:
- Around line 881-887: Update the scroll-settling logic around _isUserScrolling
so it is not cleared while _iosTouching or _iosJustTouchEnded is active; clear
it only when the 150 ms timer expires and !this.isScrolling. Extend the
onTouchEnd registration to include touchcancel so cancelled gestures and taps
cannot leave stale touch state.

---

Nitpick comments:
In `@packages/virtual-core/src/index.ts`:
- Line 712: Add regression tests in the existing virtual-core test suite
covering both compensation provenance paths: verify scrollToIndex and
scrollToOffset remain synchronous before paint during active touch, while
momentum compensation remains deferred after touchend. Reuse the existing
touch/scroll test setup and assert timing separately for _iosTouching and
_iosJustTouchEnded behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f6154467-0c4a-43cd-b91e-8b8e45ce25fb

📥 Commits

Reviewing files that changed from the base of the PR and between d2cf98b and 585b1c7.

📒 Files selected for processing (1)
  • packages/virtual-core/src/index.ts

Comment on lines +881 to +887
// When the scroll fully settles, touch provenance for the sequence
// ends: programmatic scrolls never set it, and a user sequence is
// done once the momentum/subsequent scroll events stop. Clearing it
// here lets a later app-initiated scroll (which fires its own
// scroll events) take the synchronous adjustment path as intended
// (#1250).
if (!isScrolling) this._isUserScrolling = false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 12 \
  'observeElementOffset|isScrollingResetDelay|touch(start|end|cancel)|_isUserScrolling' \
  packages/virtual-core/src packages/virtual-core/tests

Repository: TanStack/virtual

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- observeOffset implementation ---'
sed -n '160,225p' packages/virtual-core/src/index.ts

printf '%s\n' '--- lifecycle and flush code ---'
sed -n '820,1035p' packages/virtual-core/src/index.ts

printf '%s\n' '--- relevant tests and test files ---'
rg -n -C 8 \
  '_iosTouching|_iosJustTouchEnded|_iosTouchEndTimerId|_isUserScrolling|touchcancel|touchend|isScrollingResetDelay|resizeItem' \
  packages/virtual-core/tests packages/virtual-core/src/index.ts \
  | head -n 500

Repository: TanStack/virtual

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from dataclasses import dataclass

`@dataclass`
class State:
    user: bool = False
    touching: bool = False
    just_ended: bool = False
    scrolling: bool = False

def gated(s: State) -> bool:
    return s.user or s.touching or s.just_ended

def existing_tap():
    s = State()
    s.user = True
    s.touching = True
    s.touching = False
    s.just_ended = True
    s.just_ended = False       # 150 ms timer
    return s, gated(s)

def existing_pause_resume():
    s = State()
    s.user = True
    s.touching = True
    s.scrolling = True
    s.scrolling = False        # debounce callback during touch pause
    s.user = False             # current reset
    s.scrolling = True         # resumed movement; no second touchstart
    s.touching = False
    s.just_ended = True
    s.just_ended = False       # timer expires while momentum continues
    return s, gated(s)

def proposed_tap():
    s = State()
    s.user = True
    s.touching = True
    s.touching = False
    s.just_ended = True
    s.just_ended = False
    if not s.scrolling:
        s.user = False
    return s, gated(s)

def proposed_pause_resume():
    s = State()
    s.user = True
    s.touching = True
    s.scrolling = True
    s.scrolling = False        # guarded reset does not clear while touching
    s.scrolling = True
    s.touching = False
    s.just_ended = True
    s.just_ended = False
    if not s.scrolling:
        s.user = False
    return s, gated(s)

for name, probe in [
    ("existing tap", existing_tap),
    ("existing pause/resume", existing_pause_resume),
    ("proposed tap", proposed_tap),
    ("proposed pause/resume", proposed_pause_resume),
]:
    state, blocked = probe()
    print(f"{name}: {state}; iOS adjustment deferred={blocked}")
PY

Repository: TanStack/virtual

Length of output: 622


Keep _isUserScrolling active until the touch sequence settles.

When isScrolling === false occurs during _iosTouching or _iosJustTouchEnded, do not clear _isUserScrolling. A resumed gesture has no new touchstart; after _iosJustTouchEnded expires, momentum can pass the iOS adjustment gates. Clear the flag from the 150 ms timer only when !this.isScrolling. Register onTouchEnd for touchcancel to prevent taps and cancelled gestures from leaving stale state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/virtual-core/src/index.ts` around lines 881 - 887, Update the
scroll-settling logic around _isUserScrolling so it is not cleared while
_iosTouching or _iosJustTouchEnded is active; clear it only when the 150 ms
timer expires and !this.isScrolling. Extend the onTouchEnd registration to
include touchcancel so cancelled gestures and taps cannot leave stale touch
state.

@piecyk

piecyk commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

@waterWang Thanks for the writeup — the diagnosis is right. isScrolling genuinely does over-capture, and since _iosTouching/_iosJustTouchEnded only cover the drag plus a 150 ms tail, removing isScrolling from the gate does need something that spans momentum. Two findings before this can land, though.

1. _isUserScrolling never gets cleared if the touch doesn't scroll

It's set on touchstart and cleared in only two places: cleanup(), and the !isScrolling branch of the scroll callback. That second clear needs a scroll event to have happened — with the default useScrollendEvent: false, !isScrolling comes from the debounce in observeOffset, which is only armed from inside the scroll handler.

So any touch that doesn't scroll latches the flag true for good: tapping a row, a long-press, a horizontal swipe in a vertical list. Verified at 585b1c7touchstart + touchend, no scroll events, after the grace window:

touching: false  justEnded: false  userScrolling: true
applied sync: false  deferred: 20

Two consequences after one stray tap: #1250 still reproduces (the gate is stuck, so the next scrollToIndex defers, paints sagged, snaps), and the deferred delta is stranded_flushIosDeferredIfReady only runs from the scroll callback and the touchend timer, and its readiness check (!isScrolling && !_iosTouching && !_iosJustTouchEnded) is already satisfied, so nothing re-invokes it. Worth keeping as a rule: the deferral gate and the flush predicate have to agree, otherwise a delta can be deferred by a condition the flush path can never observe.

2. A tap-triggered landing still defers — in both this version and mine

Built the issue's repro as a test (500 rows, estimate 50 / actual 58, scrollToIndex(300)). The filed case — no touch at all — is fixed. But when the landing is triggered by a tap, e.g. tapping a search result whose click handler calls scrollToIndex, the call lands inside the post-touchend window and the compensation defers anyway:

A no-touch  -> deferred: 0   sync writes: 1
B tap       -> deferred: 8   sync writes: 0

Since the issue's expectation is the general one ("a programmatic scroll should land compensated on its first painted frame"), closing this needs one more thing: scrollToOffset/scrollToIndex should also close the post-touchend window, right where they already drop _iosDeferredAdjustment. The write those commands are about to make cancels any in-flight momentum by itself, so the window has nothing left to protect. (_iosTouching should stay — with a finger still down the user owns the scroll.)

Suggested shape

Rather than a new field, re-arm the window that already exists. Momentum fires a scroll event per frame, so pushing _iosJustTouchEnded's timer out on each one spans the whole fling and self-terminates 150 ms after the last frame:

private _armIosTouchWindow = () => {
  if (!isIOSWebKit() || this.targetWindow == null) return
  this._iosJustTouchEnded = true
  if (this._iosTouchEndTimerId !== null) {
    this.targetWindow.clearTimeout(this._iosTouchEndTimerId)
  }
  this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => {
    this._iosJustTouchEnded = false
    this._iosTouchEndTimerId = null
    this._flushIosDeferredIfReady()
  }, 150)
}

onTouchEnd calls it, and the scroll callback calls it when isScrolling && this._iosJustTouchEnded. The gate goes back to _iosTouching || _iosJustTouchEnded, matches the flush predicate again, and every piece of state is timer-bounded so nothing can latch. One caveat to be aware of: a self-write echo arriving inside the window also re-arms it, which is part of why fix #2 above is needed rather than optional.

Also

  • The existing tests need updating with this change. At 585b1c7, 10 iOS tests in packages/virtual-core/tests/index.test.ts fail — they simulate a live scroll with no touch and assert deferral, which is precisely the behavior iOS scroll-adjustment deferral engages for programmatic scrolls, making scrollToIndex landings paint sagged then snap #1250 calls a bug. They have to move to touch provenance, and one (iOS Phase 1: scroll-event after touchend timer cleanup also flushes) is built entirely on the old premise. Doesn't look like the test job ran on this PR — only CodeRabbit and Socket reported.
  • touchcancel isn't handled (pre-existing): iOS fires it instead of touchend when a system gesture steals the touch, which strands _iosTouching with no timer to recover it.
  • Naming: the neighbouring fields are all _ios*, and this one is only read on iOS paths.

I have the suggested shape implemented locally with the tests updated and the two new regression cases (tap doesn't latch the gate; momentum re-arms the window) — happy to push it as a commit here or a separate PR, whichever you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS scroll-adjustment deferral engages for programmatic scrolls, making scrollToIndex landings paint sagged then snap

2 participants