diff --git a/libs/expo/shared/ui-components/src/lib/BottomSheet/BottomSheetModalControlled.spec.tsx b/libs/expo/shared/ui-components/src/lib/BottomSheet/BottomSheetModalControlled.spec.tsx index 9e2aa6261a..a750b0de92 100644 --- a/libs/expo/shared/ui-components/src/lib/BottomSheet/BottomSheetModalControlled.spec.tsx +++ b/libs/expo/shared/ui-components/src/lib/BottomSheet/BottomSheetModalControlled.spec.tsx @@ -1,6 +1,7 @@ import { act, render } from '@testing-library/react-native'; import { ReactNode } from 'react'; import { Text } from 'react-native'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BottomSheetModalControlled } from './BottomSheetModalControlled'; const mocks = vi.hoisted(() => ({ @@ -11,18 +12,43 @@ vi.mock('./providers/BottomSheetModal/useBottomSheet', () => ({ useBottomSheet: () => ({ showBottomSheet: mocks.showBottomSheet }), })); -type TRenderArgs = { closeSheet: () => void }; +type TRenderArgs = { closeSheet: () => void; id: string }; -function Controlled({ isOpen }: { isOpen: boolean }) { +type TShowCall = { + render: (args: TRenderArgs) => ReactNode; + options: { onClose?: (id: string) => void }; +}; + +function Controlled({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose?: () => void; +}) { return ( - + menu ); } -function mountSheetRender(): (args: TRenderArgs) => ReactNode { - return mocks.showBottomSheet.mock.calls[0][0].render; +function showCalls(): TShowCall[] { + return mocks.showBottomSheet.mock.calls.map((call) => call[0] as TShowCall); +} + +/** Simulates the provider mounting the sheet that owns `closeSheet`. */ +function mountSheet(index: number, id: string, closeSheet: () => void) { + act(() => { + showCalls()[index].render({ id, closeSheet }); + }); +} + +/** Simulates Gorhom reporting that a sheet fully dismissed. */ +function completeDismissal(index: number, id: string) { + act(() => { + showCalls()[index].options.onClose?.(id); + }); } describe('BottomSheetModalControlled', () => { @@ -39,9 +65,7 @@ describe('BottomSheetModalControlled', () => { // Sheet mounts while isOpen is true → it stays open. const closeSheet = vi.fn(); - act(() => { - mountSheetRender()({ closeSheet }); - }); + mountSheet(0, 'sheet-1', closeSheet); expect(closeSheet).not.toHaveBeenCalled(); }); @@ -50,9 +74,7 @@ describe('BottomSheetModalControlled', () => { rerender(); const closeSheet = vi.fn(); - act(() => { - mountSheetRender()({ closeSheet }); - }); + mountSheet(0, 'sheet-1', closeSheet); rerender(); expect(closeSheet).toHaveBeenCalled(); @@ -68,7 +90,7 @@ describe('BottomSheetModalControlled', () => { const closeSheet = vi.fn(); act(() => { - mountSheetRender()({ closeSheet }); + showCalls()[0].render({ id: 'sheet-1', closeSheet }); }); // The close is scheduled on a microtask to avoid a render-phase setState. @@ -78,4 +100,70 @@ describe('BottomSheetModalControlled', () => { expect(closeSheet).toHaveBeenCalled(); }); + + it('reopening during a close keeps the NEW sheet functional (stale onClose ignored)', () => { + const parentClose = vi.fn(); + const { rerender } = render( + , + ); + + // Open #1. + rerender(); + const close1 = vi.fn(); + mountSheet(0, 'sheet-1', close1); + + // Close via state (like Cancel). + rerender(); + expect(close1).toHaveBeenCalledTimes(1); + + // Rapid reopen → a brand-new sheet is presented. + rerender(); + expect(mocks.showBottomSheet).toHaveBeenCalledTimes(2); + const close2 = vi.fn(); + mountSheet(1, 'sheet-2', close2); + + // The OLD sheet finishes dismissing AFTER the reopen. Its onClose is + // stale and must not notify the parent or break the new sheet. + completeDismissal(0, 'sheet-1'); + expect(parentClose).not.toHaveBeenCalled(); + + // The new sheet must still be closable. + rerender(); + expect(close2).toHaveBeenCalledTimes(1); + expect(close1).toHaveBeenCalledTimes(1); + }); + + it('notifies the parent when the active sheet is dismissed externally', () => { + const parentClose = vi.fn(); + const { rerender } = render( + , + ); + + rerender(); + mountSheet(0, 'sheet-1', vi.fn()); + + // Provider surfaces a user-initiated dismissal (backdrop / pan-down / + // header) via options.onClose with the active sheet's id. + completeDismissal(0, 'sheet-1'); + + expect(parentClose).toHaveBeenCalledTimes(1); + }); + + it('does not notify the parent when the close originated from state', () => { + const parentClose = vi.fn(); + const { rerender } = render( + , + ); + + rerender(); + mountSheet(0, 'sheet-1', vi.fn()); + + // State-driven close (isOpen false) — the parent already knows. + rerender(); + + // Gorhom reports the dismissal completing. + completeDismissal(0, 'sheet-1'); + + expect(parentClose).not.toHaveBeenCalled(); + }); }); diff --git a/libs/expo/shared/ui-components/src/lib/BottomSheet/BottomSheetModalControlled.tsx b/libs/expo/shared/ui-components/src/lib/BottomSheet/BottomSheetModalControlled.tsx index 610dbb99e4..b832fca307 100644 --- a/libs/expo/shared/ui-components/src/lib/BottomSheet/BottomSheetModalControlled.tsx +++ b/libs/expo/shared/ui-components/src/lib/BottomSheet/BottomSheetModalControlled.tsx @@ -39,6 +39,11 @@ export function BottomSheetModalControlled(props: TProps) { const closeSheetRef = useRef<(() => void) | null>(null); const closingFromStateRef = useRef(false); const isOpenRef = useRef(isOpen); + const didQueueCloseRef = useRef(false); + + // Id of the sheet this component currently considers "open". Lets each + // per-sheet onClose verify it belongs to the active sheet. + const activeSheetIdRef = useRef(null); // Mutable ref container to stabilize sheet inputs by render + lifecycle callbacks const stableInputsRef = useRef({ @@ -59,26 +64,44 @@ export function BottomSheetModalControlled(props: TProps) { if (!isOpen) { if (closeSheetRef.current) { closingFromStateRef.current = true; + // Dying renders of this sheet must not re-queue a close. + didQueueCloseRef.current = true; closeSheetRef.current(); closeSheetRef.current = null; } + // No active sheet while closed, so a reopen during the dismiss + // animation starts a fresh presentation. + activeSheetIdRef.current = null; + return; } - if (closeSheetRef.current) { + // Only one active sheet per open-cycle. + if (activeSheetIdRef.current) { return; } + closingFromStateRef.current = false; + didQueueCloseRef.current = false; + showBottomSheet({ - render: ({ closeSheet }) => { - closeSheetRef.current = closeSheet; + render: ({ closeSheet, id }) => { + if (isOpenRef.current) { + // Normal open render — claim this sheet. + activeSheetIdRef.current = id; + closeSheetRef.current = closeSheet; + return stableInputsRef.current.children; + } - // The sheet can mount after `isOpen` has already flipped back to - // false (e.g. a selection closed the picker while the sheet was still - // presenting). Dismiss it right away so it never lingers open. - if (!isOpenRef.current) { + // Component is closed but this sheet mounted: either a mount-race + // (presented right as isOpen flipped false) or a dying sheet. Only + // the race queues a close, once. + if (!didQueueCloseRef.current) { + didQueueCloseRef.current = true; closingFromStateRef.current = true; + activeSheetIdRef.current = id; + closeSheetRef.current = closeSheet; queueMicrotask(() => closeSheetRef.current?.()); } @@ -86,8 +109,17 @@ export function BottomSheetModalControlled(props: TProps) { }, options: { ...(stableInputsRef.current.options ?? {}), - onClose: () => { + onClose: (closingId: string) => { + // A dismissal from a sheet that is no longer the active one (e.g. a + // superseded sheet whose dismissal finished after a new one opened) + // must not touch the shared refs or notify the parent. + if (closingId !== activeSheetIdRef.current) { + return; + } + + activeSheetIdRef.current = null; closeSheetRef.current = null; + didQueueCloseRef.current = false; // only notify parent if sheet initiated the close if (!closingFromStateRef.current) { diff --git a/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBackdrop.spec.tsx b/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBackdrop.spec.tsx new file mode 100644 index 0000000000..c650cb6e5e --- /dev/null +++ b/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBackdrop.spec.tsx @@ -0,0 +1,83 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BottomSheetBackdrop } from './BottomSheetBackdrop'; + +/** + * BottomSheetBackdrop + * + * Documents the dismissal contract: + * - a tap is forwarded to onRequestClose (the app's reliable dismiss path) + * - Gorhom's own close is NOT used (pressBehavior must not be 'close'), + * otherwise the provider's forceClose and Gorhom's close() would double-dismiss + * - a numeric pressBehavior keeps the tap gesture attached while doing nothing + */ + +const mocks = vi.hoisted(() => ({ + onGorhomProps: vi.fn(), +})); + +vi.mock('@gorhom/bottom-sheet', () => { + const { Pressable } = require('react-native'); + + return { + BottomSheetBackdrop: (props: { + onPress?: () => void; + pressBehavior?: unknown; + }) => { + mocks.onGorhomProps(props); + return ( + + ); + }, + }; +}); + +describe('BottomSheetBackdrop', () => { + // Gorhom passes animatedIndex/animatedPosition into backdrop components; the + // component only forwards them, so a plain object suffices here. + const fakeSharedValue = { value: 0 } as never; + + beforeEach(() => { + mocks.onGorhomProps.mockClear(); + }); + + it('forwards taps to onRequestClose as the single dismissal path', () => { + const onRequestClose = vi.fn(); + const { getByTestId } = render( + , + ); + + const gorhomProps = mocks.onGorhomProps.mock.calls[0][0]; + + // Gorhom must not close the sheet itself — the provider force-closes via + // onRequestClose. Keeping 'close' here would double-dismiss. + expect(gorhomProps.pressBehavior).not.toBe('close'); + + // A numeric pressBehavior keeps the tap gesture (onPress fires) but is a + // no-op snap, so dismissal happens exactly once, through onRequestClose. + expect(gorhomProps.pressBehavior).toBe(0); + expect(gorhomProps.onPress).toBe(onRequestClose); + + fireEvent.press(getByTestId('gorhom-backdrop')); + expect(onRequestClose).toHaveBeenCalledTimes(1); + }); + + it('renders nothing when disableBackdrop is set', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId('gorhom-backdrop')).toBeNull(); + }); +}); diff --git a/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBackdrop.tsx b/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBackdrop.tsx index 48d211d891..f2e3097c0c 100644 --- a/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBackdrop.tsx +++ b/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBackdrop.tsx @@ -41,6 +41,12 @@ type BottomSheetBackdropWrapperProps = BottomSheetBackdropProps & { * If provided, it fully replaces the default implementation. */ component?: ComponentType; + + /** + * App-level dismiss request (dismissSheetById → modal dismiss/forceClose). + * A tap routes through here as the single dismissal path. + */ + onRequestClose?: () => void; }; export function BottomSheetBackdrop( @@ -50,6 +56,7 @@ export function BottomSheetBackdrop( disableBackdrop, opacity = 0.5, component: CustomComponent, + onRequestClose, ...rest } = props; @@ -67,7 +74,13 @@ export function BottomSheetBackdrop( appearsOnIndex={0} disappearsOnIndex={-1} opacity={opacity} - pressBehavior="close" + // Keep the tap gesture attached (onPress fires only when pressBehavior + // is not 'none'), but make Gorhom's own action a no-op: sheets here open + // at index 0, so snapping to 0 does nothing. Dismissal happens through + // onRequestClose — NOT Gorhom's internal close() — to avoid a double + // dismissal when the provider force-closes. + pressBehavior={0} + onPress={onRequestClose} /> ); } diff --git a/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBase.tsx b/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBase.tsx index 198e3ae1e3..791422558e 100644 --- a/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBase.tsx +++ b/libs/expo/shared/ui-components/src/lib/BottomSheet/core/BottomSheetBase.tsx @@ -112,7 +112,11 @@ const BottomSheetBase = forwardRef( if (!disableBackdrop) { backdropComponent = (backdropProps) => ( - + ); } diff --git a/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/BottomSheetModalProvider.spec.tsx b/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/BottomSheetModalProvider.spec.tsx new file mode 100644 index 0000000000..b47595d709 --- /dev/null +++ b/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/BottomSheetModalProvider.spec.tsx @@ -0,0 +1,235 @@ +import { act, render } from '@testing-library/react-native'; +import { useEffect } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ShowBottomSheetParams } from '../../types'; +import { BottomSheetModalProvider } from './BottomSheetModalProvider'; +import { useBottomSheet } from './useBottomSheet'; + +/** + * BottomSheetModalProvider + * + * Documents the provider's lifecycle responsibilities: + * - a sheet is PRESENTED exactly once per id, even when the provider + * re-renders and React re-attaches refs (no re-present storms) + * - onRequestClose / closeSheet dismiss the sheet imperatively + * - stackBehavior 'replace' dismisses the previous sheet + * - when a sheet fully dismisses (onDismiss), options.onClose(id) fires once + * and the sheet is removed from the stack + * + * Gorhom's own modal + BottomSheetBase are mocked so the test controls the + * imperative instance (present/dismiss) and the onDismiss signal. + */ + +type MockInstance = { + present: ReturnType; + dismiss: ReturnType; +}; + +type MountedBase = { + inst: MockInstance; + onRequestClose?: () => void; + onDismiss?: () => void; +}; + +const state = vi.hoisted(() => ({ + mountedBases: [] as MountedBase[], + makeInstance: (): MockInstance => ({ present: vi.fn(), dismiss: vi.fn() }), +})); + +vi.mock('@gorhom/bottom-sheet', () => { + const GbsProvider = ({ children }: { children?: unknown }) => + children ?? null; + + return { + BottomSheetModal: class BottomSheetModal {}, + BottomSheetModalProvider: GbsProvider, + useBottomSheetModalInternal: () => ({ + containerLayoutState: { value: { height: 800, offset: {} } }, + }), + }; +}); + +vi.mock('../../core/BottomSheetBase', () => { + const React = require('react'); + + // A class component so React attaches the provider's ref to an instance + // that exposes present()/dismiss() — no forwardRef/hooks needed. + class MockBottomSheetBase extends React.Component { + present!: MockInstance['present']; + dismiss!: MockInstance['dismiss']; + onRequestClose!: () => void; + onDismiss!: () => void; + entry?: MountedBase; + + constructor(props: Record) { + super(props); + const inst = state.makeInstance(); + this.present = inst.present; + this.dismiss = inst.dismiss; + this.onRequestClose = props.onRequestClose as () => void; + this.onDismiss = props.onDismiss as () => void; + } + + componentDidMount() { + this.entry = { + inst: { present: this.present, dismiss: this.dismiss }, + onRequestClose: this.onRequestClose, + onDismiss: this.onDismiss, + }; + state.mountedBases.push(this.entry); + } + + componentWillUnmount() { + const index = state.mountedBases.indexOf(this.entry as MountedBase); + if (index !== -1) { + state.mountedBases.splice(index, 1); + } + } + + render() { + return null; + } + } + + return { BottomSheetBase: MockBottomSheetBase }; +}); + +function Harness({ + onReady, +}: { + onReady: (show: (params: ShowBottomSheetParams) => void) => void; +}) { + const { showBottomSheet } = useBottomSheet(); + + useEffect(() => { + onReady(showBottomSheet); + }, [showBottomSheet, onReady]); + + return null; +} + +describe('BottomSheetModalProvider', () => { + beforeEach(() => { + state.mountedBases.length = 0; + }); + + function renderProvider() { + const ref: { show?: (params: ShowBottomSheetParams) => void } = {}; + + render( + + (ref.show = fn)} /> + , + ); + + if (!ref.show) { + throw new Error('Harness did not receive showBottomSheet'); + } + + return { show: ref.show }; + } + + function showSheet( + show: (params: ShowBottomSheetParams) => void, + options?: ShowBottomSheetParams['options'], + ) { + act(() => { + show({ render: () => null, options }); + }); + } + + it('presents each sheet exactly once, even across provider re-renders', () => { + const { show } = renderProvider(); + + showSheet(show, { stackBehavior: 'replace' }); + expect(state.mountedBases).toHaveLength(1); + const first = state.mountedBases[0].inst; + expect(first.present).toHaveBeenCalledTimes(1); + + // Adding a second sheet re-renders the provider, which re-attaches the + // ref callbacks for ALL mounted sheets. present() must NOT re-fire. + showSheet(show, { stackBehavior: 'push' }); + expect(state.mountedBases).toHaveLength(2); + expect(first.present).toHaveBeenCalledTimes(1); + expect(state.mountedBases[1].inst.present).toHaveBeenCalledTimes(1); + }); + + it("'replace' dismisses the previous sheet before mounting the new one", () => { + const { show } = renderProvider(); + + showSheet(show, { stackBehavior: 'replace' }); + const first = state.mountedBases[0]; + expect(first.inst.dismiss).not.toHaveBeenCalled(); + + showSheet(show, { stackBehavior: 'replace' }); + + expect(first.inst.dismiss).toHaveBeenCalledTimes(1); + expect(state.mountedBases).toHaveLength(1); + }); + + it('onRequestClose notifies onClose at request time and dismisses (backdrop / header X)', () => { + const { show } = renderProvider(); + const onClose = vi.fn(); + + showSheet(show, { onClose }); + const base = state.mountedBases[0]; + + act(() => { + base.onRequestClose?.(); + }); + + // Request-time notification lets a controlled sheet close its state + // immediately (like a Cancel button) instead of waiting for onDismiss. + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose.mock.calls[0][0]).toMatch(/^sheet-/); + expect(base.inst.dismiss).toHaveBeenCalledTimes(1); + + // When Gorhom later reports the dismissal finishing, onClose is NOT fired + // again (once-per-sheet guard) and the sheet is removed. + act(() => { + base.onDismiss?.(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(state.mountedBases).toHaveLength(0); + }); + + it('closeSheet (render API) dismisses the sheet', () => { + const { show } = renderProvider(); + + let closeSheet: (() => void) | undefined; + act(() => { + show({ + render: ({ closeSheet: cs }) => { + closeSheet = cs; + return null; + }, + }); + }); + + const base = state.mountedBases[0]; + expect(base.inst.dismiss).not.toHaveBeenCalled(); + + act(() => { + closeSheet?.(); + }); + + expect(base.inst.dismiss).toHaveBeenCalledTimes(1); + }); + + it('fires options.onClose(id) once on dismissal and removes the sheet', () => { + const { show } = renderProvider(); + const onClose = vi.fn(); + + showSheet(show, { onClose }); + expect(state.mountedBases).toHaveLength(1); + + act(() => { + state.mountedBases[0].onDismiss?.(); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose.mock.calls[0][0]).toMatch(/^sheet-/); + // Sheet fully dismissed → removed from the rendered stack. + expect(state.mountedBases).toHaveLength(0); + }); +}); diff --git a/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/BottomSheetModalProvider.tsx b/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/BottomSheetModalProvider.tsx index bf2ac7b6c6..adb643ca44 100644 --- a/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/BottomSheetModalProvider.tsx +++ b/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/BottomSheetModalProvider.tsx @@ -121,7 +121,7 @@ export function BottomSheetModalProvider(props: BottomSheetProviderProps) { ); const [closingSheetIds, setClosingSheetIds] = useState>( - new Set(), + () => new Set(), ); /** @@ -130,6 +130,13 @@ export function BottomSheetModalProvider(props: BottomSheetProviderProps) { */ const [sheets, setSheets] = useState([]); + const presentedIdsRef = useRef>(new Set()); + /** + * Sheets whose options.onClose has already fired — either at dismissal + * request time (backdrop tap / header X) or at dismissal end (gorhom- + * initiated, e.g. pan-down). Guarantees onClose fires at most once. + */ + const closeNotifiedIdsRef = useRef>(new Set()); /** * Map of sheet id → gorhom instance. * Used for imperative dismissal. @@ -147,11 +154,38 @@ export function BottomSheetModalProvider(props: BottomSheetProviderProps) { return; } - setClosingSheetIds((prev) => new Set(prev).add(id)); + setClosingSheetIds((prev) => { + if (prev.has(id)) { + return prev; + } + + return new Set(prev).add(id); + }); instance.dismiss(); }, []); + /** + * Invoke a sheet's options.onClose exactly once per sheet. + * + * Called at dismissal REQUEST time for user-initiated closes (backdrop tap / + * header X) so a controlled sheet flips `isOpen` closed immediately — the + * same behavior a Cancel button gets — and can be reopened during the dismiss + * animation. Falls back to dismissal END (onDismiss) for gorhom-initiated + * closes (e.g. pan-down). The id-guarded wrapper in BottomSheetModalControlled + * makes a late second call a safe no-op. + */ + const notifyClose = useCallback( + (sheetId: string, sheetOptions: BottomSheetOptions) => { + if (closeNotifiedIdsRef.current.has(sheetId)) { + return; + } + closeNotifiedIdsRef.current.add(sheetId); + sheetOptions.onClose?.(sheetId); + }, + [], + ); + const { addSheet } = useBottomSheetStack({ sheetRefs, setSheets, @@ -245,6 +279,7 @@ export function BottomSheetModalProvider(props: BottomSheetProviderProps) { {sharedBackdrop.render()} + {/* eslint-disable react-hooks/refs */} {sheets.map(({ id, render, options }) => ( { + notifyClose(id, options); dismissSheetById(id); }} onDismiss={() => { - options.onClose?.(); + notifyClose(id, options); sheetRefs.current.delete(id); + presentedIdsRef.current.delete(id); + closeNotifiedIdsRef.current.delete(id); setClosingSheetIds((prev) => { + // already closing → no new Set → no re-render + if (!prev.has(id)) { + return prev; + } + const next = new Set(prev); + next.delete(id); return next; @@ -277,7 +326,7 @@ export function BottomSheetModalProvider(props: BottomSheetProviderProps) { setSheets((prev) => prev.filter((s) => s.id !== id)); }} > - {render({ closeSheet: () => dismissSheetById(id) })} + {render({ id, closeSheet: () => dismissSheetById(id) })} ))} diff --git a/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/useBottomSheetStack.spec.ts b/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/useBottomSheetStack.spec.ts new file mode 100644 index 0000000000..cc36cf8e76 --- /dev/null +++ b/libs/expo/shared/ui-components/src/lib/BottomSheet/providers/BottomSheetModal/useBottomSheetStack.spec.ts @@ -0,0 +1,151 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { RefObject } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { TBottomSheetInstance } from './types.internal'; +import { useBottomSheetStack } from './useBottomSheetStack'; + +/** + * useBottomSheetStack + * + * Documents the three stack behaviors: + * - 'push' → append on top, dismiss nothing + * - 'switch' → dismiss the top sheet, replace it in place + * - 'replace' → dismiss every existing sheet, keep only the new one + * + * Dismissals are driven imperatively through the shared sheetRefs map. + */ + +type FakeSheetInstance = { dismiss: ReturnType }; + +function makeSheet(id: string): TBottomSheetInstance { + return { + id, + render: () => null, + options: {}, + } as unknown as TBottomSheetInstance; +} + +function applyLastUpdater( + setSheets: ReturnType, + previous: TBottomSheetInstance[], +): TBottomSheetInstance[] { + const calls = setSheets.mock.calls as Array< + [(prev: TBottomSheetInstance[]) => TBottomSheetInstance[]] + >; + const updater = calls[calls.length - 1]?.[0]; + return updater ? updater(previous) : previous; +} + +describe('useBottomSheetStack', () => { + function setup() { + const sheetRefs = { + current: new Map(), + } as unknown as RefObject>; + + const setSheets = vi.fn(); + + const { result } = renderHook(() => + useBottomSheetStack({ + sheetRefs, + setSheets: setSheets as never, + }), + ); + + const register = (...ids: string[]): FakeSheetInstance[] => { + const instances = ids.map(() => ({ dismiss: vi.fn() })); + ids.forEach((id, index) => + sheetRefs.current.set(id, instances[index] as never), + ); + return instances; + }; + + return { + addSheet: result.current.addSheet, + setSheets, + register, + applyLast: (previous: TBottomSheetInstance[]) => + applyLastUpdater(setSheets, previous), + }; + } + + it("'push' appends the new sheet on top and dismisses nothing", () => { + const { addSheet, register, applyLast } = setup(); + const a = makeSheet('a'); + register('a'); + const b = makeSheet('b'); + + act(() => { + addSheet(b, 'push'); + }); + + expect(applyLast([a]).map((s) => s.id)).toEqual(['a', 'b']); + }); + + it("'push' onto an empty stack keeps only the new sheet", () => { + const { addSheet, applyLast } = setup(); + const b = makeSheet('b'); + + act(() => { + addSheet(b, 'push'); + }); + + expect(applyLast([]).map((s) => s.id)).toEqual(['b']); + }); + + it("'switch' dismisses only the top sheet and replaces it in place", () => { + const { addSheet, register, applyLast } = setup(); + const a = makeSheet('a'); + const b = makeSheet('b'); + const instances = register('a', 'b'); + const c = makeSheet('c'); + + act(() => { + addSheet(c, 'switch'); + }); + + const next = applyLast([a, b]); + expect(next.map((s) => s.id)).toEqual(['a', 'c']); + expect(instances[0].dismiss).not.toHaveBeenCalled(); + expect(instances[1].dismiss).toHaveBeenCalledTimes(1); + }); + + it("'replace' dismisses all existing sheets and keeps only the new one", () => { + const { addSheet, register, applyLast } = setup(); + const a = makeSheet('a'); + const b = makeSheet('b'); + const instances = register('a', 'b'); + const c = makeSheet('c'); + + act(() => { + addSheet(c, 'replace'); + }); + + const next = applyLast([a, b]); + expect(next.map((s) => s.id)).toEqual(['c']); + expect(instances[0].dismiss).toHaveBeenCalledTimes(1); + expect(instances[1].dismiss).toHaveBeenCalledTimes(1); + }); + + it("'replace' with no existing sheets keeps only the new sheet", () => { + const { addSheet, applyLast } = setup(); + const c = makeSheet('c'); + + act(() => { + addSheet(c, 'replace'); + }); + + expect(applyLast([]).map((s) => s.id)).toEqual(['c']); + }); + + it('tolerates a missing instance when dismissing an existing sheet', () => { + const { addSheet, applyLast } = setup(); + const a = makeSheet('a'); // intentionally NOT registered in sheetRefs + const b = makeSheet('b'); + + act(() => { + addSheet(b, 'replace'); + }); + + expect(applyLast([a]).map((s) => s.id)).toEqual(['b']); + }); +}); diff --git a/libs/expo/shared/ui-components/src/lib/BottomSheet/types.ts b/libs/expo/shared/ui-components/src/lib/BottomSheet/types.ts index e979bc18d3..cc2afa2fb0 100644 --- a/libs/expo/shared/ui-components/src/lib/BottomSheet/types.ts +++ b/libs/expo/shared/ui-components/src/lib/BottomSheet/types.ts @@ -100,9 +100,10 @@ export type BottomSheetProviderOptions = { stackBehavior?: StackBehavior; /** - * Optional callback invoked after the sheet is fully dismissed. + * Invoked when the sheet is dismissed. Receives the dismissed sheet's id so + * callers can distinguish a current dismissal from a superseded one. */ - onClose?: () => void; + onClose?: (id: string) => void; }; /** @@ -235,6 +236,11 @@ export type BottomSheetRenderApi = { * Imperatively closes the current sheet. */ closeSheet: () => void; + + /** + * The provider-assigned id of this sheet. + */ + id: string; }; /**