Fix isolated bottom sheet modal lifecycle

This commit is contained in:
Mohamed Boudra
2026-05-02 12:12:44 +07:00
parent 84796bc74d
commit 8e9134d255
6 changed files with 173 additions and 55 deletions
@@ -196,7 +196,7 @@ export function AdaptiveModalSheet({
() => ({ backgroundColor: theme.colors.surface2 }), () => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2], [theme.colors.surface2],
); );
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible, visible,
isEnabled: isMobile, isEnabled: isMobile,
onClose, onClose,
@@ -228,6 +228,7 @@ export function AdaptiveModalSheet({
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
onChange={handleSheetChange} onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
backgroundComponent={SheetBackground} backgroundComponent={SheetBackground}
+20 -17
View File
@@ -1,4 +1,5 @@
import React, { createContext, useContext, useCallback, useMemo, useRef, ReactNode } from "react"; import React, { createContext, useContext, useCallback, useMemo } from "react";
import type { ReactNode } from "react";
import { View, Text, Pressable } from "react-native"; import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated from "react-native-reanimated"; import Animated from "react-native-reanimated";
@@ -11,7 +12,7 @@ import { X } from "lucide-react-native";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
type IsolatedBottomSheetModalRef, useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal"; } from "@/components/ui/isolated-bottom-sheet-modal";
import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
@@ -63,32 +64,33 @@ interface ToolCallSheetProviderProps {
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) { export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const bottomSheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const hasPresentedRef = useRef(false);
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null); const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
const [isSheetOpen, setIsSheetOpen] = React.useState(false);
const snapPoints = useMemo(() => ["60%", "95%"], []); const snapPoints = useMemo(() => ["60%", "95%"], []);
const openToolCall = useCallback((data: ToolCallSheetData) => { const openToolCall = useCallback((data: ToolCallSheetData) => {
setSheetData(data); setSheetData(data);
if (hasPresentedRef.current) { setIsSheetOpen(true);
requestAnimationFrame(() => bottomSheetRef.current?.snapToIndex(0));
return;
}
hasPresentedRef.current = true;
bottomSheetRef.current?.present();
}, []); }, []);
const closeToolCall = useCallback(() => { const closeToolCall = useCallback(() => {
bottomSheetRef.current?.close(); setIsSheetOpen(false);
}, []); }, []);
const handleSheetChange = useCallback((index: number) => { const {
if (index === -1) { sheetRef: bottomSheetRef,
setSheetData(null); handleSheetChange,
} handleSheetDismiss,
}, []); } = useIsolatedBottomSheetVisibility({
visible: isSheetOpen,
onClose: closeToolCall,
});
const handleToolCallSheetDismiss = useCallback(() => {
handleSheetDismiss();
setSheetData(null);
}, [handleSheetDismiss]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
@@ -116,6 +118,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
onChange={handleSheetChange} onChange={handleSheetChange}
onDismiss={handleToolCallSheetDismiss}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
backgroundComponent={CustomSheetBackground} backgroundComponent={CustomSheetBackground}
+12 -3
View File
@@ -910,10 +910,13 @@ function resolveInitialActiveIndex(
return selectedIndex >= 0 ? selectedIndex : fallbackIndex; return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
} }
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
interface MobileBodyProps { interface MobileBodyProps {
bottomSheetRef: ReturnType<typeof useIsolatedBottomSheetVisibility>["sheetRef"]; bottomSheetRef: BottomSheetVisibility["sheetRef"];
snapPoints: string[]; snapPoints: string[];
handleSheetChange: ReturnType<typeof useIsolatedBottomSheetVisibility>["handleSheetChange"]; handleSheetChange: BottomSheetVisibility["handleSheetChange"];
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
handleIndicatorStyle: { backgroundColor: string }; handleIndicatorStyle: { backgroundColor: string };
titleColor: string; titleColor: string;
title: string; title: string;
@@ -971,6 +974,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
onChange={props.handleSheetChange} onChange={props.handleSheetChange}
onDismiss={props.handleSheetDismiss}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
backgroundComponent={ComboboxSheetBackground} backgroundComponent={ComboboxSheetBackground}
@@ -1298,7 +1302,11 @@ export function Combobox({
referenceWidth, referenceWidth,
}); });
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: isOpen, visible: isOpen,
isEnabled: isMobile, isEnabled: isMobile,
onClose: handleClose, onClose: handleClose,
@@ -1454,6 +1462,7 @@ export function Combobox({
bottomSheetRef={bottomSheetRef} bottomSheetRef={bottomSheetRef}
snapPoints={snapPoints} snapPoints={snapPoints}
handleSheetChange={handleSheetChange} handleSheetChange={handleSheetChange}
handleSheetDismiss={handleSheetDismiss}
handleIndicatorStyle={handleIndicatorStyle} handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor} titleColor={titleColor}
title={title} title={title}
@@ -400,7 +400,11 @@ export function ContextMenuContent({
setOpen(false); setOpen(false);
}, [setOpen]); }, [setOpen]);
const { sheetRef: bottomSheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: open, visible: open,
isEnabled: useMobileSheet, isEnabled: useMobileSheet,
onClose: handleClose, onClose: handleClose,
@@ -534,6 +538,7 @@ export function ContextMenuContent({
snapPoints={sheetSnapPoints} snapPoints={sheetSnapPoints}
enableDynamicSizing={false} enableDynamicSizing={false}
onChange={handleSheetChange} onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderSheetBackdrop} backdropComponent={renderSheetBackdrop}
enablePanDownToClose enablePanDownToClose
backgroundStyle={sheetBackgroundStyle} backgroundStyle={sheetBackgroundStyle}
@@ -14,7 +14,12 @@ const SNAP_POINTS_90: (string | number)[] = ["90%"];
function noop(): void {} function noop(): void {}
const { modalMethods, modalProps } = vi.hoisted(() => ({ interface CapturedModalProps {
onChange?: (index: number) => void;
onDismiss?: () => void;
}
const { modalMethods, modalProps, shouldExposeModalRef } = vi.hoisted(() => ({
modalMethods: { modalMethods: {
present: vi.fn(), present: vi.fn(),
close: vi.fn(), close: vi.fn(),
@@ -22,6 +27,7 @@ const { modalMethods, modalProps } = vi.hoisted(() => ({
dismiss: vi.fn(), dismiss: vi.fn(),
}, },
modalProps: vi.fn(), modalProps: vi.fn(),
shouldExposeModalRef: { current: true },
})); }));
vi.mock("@gorhom/bottom-sheet", async () => { vi.mock("@gorhom/bottom-sheet", async () => {
@@ -29,7 +35,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
const MockBottomSheetModal = React.forwardRef( const MockBottomSheetModal = React.forwardRef(
(props: Record<string, unknown>, ref: React.ForwardedRef<unknown>) => { (props: Record<string, unknown>, ref: React.ForwardedRef<unknown>) => {
modalProps(props); modalProps(props);
React.useImperativeHandle(ref, () => modalMethods); React.useImperativeHandle(ref, () => (shouldExposeModalRef.current ? modalMethods : null));
return React.createElement( return React.createElement(
"div", "div",
{ "data-testid": "bottom-sheet" }, { "data-testid": "bottom-sheet" },
@@ -54,7 +60,7 @@ vi.mock("@gorhom/portal", async () => {
}); });
function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) { function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }) {
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({ const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible, visible,
onClose, onClose,
}); });
@@ -65,26 +71,34 @@ function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }
index={0} index={0}
snapPoints={SNAP_POINTS_50} snapPoints={SNAP_POINTS_50}
onChange={handleSheetChange} onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
> >
<div>Sheet content</div> <div>Sheet content</div>
</IsolatedBottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
function latestModalProps(): CapturedModalProps {
const props = modalProps.mock.lastCall?.[0];
expect(props).toBeDefined();
return props as CapturedModalProps;
}
describe("IsolatedBottomSheetModal", () => { describe("IsolatedBottomSheetModal", () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
shouldExposeModalRef.current = true;
}); });
it("forces sheet isolation and keeps modal content mounted while hidden", () => { it("forces sheet isolation and uses the modal dismissal lifecycle", () => {
const onClose = vi.fn(); const onClose = vi.fn();
const { getByTestId, rerender } = render(<Harness visible={false} onClose={onClose} />); const { getByTestId, rerender } = render(<Harness visible={false} onClose={onClose} />);
expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root"); expect(getByTestId("app-portal").getAttribute("data-host")).toBe("root");
expect(modalProps).toHaveBeenLastCalledWith( expect(modalProps).toHaveBeenLastCalledWith(
expect.objectContaining({ expect.objectContaining({
enableDismissOnClose: false, enableDismissOnClose: true,
stackBehavior: "replace", stackBehavior: "replace",
}), }),
); );
@@ -94,28 +108,61 @@ describe("IsolatedBottomSheetModal", () => {
expect(modalMethods.present).toHaveBeenCalledTimes(1); expect(modalMethods.present).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />); rerender(<Harness visible={false} onClose={onClose} />);
expect(modalMethods.close).toHaveBeenCalledTimes(1); expect(modalMethods.dismiss).toHaveBeenCalledTimes(1);
expect(modalMethods.dismiss).not.toHaveBeenCalled(); expect(modalMethods.close).not.toHaveBeenCalled();
rerender(<Harness visible onClose={onClose} />); rerender(<Harness visible onClose={onClose} />);
expect(modalMethods.present).toHaveBeenCalledTimes(1); expect(modalMethods.present).toHaveBeenCalledTimes(2);
expect(modalMethods.snapToIndex).toHaveBeenCalledWith(0); expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
}); });
it("only reports a user close when the sheet was visible", () => { it("only reports a user close when the sheet was visible", () => {
const onClose = vi.fn(); const onClose = vi.fn();
const { rerender } = render(<Harness visible onClose={onClose} />); const { rerender } = render(<Harness visible onClose={onClose} />);
const latestProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void }; latestModalProps().onChange?.(-1);
latestProps.onChange(-1);
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
rerender(<Harness visible={false} onClose={onClose} />); rerender(<Harness visible={false} onClose={onClose} />);
const closedProps = modalProps.mock.lastCall?.[0] as { onChange: (index: number) => void }; latestModalProps().onChange?.(-1);
closedProps.onChange(-1);
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
it("reports a dismiss while visible as a close request", () => {
const onClose = vi.fn();
render(<Harness visible onClose={onClose} />);
latestModalProps().onDismiss?.();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("deduplicates close notifications from change and dismiss callbacks", () => {
const onClose = vi.fn();
render(<Harness visible onClose={onClose} />);
const props = latestModalProps();
props.onChange?.(-1);
props.onDismiss?.();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("presents when the sheet ref becomes available after opening", () => {
shouldExposeModalRef.current = false;
const onClose = vi.fn();
const { rerender } = render(<Harness visible onClose={onClose} />);
expect(modalMethods.present).not.toHaveBeenCalled();
expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
shouldExposeModalRef.current = true;
rerender(<Harness visible onClose={onClose} />);
expect(modalMethods.present).toHaveBeenCalledTimes(1);
expect(modalMethods.snapToIndex).not.toHaveBeenCalled();
});
it("allows nested sheets inside a parent sheet without creating a sibling provider", () => { it("allows nested sheets inside a parent sheet without creating a sibling provider", () => {
const { getAllByTestId } = render( const { getAllByTestId } = render(
<IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_90}> <IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_90}>
@@ -41,7 +41,7 @@ export const IsolatedBottomSheetModal = forwardRef<
<GorhomBottomSheetModal <GorhomBottomSheetModal
{...bottomSheetProps} {...bottomSheetProps}
ref={ref} ref={ref}
enableDismissOnClose={false} enableDismissOnClose
stackBehavior={isNestedSheet ? "push" : "replace"} stackBehavior={isNestedSheet ? "push" : "replace"}
> >
{scopedChildren} {scopedChildren}
@@ -68,39 +68,92 @@ export function useIsolatedBottomSheetVisibility({
isEnabled?: boolean; isEnabled?: boolean;
onClose: () => void; onClose: () => void;
}) { }) {
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null); const sheetRef = useRef<IsolatedBottomSheetModalRef | null>(null);
const hasPresentedRef = useRef(false); const visibleRef = useRef(visible);
const isEnabledRef = useRef(isEnabled);
const isPresentedRef = useRef(false);
const hasNotifiedCloseRef = useRef(false);
visibleRef.current = visible;
isEnabledRef.current = isEnabled;
const presentSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => {
if (isPresentedRef.current) {
return;
}
isPresentedRef.current = true;
hasNotifiedCloseRef.current = false;
sheet.present();
}, []);
const dismissSheet = useCallback((sheet: IsolatedBottomSheetModalRef) => {
if (!isPresentedRef.current) {
return;
}
isPresentedRef.current = false;
sheet.dismiss();
}, []);
const notifyClose = useCallback(() => {
if (hasNotifiedCloseRef.current) {
return;
}
hasNotifiedCloseRef.current = true;
onClose();
}, [onClose]);
const handleSheetDismiss = useCallback(() => {
isPresentedRef.current = false;
if (visibleRef.current) {
notifyClose();
return;
}
hasNotifiedCloseRef.current = false;
}, [notifyClose]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1 && visibleRef.current) {
notifyClose();
}
},
[notifyClose],
);
const setSheetRef = useCallback(
(instance: IsolatedBottomSheetModalRef | null) => {
sheetRef.current = instance;
if (instance && visibleRef.current && isEnabledRef.current !== false) {
presentSheet(instance);
}
},
[presentSheet],
);
useEffect(() => { useEffect(() => {
if (isEnabled === false) return; if (isEnabled === false) return;
const sheet = sheetRef.current;
if (visible) { if (visible) {
if (hasPresentedRef.current) { if (!sheet) {
sheetRef.current?.snapToIndex(0);
return; return;
} }
hasPresentedRef.current = true; presentSheet(sheet);
sheetRef.current?.present();
return; return;
} }
if (hasPresentedRef.current) { if (sheet) {
sheetRef.current?.close(); dismissSheet(sheet);
} }
}, [isEnabled, visible]); }, [dismissSheet, isEnabled, presentSheet, visible]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1 && visible) {
onClose();
}
},
[onClose, visible],
);
return { return {
sheetRef, sheetRef: setSheetRef,
handleSheetChange, handleSheetChange,
handleSheetDismiss,
}; };
} }