Skip to content

fix(modal): stop present() silently no-opping forever (two separate wedges) - #2725

Open
ronakbothraa wants to merge 2 commits into
gorhom:masterfrom
ronakbothraa:fix/2723-silent-present-noop
Open

fix(modal): stop present() silently no-opping forever (two separate wedges)#2725
ronakbothraa wants to merge 2 commits into
gorhom:masterfrom
ronakbothraa:fix/2723-silent-present-noop

Conversation

@ronakbothraa

Copy link
Copy Markdown

Summary

Two independent defects in BottomSheetModal that both end in the same silent failure: present() mounts the portal but the sheet never appears, permanently, with no error or warning. Only unmounting and remounting the component recovers.

They have different triggers and different code paths, but they live a few lines apart in the same lifecycle machinery, so they're fixed together here. Each is a separate commit and can be split if you'd prefer.

Trigger Wedged status Commit
1 dismiss() while the modal is not presented DISMISSING 49af5f6
2 Orphaned portal entry survives teardown INITIAL c9995c3

1. dismiss() on a non-presented modal — fixes #2723

Full credit to @Quraian for the report and the diagnosis; this implements the first of the two fixes suggested there.

handleDismiss's early-exit guard checks CLOSED, MINIMIZED, and DISMISSING && currentIndexRef === -1 — but not INITIAL. Both a never-presented modal and one that has already been torn down sit at INITIAL (unmount() resets the status and sets mount: false, so bottomSheetRef.current becomes null).

So a dismiss() in that state falls through, sets statusRef.current = DISMISSING, and calls forceClose() on a null ref — a silent no-op. Nothing is animating, so onClose never fires, so nothing transitions the status out of DISMISSING. handlePortalRender then suppresses every subsequent render.

This is easy to hit from any parent that mirrors "sheet open" into its own state: the modal's onDismiss → state update → effect → a second dismiss() after the user already dismissed it themselves.

Fix: there is nothing to dismiss at INITIAL, so return early.

2. Orphaned portal entry keeps the sheet mounted but unopenable

This one is separate from #2723 and, as far as I can tell, unreported. It survives the usual userland workaround for #2723 (tracking presented-ness in the parent), because here dismiss() is only ever called while genuinely presented.

Portal's handleOnUpdate effect is keyed on children, whose element identity changes on every parent render. A trailing update can therefore land in the window between unmount()'s removePortal and the Portal component actually unmounting — re-adding the entry that was just removed.

handlePortalOnUnmount then early-returns at INITIAL without calling the removePortal callback that Portal hands it (the prop is typed handleOnUnmount?: (unmount: () => void) => void — it's deliberately provided for exactly this). So the re-added entry is orphaned permanently.

Consequences:

  • The host keeps rendering the stale node, so the inner BottomSheet is never unmounted — every closed sheet leaks a live subtree.
  • The next present() cannot open it either. handlePresent reads a captured mount of false and so skips the snapToIndex branch, while the still-mounted sheet already has didAnimateOnMount === true and therefore runs no mount animation. Nothing ever asks the sheet to open.

Fix: call the provided removePortal in the INITIAL branch. removePortal is idempotent (findIndex → -1 → no-op), so this does nothing when there is no orphan.

Trace from a real app

Captured on device, 5.2.14, New Architecture (Fabric), React 19, reanimated 4. Sheet had closed cleanly ~600ms earlier; note the status is 0 (INITIAL) throughout — never DISMISSING, which is what distinguishes this from #2723:

BottomSheetModal::unmount
BottomSheetModal::resetVariables          ← statusRef := INITIAL
BottomSheetModal::handlePortalRender status:0
PORTAL action=REMOVE  before=[modal-110] after=[]        ← unmount() removed it
PORTAL action=ADD     before=[]          after=[modal-110]  ← re-added 1ms later
PORTAL host RENDER count=1 names=[modal-110]             ← orphan being rendered
BottomSheetModal::handlePortalOnUnmount status:0         ← early-returns, orphan kept

--- user taps to reopen ---
BottomSheetModal::handlePresent currentIndexRef:-1 status:0
BottomSheetModal::handlePortalRender status:0
   ...no inner mount, no layout, no onSnapPointChange, no animation. Sheet never appears.

Verification

Honest breakdown of what is and isn't verified:

  • Defect 2 is verified end-to-end. Root-caused from the device trace above (instrumented evaluatePosition, the portal reducer, and the host render), and confirmed fixed on device after the change. In our app it additionally turns 3 pre-existing red tests greenrenders children if isVisible is true, should render scroll view if scrollable is true, should not render scroll view if scrollable is false — with zero newly-failing tests (controlled A/B, stable across repeated runs).
  • Defect 1 is a code-level fix, matching the mechanism traced in [Bug]: dismiss() on a not-presented BottomSheetModal permanently wedges status at DISMISSING — later present() calls silently no-op #2723 and the reporter's own suggestion. I have not run the Expo Snack repro myself, so please treat that as reviewed-not-executed.
  • yarn typescript clean, yarn lint clean on the changed file (the one remaining repo warning is pre-existing in src/hooks/useBoundingClientRect.ts, identical on master), yarn build succeeds.
  • No tests added. CONTRIBUTING.md mentions yarn test, but there's no test script or jest setup in the repo, so adding tests would mean introducing the whole harness. Very happy to do that in a separate PR if you'd like it.

Notes for reviewers

  • Both changes are purely additive to branches that previously did nothing (a bare return, and a missing guard). No existing code path changes behaviour.
  • statusRef is INITIAL in exactly two situations: the initial value, and after resetVariables() — whose only caller is unmount(). So the new handlePortalOnUnmount behaviour can only ever fire on an already-torn-down sheet.
  • Paths I checked as unaffected by change 2 (status is not INITIAL at cleanup time): backdrop tap, swipe-to-dismiss, unmounting the screen with the sheet open, enableDismissOnClose={false}, and rapid dismiss()present() mid-close (the Portal never unmounts there).
  • [Bug]: dismiss() on a not-presented BottomSheetModal permanently wedges status at DISMISSING — later present() calls silently no-op #2723 also suggests a second, complementary guard: having handlePresent clear a stale DISMISSING status so an already-wedged modal self-heals. I left it out to keep this focused, but it would add defence-in-depth for any residual path that can still strand the status (e.g. handlePortalOnUnmount's non-INITIAL branch calling close() on a null ref). Happy to add it if you want.

🤖 Generated with Claude Code

ronakbothraa and others added 2 commits July 28, 2026 20:47
Calling dismiss() on a BottomSheetModal that is not currently presented — either
never presented, or already self-dismissed by the user via backdrop tap or
pan-down — left the modal permanently broken. Every later present() mounted the
Portal but rendered nothing, with no error or warning; only unmounting and
remounting the component recovered.

handleDismiss's early-exit guard checked CLOSED, MINIMIZED and
DISMISSING && currentIndexRef === -1, but not INITIAL. unmount() resets the
status to INITIAL and sets mount: false (so bottomSheetRef.current becomes
null), and a never-presented modal starts at INITIAL. A dismiss() in that state
therefore fell through, set the status to DISMISSING and called forceClose() on
a null ref — a silent no-op. With nothing animating, onClose never fired, so
nothing transitioned the status out of DISMISSING, and handlePortalRender
suppresses every render while DISMISSING.

There is nothing to dismiss at INITIAL, so return early.

Fixes gorhom#2723

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… down

After a sheet closed, a later present() could mount the portal but never open
the sheet — silently, with no error. Additionally, every closed sheet leaked a
still-mounted BottomSheet subtree in the portal host.

Portal's handleOnUpdate effect is keyed on `children`, whose element identity
changes on every parent render. A trailing update can therefore land in the
window between unmount()'s removePortal and the Portal component actually
unmounting, re-adding the entry that was just removed. handlePortalOnUnmount
early-returned at INITIAL without calling the removePortal callback that Portal
hands it, so that re-added entry was orphaned permanently.

The host then kept rendering the stale node, so the inner BottomSheet was never
unmounted. The next present() could not open it either: handlePresent reads a
captured `mount` of false and so skips the snapToIndex branch, while the
still-mounted sheet already has didAnimateOnMount === true and therefore runs no
mount animation. Nothing ever asked the sheet to open.

handleOnUnmount is typed `(unmount: () => void) => void` — Portal deliberately
hands the consumer its removePortal function. Call it in the INITIAL branch.
removePortal is idempotent, so this is a no-op when there is no orphan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: dismiss() on a not-presented BottomSheetModal permanently wedges status at DISMISSING — later present() calls silently no-op

1 participant