fix(modal): stop present() silently no-opping forever (two separate wedges) - #2725
Open
ronakbothraa wants to merge 2 commits into
Open
fix(modal): stop present() silently no-opping forever (two separate wedges)#2725ronakbothraa wants to merge 2 commits into
ronakbothraa wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two independent defects in
BottomSheetModalthat 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.
dismiss()while the modal is not presentedDISMISSING49af5f6INITIALc9995c31.
dismiss()on a non-presented modal — fixes #2723Full credit to @Quraian for the report and the diagnosis; this implements the first of the two fixes suggested there.
handleDismiss's early-exit guard checksCLOSED,MINIMIZED, andDISMISSING && currentIndexRef === -1— but notINITIAL. Both a never-presented modal and one that has already been torn down sit atINITIAL(unmount()resets the status and setsmount: false, sobottomSheetRef.currentbecomesnull).So a
dismiss()in that state falls through, setsstatusRef.current = DISMISSING, and callsforceClose()on a null ref — a silent no-op. Nothing is animating, soonClosenever fires, so nothing transitions the status out ofDISMISSING.handlePortalRenderthen 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 seconddismiss()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'shandleOnUpdateeffect is keyed onchildren, whose element identity changes on every parent render. A trailing update can therefore land in the window betweenunmount()'sremovePortaland thePortalcomponent actually unmounting — re-adding the entry that was just removed.handlePortalOnUnmountthen early-returns atINITIALwithout calling theremovePortalcallback thatPortalhands it (the prop is typedhandleOnUnmount?: (unmount: () => void) => void— it's deliberately provided for exactly this). So the re-added entry is orphaned permanently.Consequences:
BottomSheetis never unmounted — every closed sheet leaks a live subtree.present()cannot open it either.handlePresentreads a capturedmountoffalseand so skips thesnapToIndexbranch, while the still-mounted sheet already hasdidAnimateOnMount === trueand therefore runs no mount animation. Nothing ever asks the sheet to open.Fix: call the provided
removePortalin theINITIALbranch.removePortalis 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 is0 (INITIAL)throughout — neverDISMISSING, which is what distinguishes this from #2723:Verification
Honest breakdown of what is and isn't verified:
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 green —renders 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).yarn typescriptclean,yarn lintclean on the changed file (the one remaining repo warning is pre-existing insrc/hooks/useBoundingClientRect.ts, identical onmaster),yarn buildsucceeds.CONTRIBUTING.mdmentionsyarn test, but there's notestscript 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
return, and a missing guard). No existing code path changes behaviour.statusRefisINITIALin exactly two situations: the initial value, and afterresetVariables()— whose only caller isunmount(). So the newhandlePortalOnUnmountbehaviour can only ever fire on an already-torn-down sheet.INITIALat cleanup time): backdrop tap, swipe-to-dismiss, unmounting the screen with the sheet open,enableDismissOnClose={false}, and rapiddismiss()→present()mid-close (the Portal never unmounts there).handlePresentclear a staleDISMISSINGstatus 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-INITIALbranch callingclose()on a null ref). Happy to add it if you want.🤖 Generated with Claude Code