From 49af5f6f0e4ed07f9c250b6ebbb8e5f069a6a651 Mon Sep 17 00:00:00 2001 From: Ronak Bothra Date: Tue, 28 Jul 2026 20:47:25 +0530 Subject: [PATCH 1/2] fix(modal): ignore dismiss() when the modal is not presented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #2723 Co-Authored-By: Claude Opus 5 (1M context) --- .../bottomSheetModal/BottomSheetModal.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/bottomSheetModal/BottomSheetModal.tsx b/src/components/bottomSheetModal/BottomSheetModal.tsx index 2954b3f4..5689f676 100644 --- a/src/components/bottomSheetModal/BottomSheetModal.tsx +++ b/src/components/bottomSheetModal/BottomSheetModal.tsx @@ -272,6 +272,23 @@ function BottomSheetModalComponent( }); } + /** + * if the modal was never presented, or has already been fully torn down + * (`unmount()` resets the status back to `INITIAL`), then there is nothing + * to dismiss and no inner sheet to close. + * + * Falling through would set the status to `DISMISSING` and call + * `forceClose()` on a `bottomSheetRef` that is still `null` — a silent + * no-op. Since nothing is animating, `onClose` never fires, so nothing ever + * transitions the status out of `DISMISSING`, and `handlePortalRender` + * suppresses every subsequent render. The modal is then permanently wedged: + * later `present()` calls mount the portal but render nothing, with no + * error or warning. + */ + if (statusRef.current === MODAL_STATUS.INITIAL) { + return; + } + /** * if the modal position is already in a closed position, * then we unmount the node and early exit. From c9995c37f0de6e526c8146621cfe5a94fbf82a8d Mon Sep 17 00:00:00 2001 From: Ronak Bothra Date: Tue, 28 Jul 2026 20:47:45 +0530 Subject: [PATCH 2/2] fix(modal): drop orphaned portal entry when the sheet is already torn down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../bottomSheetModal/BottomSheetModal.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/bottomSheetModal/BottomSheetModal.tsx b/src/components/bottomSheetModal/BottomSheetModal.tsx index 5689f676..cef7a023 100644 --- a/src/components/bottomSheetModal/BottomSheetModal.tsx +++ b/src/components/bottomSheetModal/BottomSheetModal.tsx @@ -379,7 +379,7 @@ function BottomSheetModalComponent( //#region callbacks const handlePortalOnUnmount = useCallback( - function handlePortalOnUnmount() { + function handlePortalOnUnmount(removePortalFromHost?: () => void) { if (__DEV__) { print({ component: 'BottomSheetModal', @@ -391,6 +391,27 @@ function BottomSheetModalComponent( } if (statusRef.current === MODAL_STATUS.INITIAL) { + /** + * The sheet is already fully torn down — `unmount()` removed this portal + * from the host and reset the status. + * + * However `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 + * this component actually unmounting, RE-ADDING the entry we just removed. + * + * Left behind, that orphan makes the host keep rendering the stale node, + * so the inner `BottomSheet` is never unmounted. The next `present()` then + * cannot open it: `handlePresent` reads a captured `mount` of `false`, so + * it skips the `snapToIndex` branch, while the still-mounted sheet already + * has `didAnimateOnMount === true` and so runs no mount animation either. + * The sheet silently never opens again, and every closed sheet leaks a + * live subtree. + * + * This is the last chance to drop that orphan. `removePortal` is + * idempotent, so this is a no-op when there is nothing stale to remove. + */ + removePortalFromHost?.(); return; }