fix: scroll to index autoscroll race#2290
Merged
naqvitalha merged 3 commits intoMay 22, 2026
Merged
Conversation
Contributor
Author
|
Hey @naqvitalha , just signed the CLA but I'm not sure how to rerun the job as github won't give me an option to do so |
Contributor
Author
|
Ah nevermind, I was simply supposed to comment. My bad ! |
6 tasks
isekovanic
added a commit
to GetStream/stream-chat-react-native
that referenced
this pull request
May 21, 2026
…3609) ## 🎯 Goal In the pursuit of finding a better workaround for [this PR](#3608), I believe I've found the underlying cause (or part of it at least) for why the issue actually happens. The technical details are in [this upstream PR](Shopify/flash-list#2290) for `FlashList`. ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
amit-hinge
approved these changes
May 22, 2026
naqvitalha
approved these changes
May 22, 2026
Collaborator
|
Thanks for fixing this. Looks good. |
Contributor
Author
|
Thanks a bunch for the express review @naqvitalha ! Also, as some hindsight here - I've noticed that this issue as well as the one where on Android on first render the list would visibly scroll to bottom (cannot find it anymore) have also disappeared, at least within our SDK ! Now, I have no idea why that might be (seemingly unrelated problems) but perhaps it makes sense for the reporters to retest these in case it was a lucky fix there as well. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This PR fixes a race between imperative
scrollToIndexand the MVCP autoscroll to bottom mechanism in v2. WhenautoscrollToBottomThresholdis set and the user is near the bottom of the list, callingscrollToIndeximperatively can be hijacked by the autoscroll to bottom path mid scroll animation. The list briefly lands at the target index, then snaps back to the bottom.This typically reproduces when list items are not fully stable, i.e they need to be remeasured/corrected often. In other words, something that would actually falsely trigger autoscrolling.
I've found this to be reliably reproducible in any chat style list with an actual
autoscrollToBottomThresholdset to its MVCP that also has unstable items (i.e images or GIFs, but also items which might require additional correction internally so basically anything that would trigger any type of change incontentSize):listRef.current?.scrollToIndex({ index: targetIndex, animated: true, viewPosition: 0.5 })- typically from an effect orsetTimeout(0)callback that's downstream of an unrelated state update (e.g., tapping a quoted message that first dispatches a "targeted message" state change)targetIndex, then snaps back to the bottomThe race fires reliably only on the first attempt after step 3. Subsequent calls behave correctly: the race fired
scrollToEndrefreshes the tracked anchor (viacomputeFirstVisibleIndexForOffsetCorrection), so subsequentapplyOffsetCorrectiondiff calculations evaluate to ~0 and don't emit the strayscrollBy. A long enough wait at the bottom can make the snapshot stale again however, via background remeasurement and reproduce on a later attempt.After a ton of debugging, I've found the root cause of this to actually be the two MVCP related subsystems interacting without coordinating:
1. Autoscroll-to-bottom lives in
useBoundDetection.ts. It maintains a sticky refpendingAutoscrollToBottom, settruebycheckBoundswhenever the user is withinautoscrollToBottomThresholdviewports of the bottom.runAutoScrollToBottomCheckfiresscrollToEndviarequestAnimationFrame, driven by two effects:[data, runAutoScrollToBottomCheck, windowHeight, windowWidth].[contentHeight, contentWidth, recyclerViewManager.firstItemOffset, …], gated by a 100mslastCheckBoundsTimeguard that I'm guessing is there to avoid firing during finger scroll.2. Anchor MVCP lives in
applyOffsetCorrection, runs on every layout commit and applies a compensatingscrollBy(diff)when its tracked anchor item's cached layout snapshot diverges from the live layout. Gated bypauseOffsetCorrection.current. The anchor snapshot is refreshed at the end of eachapplyOffsetCorrectionrun viacomputeFirstVisibleIndexForOffsetCorrectionand at scroll momentumend via the same function.So, the timeline of events looks something like this from my analysis:
pendingAutoscrollToBottom.current === true. The user has been sitting still long enough that background remeasurement of items (async images, font metric resolution, estimates resolving into real measurements as items leave/enter the render window) has drifted some items' cached layouts relative to the snapshot captured bycomputeFirstVisibleIndexForOffsetCorrectionat the last momentum end.pauseOffsetCorrection.currentis stillfalsebecausescrollToIndexhasn't run yet - it lives downstream in asetTimeout(0)/ effect / event-handler tick.applyOffsetCorrectionevaluates with the stale anchor snapshot, computes a nonzerodiffand firesscrollAnchorRef.current?.scrollBy(diff)contentHeight/firstItemOffsetover the next render, triggering the content changeuseEffectinuseBoundDetection.pendingAutoscrollToBottom.currentis stilltrue(the >100mslastCheckBoundsTimeguard passes), thenrunAutoScrollToBottomCheckfires, thenscrollToEndqueued viarequestAnimationFramescrollToIndexfinally runs and setssetOffsetProjectionEnabled(false)+pauseOffsetCorrection.current = true. Two scroll animations are now in flight;scrollToEndtypically wins.Fix
A single early return at the top of
runAutoScrollToBottomCheck: bail when a programmaticscrollToIndexis in flight, signalled by FlashList's ownrecyclerViewManager.isOffsetProjectionEnabledgetter, backed byengagedIndicesTracker.enableOffsetProjection(defaulttrue).isOffsetProjectionEnabledis already toggledfalseat the start ofscrollToIndexand back totrue~200–300ms after the scroll settles (here). That window covers the entirescrollToIndexoperation and its settling tail, during whichapplyOffsetCorrectioncan continue to emit corrective scrolls. If it fits the usecase better I can possibly introduce a separate ref that is going to have purely this as its responsibility.The autoscroll to bottom contract should be "if the user is at the bottom and new content arrives, stay at the bottom." When an imperative scroll has explicitly reassigned the scroll position, "stay at the bottom" no longer applies for the duration of that operation. Suppressing autoscroll while imperative scrolling owns the position should be the correct semantic in my opinion.
I've also included a new fixture that reproduces this consistently (just load all of the items up until the middle and try scrolling to bottom and then to the item using the buttons. Needed to do this because of how stable the example is and it wouldn't reproduce on items that don't particularly drift at any point.
Reviewers’ hat-rack 🎩
!recyclerViewManager.isOffsetProjectionEnabled) is the right signal vs. alternatives likepauseOffsetCorrection.currentor a new dedicated flagrunAutoScrollToBottomCheckor path into autoscroll is unintentionally suppressed by this guard.isOffsetProjectionEnabledis only flipped insidescrollToIndex, so the window is precisely the imperative scroll lifetime + settlescrollToIndex, newly arrived data won't trigger autoscroll. Autoscroll resumes when the flag flips backScreenshots or videos (if needed)
Before:
ScreenRecording_05-21-2026.15-23-14_1.MP4
(I forgot to add feedback on clicking the buttons but essentially I'm just pressing on the
scrollToIndexbutton and it succeeds only once)After:
ScreenRecording_05-21-2026.15-24-36_1.MP4
(same drill, except it succeeds all times)