Skip to content

[$175] Image attachment - Pinch-to-zoom stops working after reaching maximum zoom #103648

Open

Description

@applause-bot

If you haven’t already, check out our contributing guidelines for onboarding. To join our Slack channel, fill out this form.


Version Number: v9.5.4-3
Reproducible in staging?: Yes
Reproducible in production?: Yes
If this was caught during regression testing, add the test name, ID and link from BrowserStack: https://test-management.browserstack.com/projects/2219752/test-runs/76493518/folder/13176710/41236933/1815284268, https://test-management.browserstack.com/projects/2219752/test-runs/76493518/folder/13176710/41236934/1815284269, https://test-management.browserstack.com/projects/2219752/test-runs/76493518/folder/13176710/41236935/1815284270
Email or phone of affected tester (no customers): N/A
Issue reported by: Applause Internal Team
Bug source: Regression TC Execution
Device used: Android / 16
App Component: Chat Report View

Action Performed:

  1. Open the Expensify Android app
  2. Open a conversation and click the + button in the compose box.
  3. Tap Add attachment
  4. Upload an image
  5. Click on the image to open it
  6. Pinch in with two fingers to zoom in to the maximum level
  7. Try to pinch out with two fingers to zoom out
  8. Double tap to zoom out
  9. Pinch in with two fingers to zoom in

Expected Result:

The user should be able to zoom in and out of the image using the pinch gesture.

Actual Result:

After zooming in to the maximum level, pinching out with two fingers no longer zooms user out.
Once the user zooms out using double tap, pinching gesture becomes completely unresponsive.

Workaround:

Unknown

Platforms:

  • Android: App
  • Android: mWeb Chrome
  • iOS: App
  • iOS: mWeb Safari
  • iOS: mWeb Chrome
  • Windows: Chrome
  • MacOS: Chrome Safari

Screenshots/Videos

Bug7282432_1791571705582.Recording_20261009_215447.mp4

View all open jobs on GitHub

Applause Internal Information
Exported by: Svetlana Lazutkina
Bug ID: 7282432

Upwork Automation - Do Not Edit

