iOS scroll-correction deferral: per-row record, iOS 27 momentum, and index-scroll exits
Les mainteneurs répondent en général sous 1 jour
Personne n'a encore pris cette issue.
Évaluation
- Difficulté
- 5/5
- Temps estimé
- Plus d'une semaine
- Accessibilité débutants
- 25/100
- Type d'issue
- Bug
- Clarté
- Plutôt claire
- Activité
- Active
- Stack technique
- react, typescript
- Domaine
- frontend, performance
Piste de recherche
Start with the implementation paths that maintain scroll offsets, defer iOS corrections, and retarget index scrolls, then read the failing cases and event scripts in #1308 and the proposed differential test in tests/ios-differential.test.ts. The plan splits the work into separate PRs, each with its own tests and device checks; first determine which step is available and follow its named failure. Done means that step's test.fails cases pass, the option sweep and required suites are green, and the specified iOS device checks are complete.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Description
Plan for #1307
The anchor proposal in the issue is dropped: it needs history under momentum and changes which resizes get compensated. This plan is built from the measurements in this thread (iOS 18.2, 26.5 and 27.0 simulators, iPhone on 27.0.1) and from the tests pinned in #1308.
What we know
| Phase | iOS ≤ 26 | iOS 27+ |
|---|---|---|
| Finger down | a scrollTop write is ignored |
a scrollTop write is ignored |
| Momentum | a write stops the momentum | the write lands and momentum continues |
- The deferral is needed under a finger on every version, and during momentum only on ≤ 26. On 27+ deferring during momentum just leaves the content shifted until the flush.
- Detecting 27+: the UA's OS token is frozen (
iPhone OS 18_7on 26 and 27; iPad reports a Mac).Version/is real in Safari but likely missing in in-app web views and Chrome/Firefox on iOS.CSS.supports('overflow-anchor: auto')is true only on 27 in the measured versions, doesn't read the UA, and is false (keep deferring) when absent. - One accumulated delta loses information the flush needs: a correction is replayed after the gesture has carried the viewport above the resized row (the reverse flick).
- Several bugs aren't iOS-specific: the stale
scrollOffsetbefore a write's read-back, and the pull-back of an index scroll that never reached its target.
Steps
Each step is its own PR, fixes named test.fails from #1308 (removing .fails), and leaves the rest green. Each runs a full option sweep (align, lanes, padding, scrollPaddingStart, scrollMargin, horizontal, custom adjust predicate, anchorTo, prepend, iOS UA) before it is opened, test:pr, and the chat e2e suites repeatedly.
1. Stale scrollOffset before a write's read-back (all platforms): #1309
While a scroll command's write hasn't been read back, resizes are left to reconcile instead of being compensated against the old offset; scrollBy builds on the written offset.
- Fixes: "scrollToIndex (#1307): a resize classified against the stale offset does not overwrite the index scroll".
- Adds
tests/ios-differential.test.ts(same event script on iOS and non-iOS, compares where they settle, flags a reconcile that needs the safety valve).
2. Pull-back of an index scroll that never landed (all platforms): #1310
A scroll event that isn't the read-back of our own write ends the index scroll whether or not it reached its target (today only after hasReachedTarget). On iOS this also covers the finger-down case, where the write is ignored and the scroll can never land.
- Fixes: "a drag that never reached the target is not pulled back when the target moves", "a scroll away from a target that moved before landing is not pulled back, non-iOS".
- Watch: smooth scrolls produce intermediate scroll events that aren't our read-back, and a smooth retarget resets
hasReachedTargetfor that reason. They need their own rule, likely "a smooth scroll is interrupted only by an event moving away from the target".
2a. Refactor: the scroll state as an explicit phase (no behaviour change): #1311
ScrollState described its lifecycle through flags: behavior (switched from 'smooth' to 'auto' by reconcile for the final approach, which also re-enables size compensation), hasReachedTarget, and since #1310 startedSmooth (WebKit delivers smooth-animation events after that switch). Their combinations encode four situations, so they become one phase:
smooth a smooth animation is travelling to the target
smoothLanded a smooth scroll is at its target, waiting to retire
smoothFinishing a smooth scroll switched to instant writes for the final
approach; its animation can still report positions
instant instant writes
Checks ask about the phase through two helpers: isSmoothScroll (compensation, anchor sync and far measurements hold off) and isAnimating (a scroll event that isn't our read-back may be the animation, not the user).
- Behaviour-preserving: identical traces against the parent on 3,000 random event sequences, plus the #1308 tests,
tests/ios-differential.test.ts, and the react-virtual smooth/scroll/cached-measurements e2e in Chromium, Firefox and WebKit. - Before step 4, which reworks the iOS deferral state anyway.
2b. Defer only for touch-driven scrolls (all iOS versions): next
Today the deferral is gated on isScrolling, which any scroll event sets, including the read-back of our own programmatic write. So a scrollToOffset landing paints sagged until scrolling settles and then snaps (#1250). Since #1305 a scrollToIndex landing sags for one frame until reconcile retargets. A programmatic scroll has no momentum to protect; only a touch does.
- Gate the deferral (in
applyScrollAdjustment, the prepend anchor sync, and the flush) on touch provenance: a finger is down, or the momentum tail aftertouchend. While the tail is open, each scroll event re-arms its timer, so it spans the fling and closes itself after the last frame; a scroll event with no preceding touch never opens it. - An absolute scroll command closes the tail (its write stops momentum on ≤ 26 anyway);
touchcancelis handled liketouchend. - Regression tests carried over from #1280: a tap that doesn't scroll must not latch the deferral, and a landing issued from a tap handler compensates before paint.
- Fixes #1250. Gives step 3 a precise "momentum phase" to gate on instead of
isScrolling. - A prototype of the gate on top of #1311 makes all 12 known iOS/non-iOS divergences in
tests/ios-differential.test.tsconverge and fixes three #1308test.fails(cancelScroll, the safety valve,scrollByreplacing an index scroll). About 15 existing iOS tests model momentum as scroll events without touch events and need rewriting with explicittouchstart/touchend. - #1305's "drop corrections during an index scroll" branch only stays reachable while a finger is down, where #1310 already ends the index scroll on the finger's first scroll event. Remove it if the tests and the harness agree.
- Taken from #1280, which bundled this with the prepend fix in step 5 and is closed in favour of this plan.
3. iOS 27+: don't defer during momentum (small, may ride with 2b)
Defer under a finger and in the post-touchend grace window as today; during momentum on 27+, write the correction immediately like every other platform.
- Gate:
isIOSWebKit() && CSS.supports('overflow-anchor: auto'), optionally confirmed byVersion/ >= 27. Absent or false keeps today's behaviour. - Check first, with the #1308 page on the 27 simulator and the device: whether Safari 27's native scroll anchoring adjusts anything itself for transform-positioned rows (it would double the correction), and whether the virtualizer should set
overflow-anchor: noneon the scroller. - Tests: jsdom with
CSS.supportsstubbed for both answers; the existing touch-phase tests must pass unchanged. - With 2b's explicit momentum tail this is one condition on that tail.
- Shrinks step 4: on 27+ only the finger-down phase still defers.
4. Per-row deferral record (conditional: decide after 2b and 3)
After 2b and 3, what's left for this step is iOS ≤ 26 during real touch momentum and drags, chiefly the reverse flick. Decide with the #1308 device page whether it is worth the complexity or whether accepting that case on older iOS is the better trade.
Replace _iosDeferredAdjustment with a record of the deferred corrections per row (key → delta, using the same predicate as today), plus prepend shifts.
- At flush, apply only the corrections for rows that are still above the current viewport. That fixes the reverse flick and keeps momentum travel correct.
- During an index scroll keep recording instead of dropping (#1305). A reconcile retarget write absorbs everything recorded before it, so it clears the record. Exits that end the scroll without landing (gesture,
cancelScroll,scrollBy, safety valve) leave the record for the flush. - Fixes: "a correction is not replayed once the viewport is above the resized row", and the index-scroll exits: "a gesture before the index scroll retargets…", "cancelScroll before the index scroll retargets…", "the reconcile safety valve…", "scrollBy replacing an index scroll…".
- Keeps: the five behaviour tests from #1308 (momentum baseline, forward vs backward re-measure, custom predicate opting out, first measure of the row spanning the viewport top, non-iOS gesture).
- Test rework: 31 assertions on
mainread_iosDeferredAdjustment. The ones that pin what was deferred move to a small read-only accessor or to position assertions. Two #1308test.failshave an intermediate check (_iosDeferredAdjustment === 50, an intermediate 2550) that would keep them green for the wrong reason; drop those before relying on them. - The differential harness gets one documented exception: iOS skips corrections for rows that ended below the viewport, which non-iOS applied at resize time.
5. Shared stalls and prepend fixes (separate PRs)
Found while reviewing steps 1 and 2, same on main:
scrollToOffset/scrollBy, then a row above grows after the read-back: the compensation moves off the fixed target and reconcile never rewrites it until the safety valve.- A prepend (
anchorTo: 'end') before the read-back of ascrollToIndex: the anchor sync writes from the stale offset and overrides the index scroll until the safety valve. - A deferred prepend correction is applied twice (from #1280):
setOptionsfolds the anchor delta into the trackedscrollOffseteagerly, and when the DOM sync is deferred under a finger, the flush writesscrollOffset + deferred. A fling to the top that lands on a prepend jumps by the prepend's height.
Related issues
- #1250: fixed by step 2b (partly by #1305 already:
scrollToIndexsags for one frame instead of snapping later;scrollToOffsetstill snaps). - #884: the reason the deferral exists; addressed on
mainsince@tanstack/virtual-core3.15.0 (#1168). On iOS 27 a write no longer stops momentum at all (step 3). - #1287: out of scope. Desktop Safari discards a write made during the rubber-band bounce; no touch and no iOS deferral are involved, so it needs its own fix.
Verification on devices
The #1308 page (react-virtual/e2e/app/ios-deferral) is the manual check for steps 2–4: the 18.2, 26.5 and 27.0 simulators and a 27 device, with real gestures, for the momentum baseline, the reverse flick, a resize under a finger, and scrollToIndex during momentum and under a finger. Not yet covered by anyone: in-app WKWebViews, Chrome/Firefox on iOS, and iOS versions between 26.5 and 27.0.
Open questions
- Does Safari 27's native scroll anchoring touch transform-positioned rows? (Decides whether step 3 also needs
overflow-anchor: none.) - Does
CSS.supports('overflow-anchor: auto')hold in WKWebView and in Chrome/Firefox on iOS 27? - For step 2, should a user scroll during a smooth
scrollToIndexcancel it? Today it doesn't until the target is reached.
- Langage dominant
- TypeScript
- Étoiles
- 7.1k
- Forks
- 472
- Merge moyen
- 1 j 14 h
- PR mergées (30 j)
- 18
Préparer son environnement
- Aucun Dockerfile ni fichier Docker Compose
- Propose un modèle de pull request
- Lire le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de TanStack/virtual
-
anchorTo: 'end' loses the end when the scroll element shrinksPeut-être pris @langleyd l’a pris il y a 5 jours. Ouverte
Difficulté 3/5 1-2 jours Accessibilité débutants 68/100
Les mainteneurs répondent en général sous 1 jour
-
virtual-core: desktop Safari drops the prepend anchor write when history lands during the top rubber-band bouncePeut-être pris Une pull request liée à cette issue est ouverte ou déjà fusionnée. Ouverte
Difficulté 5/5 Plus d'une semaine Accessibilité débutants 38/100
Les mainteneurs répondent en général sous 1 jour
-
marko-virtual: scrollToEnd during an in-flight prepend strands the view one prepend above the bottom (chat-pretext e2e fails on CI)Peut-être à nouveau libre Une pull request pour cette issue a été fermée sans être fusionnée. Ouverte
Difficulté 4/5 3-5 jours Accessibilité débutants 55/100
Les mainteneurs répondent en général sous 1 jour
-
Difficulté 4/5 3-5 jours Accessibilité débutants 42/100
TanStack/virtual#1252 · 1 commentaire ·
Les mainteneurs répondent en général sous 1 jour
-
lit-virtual: VirtualizerController never applies updated options after constructionPeut-être pris @waterWang l’a pris il y a 62 jours. Ouverte
Difficulté 5/5 Plus d'une semaine Accessibilité débutants 45/100
Les mainteneurs répondent en général sous 1 jour
Toutes les issues de TanStack/virtual
Issues similaires
-
Difficulté 2/5 1-3 heures Accessibilité débutants 77/100
apache/rocketmq-dashboard#6610 ·
Les mainteneurs répondent en général sous 3 jours
-
nextjs-tracking
Difficulté 2/5 1-3 heures Accessibilité débutants 69/100
cloudflare/vinext#3920 ·
Les mainteneurs répondent en général sous 1 jour
-
Team:Dashboards
Difficulté 2/5 1-3 heures Accessibilité débutants 65/100
elastic/kibana#297299 · 1 commentaire ·
Les mainteneurs répondent en général sous 1 jour
-
analytics beginner friendly community contributions-welcome good first issue hacktoberfest help wanted up-for-grabs
Difficulté 2/5 1-3 heures Accessibilité débutants 82/100
Les mainteneurs répondent en général sous 1 jour
-
affected/v17 affected/v18 area/frontend type/bug
Difficulté 2/5 1-3 heures Accessibilité débutants 82/100
umbraco/Umbraco-CMS#24186 ·
Les mainteneurs répondent en général sous 2 jours