Skip to content

fix: prevent unbounded scroll-event recursion while scrollable is locked#2717

Open
OxMarco wants to merge 1 commit into
gorhom:masterfrom
OxMarco:fix/scrollable-lock-recursion
Open

fix: prevent unbounded scroll-event recursion while scrollable is locked#2717
OxMarco wants to merge 1 commit into
gorhom:masterfrom
OxMarco:fix/scrollable-lock-recursion

Conversation

@OxMarco

@OxMarco OxMarco commented Jul 12, 2026

Copy link
Copy Markdown

Summary

useScrollEventsHandlersDefault's handleOnScroll worklet calls scrollTo(...) on every scroll event while the scrollable is LOCKED, including when the scrollable is already resting at the target offset.

scrollTo still drives the underlying scroll view, which emits another scroll event, which re-enters handleOnScroll, which calls scrollTo again. Once the offset has settled at lockPosition there is nothing left to break the cycle: the guard that would normally stop it ("the offset didn't change, so no new event") doesn't hold. The recursion is bounded only by the JS stack, and it crashes with RangeError: Maximum call stack size exceeded, with the stack alternating between Reanimated's useAnimatedScrollHandler and this handler.

We see this in production on iOS: 4 occurrences across 3 users.

The fix

Skip the scrollTo when the scrollable is already at the lock position, within a small epsilon to tolerate the sub-pixel offsets reported during scrolling. scrollableContentOffsetY.value is still assigned unconditionally, so the shared value behaves exactly as before. The only behavioural change is that a redundant scroll command is no longer issued from inside the scroll event it would itself provoke.

  if (animatedScrollableStatus.value === SCROLLABLE_STATUS.LOCKED) {
    const lockPosition = context.shouldLockInitialPosition
      ? (context.initialContentOffsetY ?? 0)
      : 0;
-   // @ts-expect-error
-   scrollTo(scrollableRef, 0, lockPosition, false);
+   if (Math.abs(y - lockPosition) > SCROLL_LOCK_EPSILON) {
+     // @ts-expect-error
+     scrollTo(scrollableRef, 0, lockPosition, false);
+   }
    scrollableContentOffsetY.value = lockPosition;
    return;
  }

y was already destructured from the event, so no new state is needed.

Scope

handleOnEndDrag and handleOnMomentumEnd contain the same unconditional scrollTo, but they fire once per gesture rather than on every scroll event, so they cannot feed themselves the way handleOnScroll can. I left them untouched to keep the change minimal. Happy to extend the guard to them if you would prefer the consistency.

Testing

  • yarn typescript passes.
  • yarn lint: the changed file lints clean. Note that yarn lint already fails on master as-is, due to a pre-existing lint/complexity/useOptionalChain warning in src/hooks/useBoundingClientRect.ts:54 that is unrelated to this change. I deliberately have not touched it.
  • CONTRIBUTING.md mentions yarn test, but there is no test script or test suite in the repo, so I could not add a unit test. If you would like this covered, I am happy to help stand up a harness for the scroll handlers.

Honest caveat

I have not reproduced this in a standalone sample. It was diagnosed from production crash reports plus reading the source. The recursion described above is unconditional and evident from the code, but I want to be upfront that this is not a "repro, fix, verify" PR. We have been running exactly this patch (via patch-package) in our app and the crash has stopped occurring for us.

Happy to adjust the approach or the epsilon value.

`handleOnScroll` issued `scrollTo(scrollableRef, 0, lockPosition, false)` on
every scroll event while the scrollable was LOCKED, including when the
scrollable was already resting at `lockPosition`.

`scrollTo` still drives the underlying scroll view, which emits another scroll
event, which re-enters `handleOnScroll`, which calls `scrollTo` again. Once the
offset has settled there is nothing left to break the cycle, so it recurses
until the JS stack overflows with `RangeError: Maximum call stack size
exceeded`, with the stack alternating between Reanimated's
`useAnimatedScrollHandler` and this handler.

Skip the `scrollTo` when the scrollable is already at the lock position, within
a small epsilon to tolerate the sub-pixel offsets reported during scrolling.
`scrollableContentOffsetY.value` is still assigned unconditionally, so the
shared value is unchanged.

`handleOnEndDrag` and `handleOnMomentumEnd` contain the same unconditional
`scrollTo`, but they fire once per gesture rather than on every scroll event, so
they cannot feed themselves. Left untouched to keep this fix minimal.
@OxMarco OxMarco force-pushed the fix/scrollable-lock-recursion branch from cc65db4 to 8fea848 Compare July 12, 2026 15:42
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.

1 participant