[Bug]: snapToPosition() dismisses a BottomSheetModal — a temporary position resolves to index -1 and fires onClose

Author: KurnosovNikitaCreated Aug 13, 2026Updated Sep 13, 2026
Labelsno-issue-activity

Re-filing #2736, which the bot closed because I wrote that I had no Expo Snack instead of building one. Fair — there is one now.

Version

v5 — 5.2.14, and master is identical at these lines.

Reanimated Version

v3 — the dropdown's newest option. We actually run 4.5.0; the Snack runs ~4.1. The code path here does not touch reanimated's version-specific surface.

Gesture Handler Version

v2 — 2.32.0.

Platforms

iOS, Android. The path is pure JS/UI-thread logic in BottomSheet.tsx, so it is not platform-specific; our field evidence is Android (RN 0.86, Expo 57).

What happened?

snapToPosition() dismisses a BottomSheetModal.

animateToPosition derives the index of its target by looking the position up in the detents array, and treats a miss as closed:

https://github.com/gorhom/react-native-bottom-sheet/blob/master/src/components/bottomSheet/BottomSheet.tsx#L650

typescript
let index = detents?.indexOf(position + offset) ?? -1;

A snapToPosition() target is by definition not a detent — that is the whole point of the API, and handleSnapToPosition marks it as such with isInTemporaryPosition.value = true. So index stays -1, and it is -1 that gets written into the animation state as nextIndex. When the animation completes:

typescript
if (nextIndex === -1) {
  runOnJS(handleOnClose)();
}

For a BottomSheetModal, onClose is handleBottomSheetOnClose → status DISMISSEDunmount()onDismiss. The sheet unmounts, and any state in its content is destroyed.

There is exactly one thing standing between snapToPosition() and this: the keyboard recovery immediately below (L656-667) re-derives an index when KEYBOARD_STATUS.SHOWN. With the keyboard hidden there is no recovery, so every snapToPosition() call on a modal ends in a dismissal.

The library already draws the distinction I am describing, in two other places — which is why this reads to me like an oversight in animateToPosition rather than an intended contract:

  • the gesture path refuses to read -1 as closed while the sheet is in a temporary position (nextPositionIndex === -1 && !isInTemporaryPosition.value);
  • animatedIndex returns the current index, not -1, while isInTemporaryPosition.

Impact, in case it helps prioritise

We ship a Android-only repair that nudges a presented sheet a pixel with snapToPosition() after the app returns from the system photo picker, to force a lost native transform to be re-committed. Every one of those nudges dismissed the sheet. In one production user's diagnostic bundle: five nudges, five dismissals 278-380 ms later, each at exactly the nudged position — an add-a-book form with a typed title and author, destroyed each time, immediately before the picked photo came back to a form that no longer existed. The sheet is dismissed while the user is looking at it and has touched nothing.

Reproduction steps

  • Present a BottomSheetModal (dynamic sizing, one detent; enableDismissOnClose at its default).
  • Make sure the keyboard is not shown.
  • Call snapToPosition(<any height that is not the current detent>) — e.g. one pixel off.
  • The sheet animates the pixel, and then unmounts: onChange fires with index: -1 at the requested position, and onDismiss fires. Nothing asked for a dismissal.
typescript
const ref = useRef<BottomSheetModal>(null);
const { animatedPosition } = useBottomSheet(); // inside the sheet

// container 800, sheet at position 0 → visible height 800
snapToPosition(799);
// → onChange(-1, 1, 0)  → onClose → unmount → onDismiss

Expected: the sheet moves to the requested position and stays presented (a reposition). Actual: the sheet is dismissed and its content unmounted.

Reproduction sample

https://snack.expo.dev/Bfj1FfiQhXKRNduNipoJN

Present the modal, tap +1 a couple of times so the sheet has state worth losing, then tap Reposition 1px (snapToPosition). The log records onChange index=-1 at the requested position, then onDismiss, and the sheet is gone with its counter. No gesture, no keyboard, no picker involved.

Written for this report as the minimal expression of the path — the behaviour it shows is what we measured in production (below) and what the patch in the PR stops.

Relevant log output

bash
# our own instrumentation on the sheet's onChange, one presentation:
sheet.foreground.reassert {"index":0,"position":251.273,"containerHeight":827.273,"keyboard":false}
# ^ sheet presented at index 0; we then call snapToPosition(827.273 - 251.273 - 1 = 575)
sheet.change {"index":-1,"position":252.273,"type":0}
# ^ 380 ms later: the requested position (827.273 - 575), reported as index -1 → onDismiss

Fix

PR: https://github.com/gorhom/react-native-bottom-sheet/pull/2738 — the recovery for a temporary position, alongside the keyboard one it mirrors. yarn typescript and biome clean, and carried as a patch-package patch on 5.2.14 in the app described above. The on-device pass there is scheduled but has not run yet, so treat the behavioural side as argued from the source path, the Snack and the bundle, not as hardware-verified.

Source: gorhom/react-native-bottom-sheet