Skip to content

馃敟 [馃悰] AttachmentPicker intercepts back events too eagerly聽#3633

Closed

Description

@vanstinator

Issue

Describe your issue here

I recently upgraded a React Native app to Expo 56, and as part of that refactoring we switched from react-native-pager-view to PagerView from @expo/ui. This new component behaves differently than the prior component, and we quickly noticed that after the change our screens that contained a <Channel> on one of the pages required triggering back on Android twice before the screen would return to the previous screen.

After several hours of debugging we isolated the problem to AttachmentPicker. It contains a back handler that seemingly always intercepts the first back request, (at least when used on a PagerView, I'm not sure yet if that is related or not).

Steps to reproduce

Steps to reproduce the behavior:

  1. Checkout https://github.com/vanstinator/expo-ui-pagerview-back-bug
  2. From a machine configured to build and debug React Native on Android, EXPO_PUBLIC_STREAM_API_KEY=your_stream_key yarn android
  3. Tap the Open Pager button
  4. Initiate a back gesture
  5. Observe that the system does not pop your navigation stack and return you to the apps home screen

Expected behavior

When a <Channel> is included anywhere in the view hierarchy and the AttachmentPicker's bottom sheet is not open the back event should never be consumed by Stream's SDK but instead be allowed to propagate up the stack to the parent handler.

Project Related Information

Customization

Click To Expand

Nothing was customized, using purely out-of-the-box components

Offline support

  • I have enabled offline support.
    - [ ] The feature I'm having does not occur when offline support is disabled. (stripe out if not applicable)

Environment

Click To Expand

I've included a full working reproduction at https://github.com/vanstinator/expo-ui-pagerview-back-bug

package.json:

# N/A

react-native info output:

 OUTPUT GOES HERE
  • Platform that you're experiencing the issue on:
    • iOS
    • Android
    • iOS but have not tested behavior on Android
    • Android but have not tested behavior on iOS
    • Both
  • stream-chat-react-native version you're using that has this issue:
    • 9.3.0
  • Device/Emulator info:
    • I am using a physical device
    • OS version: Android 16 and Android 17
    • Device/Emulator: Pixel 10 Pro Fold

Additional context

The @expo/ui is fully built on top of Jetpack Compose and there have been other issues with event propagation such as expo/expo#46623 so I can't entirely rule out it being something weird there. But I left several breadcrumbs on the repro's README that seem to indicate an actual logic bug in AttachmentPicker https://github.com/vanstinator/expo-ui-pagerview-back-bug/blob/main/README.md

There's also this block that is suspect 馃槄 https://github.com/GetStream/stream-chat-react-native/blob/develop/package/src/components/AttachmentPicker/AttachmentPicker.tsx#L133-L137

Screenshots

screen-20260609-134848-1781027312907.mp4.2.mp4
Click To Expand


Activity

  1. isekovanic commented on Jun 9, 2026

    @isekovanic
    Contributor

    Hi @vanstinator !

    Thanks a bunch for reporting this. I'll let you know when I've had a look !

  2. isekovanic commented on Jun 10, 2026

    @isekovanic
    Contributor

    Hi again @vanstinator !

    So first of all, thank you very much for the very detailed and meticulous bug report ! We always really, really appreciate this 馃

    I can confirm that the bug is indeed reproducible on your repro and I believe I've found a fix.

    @gorhom/bottom-sheet's animatedIndex isn't a state machine, it's a useDerivedValue that linearly interpolates animatedPosition between detents[0] and closedDetentPosition to a [0, -1] (in our SDK specifically) range, with CLAMP extrapolation. Both anchors are functions of containerHeight (set by the host container's onLayout): detents[0] = max(0, containerHeight - 333) and closedDetentPosition = containerHeight. animatedPosition is a separate SharedValue only moved by setToPosition()/animateToPosition().

    Derived values recompute synchronously the instant containerHeight changes, but the position correction path is reactive and runs a tick later:

    useAnimatedReaction(                                                                                                                                                                                                                                                                                                      
      () => animatedDetentsState.get().detents,                                                                                                                                                                                                                                                                               
      () => evaluatePosition(SNAP_POINT_CHANGE),  // -> setToPosition()                                                                                                                                                                                                                                                        
    );                                                                                                                                                                                                                                                                                                                        
    

    So, when containerHeight changes from h1 to h2, the detents move to the new domain immediately but animatedPosition is still sitting at h1. In that one tick gap, if containerHeight grew by at least the picker height (333px), the stuck position falls below the new detents[0] and clamp snaps animatedIndex to exactly 0. The next tick anchors the position and the index goes back to -1 (so visually we see nothing, it all happens on the UI thread), but by then AttachmentPicker has already scheduled runOnJS(reactToIndex)(0) and written selectedPicker = 'images' into the JS store.

    So why does Compose trigger it and standard RN doesn't ?

    Here's where it gets a bit tricky. In standard RN, Yoga computes all frames in one pass and dispatches a single onLayout. containerHeight goes from -999 to its final value in one step, the race window never opens. Compose's SubcomposeLayout however (which @expo/ui's PagerView uses for offscreen pages) doesn't do that, but rather it composes with placeholder dimensions, then remeasures when the page becomes visible, dispatching onLayout to the RN tree each time. Every transition is a chance to satisfy h2 >= h1 + 333.

    Two SDK details make it worse. pickerTopInset is computed from windowHeight (and we need this so accessibility works), so when Compose publishes a different effective window during composition the host container grows by exactly the picker height. And setToPosition has an asymmetric if (!targetPosition) return; guard, so while containerHeight is briefly 0 (offscreen Compose composing), animatedPosition never gets anchored at all and any later flicker has free reign.

    This PR however, should resolve it. The reactToIndex filter assumes animatedIndex === 0 only ever happens as the destination of a user initiated animation, never as a transient interpolation sideeffect. Compose violates that on every mount. currentIndexRef on the other hand, is only written via onAnimate, which only fires from animateToPosition(), which only runs for an actual snap to a snap point. The transient skips that path entirely, so the ref cleanly separates "the user opened the sheet" from "the index flickered through 0 during layout".

  3. isekovanic commented on Jun 10, 2026

    @isekovanic
    Contributor

    This has now been released in 9.3.1 !

    Changelog: https://github.com/GetStream/stream-chat-react-native/releases/tag/v9.3.1

  4. vanstinator commented on Jun 10, 2026

    @vanstinator
    Author

    Wow thanks for the great writeup, and the fast fix!

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions