[Bug]: snapToPosition() dismisses a BottomSheetModal — a temporary position resolves to index -1 and fires onClose
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:
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:
if (nextIndex === -1) {
runOnJS(handleOnClose)();
}For a BottomSheetModal, onClose is handleBottomSheetOnClose → status DISMISSED →
unmount() → 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
-1as closed while the sheet is in a temporary position (nextPositionIndex === -1 && !isInTemporaryPosition.value); animatedIndexreturns the current index, not-1, whileisInTemporaryPosition.
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;enableDismissOnCloseat 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:
onChangefires withindex: -1at the requested position, andonDismissfires. Nothing asked for a dismissal.
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 → onDismissExpected: 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
# 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 → onDismissFix
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