[Bug]: dismiss() before first present() corrupts statusRef, blocking subsequent present()

Author: ycmjasonCreated May 10, 2026Updated Sep 12, 2026

Version

v5 (5.2.14)

Reanimated Version

v3 (the dropdown does not list v4 yet — actually tested on 4.2.1, but the bug is pure JS state-machine logic so reanimated version is not load-bearing)

Gesture Handler Version

v2 (2.30.1)

Platforms

  • iOS
  • Android

What happened?

BottomSheetModal.dismiss() is not safe to call before the first present(). If a consumer calls dismiss() on a BottomSheetModal instance whose status is still MODAL_STATUS.INITIAL, the modal's internal statusRef is set to MODAL_STATUS.DISMISSING. From that point on, every subsequent present() call silently no-ops — the Portal mounts but handlePortalRender bails out on the DISMISSING check, so the underlying sheet never renders. No warning, no error.

This breaks the canonical declarative-wrapper pattern that React users naturally reach for:

typescript
useEffect(() => {
  if (visible) modalRef.current?.present();
  else modalRef.current?.dismiss();
}, [visible]);

On first mount visible === false, so dismiss() runs while gorhom is still in INITIAL. The subsequent setVisible(true) calls present(), but the sheet never appears.

Reproduction steps

  1. Render two identical <BottomSheetModal>s. Wire each to a button that calls present().
  2. On one of them, call ref.current?.dismiss() once from a useEffect(() => { ... }, []) on mount.
  3. Tap the button for the non-dismissed sheet — it presents normally.
  4. Tap the button for the dismissed-on-mount sheet — nothing happens.

Reproduction sample

https://snack.expo.dev/@ycm.jason/bottomsheetmodal-v5-2-14-dismiss-before-first-present-is-broken

Two buttons side by side. The only difference between them is the useEffect that calls dismiss() on mount.

Root cause

Verified by reading src/components/bottomSheetModal/BottomSheetModal.tsx on v5.2.14.

handleDismiss (L263) only early-exits for [CLOSED, MINIMIZED] and (DISMISSING && currentIndexRef === -1). INITIAL is not in the list and falls through:

typescript
if (
  [MODAL_STATUS.CLOSED, MODAL_STATUS.MINIMIZED].includes(statusRef.current) ||
  (statusRef.current === MODAL_STATUS.DISMISSING && currentIndexRef.current === -1)
) {
  statusRef.current = MODAL_STATUS.DISMISSED;
  unmount();
  return;
}

statusRef.current = MODAL_STATUS.DISMISSING; // ← reached when status is INITIAL
willUnmountSheet(key);
bottomSheetRef.current?.forceClose(animationConfigs); // no-op: ref is null

bottomSheetRef.current?.forceClose() is a safe no-op because the underlying BottomSheet was never mounted — but statusRef.current is now stuck at DISMISSING. Nothing resets it. The handleBottomSheetOnClose callback (L496) that would normally transition DISMISSING → DISMISSED never fires, because there is no mounted BottomSheet to emit that callback.

Then in handlePortalRender (L411):

typescript
if ([MODAL_STATUS.DISMISSING].includes(statusRef.current)) {
  return;
}
render();

So when present() later mounts the Portal, handlePortalRender bails silently and the sheet never appears.

Proposed fix

Add MODAL_STATUS.INITIAL to the early-exit list at L279. dismiss() on a never-presented modal should be idempotent (mirroring present(), which already handles the symmetric case):

diff
  if (
-   [MODAL_STATUS.CLOSED, MODAL_STATUS.MINIMIZED].includes(statusRef.current) ||
+   [MODAL_STATUS.INITIAL, MODAL_STATUS.CLOSED, MODAL_STATUS.MINIMIZED].includes(statusRef.current) ||
    (statusRef.current === MODAL_STATUS.DISMISSING && currentIndexRef.current === -1)
  ) {
    statusRef.current = MODAL_STATUS.DISMISSED;
    unmount();
    return;
  }

Workaround

Guard the dismiss call until the first present has happened:

typescript
const hasPresentedRef = useRef(false);
useEffect(() => {
  if (visible) {
    modalRef.current?.present();
    hasPresentedRef.current = true;
  } else if (hasPresentedRef.current) {
    modalRef.current?.dismiss();
  }
}, [visible]);

Relevant log output

No errors or warnings logged in the failing case — that is part of what makes this a sharp footgun for downstream wrappers.

Source: gorhom/react-native-bottom-sheet