Skip to content

fix: scroll to index autoscroll race#2290

Merged
naqvitalha merged 3 commits into
Shopify:mainfrom
isekovanic:fix/scroll-to-index-autoscroll-race
May 22, 2026
Merged

fix: scroll to index autoscroll race#2290
naqvitalha merged 3 commits into
Shopify:mainfrom
isekovanic:fix/scroll-to-index-autoscroll-race

Conversation

@isekovanic

@isekovanic isekovanic commented May 21, 2026

Copy link
Copy Markdown
Contributor

Description

This PR fixes a race between imperative scrollToIndex and the MVCP autoscroll to bottom mechanism in v2. When autoscrollToBottomThreshold is set and the user is near the bottom of the list, calling scrollToIndex imperatively 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 autoscrollToBottomThreshold set 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 in contentSize):

  1. Open the list (lands at bottom)
  2. Scroll up far enough that the target index has rendered and been measured at least once
  3. Scroll back down to the bottom and sit still for >=100ms
  4. Trigger listRef.current?.scrollToIndex({ index: targetIndex, animated: true, viewPosition: 0.5 }) - typically from an effect or setTimeout(0) callback that's downstream of an unrelated state update (e.g., tapping a quoted message that first dispatches a "targeted message" state change)
  5. List animates to targetIndex, then snaps back to the bottom

The race fires reliably only on the first attempt after step 3. Subsequent calls behave correctly: the race fired scrollToEnd refreshes the tracked anchor (via computeFirstVisibleIndexForOffsetCorrection), so subsequent applyOffsetCorrection diff calculations evaluate to ~0 and don't emit the stray scrollBy. 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 ref pendingAutoscrollToBottom, set true by checkBounds whenever the user is within autoscrollToBottomThreshold viewports of the bottom. runAutoScrollToBottomCheck fires scrollToEnd via requestAnimationFrame, driven by two effects:

2. Anchor MVCP lives in applyOffsetCorrection, runs on every layout commit and applies a compensating scrollBy(diff) when its tracked anchor item's cached layout snapshot diverges from the live layout. Gated by pauseOffsetCorrection.current. The anchor snapshot is refreshed at the end of each applyOffsetCorrection run via computeFirstVisibleIndexForOffsetCorrection and at scroll momentumend via the same function.

So, the timeline of events looks something like this from my analysis:

  • User is at the bottom; 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 by computeFirstVisibleIndexForOffsetCorrection at the last momentum end.
  • An unrelated rerender commits typically from a state update in the integrating app (e.g., highlighting the targeted message). pauseOffsetCorrection.current is still false because scrollToIndex hasn't run yet - it lives downstream in a setTimeout(0) / effect / event-handler tick.
  • During that commit, applyOffsetCorrection evaluates with the stale anchor snapshot, computes a nonzero diff and fires scrollAnchorRef.current?.scrollBy(diff)
  • The stray scroll mutates contentHeight / firstItemOffset over the next render, triggering the content change useEffect in useBoundDetection. pendingAutoscrollToBottom.current is still true (the >100ms lastCheckBoundsTime guard passes), then runAutoScrollToBottomCheck fires, then scrollToEnd queued via requestAnimationFrame
  • A tick later, scrollToIndex finally runs and sets setOffsetProjectionEnabled(false) + pauseOffsetCorrection.current = true. Two scroll animations are now in flight; scrollToEnd typically wins.

Fix

A single early return at the top of runAutoScrollToBottomCheck: bail when a programmatic scrollToIndex is in flight, signalled by FlashList's own recyclerViewManager.isOffsetProjectionEnabled getter, backed by engagedIndicesTracker.enableOffsetProjection (default true).

const runAutoScrollToBottomCheck = useCallback(() => {
+ if (!recyclerViewManager.isOffsetProjectionEnabled) {
+   return;
+ }
  if (pendingAutoscrollToBottom.current) {
    pendingAutoscrollToBottom.current = false;
    requestAnimationFrame(() => {
      const shouldAnimate =
        recyclerViewManager.props.maintainVisibleContentPosition
          ?.animateAutoScrollToBottom ?? true;
      scrollViewRef.current?.scrollToEnd({
        animated: shouldAnimate && !recyclerViewManager.ignoreScrollEvents,
      });
    });
  }
}, [requestAnimationFrame, scrollViewRef, recyclerViewManager]);

isOffsetProjectionEnabled is already toggled false at the start of scrollToIndex and back to true ~200–300ms after the scroll settles (here). That window covers the entire scrollToIndex operation and its settling tail, during which applyOffsetCorrection can 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 🎩

  • The guard predicate (!recyclerViewManager.isOffsetProjectionEnabled) is the right signal vs. alternatives like pauseOffsetCorrection.current or a new dedicated flag
  • No other call site of runAutoScrollToBottomCheck or path into autoscroll is unintentionally suppressed by this guard. isOffsetProjectionEnabled is only flipped inside scrollToIndex, so the window is precisely the imperative scroll lifetime + settle
  • Confirm the "stay at bottom on new content arriving during a programmatic scroll" behavior change is acceptable - during the 200-300ms settling tail of a scrollToIndex, newly arrived data won't trigger autoscroll. Autoscroll resumes when the flag flips back
  • Confirm the issue is reproducible through the fixture screen

Screenshots 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 scrollToIndex button and it succeeds only once)

After:

ScreenRecording_05-21-2026.15-24-36_1.MP4

(same drill, except it succeeds all times)

@isekovanic

Copy link
Copy Markdown
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

@isekovanic

Copy link
Copy Markdown
Contributor Author

Ah nevermind, I was simply supposed to comment. My bad !

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
@naqvitalha

Copy link
Copy Markdown
Collaborator

Thanks for fixing this. Looks good.

@naqvitalha naqvitalha enabled auto-merge (squash) May 22, 2026 15:12
@isekovanic

Copy link
Copy Markdown
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.

@naqvitalha naqvitalha merged commit 8ca7477 into Shopify:main May 22, 2026
13 checks passed
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.

3 participants