Skip to content

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

@ycmjason

Description

@ycmjason

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

  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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions