From 63f173cbf3c327386a56c9132865d395fbe5786b Mon Sep 17 00:00:00 2001 From: VadymBezpalko Date: Tue, 8 Sep 2026 14:49:02 +0200 Subject: [PATCH 01/13] feat(host): wrap each inline sheet's content with an optional SheetWrapper A throw in an inline sheet's body could only be caught above the host, which unmounts every sheet in the group. The wrapper renders inside each sheet's context with the ref the coordinator drives, so an app can put a boundary around one sheet and render a fallback adapter in its place. Portal and persistent sheets render at their declaration site and are not wrapped. --- src/BottomSheetHost.tsx | 23 +++++++- src/QueueItem.tsx | 28 ++++++++-- src/__tests__/host.test.tsx | 102 ++++++++++++++++++++++++++++++++++++ src/index.tsx | 1 + 4 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/host.test.tsx diff --git a/src/BottomSheetHost.tsx b/src/BottomSheetHost.tsx index 2fcdd53..395a66a 100644 --- a/src/BottomSheetHost.tsx +++ b/src/BottomSheetHost.tsx @@ -1,5 +1,6 @@ -import { useEffect } from 'react'; +import { useEffect, type ComponentType, type ReactNode } from 'react'; +import type { SheetRef } from './adapter.types'; import { useBottomSheetStore, useClearGroup } from './store'; import { initBottomSheetCoordinator } from './bottomSheetCoordinator'; import { useBottomSheetManagerContext } from './BottomSheetManager.context'; @@ -77,7 +78,24 @@ function reconcilePendingTransitions(groupId: string): () => void { }; } -export function BottomSheetHost() { +export interface SheetWrapperProps { + id: string; + /** The ref the coordinator drives; a fallback adapter binds to it so expand/close keep reaching the sheet. */ + sheetRef: SheetRef; + children: ReactNode; +} + +interface BottomSheetHostProps { + /** + * Wraps each inline sheet's content inside its context — the place for a + * per-sheet error boundary whose fallback is an adapter bound to `sheetRef`. + * Portal and persistent sheets render where they are declared and are not + * wrapped. Must be a module-scope component: `QueueItem` is memoized. + */ + SheetWrapper?: ComponentType; +} + +export function BottomSheetHost({ SheetWrapper }: BottomSheetHostProps) { const sheetRenderData = useSheetRenderData(); const clearGroup = useClearGroup(); const { groupId } = useBottomSheetManagerContext(); @@ -105,6 +123,7 @@ export function BottomSheetHost() { id={id} stackIndex={stackIndex} isActive={isActive} + SheetWrapper={SheetWrapper} /> ))} diff --git a/src/QueueItem.tsx b/src/QueueItem.tsx index 915c418..1872682 100644 --- a/src/QueueItem.tsx +++ b/src/QueueItem.tsx @@ -1,4 +1,9 @@ -import { memo, useEffect, type PropsWithChildren } from 'react'; +import { + memo, + useEffect, + type ComponentType, + type PropsWithChildren, +} from 'react'; import { StyleSheet, View } from 'react-native'; import Animated from 'react-native-reanimated'; import { useSafeAreaFrame } from 'react-native-safe-area-context'; @@ -15,20 +20,23 @@ import { useSheetUsePortal, } from './store'; import { BottomSheetBackdrop } from './BottomSheetBackdrop'; +import type { SheetWrapperProps } from './BottomSheetHost'; import { removeOnBeforeClose } from './onBeforeCloseRegistry'; -import { cleanupSheetRef } from './refsMap'; +import { cleanupSheetRef, getSheetRef } from './refsMap'; import { useSheetScaleAnimatedStyle } from './useScaleAnimation'; interface QueueItemProps { id: string; stackIndex: number; isActive: boolean; + SheetWrapper?: ComponentType; } export const QueueItem = memo(function QueueItem({ id, stackIndex, isActive, + SheetWrapper, }: QueueItemProps) { const content = useSheetContent(id); const usePortal = useSheetUsePortal(id); @@ -43,6 +51,20 @@ export const QueueItem = memo(function QueueItem({ const animatedIndex = getAnimatedIndex(id); + // Safe to read during render: `open()` registers the ref before the store + // write that schedules this render, and the entry outlives the item. A + // persistent id can be re-registered under a mounted item, hence the guard. + const sheetRef = usePortal ? undefined : getSheetRef(id); + + const inlineContent = + SheetWrapper && sheetRef ? ( + + {content} + + ) : ( + content + ); + useEffect(() => { return () => { cleanupSheetRef(id); @@ -82,7 +104,7 @@ export const QueueItem = memo(function QueueItem({ /> ) : ( - {content} + {inlineContent} )} diff --git a/src/__tests__/host.test.tsx b/src/__tests__/host.test.tsx new file mode 100644 index 0000000..a4862bc --- /dev/null +++ b/src/__tests__/host.test.tsx @@ -0,0 +1,102 @@ +import { Component, type ReactElement } from 'react'; +import { Text } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { act, render } from '@testing-library/react-native'; + +import { BottomSheetHost, type SheetWrapperProps } from '../BottomSheetHost'; +import { BottomSheetManagerProvider } from '../BottomSheetManager.provider'; +import { getSheetRef, setSheetRef } from '../refsMap'; +import { useBottomSheetContext } from '../useBottomSheetContext'; +import { makeRef, portal, setupSheetTest, store } from './testUtils'; + +// QueueItem measures the frame, which the real provider reads from native. +const initialMetrics = { + frame: { x: 0, y: 0, width: 390, height: 844 }, + insets: { top: 0, left: 0, right: 0, bottom: 0 }, +}; + +setupSheetTest(); + +/** What `useBottomSheetManager().open()` does: store first, ref registered after. */ +const openInline = (id: string, content: ReactElement, groupId = 'g1') => { + store().open({ kind: 'inline', id, groupId, content }); + setSheetRef(id, makeRef()); +}; + +const renderHost = (SheetWrapper?: React.ComponentType) => + render( + + + + + + ); + +describe('BottomSheetHost SheetWrapper', () => { + it('renders inline content unchanged when no wrapper is given', () => { + const screen = renderHost(); + act(() => openInline('a', body)); + expect(screen.getByText('body')).toBeTruthy(); + }); + + it('wraps each inline sheet with the wrapper, inside the sheet context', () => { + const seen: Array<{ id: string; sheetRef: unknown; contextId: string }> = + []; + const Wrapper = ({ id, sheetRef, children }: SheetWrapperProps) => { + const context = useBottomSheetContext(); + seen.push({ id, sheetRef, contextId: context.id }); + return <>{children}; + }; + const screen = renderHost(Wrapper); + + act(() => openInline('a', body)); + + expect(screen.getByText('body')).toBeTruthy(); + expect(seen).toHaveLength(1); + expect(seen[0]).toEqual({ + id: 'a', + sheetRef: getSheetRef('a'), + contextId: 'a', + }); + }); + + // Portal content renders at its declaration site; the host only mounts a + // PortalHost, so there is nothing for a wrapper to catch there. + it('does not wrap portal sheets', () => { + const Wrapper = jest.fn(({ children }: SheetWrapperProps) => ( + <>{children} + )); + renderHost(Wrapper); + + act(() => store().open(portal('p'))); + + expect(Wrapper).not.toHaveBeenCalled(); + }); + + it('lets a boundary replace one sheet while the others stay mounted', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + class Boundary extends Component { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + render() { + return this.state.failed ? fallback : this.props.children; + } + } + const Throws = () => { + throw new Error('boom'); + }; + const screen = renderHost(Boundary); + + act(() => { + openInline('a', first); + store().markOpen('a'); + openInline('b', ); + }); + + expect(screen.getByText('first')).toBeTruthy(); + expect(screen.getByText('fallback')).toBeTruthy(); + expect(store().stackOrderByGroup.g1).toEqual(['a', 'b']); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index fc92ead..5746b84 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -67,6 +67,7 @@ export { export { useOnBeforeClose } from './useOnBeforeClose'; // Types +export type { SheetWrapperProps } from './BottomSheetHost'; export type { BackdropConfig, BackdropComponentProps } from './backdrop.types'; export type { ScaleConfig, ScaleAnimationConfig } from './useScaleAnimation'; export type { From ed1740468e96dc4d3567f7b921ce8293f0c3a50f Mon Sep 17 00:00:00 2001 From: VadymBezpalko Date: Tue, 8 Sep 2026 14:49:52 +0200 Subject: [PATCH 02/13] fix(adapter): expand an adapter that mounts into an already-open sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coordinator reacts to status changes, so an adapter mounted after the sheet reached 'open' — a fallback rendered by a host SheetWrapper — was never driven and sat closed under an open status. useAdapterRef now catches up once on mount. --- src/__tests__/adapterRef.test.tsx | 52 +++++++++++++++++++++++++++++++ src/useAdapterRef.ts | 22 +++++++++++-- 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/adapterRef.test.tsx diff --git a/src/__tests__/adapterRef.test.tsx b/src/__tests__/adapterRef.test.tsx new file mode 100644 index 0000000..fb1973e --- /dev/null +++ b/src/__tests__/adapterRef.test.tsx @@ -0,0 +1,52 @@ +import { renderHook } from '@testing-library/react-native'; + +import { BottomSheetRefContext } from '../BottomSheetRef.context'; +import { useAdapterRef } from '../useAdapterRef'; +import { inSheet, makeRef, portal, setupSheetTest, store } from './testUtils'; + +setupSheetTest(); + +describe('useAdapterRef', () => { + it('prefers the ref from context over the forwarded one', () => { + const contextRef = makeRef(); + const forwarded = makeRef(); + const { result } = renderHook(() => useAdapterRef(forwarded), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + expect(result.current).toBe(contextRef); + }); + + it('expands on mount when the sheet is already open', () => { + store().open(portal('a')); + store().markOpen('a'); + const ref = makeRef(); + + renderHook(() => useAdapterRef(ref), { wrapper: inSheet('a') }); + + expect(ref.current.expand).toHaveBeenCalledTimes(1); + }); + + it('leaves an opening sheet to the coordinator', () => { + store().open(portal('a')); + const ref = makeRef(); + + renderHook(() => useAdapterRef(ref), { wrapper: inSheet('a') }); + + expect(ref.current.expand).not.toHaveBeenCalled(); + }); + + it('does nothing for a hidden persistent sheet or outside a sheet context', () => { + store().mount({ id: 'p', groupId: 'g1' }); + const hiddenRef = makeRef(); + renderHook(() => useAdapterRef(hiddenRef), { wrapper: inSheet('p') }); + expect(hiddenRef.current.expand).not.toHaveBeenCalled(); + + const bareRef = makeRef(); + renderHook(() => useAdapterRef(bareRef)); + expect(bareRef.current.expand).not.toHaveBeenCalled(); + }); +}); diff --git a/src/useAdapterRef.ts b/src/useAdapterRef.ts index eac2368..62dde2d 100644 --- a/src/useAdapterRef.ts +++ b/src/useAdapterRef.ts @@ -1,7 +1,9 @@ -import type { ForwardedRef } from 'react'; +import { useEffect, type ForwardedRef } from 'react'; import type { SheetAdapterRef, SheetRef } from './adapter.types'; +import { useMaybeBottomSheetContext } from './BottomSheet.context'; import { useMaybeBottomSheetRef } from './BottomSheetRef.context'; +import { useBottomSheetStore } from './store'; /** * Returns the correct ref for a custom adapter. @@ -24,5 +26,21 @@ export function useAdapterRef( forwardedRef: ForwardedRef ): SheetRef | ForwardedRef { const contextRef = useMaybeBottomSheetRef(); - return contextRef ?? forwardedRef; + const ref = contextRef ?? forwardedRef; + const id = useMaybeBottomSheetContext()?.id; + + // The coordinator drives status *changes* only, so an adapter mounted under + // an already-open sheet (a wrapper's fallback) has nobody else to open it. + // Passive effect: the imperative handle is attached by then. + useEffect(() => { + if (!id || typeof ref !== 'object') { + return; + } + if (useBottomSheetStore.getState().sheetsById[id]?.status !== 'open') { + return; + } + ref?.current?.expand(); + }, [id, ref]); + + return ref; } From 4a0ffee3862ae4e4a9d37c5b9c30a3d00cda2baa Mon Sep 17 00:00:00 2001 From: VadymBezpalko Date: Tue, 8 Sep 2026 14:52:22 +0200 Subject: [PATCH 03/13] test(host): a fallback adapter replaces a sheet that throws after opening --- src/__tests__/host.test.tsx | 80 ++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 5 deletions(-) diff --git a/src/__tests__/host.test.tsx b/src/__tests__/host.test.tsx index a4862bc..1c8744b 100644 --- a/src/__tests__/host.test.tsx +++ b/src/__tests__/host.test.tsx @@ -1,15 +1,22 @@ -import { Component, type ReactElement } from 'react'; +import { + Component, + createRef, + useImperativeHandle, + type ReactElement, +} from 'react'; import { Text } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { act, render } from '@testing-library/react-native'; import { BottomSheetHost, type SheetWrapperProps } from '../BottomSheetHost'; import { BottomSheetManagerProvider } from '../BottomSheetManager.provider'; +import type { SheetAdapterRef } from '../adapter.types'; import { getSheetRef, setSheetRef } from '../refsMap'; +import { useAdapterRef } from '../useAdapterRef'; import { useBottomSheetContext } from '../useBottomSheetContext'; import { makeRef, portal, setupSheetTest, store } from './testUtils'; -// QueueItem measures the frame, which the real provider reads from native. +// QueueItem reads the frame, which the real provider gets from native. const initialMetrics = { frame: { x: 0, y: 0, width: 390, height: 844 }, insets: { top: 0, left: 0, right: 0, bottom: 0 }, @@ -17,7 +24,6 @@ const initialMetrics = { setupSheetTest(); -/** What `useBottomSheetManager().open()` does: store first, ref registered after. */ const openInline = (id: string, content: ReactElement, groupId = 'g1') => { store().open({ kind: 'inline', id, groupId, content }); setSheetRef(id, makeRef()); @@ -32,6 +38,40 @@ const renderHost = (SheetWrapper?: React.ComponentType) => ); +const fallbackExpand = jest.fn(); + +// Module scope: the compiler outlines the `useImperativeHandle` factory there, +// so a spy declared inside a test would be out of its reach. +const FallbackAdapter = ({ + sheetRef, +}: { + sheetRef: SheetWrapperProps['sheetRef']; +}) => { + const ref = useAdapterRef(sheetRef); + useImperativeHandle(ref, () => ({ + expand: fallbackExpand, + close: jest.fn(), + })); + return fallback; +}; + +class FallbackBoundary extends Component< + SheetWrapperProps, + { failed: boolean } +> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + render() { + return this.state.failed ? ( + + ) : ( + this.props.children + ); + } +} + describe('BottomSheetHost SheetWrapper', () => { it('renders inline content unchanged when no wrapper is given', () => { const screen = renderHost(); @@ -60,8 +100,6 @@ describe('BottomSheetHost SheetWrapper', () => { }); }); - // Portal content renders at its declaration site; the host only mounts a - // PortalHost, so there is nothing for a wrapper to catch there. it('does not wrap portal sheets', () => { const Wrapper = jest.fn(({ children }: SheetWrapperProps) => ( <>{children} @@ -99,4 +137,36 @@ describe('BottomSheetHost SheetWrapper', () => { expect(screen.getByText('fallback')).toBeTruthy(); expect(store().stackOrderByGroup.g1).toEqual(['a', 'b']); }); + + it('drives a fallback adapter that replaces a sheet which throws after opening', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + fallbackExpand.mockClear(); + + const Crashable = () => { + const { params } = useBottomSheetContext(); + if ((params as { crash?: boolean } | undefined)?.crash) { + throw new Error('boom'); + } + return alive; + }; + + const screen = renderHost(FallbackBoundary); + act(() => { + store().open({ + kind: 'inline', + id: 'a', + groupId: 'g1', + content: , + }); + setSheetRef('a', createRef()); + store().markOpen('a'); + }); + expect(screen.getByText('alive')).toBeTruthy(); + + act(() => store().updateParams('a', { crash: true })); + + expect(screen.getByText('fallback')).toBeTruthy(); + expect(getSheetRef('a')?.current?.expand).toBe(fallbackExpand); + expect(fallbackExpand).toHaveBeenCalledTimes(1); + }); }); From d82993b90a8419563002cf6f78fcfefa227cd194 Mon Sep 17 00:00:00 2001 From: VadymBezpalko Date: Tue, 8 Sep 2026 14:53:53 +0200 Subject: [PATCH 04/13] docs: document SheetWrapper and the adapter catch-up on mount --- CLAUDE.md | 23 +++++++++++++++++++++++ docs/docs/api/components.md | 35 +++++++++++++++++++++++++++++++++++ docs/docs/api/types.md | 14 ++++++++++++++ docs/docs/custom-adapters.md | 2 ++ 4 files changed, 74 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index cd7c4ad..6422fba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,14 @@ adapter mounts (a portal sheet must teleport first); a single attempt silently no-ops and wedges the sheet — and for `'closing'`, wedges every later open in the group on the `group-busy` guard. +`useAdapterRef` catches up once on mount when the sheet is already `open` +(`ref.current.expand()`): the coordinator drives status *changes*, so an adapter +that replaces another under an open status has nobody else to open it. +`opening` is left to `driveSheetRef`; `closing`/`hidden` are not caught up — an +adapter that never opened cannot animate a close; the honest action would be +`finishClosing(id)` as in `driveSheetRef`'s give-up path, deliberately not done +yet. + Also exported publicly for adapter authors: `requestClose(id)` and `closeAllAnimated(groupId, opts)`. @@ -198,6 +206,21 @@ backdrop below its own sheet but above the one beneath. The offset lifts the whole stack above arbitrary app chrome — without it any host view with a modest `zIndex` paints over the sheets. +`BottomSheetHost` takes `SheetWrapper?: ComponentType`; +`QueueItem` renders it around an inline sheet's `content`, inside +`BottomSheetContext`, with `getSheetRef(id)` as `sheetRef`. The render-time +registry read is sound because `open()` writes the store and registers the ref +in the same synchronous call, and the store write only *schedules* the render +that mounts the item — the item's first render already sees the final entry, +which it must, because the compiler caches the read on `id` and never re-runs +it. An inline id is minted per open and the item's unmount cleanup removes it, +so the value cannot change while the item is mounted; the read is guarded on +`usePortal` because a persistent id *can* be re-registered under a +still-mounted item. A caller-supplied inline id re-opened in the same tick as +its removal hands the wrapper the previous ref; the coordinator reads the +registry fresh, so the sheet still closes via the give-up path. The prop must be +a module-scope component — `QueueItem` is `memo`. + `BottomSheetBackdrop` is mounted from the sheet's first frame and faded purely by `animatedIndex`. Do not add a timer or delay gate: deferring the mount drops the opening frames the adapter already drove, and the backdrop pops in mid-fade. diff --git a/docs/docs/api/components.md b/docs/docs/api/components.md index c98a8ae..eabe8af 100644 --- a/docs/docs/api/components.md +++ b/docs/docs/api/components.md @@ -45,6 +45,41 @@ Renders the bottom sheet stack. Must be placed inside `BottomSheetManagerProvide Place `BottomSheetHost` **outside** of `BottomSheetScaleView` to prevent sheets from scaling. ::: +### Props + +| Prop | Type | Description | +|------|------|-------------| +| `SheetWrapper` | `React.ComponentType` | Rendered around every **inline** sheet's content, inside that sheet's context. Receives `{ id, sheetRef, children }`. Portal and persistent sheets render where they are declared and are not wrapped. Define it at module scope. | + +Use it to put an error boundary around each sheet, so one sheet failing never takes the host down. The fallback renders an adapter bound to `sheetRef`, and the manager keeps driving the sheet — `useBottomSheetContext().close()` still closes it: + +```tsx +class SheetErrorBoundary extends React.Component< + SheetWrapperProps, + { failed: boolean } +> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + render() { + if (!this.state.failed) { + return this.props.children; + } + return ( + + This sheet could not be shown. + + + ); + } +} + +; +``` + +An adapter that mounts while its sheet is already `open` is expanded on mount, so the fallback appears in place without extra wiring. + --- ## BottomSheetScaleView diff --git a/docs/docs/api/types.md b/docs/docs/api/types.md index 8d35c8b..05e104c 100644 --- a/docs/docs/api/types.md +++ b/docs/docs/api/types.md @@ -195,6 +195,20 @@ type SheetRef = RefObject; --- +### SheetWrapperProps + +```ts +interface SheetWrapperProps { + id: string; // same as useBottomSheetContext().id + sheetRef: SheetRef; // the ref the coordinator drives; bind a fallback adapter to it + children: ReactNode; +} +``` + +Props of the component passed to `BottomSheetHost`'s `SheetWrapper`. + +--- + ## Configuration Types ### ScaleConfig diff --git a/docs/docs/custom-adapters.md b/docs/docs/custom-adapters.md index ada0809..963e0b7 100644 --- a/docs/docs/custom-adapters.md +++ b/docs/docs/custom-adapters.md @@ -292,6 +292,8 @@ const ref = useAdapterRef(forwardedRef); useImperativeHandle(ref, () => ({ expand: ..., close: ... })); ``` +An adapter that mounts while its sheet is already `open` is expanded once on mount. The coordinator only reacts to status changes, so this is what lets an adapter rendered *in place of* another — an error-boundary fallback under `BottomSheetHost`'s `SheetWrapper` — come up open. Your adapter needs nothing extra for it beyond `useAdapterRef` + `useImperativeHandle`. + ### Prop-Controlled vs Ref-Controlled Libraries **Ref-controlled** (e.g., TrueSheet with `present()`/`dismiss()`): From cb69e4ea422a6d0c184a98d8a9903684052c4361 Mon Sep 17 00:00:00 2001 From: VadymBezpalko Date: Tue, 8 Sep 2026 14:55:30 +0200 Subject: [PATCH 05/13] feat(example): error boundary around each inline sheet --- example/src/App.tsx | 8 +- example/src/components/SheetErrorBoundary.tsx | 84 +++++++++++++++++++ example/src/components/index.ts | 1 + example/src/screens/HomeScreen.tsx | 10 +++ example/src/sheets/ErrorBoundarySheets.tsx | 42 ++++++++++ example/src/sheets/index.ts | 1 + 6 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 example/src/components/SheetErrorBoundary.tsx create mode 100644 example/src/sheets/ErrorBoundarySheets.tsx diff --git a/example/src/App.tsx b/example/src/App.tsx index fd09c07..4b27ff6 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -9,6 +9,7 @@ import { KeyboardProvider } from 'react-native-keyboard-controller'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { BottomSheetDebugMonitor } from './components/BottomSheetDebugMonitor'; +import { SheetErrorBoundary } from './components/SheetErrorBoundary'; import { UserProvider } from './context/UserContext'; import { HomeScreen } from './screens'; import { @@ -37,7 +38,12 @@ export default function App() { - + {/* + Every inline sheet in this group renders inside + SheetErrorBoundary, so a sheet whose body throws is replaced by a + fallback sheet instead of taking the host down with it. + */} + {/* Persistent sheet - always mounted, opens instantly */} diff --git a/example/src/components/SheetErrorBoundary.tsx b/example/src/components/SheetErrorBoundary.tsx new file mode 100644 index 0000000..83016d8 --- /dev/null +++ b/example/src/components/SheetErrorBoundary.tsx @@ -0,0 +1,84 @@ +import { Component } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + CustomModalAdapter, + useBottomSheetContext, + type SheetWrapperProps, +} from 'react-native-bottom-sheet-stack'; + +import { colors, sharedStyles } from '../styles/theme'; +import { Badge } from './Badge'; +import { Button, SecondaryButton } from './Button'; + +/** Replaces the sheet that threw; bound to its ref, so the manager keeps driving it. */ +function FallbackSheet({ + sheetRef, + onRetry, +}: { + sheetRef: SheetWrapperProps['sheetRef']; + onRetry: () => void; +}) { + const { close } = useBottomSheetContext(); + + return ( + + + + This sheet could not be shown + + Its content threw while rendering. The host and every other sheet in + the stack are untouched — only this one was replaced. + + +