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
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 calls present().
- On one of them, call
ref.current?.dismiss() once from a useEffect(() => { ... }, []) 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 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):
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.
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
What happened?
BottomSheetModal.dismiss()is not safe to call before the firstpresent(). If a consumer callsdismiss()on aBottomSheetModalinstance whose status is stillMODAL_STATUS.INITIAL, the modal's internalstatusRefis set toMODAL_STATUS.DISMISSING. From that point on, every subsequentpresent()call silently no-ops — the Portal mounts buthandlePortalRenderbails out on theDISMISSINGcheck, so the underlying sheet never renders. No warning, no error.This breaks the canonical declarative-wrapper pattern that React users naturally reach for:
On first mount
visible === false, sodismiss()runs while gorhom is still inINITIAL. The subsequentsetVisible(true)callspresent(), but the sheet never appears.Reproduction steps
<BottomSheetModal>s. Wire each to a button that callspresent().ref.current?.dismiss()once from auseEffect(() => { ... }, [])on mount.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
useEffectthat callsdismiss()on mount.Root cause
Verified by reading
src/components/bottomSheetModal/BottomSheetModal.tsxon v5.2.14.handleDismiss(L263) only early-exits for[CLOSED, MINIMIZED]and(DISMISSING && currentIndexRef === -1).INITIALis not in the list and falls through:bottomSheetRef.current?.forceClose()is a safe no-op because the underlyingBottomSheetwas never mounted — butstatusRef.currentis now stuck atDISMISSING. Nothing resets it. ThehandleBottomSheetOnClosecallback (L496) that would normally transitionDISMISSING → DISMISSEDnever fires, because there is no mountedBottomSheetto emit that callback.Then in
handlePortalRender(L411):So when
present()later mounts the Portal,handlePortalRenderbails silently and the sheet never appears.Proposed fix
Add
MODAL_STATUS.INITIALto the early-exit list at L279.dismiss()on a never-presented modal should be idempotent (mirroringpresent(), which already handles the symmetric case):Workaround
Guard the dismiss call until the first present has happened:
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.