diff --git a/CLAUDE.md b/CLAUDE.md index cd7c4ad..3ce597f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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)`. @@ -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` 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. | @@ -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`; +`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. diff --git a/docs/docs/api/components.md b/docs/docs/api/components.md index c98a8ae..56c0c17 100644 --- a/docs/docs/api/components.md +++ b/docs/docs/api/components.md @@ -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` | 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 ( + + 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. 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 diff --git a/docs/docs/api/types.md b/docs/docs/api/types.md index 8d35c8b..68366e5 100644 --- a/docs/docs/api/types.md +++ b/docs/docs/api/types.md @@ -195,6 +195,22 @@ type SheetRef = RefObject; --- +### 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 diff --git a/docs/docs/custom-adapters.md b/docs/docs/custom-adapters.md index ada0809..1f53e0d 100644 --- a/docs/docs/custom-adapters.md +++ b/docs/docs/custom-adapters.md @@ -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()`): 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..deb05f1 --- /dev/null +++ b/example/src/components/SheetErrorBoundary.tsx @@ -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 ( + + + + 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. + + +