Activity

  1. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 9, 2026
  2. applause-bot commented on Oct 9, 2026

    @applause-bot
    Author

    While Applause doesn't embed attachments for this bug, here are the links for quick view:
    https://platform.applause.com/services/links/v1/external/663223e627b1272172512388985b1ecaccc13f53f2af5df010a8e278a48819bc

  3. MelvinBot commented on Oct 9, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    There are two causes.

    1. Pinch-out does nothing after max zoom (confirmed in code). In usePinchGesture.ts:152-159, onChange checks the old zoomScale against the bounce range (0.7–30), not the new one. The first event that goes past 30 still gets written. After that, every later event fails the check, so zoomScale freezes until you lift both fingers. Reversing the pinch mid-gesture is ignored.
    2. Pinch stops responding after double-tap (Android only, likely cause). This started after Bump react-native-gesture-handler to 3.3.0 moved RNGH from 2.32.0 to 3.3.0. The PR changed only type imports in our code. RNGH 3.3 changed Android pinch handling in two ways that fit this symptom:
      • The pinch handler now creates its scale detector only on the first finger's ACTION_DOWN. Our code cancels the handler by toggling .enabled(false) whenever the pointer count isn't 2 (usePinchGesture.ts:145-150). That can leave it with no detector, so it never activates.
      • When a native view such as the carousel pager claims the touch, RNGH 3.3 now cancels all builder-API handlers. A double-tap reset to scale 1 re-enables pager scrolling, and pinch is not marked simultaneous with the pager gesture.

    What changes do you think we should make in order to solve the problem?

    1. In usePinchGesture onChange, compute the new scale and clamp it to the bounce range with the existing clamp util. Then always write the clamped value. Derive currentPinchScale from the clamped value so translation stays in sync.
    2. When numberOfPointers !== 2, return early instead of calling setPinchEnabled(false). Remove the pinchEnabled state and the effect that re-enables it.
    3. Make the pinch gesture simultaneous with the pager's external native gesture, the same way the pan gesture already does in MultiGestureCanvas/index.tsx. This stops the pager from cancelling an active pinch.

    Test steps 2 and 3 on an Android device. To confirm that the RNGH upgrade caused part 2, run the repro on 9.5.1-9, which still uses RNGH 2.32.0.

    What alternative solutions did you explore? (Optional)

    Roll RNGH back to 2.32.0. This would undo the Android regression but would not fix the clamp bug. It would also block Migrate react-native-gesture-handler builder API to the 3.x hook API, which may fix part 2 if it lands first.

    Investigation details
    • DEFAULT_ZOOM_RANGE is 1–20 and the bounce factors are 0.7/1.5 (constants.ts:17-27). This gives the 0.7–30 window. The same bug blocks pinch-in below 0.7.
    • onEnd springs the scale back to 20 (usePinchGesture.ts:197-201), so lifting fingers restores the gesture. This matches the "stuck until release" behavior.
    • The usePinchGesture logic is unchanged since 9.5.0-5. RNGH changed from 2.32.0 in 9.5.1-9 to 3.3.0 in 9.5.2-4 and later, including the reported 9.5.4-3.
    • Relevant RNGH 3.3 sources: PinchGestureHandler.kt (detector set up only on ACTION_DOWN in UNDETERMINED) and RNGestureHandlerRootHelper.requestDisallowInterceptTouchEvent → cancelAllLegacyHandlers().
    • Not verified: which of the two RNGH changes triggers part 2. I didn't reproduce this on Android because this run only had a web environment, and web is not affected. Add logcat logging in pinch onBegin/onStart/onFinalize to tell whether the pinch never activates or is cancelled.

    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/103648) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 9, 2026
  5. melvin-bot commented on Oct 9, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @situchan (External)

  6. changed the title [-]Image attachment - Pinch-to-zoom stops working after reaching maximum zoom[/-] [+][$175] Image attachment - Pinch-to-zoom stops working after reaching maximum zoom[/+] on Oct 9, 2026
  7. melvin-bot commented on Oct 9, 2026

    @melvin-bot
  8. janiya2 commented on Oct 9, 2026

    @janiya2

    Proposal

    What is the root cause of that problem?

    There are two primary causes for this issue:

    1. Pinch-out freezes after reaching max zoom:
      In src/components/MultiGestureCanvas/usePinchGesture.ts:154, the boundary check evaluates the previous frame value (zoomScale.get()) rather than evaluating and clamping the incoming newZoomScale:

      if (zoomScale.get() >= zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min && zoomScale.get() <= zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max)

      When pinching in rapidly, a scale update pushes zoomScale past zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max (e.g. past 30). On all subsequent onChange events, zoomScale.get() <= maxBounce evaluates to false. As a result, when the user reverses the pinch direction to zoom out (evt.scale < 1), the block is skipped and zoomScale remains completely frozen until both fingers are lifted.

    2. Pinch gesture unresponsiveness after double-tap reset (Android):
      In usePinchGesture.ts:147-150, when evt.numberOfPointers !== 2, it toggles .enabled(false) via scheduleOnRN(setPinchEnabled, false). With the upgrade to react-native-gesture-handler 3.3.0, disabling builder-API gesture handlers cancels the scale detector on Android. When double-tap resets scale to 1, pager navigation takes precedence and the pinch detector fails to re-arm.

    What changes do you think we should make in order to solve the problem?

    1. Clamp newZoomScale in usePinchGesture.ts:
      In onChange, calculate newZoomScale and clamp it using the existing clamp utility between zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min and zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max.
      Set zoomScale to this clamped value and derive currentPinchScale from the clamped scale relative to pinchScale.get(). This ensures that even when the pinch exceeds maximum bounce threshold, reversing the pinch to zoom out immediately registers and updates the scale without freezing.

    2. Prevent gesture de-registration on pointer change:
      In onChange, when evt.numberOfPointers !== 2, simply return early instead of toggling scheduleOnRN(setPinchEnabled, false). This keeps the gesture recognizer active and avoids breaking the scale detector on Android.

    3. Ensure simultaneous gesture handling with Pager:
      In src/components/MultiGestureCanvas/index.tsx, ensure the pinch gesture is allowed to run simultaneously with external pager gestures (similar to how pan gesture is handled), preventing the carousel pager from cancelling pinch activation when zoomed out to scale 1.

    What alternative solutions did you explore? (Optional)

    1. Downgrading react-native-gesture-handler to 2.32.0. This is not recommended as it doesn't solve the scale clamping freeze and conflicts with future gesture-handler migrations.
    2. Only allowing zoom-out via double-tap. This degrades user experience since continuous pinch-in to pinch-out is expected standard mobile behavior.
  9. melvin-bot commented on Oct 9, 2026

    @melvin-bot

    📣 @janiya2! 📣
    Hey, it seems we don’t have your contributor details yet! You'll only have to do this once, and this is how we'll hire you on Upwork.
    Please follow these steps:

    1. Make sure you've read and understood the contributing guidelines.
    2. Get the email address used to login to your Expensify account. If you don't already have an Expensify account, create one here. If you have multiple accounts (e.g. one for testing), please use your main account email.
    3. Get the link to your Upwork profile. It's necessary because we only pay via Upwork. You can access it by logging in, and then clicking on your name. It'll look like this. If you don't already have an account, sign up for one here.
    4. Copy the format below and paste it in a comment on this issue. Replace the placeholder text with your actual details.
      Screen Shot 2022-11-16 at 4 42 54 PM
      Format:
    Contributor details
    Your Expensify account email: <REPLACE EMAIL HERE>
    Upwork Profile Link: <REPLACE LINK HERE>
    
  10. janiya2 commented on Oct 9, 2026

    @janiya2

    Contributor details
    Your Expensify account email: janithad2@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/janithad

  11. melvin-bot commented on Oct 9, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  12. khizzcr7 commented on Oct 10, 2026

    @khizzcr7

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    In the image attachment viewer, pinching to max zoom then pinching out does not zoom back out; after a double-tap zoom-out, the pinch gesture becomes completely unresponsive.

    What is the root cause of that problem?

    The zoom gesture state in the attachment viewer does not reset correctly when the scale hits the maximum clamp: the gesture stays consumed at the ceiling so subsequent pinch-out deltas are ignored, and the double-tap zoom-out desyncs the gesture handler's internal scale state so further pinches register no movement.

    What changes do you think we should make in order to solve the problem?

    In the image viewer/zoom component, ensure the pinch gesture state (scale shared value and gesture active flag) resets on gesture end and after double-tap, so the responder re-arms at the current scale instead of staying stuck at the max-zoom state. I will verify the repro on Android: pinch to max, pinch out, then double-tap out and pinch again - each step should zoom smoothly, and normal pan/swipe of the viewer should be unaffected.

  13. jloa-dev commented on Oct 10, 2026

    @jloa-dev

    Proposal

    Please re-state the problem that the issue is addressing

    Image attachment - Pinch-to-zoom stops working after reaching maximum zoom and pinch gesture becomes unresponsive after double-tap zoom out on Android.

    What is the root cause of that problem?

    There are two distinct root causes:

    1. Pinch scale check evaluates previous scale instead of clamping incoming scale:
      In \src/components/MultiGestureCanvas/usePinchGesture.ts:154, the boundary check evaluates \zoomScale.get():
      \\ ypescript
      if (zoomScale.get() >= zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min && zoomScale.get() <= zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max)
      \
      When pinching in rapidly, a scale update pushes \zoomScale\ slightly past the maximum bounce threshold (e.g. > 30). On subsequent frames, \zoomScale.get() <= maxBounce\ evaluates to \ alse. When the user reverses the pinch gesture to zoom out (\evt.scale < 1), the condition is skipped, leaving \zoomScale\ completely frozen until both fingers are released.
    2. Gesture handler destruction and pager cancellation on Android:
      • In \usePinchGesture.ts:148, when one finger is released (\evt.numberOfPointers !== 2), \scheduleOnRN(setPinchEnabled, false)\ toggles .enabled(false). In
        eact-native-gesture-handler\ 3.3.0 on Android, toggling .enabled(false)\ destroys the native scale detector and desyncs pointer tracking.
      • In \src/components/MultiGestureCanvas/index.tsx:255, \pinchGesture\ is only marked simultaneous with \panGesture, singleTapGesture, doubleTapGesture, omitting \pagerRef. When zoomed out to scale 1 (e.g. after double-tap), horizontal pointer movement causes the carousel pager to claim touches and cancel the pinch gesture.

    What changes do you think we should make in order to solve the problem?

    1. In \src/components/MultiGestureCanvas/usePinchGesture.ts:
      • Clamp
        ewZoomScale\ directly between the min and max bounce bounds using the existing \clamp\ helper from ./utils:
        \\ ypescript
        const minBounce = zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min;
        const maxBounce = zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max;
        const clampedZoomScale = clamp(newZoomScale, minBounce, maxBounce);
        zoomScale.set(clampedZoomScale);
        currentPinchScale.set(clampedZoomScale / pinchScale.get());
        triggerScaleChangedEvent();
        \\
      • When \evt.numberOfPointers !== 2, simply return early on the UI thread without destroying the native handler via \setPinchEnabled(false).
      • Remove the unnecessary \pinchEnabled\ state and effect.
    2. In \src/components/MultiGestureCanvas/index.tsx:
      • Include \simultaneousGestureList\ (which holds \pagerRef) in \pinchGesture.simultaneousWithExternalGesture(panGesture, ...simultaneousGestureList), preventing external carousel pager from cancelling pinch activation when zoomed out to scale 1.

    Tested branch pre-coded on:
    https://github.com/jloa-dev/App/tree/fix/103648-pinch-zoom-max-clamp

  14. RajGahoi commented on Oct 10, 2026

    @RajGahoi

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    In the image attachment viewer on Android, pinching in to zoom past or up to the maximum zoom ceiling prevents pinching back out to zoom out. After resetting the scale back to 1 via double-tap, the pinch-to-zoom gesture becomes completely unresponsive.

    What is the root cause of that problem?

    There are two distinct root causes:

    1. Scale boundary check evaluates prior zoom scale instead of clamping target scale:
      In src/components/MultiGestureCanvas/usePinchGesture.ts:154:

      if (zoomScale.get() >= zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min && zoomScale.get() <= zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max) {
          zoomScale.set(newZoomScale);
          currentPinchScale.set(evt.scale);
          triggerScaleChangedEvent();
      }

      When a user pinches in rapidly, a scale delta drives zoomScale slightly past the maximum bounce threshold (e.g. > 30). On subsequent onChange events, zoomScale.get() <= maxBounce evaluates to false. When the user reverses gesture direction to pinch out (evt.scale < 1), this conditional guard is skipped, leaving zoomScale completely frozen until both fingers are lifted.

    2. Native scale detector destruction and carousel pager preemption on Android:

      • In usePinchGesture.ts:147-150, whenever one pointer is lifted (evt.numberOfPointers !== 2), scheduleOnRN(setPinchEnabled, false) toggles .enabled(false). Under react-native-gesture-handler on Android, toggling .enabled(false) during an active gesture dismantles the native scale gesture detector and drops pointer state tracking.
      • In src/components/MultiGestureCanvas/index.tsx:255, pinchGesture is configured via .simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture), omitting pagerRef. When zoomed out to scale 1 (via double-tap), horizontal movement causes the external carousel pager to claim touches and preempt the pinch gesture detector.

    What changes do you think we should make in order to solve the problem?

    1. In src/components/MultiGestureCanvas/usePinchGesture.ts:

      • Calculate newZoomScale and clamp it between the minimum and maximum bounce bounds using the existing clamp helper from ./utils:
        const minBounce = zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min;
        const maxBounce = zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max;
        const newZoomScale = clamp(pinchScale.get() * evt.scale, minBounce, maxBounce);
        zoomScale.set(newZoomScale);
        currentPinchScale.set(newZoomScale / pinchScale.get());
        triggerScaleChangedEvent();
      • When evt.numberOfPointers !== 2, simply return early on the UI thread without disabling the native gesture handler via setPinchEnabled(false).
      • Remove the unnecessary pinchEnabled state, effect, and unused imports.
    2. In src/components/MultiGestureCanvas/index.tsx:

      • Pass ...panGestureSimultaneousList (which includes pagerRef) to pinchGesture.simultaneousWithExternalGesture(panGesture, ...panGestureSimultaneousList). This prevents the external carousel pager from preempting pinch gesture activation when the image is at base scale 1.

    Tested and pre-coded on branch:
    https://github.com/RajGahoi/App/tree/feature/103648-pinch-zoom-max-clamp

    What alternative solutions did you explore? (Optional)

    • Only clamping newZoomScale without updating currentPinchScale: this would cause focal translation drift during bounce clamping. Deriving currentPinchScale = newZoomScale / pinchScale.get() keeps focal translations perfectly synchronized with the clamped visual scale.

    Contributor details

    Your Expensify account email: gahoiraj01@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01b77415f16d156068

  15. melvin-bot commented on Oct 10, 2026

    @melvin-bot

    ⚠️ Unable to store contributor details

    Details
    Auth SetContributorDetails returned an error
    401 Contributor details already exist
    
  16. taieddie commented on Oct 10, 2026

    @taieddie

    Contributor details
    Your Expensify account email: taieddie8@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01af851c9a7f771775

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    In the image attachment viewer, zooming in past the maximum zoom boundary causes the canvas to freeze at the maximum ceiling, ignoring any pinch gestures to zoom back out. In addition, after resetting the zoom level back to 1 via double-tap on Android, pinch-to-zoom stops working completely.

    What is the root cause of that problem?

    There are two main issues causing this behavior, plus a simultaneous gesture conflict with the carousel pager:

    1. The scale boundary check looks at the previous frame scale instead of clamping the target scale:
      In src/components/MultiGestureCanvas/usePinchGesture.ts (lines 152-160):

      const newZoomScale = pinchScale.get() * evt.scale;
      // Limit the zoom scale to zoom range including bounce range
      if (zoomScale.get() >= zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min && zoomScale.get() <= zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max) {
          zoomScale.set(newZoomScale);
          currentPinchScale.set(evt.scale);
      
          triggerScaleChangedEvent();
      }

      The if condition checks zoomScale.get(), which is the scale value from the previous frame, rather than checking or clamping newZoomScale.
      With DEFAULT_ZOOM_RANGE of [1, 20] and ZOOM_RANGE_BOUNCE_FACTORS of [0.7, 1.5], the upper bounce limit is 30 (20 * 1.5).
      During a fast zoom, a single gesture update can push zoomScale slightly past 30 (for example, 30.15). Once that happens, zoomScale.get() <= 30 evaluates to false on every subsequent onChange call.
      When the user reverses the gesture to zoom back out (evt.scale < 1), zoomScale.get() is still stuck at 30.15. Because the condition keeps checking the stale out-of-bounds value, the entire update block is skipped. Neither zoomScale nor currentPinchScale updates, leaving the zoom completely frozen until the user lifts both fingers.

    2. Toggling setPinchEnabled(false) via React state destroys Android's native gesture detector:
      In src/components/MultiGestureCanvas/usePinchGesture.ts (lines 108-114 and 147-150):

      const [pinchEnabled, setPinchEnabled] = useState(true);
      useEffect(() => {
          if (pinchEnabled) {
              return;
          }
          setPinchEnabled(true);
      }, [pinchEnabled]);
      ...
      if (evt.numberOfPointers !== 2) {
          scheduleOnRN(setPinchEnabled, false);
          return;
      }

      When one finger is lifted (which almost always happens when ending a pinch gesture), scheduleOnRN(setPinchEnabled, false) triggers a React state update on the JS thread to re-render with .enabled(false).
      In react-native-gesture-handler on Android, disabling an active gesture handler tears down the underlying native ScaleGestureDetector. By the time useEffect re-enables it, the native detector has lost the ongoing touch stream and will not recognize subsequent pinch gestures until the touch sequence completely resets.
      We do not need React state or component re-renders here at all. Returning early on the UI thread when evt.numberOfPointers !== 2 prevents content jumping while keeping the native detector intact.

    3. pinchGesture is missing pagerRef in its simultaneous gesture configuration:
      In src/components/MultiGestureCanvas/index.tsx (lines 219-222, 239, and 255):
      panGesture is configured with .simultaneousWithExternalGesture(...panGestureSimultaneousList), which includes pagerRef.
      However, pinchGesture only includes:
      .simultaneousWithExternalGesture(panGesture, singleTapGesture, doubleTapGesture);
      omitting pagerRef.
      When an image is reset to scale 1 (like after a double-tap zoom out), the carousel pager re-enables horizontal page swiping. Because pinchGesture is not marked simultaneous with pagerRef, any horizontal finger movement during a new pinch causes the carousel pager to claim the touch stream, canceling the pinch gesture on Android.

    What changes do you think we should make in order to solve the problem?

    1. In src/components/MultiGestureCanvas/usePinchGesture.ts:

      • Import clamp from ./utils.
      • Remove the pinchEnabled state, its useEffect hook, and .enabled(pinchEnabled) from Gesture.Pinch(). Remove the unused useState and useEffect imports.
      • In onStart, cancel any running animations on zoomScale and pinchBounceTranslateX/Y, sync pinchScale to zoomScale.get(), and reset bounce translations. This ensures that starting a pinch while a bounce spring animation is settling catches the content seamlessly at its current scale:
        stopAnimation();
        cancelAnimation(zoomScale);
        cancelAnimation(pinchBounceTranslateX);
        cancelAnimation(pinchBounceTranslateY);
        pinchScale.set(zoomScale.get());
        pinchBounceTranslateX.set(0);
        pinchBounceTranslateY.set(0);
        isPinchSettling.set(false);
        isTransformGestureActive.set(true);
      • In onChange, return early on the UI thread when evt.numberOfPointers !== 2:
        if (evt.numberOfPointers !== 2) {
            return;
        }
      • Calculate and clamp newZoomScale between the minimum and maximum bounce bounds using clamp:
        const minBounce = zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min;
        const maxBounce = zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max;
        const clampedZoomScale = clamp(pinchScale.get() * evt.scale, minBounce, maxBounce);
        zoomScale.set(clampedZoomScale);
        currentPinchScale.set(clampedZoomScale / pinchScale.get());
        triggerScaleChangedEvent();
        Note on currentPinchScale: Setting currentPinchScale to clampedZoomScale / pinchScale.get() instead of raw evt.scale is essential. If left as evt.scale, the focal translation offset continues shifting while the visual scale is clamped at the ceiling. That causes the focal point to drift across the canvas during the pinch and visibly snap back upon release.
      • In the translation update block, when the scale is within normal bounds (zoomScale.get() >= zoomRange.min && zoomScale.get() <= zoomRange.max), explicitly reset pinchBounceTranslateX.set(0) and pinchBounceTranslateY.set(0). This handles the case where a user pinches into the over-zoom bounce zone and then pinches back into normal range within the same gesture, ensuring no leftover bounce offset triggers an unwanted release spring animation.
    2. In src/components/MultiGestureCanvas/index.tsx:

      • Pass ...panGestureSimultaneousList to pinchGesture.simultaneousWithExternalGesture(panGesture, ...panGestureSimultaneousList) so that pinchGesture operates simultaneously with pagerRef alongside panGesture, singleTapGesture, and doubleTapGesture.

    What alternative solutions did you explore? (Optional)

    1. Clamping newZoomScale but keeping currentPinchScale.set(evt.scale):
      While this prevents the scale freeze, newPinchTranslateX/Y relies on currentPinchScale to position content under the user fingers. If currentPinchScale keeps increasing while zoomScale is clamped, the focal point drifts across the canvas during the pinch and visibly snaps back upon release. Scaling currentPinchScale proportionally to the clamped scale keeps the touch focal point and visual content locked together.

    2. Re-enabling pinchEnabled via requestAnimationFrame or setTimeout:
      This still crosses the JS bridge and triggers component re-renders, causing the same native gesture detector recreation on Android. Handling the pointer count check directly on the UI thread with an early return is simpler, faster, and avoids re-renders altogether.

    Test Plan

    Test Case 1: Pinch past maximum zoom and pinch back out

    1. Open any chat report and tap an image attachment to open the lightbox viewer.
    2. Spread two fingers outward past the maximum zoom boundary (scale 20) into the bounce zone.
    3. Without lifting fingers, pinch fingers back together to zoom out.
      • Verify: The image zooms back out smoothly without freezing or locking at the maximum ceiling.

    Test Case 2: Double-tap zoom out followed by pinch-to-zoom (Android)

    1. In the lightbox viewer, pinch to zoom into the image.
    2. Release both fingers.
    3. Double-tap the image to reset zoom back to base scale (1.0).
    4. Pinch to zoom into the image again.
      • Verify: The pinch gesture activates immediately on Android and zooms smoothly without becoming unresponsive.

    Test Case 3: Reversal from over-zoom bounce back into normal scale

    1. Spread two fingers outward past maximum zoom into the bounce zone.
    2. Without lifting fingers, pinch back inward into the normal range (e.g. around 15x) and release.
      • Verify: The image settles at 15x immediately without an unwanted spring bounce animation.

    Test Case 4: Carousel pager navigation at base scale (1.0)

    1. In a chat report with multiple image attachments, tap an image to open the attachment carousel.
    2. At scale 1.0, swipe left and right between attachments.
      • Verify: The carousel navigates between attachments smoothly.
    3. On any attachment at scale 1.0, pinch to zoom in.
      • Verify: The pinch gesture activates cleanly; the carousel pager does not cancel or preempt the pinch.

    Test Case 5: Single and double-tap interactions

    1. Tap once on the canvas.
      • Verify: Header and footer overlays toggle visibility cleanly.
    2. Double-tap to zoom in, then double-tap to zoom out.
      • Verify: Double-tap zoom transitions operate smoothly across all platforms.
  17. melvin-bot commented on Oct 10, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  18. melvin-bot commented on Oct 12, 2026

    @melvin-bot

    @situchan Uh oh! This issue is overdue by 2 days. Don't forget to update your issues!

  19. camesenin commented on Oct 13, 2026

    @camesenin

    Contributor details
    Your Expensify account email: camesenin@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    In the image attachment preview on mobile and web, when zooming into an image using a pinch gesture until reaching maximum zoom, the pinch-to-zoom gesture completely freezes. Reversing the gesture to zoom back out does not respond, leaving the image stuck at maximum zoom until the user lifts both fingers from the screen.

    What is the root cause of that problem?

    In src/components/MultiGestureCanvas/usePinchGesture.ts:152-160:

    if (zoomScale.get() >= zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min && zoomScale.get() <= zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max) {
        zoomScale.set(newZoomScale);
        currentPinchScale.set(newZoomScale / pinchScale.get());
    }
    1. Stale Zoom Check vs. Clamped New Scale: The boundary guard evaluates the previous frame's scale value (zoomScale.get()) against the bounce factor boundaries (e.g. 20 * 1.5 = 30) instead of computing and clamping the incoming newZoomScale.
    2. When the user pinches outward quickly, a frame update sets zoomScale past 30. On subsequent onChange events, zoomScale.get() <= maxBounce evaluates to false.
    3. Consequently, when the user reverses finger direction to pinch inward (evt.scale < 1), the conditional block is completely skipped because zoomScale.get() is already above 30. The scale freezes and cannot update until onEnd runs when both fingers are lifted.
    4. Additionally, toggling scheduleOnRN(setPinchEnabled, false) when numberOfPointers !== 2 de-registers the builder API scale detector in react-native-gesture-handler 3.3.0 on Android, causing intermittent gesture dropped states.

    What changes do you think we should make in order to solve the problem?

    1. In src/components/MultiGestureCanvas/usePinchGesture.ts:

      • Compute newZoomScale and clamp it within the allowed bounce factor range using Expensify's existing clamp utility:
        const minBounce = zoomRange.min * ZOOM_RANGE_BOUNCE_FACTORS.min;
        const maxBounce = zoomRange.max * ZOOM_RANGE_BOUNCE_FACTORS.max;
        const clampedNewZoomScale = clamp(newZoomScale, minBounce, maxBounce);
      • Always set zoomScale to clampedNewZoomScale:
        zoomScale.set(clampedNewZoomScale);
        currentPinchScale.set(clampedNewZoomScale / pinchScale.get());
      • This guarantees that even if a pinch reaches the maximum threshold, reversing the gesture immediately produces a scale smaller than the upper clamp, allowing continuous smooth zooming without freezing.
    2. In pointer count handling:

      • When evt.numberOfPointers !== 2, return early instead of toggling scheduleOnRN(setPinchEnabled, false). This avoids breaking RNGH 3.3's native Android scale detector lifecycle.
    3. In MultiGestureCanvas/index.tsx:

      • Ensure the pinch gesture runs simultaneously with the carousel pager's external gesture, preventing native pager touch interception from canceling in-flight pinches.

    What alternative solutions did you explore? (Optional)

    Downgrading react-native-gesture-handler to 2.32.0. This was dismissed because it does not fix the mathematical scale boundary freeze in usePinchGesture.ts and blocks the upcoming migration to RNGH 3.x hooks.

  20. melvin-bot commented on Oct 13, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  21. melvin-bot commented on Oct 14, 2026

    @melvin-bot

    @situchan Eep! 4 days overdue now. Issues have feelings too...

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

Metadata

Metadata

Assignees

Labels

BugSomething is broken. Auto assigns a BugZero manager.ExternalAdded to denote the issue can be worked on by a contributorReviewingHas a PR in reviewWeeklyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions