Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,23 @@ is what makes a user gesture honour the interceptor — do not simplify it back.
`driveSheetRef` retries a ref call across up to 10 `requestAnimationFrame`s,
re-checking status each time. The store can reach a terminal status before the
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.
no-ops and wedges the sheet. A sheet stuck at `'closing'` does **not** wedge
later opens in the group — `group-busy` is `'opening'`-only. It costs
`closeAllAnimated` skipping it, `requestClose` answering `not-closable`, no back
handler at all (`useIsTopmostAndOpen` needs `'open'` *and* top) and its own id
answering `already-active` for the rest of the session.

`useAdapterRef` catches up once on mount off the sheet's live status: `'open'` →
`ref.current.expand()`, `'closing'` → `finishClosing(id)`. The coordinator
drives status *changes*, so an adapter that replaces another under a live status
has nobody else to drive it. `'opening'` is left to `driveSheetRef`. A fresh
adapter cannot animate a close it never opened, so `'closing'` is ended in the
store as in `driveSheetRef`'s give-up path — a mid-close remount ends the sheet
without re-animating it out. `'hidden'` is deliberately excluded: a
`switch`-parked sheet sits there *on* the stack and is restored by
`detachFromGroup`, and `mount()` parks every persistent sheet there with its
adapter rendered, so `finishClosing` would delete a live sheet on every
persistent mount.

Also exported publicly for adapter authors: `requestClose(id)` and
`closeAllAnimated(groupId, opts)`.
Expand All @@ -137,7 +152,7 @@ Four maps that outlive React, which is why `resetBottomSheetRegistries()` exists

| Registry | Holds | Non-obvious part |
|---|---|---|
| `refsMap` | adapter refs | Refs are not serializable, so they cannot live in the store. Registered **only after** the store accepts the open, or a rejected open leaks an entry nothing can reclaim. Cleaned up by `QueueItem`'s unmount. |
| `refsMap` | adapter refs | Refs are not serializable, so they cannot live in the store. Registered **before** the store write, because that write is what schedules the `QueueItem` render which reads the map. A rejected open reclaims only the entry it created — an orphan is unreclaimable, and deleting a pre-existing one strips a live sheet. Cleaned up by `QueueItem`'s unmount. |
| `animatedRegistry` | `SharedValue<number>` per sheet | Created **eagerly** in `open()` / `mount()` so the backdrop always finds one. `resetAnimatedIndex` rewinds to `-1` on open, so a re-opened persistent sheet does not carry last cycle's value. `getAnimatedIndex` is a pure read and never creates. |
| `onBeforeCloseRegistry` | close interceptors | Found from outside React by `requestClose` / `handleDismiss`. Its presence also flips `preventDismiss` on the store record. |
| `portalSessionRegistry` | monotonic counter per id | Feeds the `Portal`/`PortalHost` name. **Persists across sheet deletion on purpose** — reusing a name after a replace hits a react-native-teleport connection bug. |
Expand Down Expand Up @@ -198,6 +213,25 @@ 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<SheetWrapperProps>`;
`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()` registers the ref and then writes the
store in one 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 keeps one ref object: `open()` reuses whatever is registered for the
id, so the wrapper and the coordinator hold the same ref. The prop *value*
must be stable for the life of the host: it is the element type, so a new value
remounts every inline sheet (replayed open animation, a stateful wrapper's state
gone). A derived value (`flag ? Wrapper : undefined`) breaks it even under the
compiler, which outlines a capture-free inline arrow — hence a dev warning on
change, not just a "module scope" line in the JSDoc.

`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.
Expand Down
39 changes: 39 additions & 0 deletions docs/docs/api/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,45 @@ 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<SheetWrapperProps>` | 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. The prop **value** must stay stable for the life of the host — see below. |

The wrapper is used as an element **type**, so a new prop value remounts every inline sheet: the adapter replays its open animation and a stateful wrapper loses its state — an error boundary forgets that it already failed. Pass a module-scope component; an inline arrow and a derived value (`flag ? SheetErrorBoundary : undefined`, or swapping one module-scope wrapper for another) both break it, the latter on the frame the flag resolves, typically while a sheet is open. Changing the value warns once per host in dev.

Use it to put an error boundary around each sheet, so one sheet failing never takes the host down. The fallback **must** render an adapter bound to `sheetRef`, so the manager keeps driving the sheet — `useBottomSheetContext().close()` still closes it. A fallback without an adapter leaves a sheet that crashes mid-close stuck at `closing`: `closeAll` skips it, `close()` answers `not-closable`, the Android back button is dead for the group, and its id is unusable until `destroyAll()`.

```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 (
<CustomModalAdapter ref={this.props.sheetRef}>
<Text>This sheet could not be shown.</Text>
<CloseButton />
</CustomModalAdapter>
);
}
}

<BottomSheetHost SheetWrapper={SheetErrorBoundary} />;
```

An adapter that mounts while its sheet is already `open` is expanded on mount, so the fallback appears in place without extra wiring. If it mounts while the sheet is `closing`, the sheet is ended instead — a crash mid-dismissal finishes the close rather than popping the error card back up.

Give the fallback no scrim of its own — the manager's backdrop is still mounted behind it, and a second dim stacks on the first. Expect one visual artefact instead: a fallback adapter that seeds its position from zero rewinds the sheet's shared `animatedIndex` to -1 on its first render, so under an open sheet the manager backdrop blanks and fades back in behind the fallback. Seed the adapter from the sheet's current status to avoid it. Note too that the crashed adapter's unmount re-enables the manager scrim through its backdrop cleanup, so a sheet that passed `backdrop={false}` gets the group backdrop back for its fallback.

---

## BottomSheetScaleView
Expand Down
16 changes: 16 additions & 0 deletions docs/docs/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,22 @@ type SheetRef = RefObject<SheetAdapterRef | null>;

---

### SheetWrapperProps

```ts
interface SheetWrapperProps {
id: string; // same as useBottomSheetContext().id
sheetRef: SheetRef | undefined; // the ref the coordinator drives; bind a fallback adapter to it
children: ReactNode;
}
```

Props of the component passed to `BottomSheetHost`'s `SheetWrapper`.

`sheetRef` is `undefined` when no ref is registered for the id — the wrapper still renders, but nothing drives a fallback adapter: the mount catch-up is skipped (it neither opens under `open` nor ends the sheet under `closing`) and a programmatic close removes the sheet with no exit animation.

---

## Configuration Types

### ScaleConfig
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/custom-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,10 @@ const ref = useAdapterRef(forwardedRef);
useImperativeHandle(ref, () => ({ expand: ..., close: ... }));
```

An adapter that mounts while its sheet is already `open` is expanded once on mount, and one that mounts while the sheet is `closing` ends that sheet instead (it never opened, so it cannot animate the close out). 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, or finish a dismissal it interrupted. Your adapter needs nothing extra for it beyond `useAdapterRef` + `useImperativeHandle`.

An adapter that seeds its position from zero rewinds the sheet's shared `animatedIndex` on that mount, which the backdrop follows — see [`BottomSheetHost`](/api/components#bottomsheethost) for what a consumer sees and how to avoid it.

### Prop-Controlled vs Ref-Controlled Libraries

**Ref-controlled** (e.g., TrueSheet with `present()`/`dismiss()`):
Expand Down
8 changes: 7 additions & 1 deletion example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -37,7 +38,12 @@ export default function App() {
<HomeScreen />
</UserProvider>
</BottomSheetScaleView>
<BottomSheetHost />
{/*
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.
*/}
<BottomSheetHost SheetWrapper={SheetErrorBoundary} />
{/* Persistent sheet - always mounted, opens instantly */}
<BottomSheetPersistent id="scanner-sheet">
<ScannerSheet />
Expand Down
83 changes: 83 additions & 0 deletions example/src/components/SheetErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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 (
<CustomModalAdapter ref={sheetRef} contentContainerStyle={styles.content}>
<View style={styles.card}>
<Badge label="Fallback" color={colors.error} />
<Text style={sharedStyles.h1}>This sheet could not be shown</Text>
<Text style={sharedStyles.text}>
Its content threw while rendering. The host and every other sheet in
the stack are untouched — only this one was replaced.
</Text>
<View style={styles.actions}>
<Button title="Retry" onPress={onRetry} />
<SecondaryButton title="Close" onPress={close} />
</View>
</View>
</CustomModalAdapter>
);
}

