[Bug]: dismiss() before first present() corrupts statusRef, blocking subsequent present()
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:
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
- Render two identical
<BottomSheetModal>s. Wire each to a button that callspresent(). - On one of them, call
ref.current?.dismiss()once from auseEffect(() => { ... }, [])on mount. - Tap the button for the non-dismissed sheet — it presents normally.
- 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:
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 nullbottomSheetRef.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):
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):
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:
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