/** Module scope on purpose: `QueueItem` is memoized. */
export class SheetErrorBoundary extends Component<
SheetWrapperProps,
{ failed: boolean }
> {
state = { failed: false };

static getDerivedStateFromError() {
return { failed: true };
}

render() {
if (!this.state.failed) {
return this.props.children;
}

return (
<FallbackSheet
sheetRef={this.props.sheetRef}
onRetry={() => this.setState({ failed: false })}
/>
);
}
}

const styles = StyleSheet.create({
content: {
paddingHorizontal: 24,
},
card: {
backgroundColor: colors.surface,
borderRadius: 20,
padding: 24,
width: '100%',
borderWidth: 1,
borderColor: colors.border,
gap: 8,
},
actions: {
gap: 12,
marginTop: 12,
},
});
1 change: 1 addition & 0 deletions example/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { Badge } from './Badge';
export { Button, SecondaryButton, SmallButton } from './Button';
export { DemoCard, FeatureItem } from './DemoCard';
export { Sheet } from './Sheet';
export { SheetErrorBoundary } from './SheetErrorBoundary';
10 changes: 10 additions & 0 deletions example/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
BackdropDemo,
ContextComparisonSheet,
ContextSheetPortal,
ErrorBoundaryDemoSheet,
ForceCloseDemo,
GroupASheet,
HeavySheet,
Expand Down Expand Up @@ -216,6 +217,15 @@ export function HomeScreen() {
onPress={() => open(<ForceCloseDemo />, { scaleBackground: true })}
/>

<DemoCard
title="Sheet error boundary"
description="Crash one inline sheet's body — a fallback sheet replaces it, the host stays up"
color={colors.error}
onPress={() =>
open(<ErrorBoundaryDemoSheet />, { scaleBackground: true })
}
/>

<DemoCard
title="Group Isolation"
description="Two managers, two stacks — closeAll() in one leaves the other standing"
Expand Down
42 changes: 42 additions & 0 deletions example/src/sheets/ErrorBoundarySheets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
import { forwardRef, useState } from 'react';
import { Text, View } from 'react-native';
import { useBottomSheetContext } from 'react-native-bottom-sheet-stack';

import { Badge, Button, SecondaryButton, Sheet } from '../components';
import { colors, sharedStyles } from '../styles/theme';

/** Throws after it has opened — the case the wrapper exists for. */
export const ErrorBoundaryDemoSheet = forwardRef<BottomSheetMethods>(
(_, ref) => {
const { close } = useBottomSheetContext();
const [crashed, setCrashed] = useState(false);

if (crashed) {
throw new Error('ErrorBoundaryDemoSheet failed to render');
}

return (
<Sheet ref={ref}>
<Badge label="Inline" color={colors.error} />
<Text style={sharedStyles.h1}>Sheet error boundary</Text>
<Text style={sharedStyles.text}>
The default host wraps every inline sheet in `SheetErrorBoundary`.
Crash this one and a fallback sheet takes its place, bound to the same
ref — Retry remounts this content, Close animates the sheet out.
Nothing else in the stack notices.
</Text>

<View style={{ gap: 12, marginTop: 8 }}>
<Button
title="Crash this sheet's body"
onPress={() => setCrashed(true)}
/>
<SecondaryButton title="Close" onPress={close} />
</View>
</Sheet>
);
}
);

ErrorBoundaryDemoSheet.displayName = 'ErrorBoundaryDemoSheet';
1 change: 1 addition & 0 deletions example/src/sheets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ export {
} from './BackdropSheets';
export { GroupASheet, GroupBSheet } from './GroupIsolationSheets';
export { StatusDemoPanel, StatusDemoSheet } from './SheetStatusSheets';
export { ErrorBoundaryDemoSheet } from './ErrorBoundarySheets';
Loading
Loading