diff --git a/API-REVIEW.md b/API-REVIEW.md new file mode 100644 index 0000000..d914bd1 --- /dev/null +++ b/API-REVIEW.md @@ -0,0 +1,489 @@ +# API review — `react-native-bottom-sheet-stack` + +A read-through of the whole API surface (public and internal) looking for +inconsistencies, hazards and things that make the library harder to use or +maintain. Baseline: the swmansion 0.16.2 bump. + +Every finding comes from reading the code. **Nothing was run on a device or +covered by a test** — the repo has no test suite. Where telling "bug" from +"deliberate" would need a device, that is called out. + +Numbering: `B*` = bug, `P*` = public API, `W*` = internal, `M*` = dead code. + +Each finding carries a status. See `## Status` at the end for what has been +applied and what is deliberately left alone. + +> **Reading note.** The findings below are a snapshot of the tree **as reviewed**, +> before any of them were applied. File paths, line numbers and quoted code +> describe that pre-fix tree and are deliberately left as they were — they are +> the evidence for each finding, not a description of `main`. Files have since +> been renamed and deleted (`bottomSheet.store.ts` → `store/`, `useEvent.ts` → +> `useStableCallback.ts`, and more). For the current state, read `## Status` at +> the end and `CLAUDE.md`. + +--- + +## 1. Bugs + +### B1. Group isolation is broken in three places — **priority 1** + +`BottomSheetManagerProvider` promises independent groups ("Each group has its own +stackOrder"). The store, however, keeps **one global `stackOrder`**, and three +operations reach into it without filtering by `groupId`: + +| Site | Code | Effect | +|---|---|---| +| `store.ts:37` | `applyModeToTopSheet(sheetsById, state.stackOrder, mode)` | `mode: 'switch'`/`'replace'` in group B hides/closes a sheet in group A | +| `store.ts:118` | `getTopSheetId(newStackOrder)` | Closing a sheet in group B auto-restores a hidden sheet from group A | +| `store.ts:92` | `getSheetBelowId(state.stackOrder, id)` | `startClosing` restores the sheet "below" from another group | + +For contrast, the same operations **do** filter correctly in +`initBottomSheetCoordinator`, `useSheetRenderData`, `closeAllAnimated` and +`clearGroup`. So the invariant is known — just applied inconsistently. + +**Fix:** pass `groupId` into the three helpers and filter, or — cleaner — key the +stack by group: `stackOrderByGroup: Record`. The latter kills +this whole class of bug structurally and simplifies the selectors. + +### B2. Ref leak when `open()` is rejected — **priority 1** + +`useBottomSheetManager.open()` (`useBottomSheetManager.tsx:36-38`): + +```ts +const id = options.id || Math.random().toString(36); +const ref = React.createRef(); +setSheetRef(id, ref); // ← writes to the global map +// ... +storeOpen({ id, ... }); // ← the store may reject this SILENTLY (B3) +return id; +``` + +`cleanupSheetRef(id)` only ever runs from the `useEffect` cleanup in `QueueItem`. +If the store rejects the open, `QueueItem` never mounts, so the `sheetRefsMap` +entry stays **forever**. With random IDs every rejected open adds another +unreclaimable entry. `useBottomSheetControl` has the same shape but a stable ID, +so its leak is bounded to one entry. + +**Fix:** register the ref only once the store has accepted the sheet — which +requires `open()` to report its outcome (see B3). + +### B3. `open()` is sometimes a silent no-op + +`store.ts:28-35` — two guards abort the open without any signal: + +```ts +if (existingSheet && !isActivatableKeepMounted(existingSheet)) return state; +const hasOpeningInGroup = Object.values(state.sheetsById).some( + (s) => s.groupId === sheet.groupId && s.status === 'opening'); +if (hasOpeningInGroup) return state; +``` + +The second guard is timing-dependent: two `open()` calls in the same tick (or a +second one while the first is still animating in) and the second vanishes. The +caller gets an `id` back and has no way to tell that nothing happened. This is +the shape of "the sheet sometimes doesn't open" reports. + +**Fix:** have `open()` return `{ id, opened: boolean }` (or `string | null`), plus +a `__DEV__` warning naming the reason. + +### B4. Conditional hook call in `useOnBeforeClose` + +`useOnBeforeClose.ts:75-84`: + +```ts +const context = useMaybeBottomSheetContext(); +const setPreventDismiss = useSetPreventDismiss(); +if (!context?.id) throw new Error(...); // ← throws BEFORE the later hooks +const stableCallback = useEvent(callback); // hook #3 +useEffect(...); // hook #4 +``` + +If the context disappears between renders (sheet unmounting, `clearGroup` during +a fast refresh), the hook count drops from 4 to 2 and React throws "Rendered +fewer hooks than expected", masking the real cause. + +`useBottomSheetContext` has the opposite, correct ordering (all hooks, then +throw) — at the cost of calling selectors with `''` as the ID. + +**Fix:** call every hook, then throw. Unify the pattern across both hooks. + +### B5. `animatedIndex` is binary in three adapters, so their backdrops snap + +The `animatedIndex` contract (`-1` hidden → `0` open) is honoured in two +incompatible ways: + +| Adapter | How | Backdrop | +|---|---|---| +| `GorhomSheetAdapter` | shared value handed to the library | smooth | +| `SwmansionSheetAdapter` | written from native `onPositionChange` | smooth | +| `CustomModalAdapter` | `animatedIndex.set(0)` / `set(-1)` | **snaps** | +| `ReactNativeModalAdapter` | `set(0)` / `set(-1)` | **snaps** | +| `ActionsSheetAdapter` | `set(0)` / `set(-1)` | **snaps** | + +`CustomModalAdapter` is the clearest case: it has its own `progress` animated with +`withTiming(…, 300ms)` and sets `animatedIndex` discretely in the very same +`expand()`. The modal fades in over 300 ms; the backdrop appears instantly at +full opacity. + +This is the same symptom that was reported for swmansion — there it came from a +timing gate in the backdrop, here it is baked into the adapters. + +**Fix:** for adapters with their own animation, `animatedIndex.value = +withTiming(0, cfg)` using the same config as the sheet animation; for +`CustomModalAdapter`, derive it straight from the existing `progress`. + +### B6. Ref mutated inside a Zustand selector + +`useScaleAnimation.ts:63-92` — `useSheetScaleDepth`: + +```ts +const result = useBottomSheetStore((state) => { + if (sheetIndex === -1) return prevDepthRef.current; // read + // ... + prevDepthRef.current = depth; // ← write inside selector + return depth; +}); +``` + +A Zustand selector must be pure — it runs on every store change, potentially +several times per render and twice under StrictMode. Mutating makes the result +depend on how many times it ran. The intent (hold the last depth once the sheet +leaves the stack, so the exit animation doesn't jump) is right; the mechanism +isn't. + +**Fix:** return `sheetIndex === -1 ? null : depth` from the selector and hold the +last-known value in an effect or in the shared value itself. + +### B7. A sheet can get stuck in `'closing'` + +`bottomSheetCoordinator.ts:24-40`: + +```ts +const ref = getSheetRef(id)?.current; // read once, up front +switch (status) { + case 'opening': + requestAnimationFrame(() => { getSheetRef(id)?.current?.expand(); }); // fresh read + break; + case 'hidden': + case 'closing': + ref?.close(); // ← stale ref, no retry +} +``` + +Two different strategies in one switch. If `ref.current` is still `null` (the +adapter hasn't mounted — realistic for a portal, whose content must first +teleport into its `PortalHost`), `ref?.close()` is a silent no-op. Nothing ever +calls `handleClosed()`, so the sheet sits in `'closing'` indefinitely: it doesn't +render properly and it blocks `hasOpeningInGroup` (B3) for the whole group. + +**Fix:** use the same strategy as `expand` (read inside rAF, re-check status), +plus a `__DEV__` watchdog warning about sheets stuck in a transitional state. + +### B8. `closeAllAnimated` calls `indexOf` in a loop + +`bottomSheetCoordinator.ts:152`: + +```ts +if (stagger > 0 && reversed.indexOf(sheetId) < reversed.length - 1) { +``` + +`indexOf` over the array being iterated: O(n²), and it returns the **first** +match. At realistic stack sizes the cost is irrelevant, but the semantics are +wrong and the loop index is right there. + +### B9. `requestClose` returns `true` when it did nothing + +`bottomSheetCoordinator.ts:100-104` — for a sheet in `'hidden'` (or one that +doesn't exist) the function falls past `if (currentStatus === 'open' || …)` and +returns `true`. The return value means "the interceptor didn't block", not "the +sheet is closing" — which neither the name nor the docs convey. + +--- + +## 2. Public API — inconsistencies + +### P1. `close()` throws away the interceptor result + +```ts +requestClose(id) → Promise // low-level, public +useBottomSheetManager().close(id) → void // Promise dropped +useBottomSheetControl().close() → void // Promise dropped +useBottomSheetContext().close() → void // Promise dropped +useBottomSheetManager().closeAll() → Promise // returned +``` + +`onBeforeClose` can block a close, but none of the three main `close()` calls +reports it. Finding out means reaching for `requestClose` — i.e. the +adapter-author API. Meanwhile `closeAll` does return its promise, so the rule +isn't even consistent within one hook. + +**Proposal:** every `close()` returns `Promise`. Backwards compatible — +callers ignoring `void` can keep ignoring it. + +### P2. `clear()` and `closeAll()` don't read as what they are + +```ts +closeAll() // animated cascade, respects onBeforeClose, async +clear() // immediate store wipe, BYPASSES onBeforeClose, sync +``` + +`clear()` sounds like tidying up but is a hard reset that skips the entire +data-loss guard. And `clearAll` is a deprecated alias of `clear` — a name that +implies kinship with `closeAll`, which it has nothing to do with. + +**Proposal:** `destroyAll()` / `resetGroup()` with explicit JSDoc: "skips +onBeforeClose, no animation — for teardown, not for closing". + +### P3. `params` are unavailable to inline sheets + +`useBottomSheetControl.open()` takes `params`. `useBottomSheetManager.open()` +does not. Yet `useBottomSheetContext()` returns `params` **always**, so in an +inline sheet it is permanently `undefined`. The store and `BottomSheetState` +support `params` regardless of mode — only the hook surface restricts it. + +For inline sheets they're partly redundant (values can be closed over in JSX), +but the asymmetry is undocumented and reads as an oversight. + +### P4. `isOpen` includes `'opening'` + +```ts +isOpen: status === 'open' || status === 'opening' +``` + +The name says "is open", the value means "is open or opening". There's no way to +tell the interactive state from the animation — a distinction the library itself +relies on internally (`useBackHandler` only fires on `status === 'open'`). + +**Proposal:** add `isOpening` / `isClosing` / `isVisible`, and narrow `isOpen` to +`status === 'open'` (breaking — 2.0). + +### P5. `useBottomSheetStatus(id: string)` has no type support + +The rest of the type-safe API works in `BottomSheetPortalId`. This one takes a +bare `string`, because inline sheet IDs are random. Result: no completion for +registered IDs. + +**Proposal:** `id: BottomSheetPortalId | (string & {})` — completion for +registered IDs, any string still accepted. + +### P6. The store's internals are public + +```ts +export { useBottomSheetStore } from './bottomSheet.store'; +export type { BottomSheetState } from './bottomSheet.store'; +``` + +(As reviewed. The `bottomSheet.store.ts` re-export layer is gone — see W2 — +and the public `BottomSheetState` is now `PublicBottomSheetState`, re-exported +under that name from `./store`.) + +`useBottomSheetStore` exposes the full state and every action — including +`markOpen`, `finishClosing`, `mount` and `unmount`, which only make sense for the +coordinator. `BottomSheetState` exposes `content`, `portalSession` and +`preventDismiss`, all pure implementation detail. Any change to the store's shape +is now a breaking change. + +**Proposal:** mark `@internal`, expose narrow selectors instead +(`useSheetStatus`, `useSheetParams`), and narrow the public `BottomSheetState` to +`Pick<…, 'id' | 'groupId' | 'status' | 'params'>`. + +### P7. Custom adapter authors don't get the full toolkit + +Every built-in adapter calls `useSetBackdrop(id, false)` to suppress the manager +backdrop when it provides its own. **That function is not exported** from the +main entry (neither is `useSheetPreventDismiss` — though `preventDismiss` is +reachable indirectly via `useBottomSheetContext()`). + +So an adapter written against `docs/custom-adapters.md` cannot match the +built-ins. The "Adapter utilities (for custom adapter authors)" section of +`index.tsx` is incomplete. + +### P8. Test helpers ship in the main entry + +`__resetSheetRefs`, `__resetAnimatedIndexes`, `__getAllAnimatedIndexes`, +`__resetPortalSessions`, `__resetOnBeforeClose` — five symbols in the production +bundle, prefixed with `__` but not marked `@internal`. + +**Proposal:** a `react-native-bottom-sheet-stack/testing` subpath, consistent +with the existing subpath-export pattern for adapters. + +### P9. Deprecated API with no removal horizon + +`openBottomSheet`, `clearAll`, `closeBottomSheet`, `useBottomSheetState`, +`ModalAdapter`, `BottomSheetManaged`, `BottomSheetManagedProps`, plus the +unmarked `SheetAdapterRef as BottomSheetRef` alias. + +Eight aliases at version 1.18.4, none of which says when it goes away. + +### P10. Two tiers of adapter quality + +```ts +// swmansion / gorhom — typed +interface SwmansionSheetAdapterProps extends Omit {} + +// actions-sheet / react-native-modal — untyped +interface ActionsSheetAdapterProps { children: ReactNode; [key: string]: unknown; } +``` + +`[key: string]: unknown` disables type checking entirely — a typo in a prop name +passes silently. Both libraries ship types, so there's something to build on. + +--- + +## 3. Internal — consistency and maintenance + +### W1. Three naming conventions for context hooks + +| File | Hook | +|---|---| +| `BottomSheet.context.ts` | `useMaybeBottomSheetContext` | +| `BottomSheetRef.context.ts` | `useBottomSheetRefContext` | +| `BottomSheetDefaultIndex.context.ts` | `useBottomSheetDefaultIndex` | +| `BottomSheetManager.**provider**.tsx` | `useBottomSheetManagerContext` + `useMaybe…` | + +On top of that the manager hook lives in the provider file rather than the +context file — even though `BottomSheetManager.context.tsx` exists and holds the +context itself. + +### W2. Two layers of store re-export + +`bottomSheet.store.ts` is a single line, `export * from './store'`. Imports in +the codebase go through `./bottomSheet.store` in some files and `./store` in +others, with no semantic difference. A layer to delete. + +(Deleted. Everything imports from `./store` now.) + +### W3. `TriggerState` is defined but used inconsistently + +```ts +export type TriggerState = Omit; +open(sheet: TriggerState, mode?: OpenMode): void; +mount(sheet: Omit): void; // ← same type, spelled out +``` + +### W4. `open()` takes a shape that conflates two disjoint modes + +`useBottomSheetControl` passes `content: null` even though `content` is optional +— without it there's no signal that this is a portal sheet. The mode is encoded +in the combination of `usePortal` + `keepMounted` + `content`, of which only +three of eight combinations are real. + +**Proposal:** a discriminated union: + +```ts +type OpenPayload = + | { kind: 'inline'; id: string; groupId: string; content: ReactNode; … } + | { kind: 'portal'; id: string; groupId: string; … } + | { kind: 'persistent'; id: string; groupId: string; … }; +``` + +This makes the three documented modes explicit in the types and removes +`content: null`. + +### W5. `MODE_STATUS_MAP` uses `null` for "no action" + +Which forces `if (!targetStatus) return sheetsById;`. It works, but `push` isn't +"no status" — it's "leave the previous sheet alone". Clearer as an explicit +branch. + +### W6. `shallow` on selectors returning primitives + +Eight of the eleven selectors in `store/hooks.ts` return `string | boolean | +number | undefined` and still go through `shallow`. Reference comparison is +enough; `shallow` only adds a call. `useSheet` and `useSheetParams` are the ones +that actually need it. + +### W7. `useEvent` name collision + +`src/useEvent.ts` (the useEvent RFC) and `useEvent` from +`react-native-reanimated` (a native event handler) are entirely different things +under one name, used in the same repo — and imported side by side in +`SwmansionSheetAdapter`. + +**Proposal:** rename the local one to `useStableCallback`. + +(Done. The file is `src/useStableCallback.ts`; `src/useEvent.ts` no longer +exists.) + +### W8. `useBottomSheetContext` calls selectors with `''` + +```ts +const params = useSheetParams(context?.id || ''); +``` + +It works (the selector returns `undefined`), but empty string as "no ID" is an +unwritten convention scattered through the code. + +--- + +## 4. Dead code + +Zero uses in `src/` and `example/`: + +| Symbol | File | +|---|---| +| `isOpening` | `store/helpers.ts` | +| `useSheet` | `store/hooks.ts` | +| `useIsSheetOpen` | `store/hooks.ts` | +| `useHasScaleBackgroundAbove` | `store/hooks.ts` | +| `getCurrentPortalSession` | `portalSessionRegistry.ts` | +| `useTracePropChanges` | `useTracePropChanges.ts` (whole file — a debug tool with `console.log`) | + +None is exported publicly from `index.tsx`, so removing them is not a breaking +change. `setAnimatedIndexValue` has one use — and is public despite duplicating +`useAnimatedIndex()`. + +--- + +## Status + +Applied across three stages on top of the swmansion 0.16.2 bump. + +**Stage 1 — bugs, no API change:** +B1, B2, B4, B6, B7, B8, B9 — done. + +**Stage 2 — behavioural consistency:** +B3, B5, P1, P3, P5, P7, P10, and the dead code in section 4 — done. + +**Stage 3 — cleanup (breaking, 2.0):** +P2, P4, P6, P8, P9, W2, W3, W5, W6, W7, W8 — done. + +### Closed in a follow-up + +- **W1** — one rule now: a `useMaybe*` hook may return `null`/`undefined` and + leaves handling that to the caller; a plain `use*` hook either throws or + resolves to a documented default. `useBottomSheetRefContext` became + `useMaybeBottomSheetRef`, and the manager hooks moved from the provider file + to `BottomSheetManager.context.tsx` where the context itself lives. +- **W4** — `open()` takes a discriminated `OpenPayload` (`kind: 'inline'` with + content, or `kind: 'portal'` without), and `mount()` takes `MountPayload`. + `content: null` as a "not inline" signal is gone, and the store maps `kind` + onto its internal flags in one place. +- `useBottomSheetControl().open()` now returns `boolean`. It and + `useBottomSheetManager().open()` both report rejection, each in the currency + that is useful there — an ID or a yes/no. +- `close()` returns a `CloseResult` carrying a reason (`'blocked'`, + `'interceptor-error'`, `'not-closable'`) instead of a boolean that meant four + different things. +- `closeAll()` returns a `CloseAllResult` naming what closed and which sheet + stopped the cascade. It also no longer treats a sheet that had nothing to + close as a refusal — that used to strand every sheet below it. + +### Not attempted + +`BottomSheetPortal` reads `getSheetRef(id)` during render — a module-global map +consulted from render, which is not reactive. It works only because +`portalSession` changes in the same store write that creates the ref. Fragile, +but untangling it means reworking how portals learn about refs, which is a +larger change than anything here. + +The native detent cap derives from `getLocationInWindow` / `convert(to: window)`, +which account for transforms — so a sheet scaled by another above it (via +`ScaleWrapper`) may recompute its cap mid-animation and twitch. + +None of this is covered by a test or verified on a device; the repo still has no +test suite. That is the largest outstanding gap: this work changed the store's +shape, the open/close contract and the animation timing of three adapters, and +nothing guards any of it. The changes most worth exercising on hardware are B5 +(the three adapters whose backdrop timing changed) and B7 (the coordinator's +close path). diff --git a/CLAUDE.md b/CLAUDE.md index 5b0d684..79ed0cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,10 +27,41 @@ plugins: [ ] ``` +### SANCTIONED EXCEPTION 1: `QueueItem.tsx` +`QueueItem` is wrapped in `memo`, and that is **sanctioned**. The compiler +memoizes work *inside* a component; it does not wrap one in `memo`. Because +`BottomSheetHost` builds its children with `.map()`, every host render produces +fresh element references and React must call each `QueueItem` to find out the +output is unchanged — including persistent sheets that the opening sheet does +not touch. + +Measured on device, opening one sheet with three persistent sheets mounted: +three `QueueItem` bodies ran for nothing without `memo`, zero with it. The cost +grows linearly with the number of persistent sheets in the group. + +It costs no correctness: a `QueueItem` whose `stackIndex` changes still +re-renders, and `ScaleWrapper` holds its own store subscription, so scale depth +updates while its parent is skipped (verified with a push). Do not "clean up" +this one either. + +### SANCTIONED EXCEPTION 2: `useStableCallback.ts` +`useStableCallback` uses `useCallback` deliberately, and that is **sanctioned**. +It is not an optimization: the whole point of the hook is that the returned +function has a *stable identity* while its closure stays fresh (the useEvent +RFC). `useCallback([])` is the mechanism that produces the stable identity — +removing it removes the feature. Do not add another, and do not "clean up" this +one. + +Those two are the only sanctioned manual memoization in `src/`. Anything else +is still forbidden — and both were added only after measuring, not on a hunch. + ### When Compiler Cannot Optimize: -Use the `'use no memo'` directive at the top of the file (see `BottomSheetPortal.tsx` for example). This is RARE and only needed when: -- Dynamic ref cloning breaks compiler analysis -- External library integration requires it +Use the `'use no memo'` directive at the top of the file. This is RARE. The only +current use is `BottomSheetPortal.tsx`, which reads the module-global refs map +(`getSheetRef(id)`) **during render** — not a reactive source, so the compiler's +analysis of when to re-run the component is unsound. See "Not attempted" in +`API-REVIEW.md`; it works only because `portalSession` changes in the same store +write that creates the ref. --- @@ -51,7 +82,7 @@ A library-agnostic stack manager for bottom sheets and modals in React Native. P | React Native | react-native | 0.81.5 | | Animation | react-native-reanimated | ^4.2.1 | | State | zustand | ^5.0.3 | -| Portals | react-native-teleport | ^0.5.6 | +| Portals | react-native-teleport | ^1.1.7 | ### Shipped Adapters (separate subpath exports) | Adapter | Import subpath | Wraps | @@ -90,8 +121,9 @@ A library-agnostic stack manager for bottom sheets and modals in React Native. P ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ QueueItem │ │ QueueItem │ │ QueueItem │ │ (sheet slot) │ │ (sheet slot) │ │ (sheet slot) │ - │ zIndex: 0,1 │ │ zIndex: 2,3 │ │ zIndex: 4,5 │ + │ zIndex: B+0,1 │ │ zIndex: B+2,3 │ │ zIndex: B+4,5 │ └─────────────────┘ └─────────────────┘ └─────────────────┘ + B = baseZIndex = 100_000_000 (lifts the stack above app chrome) │ ┌──────────┴──────────┐ ▼ ▼ @@ -107,35 +139,83 @@ A library-agnostic stack manager for bottom sheets and modals in React Native. P ### Core State Management -#### `bottomSheet.store.ts` - Central Zustand Store +#### `store/` - Central Zustand Store **Purpose**: Single source of truth for all sheet state and stack ordering. +Split into `store.ts` (actions), `hooks.ts` (selectors), `helpers.ts` (pure +stack operations) and `types.ts`. Import from `./store`. **State Structure**: ```typescript interface BottomSheetStoreState { - sheetsById: Record; // All sheets by ID - stackOrder: string[]; // Visible sheet IDs in order + sheetsById: Record; // All sheets by ID + stackOrderByGroup: Record; // Visible IDs, per group } +// @internal — the full record. NOT what consumers see. interface BottomSheetState { groupId: string; // Manager group ID id: string; // Unique sheet ID content?: ReactNode; // For inline mode only status: BottomSheetStatus; // 'opening' | 'open' | 'closing' | 'hidden' scaleBackground?: boolean; // Enable iOS-style scale + backdrop?: boolean; // false = suppress the manager's shared backdrop usePortal?: boolean; // Portal mode flag params?: Record; // Type-safe params keepMounted?: boolean; // Persistent sheet flag + portalSession?: number; // Unique Portal/PortalHost name counter + preventDismiss?: boolean; // Adapter should block native dismiss gestures } + +// The public surface, re-exported from index.tsx *as* `BottomSheetState`. +type PublicBottomSheetState = Pick< + BottomSheetState, + 'id' | 'groupId' | 'status' | 'params' | 'scaleBackground' | 'keepMounted' +>; ``` +**Two names, one of them a lie.** `index.tsx` exports +`PublicBottomSheetState as BottomSheetState`. So inside `src/`, +`BottomSheetState` is the full internal record; to a consumer, +`BottomSheetState` is the narrowed `Pick`. When adding a field, decide +deliberately whether it belongs in the `Pick` — anything added there is +semver-locked. `content`, `backdrop`, `usePortal`, `portalSession` and +`preventDismiss` are all deliberately outside it. + +**CRITICAL — the stack is keyed by group.** There is no global `stackOrder`. +Every stack operation takes a *single group's* array, which is what makes group +isolation structural rather than a filter someone has to remember. Reaching for +`Object.values(stackOrderByGroup).flat()` inside a store action or selector +re-introduces the exact bug this shape exists to prevent (`switch`/`replace` in +one group closing a sheet in another). + **Key Actions**: -- `open(sheet, mode)` - Opens sheet with navigation mode +- `open(sheet: OpenPayload, mode?)` - Opens sheet with navigation mode. Returns + `OpenResult` — `{ opened: false, id, reason }` for `'already-active'`, + `'group-busy'` or `'group-mismatch'`, each with a `__DEV__` warning. Never + silently drops the request. - `markOpen(id)` - Transitions 'opening' → 'open' -- `startClosing(id)` - Initiates close animation +- `startClosing(id)` - Initiates close animation. Also re-opens the sheet below + when the closing one is the group's top and the one below is `hidden` + (undoing a `switch`). - `finishClosing(id)` - Completes close (hides if keepMounted, removes otherwise) -- `mount(sheet)` - Pre-mounts persistent sheet with 'hidden' status +- `mount(sheet: MountPayload)` - Pre-mounts persistent sheet with 'hidden' status - `unmount(id)` - Removes persistent sheet +- `setBackdrop(id, boolean)` / `setPreventDismiss(id, boolean)` - Field patches +- `clearGroup(groupId)` / `clearAll()` - Teardown. No animation, no interceptors. + +**`open()` takes a discriminated union, not a bag of optional flags:** +```typescript +type OpenPayload = + | { kind: 'inline'; id; groupId; content: ReactNode; scaleBackground?; backdrop?; params? } + | { kind: 'portal'; id; groupId; scaleBackground?; backdrop?; params? }; + +type MountPayload = /* the base fields, no kind */; +``` +`kind` is what callers reason about; `usePortal` is what the renderer checks. +`toStoreFields()` in `store.ts` is the single place that maps one to the other — +don't re-encode the mode at a call site. There is no `kind: 'persistent'`: a +persistent sheet is registered by `mount()` and re-opened as `'portal'`, keeping +the `keepMounted` flag its store record already carries. **Navigation Modes** (`OpenMode`): - `push` - Keeps previous sheet visible (stacking) @@ -152,28 +232,75 @@ interface BottomSheetState { - Calls `ref.close()` when status becomes 'hidden' or 'closing' 2. **Adapter → Store** (`createSheetEventHandlers`): - - `handleDismiss`: User swipes down / back button → `startClosing()` + - `handleDismiss`: User swipes down / back button → **`requestClose(id)` when + an `onBeforeClose` interceptor is registered**, otherwise `startClosing()` + directly. Routing through `requestClose` is what makes a user gesture + honour the interceptor; do not "simplify" it back to a bare + `startClosing()`. - `handleOpened`: Show animation completes → `markOpen()` - `handleClosed`: Hide animation completes → `finishClosing()` +**Also exported from the coordinator** (and publicly, for adapter authors): +`requestClose(id): Promise` and +`closeAllAnimated(groupId, opts): Promise`. + +**`driveSheetRef`** retries a ref call across up to `REF_CALL_MAX_FRAMES` (10) +`requestAnimationFrame`s, re-checking the status each time. The store can reach +a terminal status before the adapter has mounted (a portal sheet must teleport +first); a single attempt would silently no-op and wedge the sheet — and for +`'closing'`, wedge every later open in the group on the `group-busy` guard. + ### Global Registries (Module-Level Maps) +Four of them. All are module state, so they outlive React — which is why +`resetBottomSheetRegistries()` exists (see Testing Utilities). + #### `refsMap.ts` - Sheet Reference Registry ```typescript -const sheetRefsMap = new Map>(); +const sheetRefsMap = new Map(); +// SheetRef = RefObject ``` -**Why**: Refs cannot be stored in Zustand (not serializable). Global map allows coordinator to access refs by sheet ID. +**Why**: Refs cannot be stored in Zustand (not serializable). Global map allows +coordinator to access refs by sheet ID. Cleaned up by `QueueItem`'s unmount +effect — which is why `useBottomSheetManager` registers the ref only *after* +the store accepts the open, or a rejected open would leak an unreclaimable entry. #### `animatedRegistry.ts` - Animated Index Registry ```typescript const animatedIndexRegistry = new Map>(); ``` -**Why**: Shared animated values for backdrop opacity interpolation. Created lazily via `getAnimatedIndex(id)`. +**Why**: Shared animated values for backdrop opacity interpolation. + +Created **eagerly** in store actions (`open` / `mount`) before any component +renders, so the value always exists by the time the backdrop reads it: +- `ensureAnimatedIndex(id)` — get-or-create, initialised to `HIDDEN_ANIMATED_INDEX` (`-1`) +- `resetAnimatedIndex(id)` — ensure **and rewind to `-1`**; called by `open()` + so a re-opened persistent sheet does not still carry last cycle's value +- `getAnimatedIndex(id)` — a pure read, returns `undefined` if absent. It does + **not** create. + +#### `onBeforeCloseRegistry.ts` - Close Interceptors +```typescript +const onBeforeCloseMap = new Map(); +``` +**Why**: `requestClose` and `handleDismiss` need to find a sheet's interceptor +from outside React. Written by `useOnBeforeClose`, removed by `QueueItem` on +unmount. Its presence is also what flips `preventDismiss` on the store record. + +#### `portalSessionRegistry.ts` - Portal Session Counters +```typescript +const portalSessionRegistry = new Map(); +``` +**Why**: `getNextPortalSession(id)` mints a monotonically increasing counter that +goes into the `Portal`/`PortalHost` name (`bottomsheet-${id}-${session}`). It +**persists across sheet deletion** on purpose — reusing a name after a replace +flow hits a react-native-teleport connection bug. Allocated once at `mount()` +for a persistent sheet, and on every open for a non-persistent portal sheet. ### Components -#### `BottomSheetManagerProvider.tsx` - Root Provider -Wraps app with: +#### `BottomSheetManager.provider.tsx` - Root Provider +Exports `BottomSheetManagerProvider`. Wraps app with: - `PortalProvider` (from react-native-teleport) - `BottomSheetManagerContext` (groupId, scaleConfig) @@ -189,17 +316,25 @@ Wraps app with: **Z-Index Strategy**: ```typescript -const backdropZIndex = stackIndex * 2; // Even numbers: 0, 2, 4... -const contentZIndex = stackIndex * 2 + 1; // Odd numbers: 1, 3, 5... +const baseZIndex = 100_000_000; + +const backdropZIndex = baseZIndex + stackIndex * 2; // 100000000, 100000002, ... +const contentZIndex = baseZIndex + stackIndex * 2 + 1; // 100000001, 100000003, ... ``` -This ensures backdrop always renders below its sheet's content. +Even/odd pairing ensures a backdrop always renders below its own sheet's content +but above the sheet beneath it. The `baseZIndex` offset lifts the whole stack +above arbitrary app chrome — without it, any host app view with a modest +`zIndex` would paint over the sheets. Keep it when touching this. **Rendering Modes**: - **Portal Mode** (`usePortal: true`): Renders `` that receives content from `BottomSheetPortal` - **Inline Mode** (`usePortal: false`): Renders content directly with `BottomSheetContext.Provider` #### `BottomSheetPortal.tsx` - Portal Mode Sheet Definition -**Uses `'use no memo'` directive** - Compiler cannot optimize due to dynamic ref cloning. +**Uses `'use no memo'` directive** — it calls `getSheetRef(id)`, a read of a +module-global map, **during render**. That is not a reactive source, so the +compiler cannot know when the component must re-run. (Ref *cloning* is a +different thing and lives in `useBottomSheetManager`, not here.) **Purpose**: Defines portal-based sheet content. Renders into PortalHost in QueueItem. **When to use**: When sheet needs access to parent React context (Redux, custom contexts, etc.) @@ -233,35 +368,64 @@ driven, and the backdrop pops in part-way through the fade. **Purpose**: Imperative API for opening sheets with content. ```typescript -const { open, close, clear } = useBottomSheetManager(); +const { open, close, closeAll, destroyAll } = useBottomSheetManager(); -// Open with inline content (content cloned with ref) +// Open with inline content (content cloned with ref). +// Returns `string | null` — null when the store declined the open. const id = open(, { mode: 'push', scaleBackground: true }); -close(id); +if (id !== null) { + await close(id); // Promise +} + +await closeAll(); // Promise, staggered, respects interceptors +destroyAll(); // void, immediate, BYPASSES interceptors ``` +There is no `clear()`. `destroyAll()` is the teardown primitive (no animation, +`onBeforeClose` never runs); `closeAll()` is the user-facing one. + +`open()` is where the inline ref is minted: `React.createRef()`, cloned onto the +element, and registered in `refsMap` **only after** the store accepts the sheet. + **When to use**: Opening sheets dynamically with content as parameter. #### `useBottomSheetControl.ts` - Portal Sheet Control **Purpose**: Type-safe control for portal-based sheets. ```typescript -const { open, close, updateParams } = useBottomSheetControl('user-sheet'); +const { open, close, closeAll, updateParams, resetParams } = + useBottomSheetControl('user-sheet'); -open({ params: { userId: '123' } }); +open({ params: { userId: '123' } }); // boolean — false when declined updateParams({ userId: '456' }); +await close(); // Promise ``` +Note the asymmetry with `useBottomSheetManager().open()`, which is deliberate: +both report the same rejection, each in the currency useful at that call site — +an ID you did not have, or a yes/no when you already know the ID. + **When to use**: Controlling pre-defined portal sheets with type-safe params. #### `useBottomSheetContext.ts` - Sheet Internal Context -**Purpose**: Access current sheet's ID and params from within the sheet. +**Purpose**: Access current sheet's ID, params and close functions from within +the sheet. Throws outside a sheet. ```typescript // Inside a sheet component -const { id, params, close } = useBottomSheetContext<'user-sheet'>(); +const { id, params, preventDismiss, close, forceClose } = + useBottomSheetContext<'user-sheet'>(); ``` +`close()` respects `onBeforeClose`; `forceClose()` calls `startClosing()` +directly and bypasses it. Selectors are called with a `NO_SHEET_ID` sentinel +rather than conditionally, so the hook count stays stable before the throw. + +#### `useOnBeforeClose.ts` - Close Interception +**Purpose**: Registers a callback in `onBeforeCloseRegistry` for the current +sheet, and sets `preventDismiss: true` on its store record so adapters disable +native dismiss gestures. Inside-a-sheet only. + #### `useBottomSheetStatus.ts` - Sheet Status Monitoring **Purpose**: Observe sheet status from outside the sheet. @@ -269,15 +433,32 @@ const { id, params, close } = useBottomSheetContext<'user-sheet'>(); ```typescript // Portal/persistent sheet (registered ID) -const { status, isOpen } = useBottomSheetStatus('user-sheet'); +const { status, isOpen, isOpening, isClosing, isVisible } = + useBottomSheetStatus('user-sheet'); -// Inline sheet (dynamic ID from useBottomSheetManager) +// Inline sheet (dynamic ID from useBottomSheetManager). +// open() returns `string | null`, so guard before handing it over. const { open } = useBottomSheetManager(); -const sheetId = open(); +const [sheetId, setSheetId] = useState(null); +setSheetId(open()); // Later... -const { status, isOpen } = useBottomSheetStatus(sheetId); +const { status } = useBottomSheetStatus(sheetId ?? ''); ``` +**BREAKING in 2.0 — `isOpen` was narrowed** to `status === 'open'`. It used to +include `'opening'`. Four flags now: + +| Flag | True when | +|------|-----------| +| `isOpen` | `'open'` only — fully open and interactive | +| `isOpening` | `'opening'` | +| `isClosing` | `'closing'` | +| `isVisible` | `'opening' \| 'open' \| 'closing'` — the "on screen at all" one | + +The classic bug this causes: branching on `isOpen` to choose update-vs-open. +A second call while the sheet is still `'opening'` takes the open branch and the +store rejects it as `'already-active'`. Use `isVisible` for that. + #### `useScaleAnimation.ts` - Scale Animation Logic **Purpose**: Calculates scale animation values based on sheet depth. @@ -287,21 +468,86 @@ const currentScale = Math.pow(scale, depth); // e.g., 0.92^1 = 0.92, 0.92^2 = 0 ``` Creates cascading scale effect for nested sheets. -**useScaleDepth**: Returns number of `scaleBackground: true` sheets above current position. +**Two depth hooks, and they do different things** (there is no `useScaleDepth`): + +- **`useBackgroundScaleDepth(groupId)`** — for `BottomSheetScaleView`. Walks the + group's stack from the bottom, finds the **first** sheet that is not `closing` + or `hidden`, and returns *that sheet's* `scaleBackground` flag as `1` or `0`. + It stops there. So a stack whose bottom-most live sheet has + `scaleBackground: false` yields `0` no matter what sits above it — it does + **not** count all scaling sheets. Binary on purpose: the app background scales + once, however deep the stack goes. +- **`useSheetScaleDepth(groupId, sheetId)`** — for an individual sheet. Counts + the live `scaleBackground` sheets strictly *above* it in its own group's + stack, so nested sheets cascade. + +`useSheetScaleDepth` returns `null` from the selector once the sheet leaves the +stack, and the caller holds the last known depth in state — a sheet mid-exit +must keep its scale rather than snap back to 0 while animating out. That hold +lives in an **effect, not the selector**: a Zustand selector runs on every store +change (twice per render under StrictMode), so a ref write inside it would make +the result depend on how often it ran (see B6 in `API-REVIEW.md`). + +Public exports are the style hooks — `useBackgroundScaleAnimatedStyle()` and +`useSheetScaleAnimatedStyle(sheetId)`; the depth hooks are module-private. #### `useSheetRenderData.ts` - Render Order Logic **Purpose**: Determines which sheets to render and in what order. **Render Order**: 1. Hidden persistent sheets (keepMounted=true, not in stack) -2. Active sheets (in stackOrder) +2. Active sheets (in its group's stack) This prevents React from unmounting/remounting during state transitions. -#### `useEvent.ts` - Stable Callback Utility -**Purpose**: RFC useEvent implementation - stable function identity with latest closure. - -**Usage**: Used in `BottomSheetPersistent` for mount callback. +#### `useStableCallback.ts` - Stable Callback Utility +**Purpose**: RFC useEvent implementation — stable function identity with latest closure. + +**Named `useStableCallback`, not `useEvent`**, because `react-native-reanimated` +exports an unrelated `useEvent` for native event handlers and adapters import +both side by side (`SwmansionSheetAdapter` does). Do not rename it back. + +**Usage**: `BottomSheetPersistent`'s mount callback. Holds the codebase's only +sanctioned `useCallback` — see the memoization section at the top. + +### Adapter-Facing Hooks + +All four are **public exports**, so a third-party adapter can reach parity with +the shipped ones (this was P7 in `API-REVIEW.md`). + +#### `useAdapterRef.ts` +`useAdapterRef(forwardedRef)` returns the ref an adapter should pass to +`useImperativeHandle`: the one from `BottomSheetRefContext` when present +(portal/persistent), otherwise the forwarded one (inline). One line, but it is +what lets a single adapter work in all three modes. + +#### `useAnimatedIndex.ts` +`ensureAnimatedIndex(id)` for the current sheet, read from context. The value is +`-1` hidden → `0` fully visible; the backdrop interpolates it. + +**Drive it continuously.** Setting it discretely in expand/close snaps the +backdrop to full opacity a whole animation ahead of the sheet. That was B5, and +all three offending adapters were fixed: `CustomModalAdapter` derives it from +its own `progress` shared value, `ReactNativeModalAdapter` runs `withTiming` +over the modal's own `animationInTiming`/`OutTiming`, `ActionsSheetAdapter` +runs `withSpring` with the sheet's own open/close configs, and gorhom/swmansion +get a continuous position from the library itself. + +#### `useBackHandler.ts` +`useBackHandler(id, onBackPress)`. The listener is only registered while the +sheet is fully open **and** topmost **in its own group** (via +`useIsTopmostAndOpen`, which resolves the group from the sheet). Used by gorhom, +custom-modal and swmansion; rn-modal and actions-sheet route their library's own +back callback into `handleDismiss` instead. + +#### `useSetBackdrop` / `useSheetPreventDismiss` (from `store/hooks.ts`) +Re-exported from `index.tsx` for adapter authors. +- `useSetBackdrop()` returns `setBackdrop(id, boolean)` — suppress the manager's + shared backdrop when the adapter renders its own. Only `GorhomSheetAdapter` + uses it, and only when given a custom `backdropComponent`. +- `useSheetPreventDismiss(id)` returns whether an interceptor is blocking, so + the adapter can disable its native gestures. Every shipped adapter reads it + **except `CustomModalAdapter`**, which has no dismiss gesture of its own. ### Context Files @@ -312,7 +558,17 @@ Provides current sheet ID to children. Used by `useBottomSheetContext`. Provides groupId and scaleConfig to all components within a manager. #### `BottomSheetRef.context.ts` -Passes sheet ref from Persistent/Portal to Managed without user intervention. +Passes the sheet ref from `BottomSheetPersistent` / `BottomSheetPortal` down to +the adapter, which picks it up via `useAdapterRef()` — so the user never wires a +ref by hand. Read with `useMaybeBottomSheetRef()` (may be `null`: inline mode +has no ref context and uses the forwarded ref instead). + +#### `BottomSheetDefaultIndex.context.ts` +Supplies the adapter's initial `index`: `0` from `BottomSheetPortal` (a portal +sheet renders only once it is being opened) and `-1` from +`BottomSheetPersistent` (which is mounted long before it is opened, and must +start closed). `useBottomSheetDefaultIndex()` defaults to `0` with no provider. +Currently consumed by `GorhomSheetAdapter`. ### Type Definitions @@ -329,10 +585,17 @@ declare module 'react-native-bottom-sheet-stack' { } ``` -**Key Types**: +**Key Types** (public — exported from `index.tsx`): +- `BottomSheetPortalRegistry` - The interface consumers augment - `BottomSheetPortalId` - Union of registered sheet IDs (or `string` if no registry) -- `BottomSheetPortalParams` - Params type for specific sheet ID -- `HasParams` - Boolean type for param requirement checking +- `BottomSheetPortalParams` - Params type for a specific sheet ID. **Always + unions `| undefined`**, even for required params, because `resetParams()` can + clear them on an open sheet. Consumers must read `params?.foo`. + +**Internal** (defined here, *not* exported from `index.tsx`): +- `HasParams` - Boolean type driving whether `open()` requires a `params` + property. Only `useBottomSheetControl` consumes it. Keep it unexported — + exporting it would semver-lock a helper that exists to shape one signature. --- @@ -355,7 +618,7 @@ declare module 'react-native-bottom-sheet-stack' { │ (coordinator calls ref.expand(), animation starting) │ └─────────────────────────────────────────────────────────────┘ │ - handleChange(index >= 0) + handleOpened() │ ▼ ┌─────────────────────────────────────────────────────────────┐ @@ -371,7 +634,7 @@ declare module 'react-native-bottom-sheet-stack' { │ (coordinator calls ref.close(), animation running) │ └─────────────────────────────────────────────────────────────┘ │ - handleClose() + handleClosed() │ ┌────────────────┴────────────────┐ ▼ ▼ @@ -405,7 +668,7 @@ import { GorhomSheetAdapter } from 'react-native-bottom-sheet-stack/gorhom'; const { open, close } = useBottomSheetManager(); -// Open with inline content +// Open with inline content — `string | null` const id = open( @@ -413,8 +676,10 @@ const id = open( { scaleBackground: true, mode: 'push' } ); -// Close by ID -close(id); +// Close by ID — guard, since a rejected open returns null +if (id !== null) { + await close(id); +} ``` **Data Flow**: @@ -509,20 +774,20 @@ open(); // Reopens with previous state intact **Data Flow**: 1. On component mount: `mount()` action creates sheet with `status: 'hidden'` -2. Sheet exists in `sheetsById` but NOT in `stackOrder` -3. On `open()`: Sheet added to `stackOrder`, status → 'opening' -4. On close: Status → 'hidden', removed from `stackOrder` but KEPT in `sheetsById` +2. Sheet exists in `sheetsById` but NOT in its group's stack +3. On `open()`: Sheet added to its group's stack, status → 'opening' +4. On close: Status → 'hidden', removed from its group's stack but KEPT in `sheetsById` 5. Content stays mounted (just hidden), state preserved 6. On component unmount: `unmount()` removes from store completely **Lifecycle Diagram**: ``` -Component Mount → store.mount() → status: 'hidden' (in sheetsById, not in stackOrder) +Component Mount → store.mount() → status: 'hidden' (in sheetsById, not in its group's stack) │ open() called │ ▼ - status: 'opening' (added to stackOrder) + status: 'opening' (added to its group's stack) │ animation done │ @@ -533,7 +798,7 @@ Component Mount → store.mount() → status: 'hidden' (in sheetsById, not in st │ ▼ status: 'closing' → 'hidden' - (removed from stackOrder, kept in sheetsById) + (removed from its group's stack, kept in sheetsById) Content stays mounted! State preserved! │ open() again @@ -563,24 +828,24 @@ Component Mount → store.mount() → status: 'hidden' (in sheetsById, not in st INLINE MODE (useBottomSheetManager): ┌─────────────────────────────────────────────────────┐ │ sheetsById: { 'abc123': { content: , ... } } │ -│ stackOrder: ['abc123'] │ +│ stackOrderByGroup: { default: ['abc123'] } │ └─────────────────────────────────────────────────────┘ After close: Sheet DELETED from sheetsById PORTAL MODE (BottomSheetPortal): ┌─────────────────────────────────────────────────────┐ │ sheetsById: { 'user-sheet': { usePortal: true } } │ -│ stackOrder: ['user-sheet'] │ +│ stackOrderByGroup: { default: ['user-sheet'] } │ └─────────────────────────────────────────────────────┘ After close: Sheet DELETED from sheetsById PERSISTENT MODE (BottomSheetPersistent): ┌──────────────────────────────────────────────────────────────────┐ │ sheetsById: { 'scanner': { usePortal: true, keepMounted: true } }│ -│ stackOrder: ['scanner'] │ +│ stackOrderByGroup: { default: ['scanner'] } │ └──────────────────────────────────────────────────────────────────┘ After close: Sheet KEPT in sheetsById with status: 'hidden' - Removed from stackOrder only + Removed from its group's stack only ``` --- @@ -635,12 +900,18 @@ type ScaleAnimationConfig = ### How It Works -1. **Depth Calculation** (`useScaleDepth`): - - Counts sheets with `scaleBackground: true` above current position - - For `BottomSheetScaleView`: Counts all scaleBackground sheets (binary 0 or 1) - - For sheets: Counts scaleBackground sheets above it in stack - -2. **Power Scaling**: +1. **Depth Calculation** — two different hooks, see `useScaleAnimation.ts` above: + - `useBackgroundScaleDepth(groupId)` for `BottomSheetScaleView`: binary `0`/`1`, + taken from the `scaleBackground` flag of the **first live sheet** in the + group's stack. It stops at that sheet — it does *not* count all scaling + sheets, so a stack whose bottom-most live sheet is `false` yields `0` + regardless of what is above it. + - `useSheetScaleDepth(groupId, sheetId)` for an individual sheet: counts the + live `scaleBackground` sheets strictly above it in its own group's stack. + +2. **Power Scaling** (`p` is the animated depth; an empty style is returned when + `p` is 0, because an identity transform on the first frame collapses layout + in RN 0.85's animation backend): ```typescript currentScale = scale^depth // e.g., 0.92^2 = 0.8464 currentTranslateY = translateY * depth @@ -668,8 +939,8 @@ Multiple `BottomSheetManagerProvider` instances can run independently: ``` Each group: -- Has its own stackOrder -- Sheets filtered by groupId in coordinator +- Has its own entry in `stackOrderByGroup` +- Stack is keyed by group, so no filtering is needed anywhere - `clearGroup(groupId)` clears only that group --- @@ -701,12 +972,12 @@ Each group: ## Testing Utilities ```typescript -import { __resetSheetRefs, __resetAnimatedIndexes } from 'react-native-bottom-sheet-stack'; +import { resetBottomSheetRegistries } from 'react-native-bottom-sheet-stack/testing'; -beforeEach(() => { - __resetSheetRefs(); - __resetAnimatedIndexes(); -}); +// Clears the store and every module-level registry (refs, animated values, +// portal sessions, onBeforeClose). One call, so it cannot go stale as +// registries are added. +beforeEach(resetBottomSheetRegistries); ``` --- @@ -766,17 +1037,21 @@ open({ mode: 'replace' }); ``` src/ ├── index.tsx # Public exports (no 3rd-party adapter deps) -├── bottomSheet.store.ts # Zustand store (state + actions) +├── testing.ts # → 'react-native-bottom-sheet-stack/testing' +├── store/ # Zustand store (store/hooks/helpers/types) ├── bottomSheetCoordinator.ts # Store ↔ adapter sync ├── refsMap.ts # Global sheet refs registry ├── animatedRegistry.ts # Global animated values registry +├── onBeforeCloseRegistry.ts # Global close-interceptor registry +├── portalSessionRegistry.ts # Global portal session counters ├── adapter.types.ts # SheetAdapterRef, SheetAdapterEvents types ├── portal.types.ts # Type-safe portal registry types │ -├── BottomSheetManager.provider.tsx # Root provider component -├── BottomSheetManager.context.tsx # Manager context definition -├── BottomSheet.context.ts # Sheet context definition -├── BottomSheetRef.context.ts # Ref context definition +├── BottomSheetManager.provider.tsx # Root provider component +├── BottomSheetManager.context.tsx # Manager context + useMaybe… hooks +├── BottomSheet.context.ts # Sheet context definition +├── BottomSheetRef.context.ts # Ref context definition +├── BottomSheetDefaultIndex.context.ts # Initial adapter index (0 portal / -1 persistent) │ ├── BottomSheetHost.tsx # Sheet queue renderer ├── QueueItem.tsx # Individual sheet slot @@ -789,12 +1064,13 @@ src/ ├── useBottomSheetControl.ts # Portal sheet control hook ├── useBottomSheetContext.ts # Sheet internal context hook ├── useBottomSheetStatus.ts # External status monitoring hook +├── useOnBeforeClose.ts # Close interception hook ├── useAdapterRef.ts # Adapter ref helper hook ├── useAnimatedIndex.ts # Animated index context hook ├── useBackHandler.ts # Android back button handler ├── useScaleAnimation.ts # Scale animation hooks ├── useSheetRenderData.ts # Render order computation hook -├── useEvent.ts # Stable callback utility +├── useStableCallback.ts # Stable callback utility (RFC useEvent) │ └── adapters/ # Each adapter is a separate subpath export ├── gorhom-sheet/ # → 'react-native-bottom-sheet-stack/gorhom' @@ -811,9 +1087,14 @@ src/ │ └── ActionsSheetAdapter.tsx └── swmansion/ # → 'react-native-bottom-sheet-stack/swmansion' ├── index.ts - └── SwmansionSheetAdapter.tsx + ├── SwmansionSheetAdapter.tsx + └── SwmansionKeyboardInset.tsx # keyboardBehavior="inset" (optional peer) ``` +Note there is no `adapters/index.ts` barrel — it was deleted. A barrel would +import every adapter, which is exactly the Metro resolution error the subpath +exports exist to avoid. Import each adapter from its own directory. + --- ## Dependencies Graph @@ -821,7 +1102,7 @@ src/ ``` CORE (main entry — no 3rd-party bottom sheet deps): react-native-reanimated ──────▶ bottomSheetCoordinator, useScaleAnimation -zustand ──────────────────────▶ bottomSheet.store +zustand ──────────────────────▶ store/ (store.ts, hooks.ts) react-native-teleport ────────▶ BottomSheetPortal, BottomSheetPersistent, QueueItem react-native-safe-area-context ▶ QueueItem (useSafeAreaFrame) @@ -870,6 +1151,7 @@ Adapters with 3rd-party dependencies are shipped as **separate subpath exports** ```json { ".": "./lib/commonjs/index.js", + "./testing": "./lib/commonjs/testing.js", "./gorhom": "./lib/commonjs/adapters/gorhom-sheet/index.js", "./react-native-modal": "./lib/commonjs/adapters/react-native-modal/index.js", "./actions-sheet": "./lib/commonjs/adapters/actions-sheet/index.js", @@ -890,7 +1172,11 @@ import { ActionsSheetAdapter } from 'react-native-bottom-sheet-stack/actions-she import { SwmansionSheetAdapter } from 'react-native-bottom-sheet-stack/swmansion'; ``` -**Backward compatibility**: `BottomSheetManaged` and `BottomSheetManagedProps` are available as deprecated re-exports from the gorhom subpath. +**Deprecated aliases were removed in 2.0**: use `GorhomSheetAdapter` / `GorhomSheetAdapterProps`, `CustomModalAdapter`, `useBottomSheetContext`, `open`, `close` and `destroyAll` directly. + +**One exception to that renaming**: `CustomModalAdapter`'s props type is still +exported as **`ModalAdapterProps`**, not `CustomModalAdapterProps`. The component +was renamed; the props type was not. Don't "fix" this without a major bump. **Example app (monorepo dev)**: RNBB's `babel-plugin-module-resolver` alias breaks subpath imports. The example's `babel.config.js` adds a separate module-resolver plugin with explicit subpath aliases that runs before RNBB's override. Consumer apps do NOT need this — Metro reads `exports` from package.json directly. @@ -898,10 +1184,13 @@ import { SwmansionSheetAdapter } from 'react-native-bottom-sheet-stack/swmansion ## Pitfalls to Avoid -1. **DO NOT memoize** - React Compiler handles it +1. **DO NOT memoize** - React Compiler handles it. `useStableCallback` is the one sanctioned exception 2. **DO NOT store refs in Zustand** - Use refsMap instead 3. **DO NOT forget `BottomSheetHost`** - Sheets won't render without it 4. **DO NOT nest `BottomSheetScaleView` around `BottomSheetHost`** - They must be siblings -5. **DO NOT use same sheet ID in multiple groups** - IDs must be globally unique -6. **DO NOT call `open()` on already-open sheet** - It's a no-op by design +5. **DO NOT use same sheet ID in multiple groups** - IDs must be globally unique; the store rejects a cross-group open with `'group-mismatch'` +6. **DO NOT call `open()` on an already-open sheet** - It does not open, returns `{ opened: false, reason: 'already-active' }` and warns in `__DEV__`. Use `updateParams()` to change an open sheet, or close it first 7. **DO NOT export 3rd-party adapters from `src/index.tsx`** - They must use subpath exports to avoid Metro resolution errors +8. **DO NOT set `animatedIndex` discretely in an adapter** - The backdrop snaps a whole animation ahead of the sheet. Animate it alongside your own animation +9. **DO NOT branch on `isOpen` for "is it on screen"** - It excludes `'opening'`. Use `isVisible` +10. **DO NOT read `params` without `?.`** - `BottomSheetPortalParams` always unions `| undefined` diff --git a/docs/docs/adapters.md b/docs/docs/adapters.md index b70056f..6cbeade 100644 --- a/docs/docs/adapters.md +++ b/docs/docs/adapters.md @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 10 --- # Library-Agnostic Architecture @@ -15,7 +15,7 @@ The stack manager controls **when** sheets open and close. Adapters control **ho │ Stack Manager (core) │ │ ┌─────────────────────────────────────────┐ │ │ │ Zustand Store │ │ -│ │ - stackOrder, sheetsById │ │ +│ │ - sheetsById, stackOrderByGroup │ │ │ │ - push / switch / replace │ │ │ │ - scale animations, portals │ │ │ └─────────────┬───────────────────────────┘ │ @@ -108,13 +108,3 @@ Third-party adapters are shipped as separate [subpath exports](https://nodejs.or ### Custom You can [build your own adapter](/custom-adapters) for any overlay library. - -## Backward Compatibility - -`BottomSheetManaged` is available as a deprecated re-export from the gorhom subpath: - -```tsx -// These are equivalent: -import { GorhomSheetAdapter } from 'react-native-bottom-sheet-stack/gorhom'; -import { BottomSheetManaged } from 'react-native-bottom-sheet-stack/gorhom'; -``` diff --git a/docs/docs/api/components.md b/docs/docs/api/components.md index c1ea0c2..e2485d0 100644 --- a/docs/docs/api/components.md +++ b/docs/docs/api/components.md @@ -73,7 +73,8 @@ Adapters with 3rd-party dependencies are shipped as separate subpath exports: | `SwmansionSheetAdapter` | `react-native-bottom-sheet-stack/swmansion` | `@swmansion/react-native-bottom-sheet` | [SwmansionSheetAdapter](/built-in-adapters/swmansion) | :::tip -`BottomSheetManaged` is available as a deprecated re-export from `react-native-bottom-sheet-stack/gorhom` for backward compatibility. +Each sheet in the stack picks its own adapter — bottom sheets and modals can be +mixed freely in one stack. ::: See [Library-Agnostic Architecture](/adapters) for how adapters work, or [Building Custom Adapters](/custom-adapters) to create your own. diff --git a/docs/docs/api/hooks.md b/docs/docs/api/hooks.md index 2e94634..fd3fb64 100644 --- a/docs/docs/api/hooks.md +++ b/docs/docs/api/hooks.md @@ -4,7 +4,10 @@ sidebar_position: 2 # Hooks -Hooks are divided into two categories based on where they can be used: +Hooks fall into two groups: the ones you use to drive sheets from your app, and +the ones an [adapter](/custom-adapters) uses to wire a UI library into the stack. + +### App hooks | Hook | Where to use | Purpose | |------|--------------|---------| @@ -14,6 +17,19 @@ Hooks are divided into two categories based on where they can be used: | `useBottomSheetContext` | **Inside sheet only** | Access current sheet's state and params | | `useOnBeforeClose` | **Inside sheet only** | Intercept close and optionally prevent it | +### Adapter hooks + +Exported for [custom adapter authors](/custom-adapters) — every shipped adapter +is built from these. You do not need them to use the library. + +| Hook | Where to use | Purpose | +|------|--------------|---------| +| `useAdapterRef` | **Inside adapter only** | Resolve the right ref for inline/portal/persistent mode | +| `useAnimatedIndex` | **Inside adapter only** | The sheet's `animatedIndex` shared value, driving backdrop and scale | +| `useBackHandler` | **Inside adapter only** | Android back button, scoped to the topmost open sheet **of its own group** | +| `useSetBackdrop` | Anywhere | Returns `setBackdrop(id, boolean)` — suppress the manager's shared backdrop for a sheet that renders its own | +| `useSheetPreventDismiss` | Anywhere | `useSheetPreventDismiss(id)` — whether an interceptor is currently blocking dismissal, so the adapter can disable native gestures | + --- ## useBottomSheetManager @@ -21,17 +37,17 @@ Hooks are divided into two categories based on where they can be used: Main hook for opening and managing bottom sheets imperatively. ```tsx -const { open, close, closeAll, clear } = useBottomSheetManager(); +const { open, close, closeAll, destroyAll } = useBottomSheetManager(); ``` ### Returns | Property | Type | Description | |----------|------|-------------| -| `open` | `(content, options?) => string` | Opens a bottom sheet and returns its ID | -| `close` | `(id: string) => void` | Closes a specific sheet by ID | -| `closeAll` | `(options?) => Promise` | Closes all sheets with cascading animation | -| `clear` | `() => void` | Removes all sheets immediately (no animation) | +| `open` | `(content, options?) => string \| null` | Opens a bottom sheet and returns its ID, or `null` if the store declined | +| `close` | `(id: string) => Promise` | Closes a specific sheet by ID | +| `closeAll` | `(options?) => Promise` | Closes all sheets with cascading animation | +| `destroyAll` | `() => void` | Removes all sheets immediately — no animation, **bypasses `onBeforeClose`** | ### closeAll Options @@ -56,10 +72,11 @@ await closeAll({ stagger: 0 }); ```tsx open(, { - id: 'my-sheet-id', // Custom ID (optional) - groupId: 'my-group', // Custom group (optional) - mode: 'push', // 'push' | 'switch' | 'replace' - scaleBackground: true, // Enable scale animation + id: 'my-sheet-id', // Custom ID (optional) + groupId: 'my-group', // Custom group (optional) + mode: 'push', // 'push' | 'switch' | 'replace' + scaleBackground: true, // Enable scale animation + params: { userId: '1' }, // Readable via useBottomSheetContext() }); ``` @@ -69,14 +86,50 @@ open(, { | `groupId` | `string` | context or `'default'` | Group ID for the sheet | | `mode` | `OpenMode` | `'push'` | Navigation mode | | `scaleBackground` | `boolean` | `false` | Enable background scaling | -| `backdrop` | `boolean` | `true` | When `false`, the manager's shared backdrop is not rendered for this sheet. Built-in adapters set this automatically when you give them their own backdrop (e.g. a custom gorhom `backdropComponent`), so you rarely set it by hand. | +| `backdrop` | `boolean` | `true` | When `false`, the manager's shared backdrop is not rendered for this sheet. `GorhomSheetAdapter` sets this itself when you pass it a custom `backdropComponent`; the other shipped adapters use the manager's backdrop and never touch it. | +| `params` | `Record` | - | Params for the sheet, readable inside it via `useBottomSheetContext()`. Untyped here — the typed variant lives on `useBottomSheetControl` | + +`open()` returns the sheet's ID, or **`null`** when the store declined to open it — because the sheet is already on the stack, or another sheet in the group is still animating open. A dev-mode warning explains which. + +```tsx +const id = open(); +if (id === null) { + // Not opened. Nothing to close, nothing to track. +} +``` -### Deprecated Aliases +### `destroyAll()` vs `closeAll()` -| Deprecated | Use Instead | -|------------|-------------| -| `openBottomSheet` | `open` | -| `clearAll` | `clear` | +| | `closeAll()` | `destroyAll()` | +|---|---|---| +| Animation | staggered cascade | none | +| `onBeforeClose` | respected | **bypassed** | +| Returns | `Promise` | `void` | + +`destroyAll()` is a teardown primitive — it drops every sheet in the group from the store immediately, without asking an interceptor that may be guarding unsaved work. Use `closeAll()` for anything user-facing. + +### Close results + +Every `close()` resolves to a `CloseResult`, and `closeAll()` to a `CloseAllResult`. Both carry more than a boolean, because "the user declined" and "there was nothing to close" are different answers: + +```tsx +const result = await close(id); +if (!result.closed) { + switch (result.reason) { + case 'blocked': // an onBeforeClose interceptor said no + case 'interceptor-error': // the interceptor threw; cancelled for safety + case 'not-closable': // already closing, hidden, or unknown sheet + } +} + +const cascade = await closeAll(); +if (!cascade.closedAll) { + // cascade.stoppedAt — the sheet whose interceptor stopped it. + // cascade.closed — the ones that did close, topmost first. +} +``` + +A sheet with nothing to close no longer stops a cascade — only a refusal does. --- @@ -103,9 +156,16 @@ Pass the portal sheet ID as a generic to get typed params: ```tsx // If registry defines: 'user-sheet': { userId: string } const { params } = useBottomSheetContext<'user-sheet'>(); -console.log(params.userId); // type-safe: string +console.log(params?.userId); // type-safe: string | undefined ``` +:::note Always optional +`BottomSheetPortalParams` resolves to `{ userId: string } | undefined`, even +when the registry marks the params as required. `resetParams()` can clear them +while the sheet is open, so the sheet must handle their absence — under `strict`, +`params.userId` is a type error. Read them with `params?.userId`. +::: + ### Returns | Property | Type | Description | @@ -113,16 +173,9 @@ console.log(params.userId); // type-safe: string | `id` | `string` | Current sheet's ID | | `params` | `BottomSheetPortalParams` or `unknown` | Type-safe params when generic provided | | `preventDismiss` | `boolean` | Whether dismissal is currently blocked for this sheet (set via `useOnBeforeClose`). Useful for UI that should reflect it — e.g. hiding a grab handle. | -| `close` | `() => void` | Closes this sheet (respects `useOnBeforeClose`) | +| `close` | `() => Promise` | Closes this sheet (respects `useOnBeforeClose`). See [Close results](#close-results). | | `forceClose` | `() => void` | Closes this sheet immediately, bypassing any `useOnBeforeClose` interceptor | -### Deprecated Aliases - -| Deprecated | Use Instead | -|------------|-------------| -| `useBottomSheetState` | `useBottomSheetContext` | -| `closeBottomSheet` | `close` | - --- ## useBottomSheetControl @@ -147,9 +200,9 @@ const { open, close, closeAll, updateParams, resetParams } = useBottomSheetContr | Property | Type | Description | |----------|------|-------------| -| `open` | `(options?) => void` | Opens the sheet | -| `close` | `() => void` | Closes the sheet (respects `useOnBeforeClose`) | -| `closeAll` | `(options?) => Promise` | Closes all sheets with cascading animation | +| `open` | `(options?) => boolean` | Opens the sheet. `false` if the store declined | +| `close` | `() => Promise` | Closes the sheet (respects `useOnBeforeClose`) | +| `closeAll` | `(options?) => Promise` | Closes all sheets with cascading animation | | `updateParams` | `(params) => void` | Updates the sheet's params | | `resetParams` | `() => void` | Resets params to `undefined` | @@ -172,9 +225,11 @@ open({ |--------|------|---------|-------------| | `mode` | `OpenMode` | `'push'` | Navigation mode | | `scaleBackground` | `boolean` | `false` | Enable background scaling | -| `backdrop` | `boolean` | `true` | When `false`, the manager's shared backdrop is not rendered for this sheet. Built-in adapters set this automatically when given their own native backdrop/scrim, so you rarely set it by hand. | +| `backdrop` | `boolean` | `true` | When `false`, the manager's shared backdrop is not rendered for this sheet. `GorhomSheetAdapter` sets this itself when you pass it a custom `backdropComponent`; the other shipped adapters use the manager's backdrop and never touch it. | | `params` | `BottomSheetPortalParams` | - | Type-safe params | +`useBottomSheetManager().open()` also accepts `params` now, so inline sheets can read them from `useBottomSheetContext()` just like portal sheets. + --- ## useBottomSheetStatus @@ -191,23 +246,36 @@ const { status, isOpen } = useBottomSheetStatus('my-sheet'); // Inline sheet (using ID from open()) const { open } = useBottomSheetManager(); -const sheetId = open(); -// ... -const { status, isOpen } = useBottomSheetStatus(sheetId); +const [sheetId, setSheetId] = useState(null); + +const handleOpen = () => { + // open() returns `string | null` — null means the store declined + setSheetId(open()); +}; + +// The hook needs a string, so fall back to an ID that matches nothing +const { status, isOpen } = useBottomSheetStatus(sheetId ?? ''); ``` ### Parameters | Parameter | Type | Description | |-----------|------|-------------| -| `id` | `string` | The sheet ID to observe (portal ID or inline sheet ID) | +| `id` | `BottomSheetPortalId \| (string & {})` | The sheet ID to observe. Registered portal IDs get completion; the random IDs from `open()` are accepted too | ### Returns | Property | Type | Description | |----------|------|-------------| | `status` | `BottomSheetStatus \| null` | Current status or `null` if never opened | -| `isOpen` | `boolean` | `true` if status is `'open'` or `'opening'` | +| `isOpen` | `boolean` | Fully open and interactive — **not** during the opening animation | +| `isOpening` | `boolean` | Animating in | +| `isClosing` | `boolean` | Animating out | +| `isVisible` | `boolean` | On screen in any form: opening, open, or closing | + +:::warning `isOpen` narrowed in 2.0 +It used to be `true` during the opening animation as well. If you were using it to mean "on screen", switch to `isVisible`. +::: ### Status Values @@ -305,3 +373,35 @@ When active, the hook: Use `forceClose()` from `useBottomSheetContext` to bypass the interceptor entirely. See [Close Interception](/close-interception) for detailed guide and examples. + +--- + +## Testing + +Test helpers ship on the `react-native-bottom-sheet-stack/testing` subpath, so +they stay out of your production bundle. + +```tsx +import { resetBottomSheetRegistries } from 'react-native-bottom-sheet-stack/testing'; + +beforeEach(resetBottomSheetRegistries); +``` + +### resetBottomSheetRegistries + +Clears the store **and** every module-level registry the library keeps: sheet +refs, animated index values, portal sessions and `onBeforeClose` interceptors. + +Those registries are module state, so they outlive React. Without this, a test +that opens a sheet leaves its ref and animated value behind for the next test, +which then sees a sheet it never opened. Prefer this one call over resetting +registries by hand — it cannot go out of date as registries are added. + +| Export | Type | Description | +|--------|------|-------------| +| `resetBottomSheetRegistries` | `() => void` | Clears the store and all registries | + +:::warning Tests only +Nothing on this subpath is meant for application code. It clears state without +running animations or `onBeforeClose` interceptors. +::: diff --git a/docs/docs/api/types.md b/docs/docs/api/types.md index a48007a..56a9a61 100644 --- a/docs/docs/api/types.md +++ b/docs/docs/api/types.md @@ -41,39 +41,111 @@ type OpenMode = 'push' | 'switch' | 'replace'; ### BottomSheetState -Full state object for a bottom sheet. +The stable, public part of a sheet's state. ```tsx interface BottomSheetState { id: string; groupId: string; status: BottomSheetStatus; - content: ReactNode; - scaleBackground?: boolean; - usePortal?: boolean; params?: Record; + scaleBackground?: boolean; keepMounted?: boolean; - preventDismiss?: boolean; } ``` +:::note Narrowed in 2.0 +`content`, `usePortal`, `portalSession` and `preventDismiss` were removed from this type. They are store plumbing — read `preventDismiss` from `useBottomSheetContext()` instead. +::: + | Property | Type | Description | |----------|------|-------------| | `keepMounted` | `boolean` | When `true`, sheet stays in store after close (persistent mode) | -| `preventDismiss` | `boolean` | When `true`, adapters block native dismiss gestures. Set by `useOnBeforeClose`. | --- -### BottomSheetRef +## Result Types + +Both `open()` and `close()` report why they did nothing, rather than failing +silently. See [Close results](/api/hooks#close-results) for how to consume them. + +### OpenResult -Backward-compatible alias for `SheetAdapterRef`. Ref type for all adapters. +Outcome of an `open()` call on the store. ```tsx -import type { BottomSheetRef } from 'react-native-bottom-sheet-stack'; +type OpenResult = + | { opened: true; id: string } + | { opened: false; id: string; reason: OpenRejectionReason }; +``` + +The public hooks project this down to the currency that is useful at each call +site: `useBottomSheetManager().open()` returns `string | null` (the ID, or +`null`), and `useBottomSheetControl().open()` returns `boolean`. + +--- + +### OpenRejectionReason -const sheetRef = useRef(null); +Why an `open()` call did not put the sheet on the stack. Each also logs a +`__DEV__` warning explaining which. + +```tsx +type OpenRejectionReason = 'already-active' | 'group-busy' | 'group-mismatch'; ``` +| Reason | Meaning | +|--------|---------| +| `already-active` | The sheet is already on the stack. Re-opening an active sheet is a no-op — close it first, or use `updateParams()` | +| `group-busy` | Another sheet in the same group is still animating open. Wait for it to settle (`useBottomSheetStatus`) | +| `group-mismatch` | The sheet is registered to a different manager group than the one opening it. A sheet belongs to the group that mounted it | + +--- + +### CloseResult + +Outcome of a close. Returned by `close()` on `useBottomSheetManager`, +`useBottomSheetControl` and `useBottomSheetContext`. + +```tsx +type CloseResult = + | { closed: true } + | { closed: false; reason: CloseRejectionReason }; +``` + +--- + +### CloseRejectionReason + +```tsx +type CloseRejectionReason = 'blocked' | 'interceptor-error' | 'not-closable'; +``` + +| Reason | Meaning | +|--------|---------| +| `blocked` | A [`useOnBeforeClose`](/close-interception) interceptor declined | +| `interceptor-error` | The interceptor threw; the close is cancelled for safety | +| `not-closable` | There was nothing to close — already closing, hidden, or unknown sheet. Distinct from `blocked`: no interceptor had an opinion | + +--- + +### CloseAllResult + +Outcome of a cascading `closeAll()`. + +```tsx +interface CloseAllResult { + /** Whether every sheet in the group closed. */ + closedAll: boolean; + /** IDs that closed, topmost first. */ + closed: string[]; + /** The sheet whose interceptor stopped the cascade, if one did. */ + stoppedAt?: string; +} +``` + +A sheet with nothing to close does not stop the cascade — only a refusal does. + --- ## Adapter Types @@ -223,6 +295,13 @@ type Params = BottomSheetPortalParams<'settings-sheet'>; // Result: undefined ``` +:::note `undefined` is always in the union +Even for a sheet with required params, the resolved type is `T | undefined` — +`resetParams()` can clear params on an open sheet, so a sheet reading its own +params can always find them missing. Under `strict`, read them optionally: +`params?.userId`. +::: + --- ## Hook Return Types @@ -233,9 +312,10 @@ Return type of `useBottomSheetControl` hook. ```tsx interface UseBottomSheetControlReturn { + /** `false` when the store declined — see OpenRejectionReason. */ open: OpenFunction; - close: () => void; - closeAll: (options?: CloseAllOptions) => Promise; + close: () => Promise; + closeAll: (options?: CloseAllOptions) => Promise; updateParams: (params: BottomSheetPortalParams) => void; resetParams: () => void; } @@ -252,7 +332,7 @@ interface UseBottomSheetContextReturn { id: string; params: TParams; preventDismiss: boolean; - close: () => void; + close: () => Promise; forceClose: () => void; } ``` @@ -266,7 +346,12 @@ Return type of `useBottomSheetStatus` hook. ```tsx interface UseBottomSheetStatusReturn { status: BottomSheetStatus | null; + /** Fully open and interactive — not during the opening animation. */ isOpen: boolean; + isOpening: boolean; + isClosing: boolean; + /** On screen in any form: opening, open, or closing. */ + isVisible: boolean; } ``` diff --git a/docs/docs/built-in-adapters/actions-sheet.md b/docs/docs/built-in-adapters/actions-sheet.md index ef2e409..342c1c7 100644 --- a/docs/docs/built-in-adapters/actions-sheet.md +++ b/docs/docs/built-in-adapters/actions-sheet.md @@ -29,10 +29,38 @@ function MyActionsSheet() { ## Props -All [`react-native-actions-sheet` props](https://github.com/ammarahm-ed/react-native-actions-sheet#actionsheet-props) are accepted via spread. +`ActionsSheetAdapterProps` is `Omit` — most of +[`react-native-actions-sheet`'s props](https://github.com/ammarahm-ed/react-native-actions-sheet#actionsheet-props) +pass straight through, but five are removed from the type because the manager +owns them. -Adapter defaults (overridable): `gestureEnabled`, `closeOnTouchBackdrop`, `closeOnPressBack`, `keyboardHandlerEnabled`. +**Managed by the adapter (not accepted):** -:::tip -This adapter uses `isModal={false}` internally to avoid wrapping in a redundant Modal — the stack manager handles the overlay lifecycle. +| Prop | Why | +|------|-----| +| `isModal` | Forced `false` — wrapping in a native Modal would take the sheet out of the stack's z-index layering. The manager handles the overlay lifecycle | +| `defaultOverlayOpacity` | Forced `0` — the library paints its own overlay regardless of `isModal`, which would stack on the manager's `BottomSheetBackdrop` as a double-dark layer | +| `onOpen` | Consumed → starts the backdrop fade-in, then `handleOpened()` | +| `onClose` | Consumed → starts the backdrop fade-out, then `handleClosed()` | +| `onBeforeClose` | Consumed → `handleDismiss()` | + +**Adapter defaults (yours wins):** + +| Prop | Default | Note | +|------|---------|------| +| `gestureEnabled` | `true` | Set to `false` while a [`useOnBeforeClose`](/close-interception) interceptor is blocking dismissal | +| `keyboardHandlerEnabled` | `true` | | + +:::info Backdrop timing +`openAnimationConfig` and `closeAnimationConfig` do double duty: the adapter +springs the manager's backdrop with the same config, so the fade rides the +sheet's own curve. `onOpen` / `onClose` fire when the sheet *starts* moving, +which is what lets the two run together. +::: + +:::note Blocked dismissal keeps its escape hatches +Only the swipe gesture is disabled while an interceptor is blocking. Back button +and backdrop tap stay enabled, because they route through `onBeforeClose` into +the manager's interceptor — which is what produces the confirmation prompt. +Disabling them natively would make the sheet silently undismissable. ::: diff --git a/docs/docs/built-in-adapters/gorhom.md b/docs/docs/built-in-adapters/gorhom.md index 945dd9f..2ee84e0 100644 --- a/docs/docs/built-in-adapters/gorhom.md +++ b/docs/docs/built-in-adapters/gorhom.md @@ -3,7 +3,9 @@ The default adapter. Wraps `@gorhom/bottom-sheet` to provide feature-rich bottom sheets with snap points, spring animations, and swipe gestures. :::tip -`BottomSheetManaged` is available as a deprecated re-export from the same subpath for backward compatibility. +`@gorhom/bottom-sheet` is an optional peer dependency — install it only if you +use this adapter. It is imported from the `/gorhom` subpath, never from the +main entry point. ::: ## Installation @@ -34,7 +36,25 @@ const MySheet = forwardRef((props, ref) => { ## Props -Accepts all props from [`@gorhom/bottom-sheet`](https://gorhom.dev/react-native-bottom-sheet/props). The adapter overrides `enablePanDownToClose` to `true` by default. +`GorhomSheetAdapterProps` extends [`BottomSheetProps`](https://gorhom.dev/react-native-bottom-sheet/props) — the full gorhom prop surface is accepted, nothing is omitted from the type. But the manager owns some of it at runtime. + +**Managed by the adapter (your value is ignored or wrapped):** + +| Prop | What the adapter does | +|------|----------------------| +| `index` | Set from the manager: `0` for portal and inline sheets, `-1` for a persistent sheet that is mounted but closed | +| `animatedIndex` | Replaced with the manager's shared value, which drives the backdrop and scale. A value you pass is still **mirrored** — the adapter writes every frame into it, so `animatedIndex` you own keeps working | +| `onChange` | Wrapped — reports `handleOpened()` at index `>= 0`, then calls yours | +| `onClose` | Wrapped — calls yours, then reports `handleClosed()` | +| `onAnimate` | Wrapped — reports `handleDismiss()` when animating toward `-1`, then calls yours | + +**Adapter defaults (yours wins):** + +| Prop | Default | Note | +|------|---------|------| +| `animationConfigs` | spring — `stiffness: 400`, `damping: 80`, `mass: 0.7` | | +| `backdropComponent` | a component returning `null` | See [Backdrop](#backdrop) | +| `enablePanDownToClose` | `true` | Forced to `false` while a [`useOnBeforeClose`](/close-interception) interceptor is blocking dismissal, so the interceptor always gets to run | ## Backdrop diff --git a/docs/docs/built-in-adapters/react-native-modal.md b/docs/docs/built-in-adapters/react-native-modal.md index ab813b2..2b4c0a1 100644 --- a/docs/docs/built-in-adapters/react-native-modal.md +++ b/docs/docs/built-in-adapters/react-native-modal.md @@ -21,7 +21,8 @@ function FancyModal() { animationIn="slideInUp" animationOut="slideOutDown" swipeDirection="down" - backdropOpacity={0.6} + animationInTiming={400} + animationOutTiming={250} > Fancy animated modal @@ -34,6 +35,37 @@ function FancyModal() { ## Props -All [`react-native-modal` props](https://github.com/react-native-modal/react-native-modal#available-props) are accepted via spread. +`ReactNativeModalAdapterProps` is `Partial>` — most of +[`react-native-modal`'s props](https://github.com/react-native-modal/react-native-modal#available-props) +pass straight through, but seven are removed from the type because the manager +owns them. -Adapter defaults (overridable): `swipeDirection="down"`, `backdropOpacity={0.5}`, `useNativeDriver`, `hideModalContentWhileAnimating`. +**Managed by the adapter (not accepted):** + +| Prop | Why | +|------|-----| +| `isVisible` | The manager drives visibility through the adapter ref | +| `coverScreen` | Forced `false`, so the modal renders as a plain `View` and `QueueItem`'s z-index controls stacking in `push` mode | +| `hasBackdrop` | Forced `false` — the manager's stack-aware `BottomSheetBackdrop` provides the overlay | +| `onModalShow` | Consumed → `handleOpened()` | +| `onModalHide` | Consumed → `handleClosed()` | +| `onBackButtonPress` | Consumed → `handleDismiss()` (this adapter uses the library's own back handling rather than the manager's `useBackHandler`) | +| `onSwipeComplete` | Consumed → `handleDismiss()`, and disabled while dismissal is blocked | + +**Adapter defaults (yours wins):** + +| Prop | Default | Note | +|------|---------|------| +| `animationInTiming` | `300` | Also times the manager's backdrop fade-in — see below | +| `animationOutTiming` | `300` | Also times the manager's backdrop fade-out | +| `swipeDirection` | `'down'` | Set to `undefined` while a [`useOnBeforeClose`](/close-interception) interceptor is blocking dismissal | +| `useNativeDriver` | `true` | | +| `hideModalContentWhileAnimating` | `true` | | + +:::info Backdrop timing +Because `hasBackdrop` is forced off, `backdropOpacity`, `backdropColor` and the +other backdrop props have no effect — there is no react-native-modal backdrop to +style. The manager's backdrop is faded with `withTiming` over +`animationInTiming` / `animationOutTiming`, so overriding those keeps the +backdrop in step with the modal instead of letting it run ahead. +::: diff --git a/docs/docs/built-in-adapters/swmansion.md b/docs/docs/built-in-adapters/swmansion.md index 26a4072..4b33024 100644 --- a/docs/docs/built-in-adapters/swmansion.md +++ b/docs/docs/built-in-adapters/swmansion.md @@ -58,15 +58,13 @@ The detent at index `0` must resolve to `0` (collapsed) so the manager can close ## Props -Accepts the full prop surface of [`@swmansion/react-native-bottom-sheet`](https://github.com/software-mansion-labs/react-native-bottom-sheet)'s `BottomSheet` (`detents`, `style`, `extendUnderStatusBar`, `animateContentHeight`, `onIndexChange`, `onSettle`, `onPositionChange`), **except** the props the manager owns: +Accepts the full prop surface of [`@swmansion/react-native-bottom-sheet`](https://github.com/software-mansion-labs/react-native-bottom-sheet)'s `BottomSheet` (`detents`, `style`, `surface`, `extendUnderStatusBar`, `animateContentHeight`, `disableScrollableNegotiation`, `onIndexChange`, `onSettle`), **except** the props the manager owns: - `index` — the adapter is the source of truth. Use `expandedIndex` (a prop added by the adapter, defaults to the last detent) to choose which detent the sheet opens to. - `animateIn` — the manager controls the open animation, so it is forced on. - `onPositionChange` / `wrapNativeView` — consumed by the adapter to drive the backdrop fade on the UI thread. -`scrollableNegotiation` is forwarded too, but the native side only honors it from **0.17**; on 0.16 use the (deprecated) `disableScrollableNegotiation`. - -Your `onIndexChange` / `onSettle` / `onPositionChange` handlers are still invoked after the adapter's own logic. The `programmatic()` helper plus the `Detent`, `DetentValue`, `SwmansionSheetAdapterProps` and `SwmansionHandleConfig` (the `handle` object form) types are exported from the subpath for convenience. +Your `onIndexChange` / `onSettle` handlers are still invoked after the adapter's own logic. The `programmatic()` helper plus the `Detent`, `DetentValue`, `SwmansionSheetAdapterProps` and `SwmansionHandleConfig` (the `handle` object form) types are exported from the subpath for convenience. :::info `onIndexChange` is wider than the native prop The adapter's `onIndexChange` differs from the native one in two ways: @@ -94,10 +92,11 @@ The native sheet is intentionally minimal. The adapter layers a few **opt-in** c | Prop | Type | Default | What it does | | --- | --- | --- | --- | +| `expandedIndex` | `number` | _last detent_ | Index into `detents` the sheet expands to when opened. Replaces the native `index`, which the adapter owns. The detent at index `0` must still resolve to `0`, since that is what the manager snaps back to when closing. | | `handle` | `boolean \| { color?, width?, height? } \| ReactElement` | `false` | Renders a grab handle as a chrome layer over the `surface` and insets the content to clear it. Pass `true` for the default pill, an object to restyle it, or a React element for full control. Auto-hidden when dismissal is blocked (see [Close interception](/close-interception)) — a non-draggable sheet showing a grab handle would mislead. | | `fullHeight` | `boolean` | `false` | Expands the sheet to the full height available to it. swmansion detents are only `number` / `'content'`, and neither expresses "as tall as you can go" — this passes a detent taller than any screen, which native clamps to the height it actually measured. Stays below the status bar unless you also pass `extendUnderStatusBar`; combined with `detached`, it means the detached frame's height. Ignored when explicit `detents` are passed. | | `detached` | `boolean` | `false` | Floats the sheet free of the screen edges — the *detached* presentation from `@gorhom/bottom-sheet`. All four corners are rounded and the sheet rises from the inset frame. See [Detached sheets](#detached-sheets). | -| `bottomInset` | `number` | _safe-area bottom_ | Gap below the sheet. Only meaningful with `detached`. Falls back to `16` where there is no bottom inset. | +| `bottomInset` | `number` | _safe-area bottom, at least `16`_ | Gap below the sheet. Only meaningful with `detached`. | | `horizontalInset` | `number` | `16` | Gap on each side. Only meaningful with `detached`. | | `fillContent` | `boolean` | _auto_ | Stretches the content to fill the sheet (`flex: 1`), so a `flex: 1` scrollable expands and a bottom footer pins to the bottom instead of floating up under the content. Auto and rarely set by hand: `true` for fixed-height sheets (numeric detents or `fullHeight`), `false` for content-sized ones (which must size to their content). Pass a boolean to override. | | `keyboardBehavior` | `'none' \| 'inset'` | `'none'` | Keyboard avoidance — the native sheet has none. `'inset'` insets the content by the keyboard height (works for both content-sized and fixed-height sheets); `'none'` lets the content handle it. See [Keyboard avoidance](#keyboard-avoidance) for when to use which. Reads the keyboard height from `react-native-keyboard-controller`. | @@ -195,7 +194,7 @@ Pick exactly one. Combining `'inset'` with a keyboard-aware scrollable lifts the Detaching works by giving the sheet a **smaller canvas**: the adapter wraps it in a frame inset by those values, and the native host fills that frame. The detent cap is measured from it, so `'content'` and `fullHeight` resolve against the detached height — no arithmetic on your side. All four corners are rounded (an anchored sheet keeps its bottom two square), and the content is clipped to match. -The frame also clips, and that part is load-bearing rather than cosmetic: the native sheet container is a full-canvas view translated down to the current position, and it is explicitly *not* clipped to its host, so its surface hangs below by whatever the sheet has not expanded yet. Unclipped, that surface would paint straight over the bottom gap and the sheet would not read as detached at all. +The frame also clips, and that part is load-bearing rather than cosmetic: the native sheet container is a full-canvas view translated down to the current position, and it is explicitly *not* clipped to its host, so its surface hangs below by whatever the sheet has not expanded yet. Unclipped, that surface would paint straight over the bottom gap and the sheet would not read as detached at all. The same overhang is why the frame — not the surface — carries the bottom corner radii: the surface's own bottom edge sits outside the frame entirely. :::note Shadows on a custom `surface` Because the frame clips, a shadow cast by a custom `surface` is clipped with it. The native sheet itself draws no shadow, so the default surface is unaffected. @@ -213,7 +212,7 @@ swmansion's `scrimColor` / `scrimOpacities` only apply to **modal** sheets. The ## Android back button -This adapter registers a hardware-back handler automatically (via the internal `useBackHandler`): pressing Android back dismisses the top, fully-open sheet — the same contract the other adapters honor. You don't need to wire anything up yourself. +This adapter registers a hardware-back handler automatically (via `useBackHandler`): pressing Android back dismisses the top, fully-open sheet — the same contract the other adapters honor. You don't need to wire anything up yourself. ## When to Use diff --git a/docs/docs/close-interception.md b/docs/docs/close-interception.md index 486e5e2..4c6a69a 100644 --- a/docs/docs/close-interception.md +++ b/docs/docs/close-interception.md @@ -162,16 +162,62 @@ Result: [SheetA, SheetB] remain open This seamless integration with `closeAll()` is why the callback pattern is recommended over the boolean return pattern. -## Adapter Support +## Observing a blocked close -For `useOnBeforeClose` to fully work, the adapter must respect the `preventDismiss` prop. All built-in adapters support this: +Blocking is not silent — every close path reports what happened, so the caller +can tell "the user declined" from "there was nothing to close": -| Adapter | preventDismiss support | -|---------|----------------------| -| `GorhomSheetAdapter` | `enablePanDownToClose={false}` when active | -| `CustomModalAdapter` | Disables backdrop press | -| `ReactNativeModalAdapter` | Disables swipe and backdrop press | -| `ActionsSheetAdapter` | `closable={false}` when active | -| `SwmansionSheetAdapter` | Re-snaps up when the user swipes to the collapsed detent | +```tsx +const { close, closeAll } = useBottomSheetControl('editor'); + +const result = await close(); +if (!result.closed) { + switch (result.reason) { + case 'blocked': // the interceptor declined + case 'interceptor-error': // the interceptor threw; cancelled for safety + case 'not-closable': // already closing, hidden, or unknown sheet + } +} + +const cascade = await closeAll(); +if (!cascade.closedAll) { + cascade.stoppedAt; // the sheet whose interceptor stopped the cascade + cascade.closed; // the ones that did close, topmost first +} +``` + +`close()` on `useBottomSheetManager`, `useBottomSheetControl` and +`useBottomSheetContext` all resolve to a +[`CloseResult`](/api/types#closeresult); `closeAll()` resolves to a +[`CloseAllResult`](/api/types#closeallresult). + +A sheet that had nothing to close does **not** stop a cascade — only a refusal +does. See [Close results](/api/hooks#close-results). + +## Adapter Support + +For `useOnBeforeClose` to fully work, the adapter has to notice `preventDismiss` +and disable the dismiss paths its library handles natively — otherwise the +library closes the sheet without ever asking the interceptor. + +| Adapter | What it does while dismissal is blocked | +|---------|------------------------------------------| +| `GorhomSheetAdapter` | Forces `enablePanDownToClose={false}` | +| `ReactNativeModalAdapter` | Clears `swipeDirection` and `onSwipeComplete`, so swipe-to-dismiss is inert. Back button still routes through the interceptor | +| `ActionsSheetAdapter` | Sets `gestureEnabled={false}`. Back button and backdrop tap stay enabled — they route through `onBeforeClose` into the interceptor, which is what produces the prompt | +| `SwmansionSheetAdapter` | Rewrites detent `0` to `programmatic()` so the user cannot swipe to it, re-snaps up if the sheet reaches the collapsed detent anyway, and hides the grab handle | +| `CustomModalAdapter` | **Nothing** — see below | + +:::warning `CustomModalAdapter` does not block gestures +It never reads `preventDismiss`. It renders no backdrop of its own and has no +swipe gesture, so its only user-driven dismiss path is the Android back button — +which goes through `handleDismiss()` and therefore still runs the interceptor. +Programmatic `close()` is intercepted as normal. But if you wrap it in your own +tap-to-dismiss surface, that surface must check `preventDismiss` itself. +::: -If you're building a [custom adapter](/custom-adapters), read the `preventDismiss` value from the store and disable native dismiss gestures accordingly. +If you're building a [custom adapter](/custom-adapters), read the flag with the +exported `useSheetPreventDismiss(id)` hook and disable your library's native +dismiss gestures while it is `true`. Inside a sheet, the same value is on +`useBottomSheetContext().preventDismiss` — useful for UI that should reflect it, +such as hiding a grab handle. diff --git a/docs/docs/context-preservation.md b/docs/docs/context-preservation.md index e27abff..6a74b01 100644 --- a/docs/docs/context-preservation.md +++ b/docs/docs/context-preservation.md @@ -143,11 +143,11 @@ const UserSheet = forwardRef((props, ref) => { function UserList() { const { open, updateParams, resetParams } = useBottomSheetControl('user-sheet'); - const { isOpen } = useBottomSheetStatus('user-sheet'); + const { isVisible } = useBottomSheetStatus('user-sheet'); const showUser = (userId: string) => { - if (isOpen) { - // Sheet already open - just update the params + if (isVisible) { + // Sheet already on screen - just update the params updateParams({ userId }); } else { // Open with initial params @@ -173,3 +173,12 @@ function UserList() { ); } ``` + +:::warning Branch on `isVisible`, not `isOpen` +`isOpen` is `true` only once the sheet is **fully** open. A second tap while it +is still animating in would take the `else` branch, and `open()` would reject +the call as `'already-active'` — the params would silently not update. +`isVisible` covers `opening`, `open` and `closing`, which is what "already on +screen, just update it" means. See +[`useBottomSheetStatus`](/api/hooks#usebottomsheetstatus). +::: diff --git a/docs/docs/custom-adapters.md b/docs/docs/custom-adapters.md index d94b0e4..9a897d1 100644 --- a/docs/docs/custom-adapters.md +++ b/docs/docs/custom-adapters.md @@ -30,17 +30,43 @@ interface SheetAdapterEvents { } ``` +It should also: + +3. **Drive `animatedIndex` alongside your own animation**, so the manager's + backdrop fades in step with the sheet rather than snapping — see + [Animated Index](#animated-index) +4. **Respect `preventDismiss`** — read it with `useSheetPreventDismiss(id)` and + disable your library's native dismiss gestures while it is `true`, so a + [`useOnBeforeClose`](/close-interception) interceptor always gets to run + before the sheet goes away. Every shipped adapter except `CustomModalAdapter` + (which has no dismiss gesture of its own) does this. +5. **Handle the Android back button** with `useBackHandler(id, handleDismiss)` + unless your library already has its own back handling to route into + `handleDismiss()`. The hook is scoped to the topmost open sheet **of its own + group**, which a hand-rolled `BackHandler` listener is not. + +```tsx +import { useBackHandler, useSheetPreventDismiss } from 'react-native-bottom-sheet-stack'; + +const preventDismiss = useSheetPreventDismiss(id); +useBackHandler(id, handleDismiss); + + +``` + ## Step-by-Step Guide ### 1. Create the Adapter Component ```tsx import React, { useImperativeHandle } from 'react'; +import { withTiming } from 'react-native-reanimated'; import type { SheetAdapterRef } from 'react-native-bottom-sheet-stack'; import { createSheetEventHandlers, useAdapterRef, useAnimatedIndex, + useBackHandler, useBottomSheetContext, } from 'react-native-bottom-sheet-stack'; @@ -74,9 +100,13 @@ export const MyAdapter = React.forwardRef( }, }), []); - // 5. Wire up callbacks + // 5. Wire up callbacks. Animate animatedIndex with the same timing as your + // own show/hide animation so the manager's backdrop fades in step. + const onShowStart = () => { + animatedIndex.set(withTiming(0, { duration: 300 })); + }; + const onShown = () => { - animatedIndex.set(0); handleOpened(); }; @@ -84,17 +114,25 @@ export const MyAdapter = React.forwardRef( handleDismiss(); }; + const onHideStart = () => { + animatedIndex.set(withTiming(-1, { duration: 300 })); + }; + const onHidden = () => { - animatedIndex.set(-1); handleClosed(); }; - // 6. Render your library's component + // 6. Android back button, scoped to the topmost open sheet in this group + useBackHandler(id, handleDismiss); + + // 7. Render your library's component return ( @@ -164,34 +202,85 @@ const animatedIndex = useAnimatedIndex(); No need to pass the sheet `id` — the hook reads it from context automatically. -#### Binary strategy (CustomModalAdapter, ReactNativeModalAdapter, ActionsSheetAdapter) +:::danger Never set it discretely +`animatedIndex.set(0)` on expand and `animatedIndex.set(-1)` on close is the +obvious thing to write, and it is wrong. The backdrop reads the value on the +sheet's very first frame, so a discrete set puts it at **full opacity +immediately** — a whole animation ahead of the sheet it is meant to be backing. +The three adapters that once did this (`CustomModalAdapter`, +`ReactNativeModalAdapter`, `ActionsSheetAdapter`) were all changed away from it +for exactly that reason. + +Reserve the discrete set for libraries that expose no timing information at all +— no duration, no progress value, no position callback. There, a snap is the +only option. +::: -Set to `0` when the sheet becomes visible, `-1` when hidden. The backdrop snaps between transparent and opaque. Simple and works for any library. +#### Continuous — the library reports position (GorhomSheetAdapter, SwmansionSheetAdapter) + +Best case. If the library writes an animated value itself, hand it the shared +value directly and it stays correct through swipe gestures too: ```tsx const animatedIndex = useAnimatedIndex(); -useImperativeHandle(ref, () => ({ - expand: () => { - animatedIndex.set(0); // backdrop fully opaque - // ... show your overlay - }, - close: () => { - animatedIndex.set(-1); // backdrop fully transparent - // ... hide your overlay - }, -}), [animatedIndex]); +// gorhom writes to the shared value during gestures: + ``` -#### Continuous/dynamic strategy (GorhomSheetAdapter) +If it reports position through a callback instead, map that into `[-1, 0]`. +`SwmansionSheetAdapter` does this from the native sheet's `onPositionChange`: -Pass the shared value directly to the underlying library as a prop. The library updates it continuously during swipe gestures (intermediate values between `-1` and `0`), so the backdrop smoothly interpolates during user interaction. +```tsx +// useEvent here is Reanimated's native-event hook, not the useEvent RFC +import { useEvent } from 'react-native-reanimated'; + +const onPositionChange = useEvent((event) => { + 'worklet'; + animatedIndex.set(event.index - 1); +}, ['onPositionChange']); +``` + +#### Alongside your own animation + +If you drive the animation yourself, derive `animatedIndex` from the same +progress value — one animation, so they cannot drift. +`CustomModalAdapter` does this: ```tsx -const animatedIndex = useAnimatedIndex(); +const progress = useSharedValue(0); // 0 = hidden, 1 = shown -// The library writes to the shared value during gestures: - +useDerivedValue(() => { + animatedIndex.set(progress.value - 1); +}); +``` + +#### Alongside the library's animation + +If the library animates but only tells you *when* it starts and *how long* it +takes, run the same animation on `animatedIndex`. Both remaining adapters do +this, each using the library's own configuration so the curves match: + +```tsx +// ReactNativeModalAdapter — the modal's own timings +expand: () => { + setIsVisible(true); + animatedIndex.set(withTiming(0, { duration: animationInTiming })); +}, +close: () => { + setIsVisible(false); + animatedIndex.set(withTiming(-1, { duration: animationOutTiming })); +}, +``` + +```tsx +// ActionsSheetAdapter — the sheet's own spring configs. +// onOpen/onClose fire when the sheet *starts* moving, which is what makes +// this work: the fade runs alongside the sheet's animation, not after it. +const onOpen = () => { + animatedIndex.set(withSpring(0, openAnimationConfig)); + handleOpened(); +}; ``` ### Adapter Ref @@ -237,16 +326,42 @@ useImperativeHandle(ref, () => ({ }), [openIndex]); // Settle = animation finished → opened/closed -const onSettle = (i: number) => - i > 0 ? (animatedIndex.set(0), handleOpened()) : (animatedIndex.set(-1), handleClosed()); +const onSettle = (i: number) => (i > 0 ? handleOpened() : handleClosed()); // Index change = user-driven snap → reaching collapsed means dismiss const onIndexChange = (i: number) => { if (i <= 0) handleDismiss(); }; + +// Position change = continuous native position → drives the backdrop fade +const onPositionChange = (event) => { + 'worklet'; + animatedIndex.set(event.index - 1); +}; ``` -This is exactly how [`SwmansionSheetAdapter`](/built-in-adapters/swmansion) bridges Software Mansion's native sheet. When the library also reports a continuous position (e.g. `onPositionChange`), interpolate it into `animatedIndex` (`[-1, 0]`) for a smooth backdrop fade. +This is the shape [`SwmansionSheetAdapter`](/built-in-adapters/swmansion) uses to +bridge Software Mansion's native sheet. Note that `animatedIndex` is driven +**only** from the continuous `onPositionChange` — never from `onSettle`, which +reports the end of an animation and would therefore snap the backdrop to its +final value one animation late. + +### Suppressing the manager backdrop + +If your adapter renders a backdrop of its own, suppress the manager's shared one so the two don't stack into a double-dark overlay: + +```tsx +import { useSetBackdrop, useSheetPreventDismiss } from 'react-native-bottom-sheet-stack'; + +const setBackdrop = useSetBackdrop(); +useEffect(() => { + if (!hasOwnBackdrop) return; + setBackdrop(id, false); + return () => setBackdrop(id, true); +}, [id, hasOwnBackdrop, setBackdrop]); +``` + +`useSheetPreventDismiss(id)` reports whether a `useOnBeforeClose` interceptor is currently blocking dismissal, so you can disable your library's native swipe/tap gestures while it is. ### Libraries Without Separate Dismiss/Close Phases @@ -273,10 +388,11 @@ const ThirdPartySheet = require('third-party-sheet').default; A complete, minimal adapter — a slide-up modal using `react-native-reanimated`: ```tsx -import React, { useEffect, useImperativeHandle, useState } from 'react'; -import { BackHandler, Pressable, StyleSheet } from 'react-native'; +import React, { useImperativeHandle, useState } from 'react'; +import { Pressable, StyleSheet } from 'react-native'; import Animated, { useAnimatedStyle, + useDerivedValue, useSharedValue, withSpring, withTiming, @@ -287,7 +403,9 @@ import { createSheetEventHandlers, useAdapterRef, useAnimatedIndex, + useBackHandler, useBottomSheetContext, + useSheetPreventDismiss, } from 'react-native-bottom-sheet-stack'; interface SlideUpModalProps { @@ -299,6 +417,7 @@ export const SlideUpModal = React.forwardRef const { id } = useBottomSheetContext(); const ref = useAdapterRef(forwardedRef); const animatedIndex = useAnimatedIndex(); + const preventDismiss = useSheetPreventDismiss(id); const [visible, setVisible] = useState(false); const progress = useSharedValue(0); @@ -306,16 +425,20 @@ export const SlideUpModal = React.forwardRef const { handleDismiss, handleOpened, handleClosed } = createSheetEventHandlers(id); + // One animation drives both the sheet and the manager's backdrop, so the + // fade cannot run ahead of the sheet. + useDerivedValue(() => { + animatedIndex.set(progress.value - 1); + }); + useImperativeHandle(ref, () => ({ expand: () => { setVisible(true); - animatedIndex.set(0); progress.value = withSpring(1, { damping: 20, stiffness: 300 }, (finished) => { if (finished) runOnJS(handleOpened)(); }); }, close: () => { - animatedIndex.set(-1); progress.value = withTiming(0, { duration: 250 }, (finished) => { if (finished) { runOnJS(setVisible)(false); @@ -323,17 +446,12 @@ export const SlideUpModal = React.forwardRef } }); }, - }), [progress, animatedIndex]); - - // Android back button - useEffect(() => { - if (!visible) return; - const sub = BackHandler.addEventListener('hardwareBackPress', () => { - handleDismiss(); - return true; - }); - return () => sub.remove(); - }, [visible, handleDismiss]); + }), [progress]); + + // Android back button — only fires while this sheet is the topmost open + // one in its own group. A raw BackHandler listener would also fire for + // sheets buried under others. + useBackHandler(id, handleDismiss); const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: (1 - progress.value) * 600 }], @@ -341,8 +459,13 @@ export const SlideUpModal = React.forwardRef if (!visible) return null; + // Tapping the surface dismisses — unless an onBeforeClose interceptor is + // blocking, in which case the gesture must be inert. return ( - + {children} diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index 6a5c736..fa057f2 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -13,9 +13,18 @@ yarn add react-native-bottom-sheet-stack ### Core Peer Dependencies ```bash -yarn add react-native-reanimated react-native-safe-area-context react-native-teleport zustand +yarn add react-native-reanimated react-native-safe-area-context react-native-teleport react-native-worklets zustand ``` +`react-native-worklets` is not optional — it backs Reanimated 4 and is imported +directly by the core adapters to hop from a worklet back to the JS thread. + +:::warning Reanimated 4 required +The peer range is `react-native-reanimated >= 4.0.0`. Reanimated 3 is not +supported: the worklet runtime moved into the separate `react-native-worklets` +package in v4, and the library imports it directly. +::: + ### Adapter-Specific Dependencies Install only the dependencies for the adapter(s) you plan to use: @@ -24,7 +33,7 @@ Install only the dependencies for the adapter(s) you plan to use: # For GorhomSheetAdapter (default bottom sheet adapter) yarn add @gorhom/bottom-sheet react-native-gesture-handler -# For ModalAdapter — no extra dependencies (uses React Native's built-in Modal) +# For CustomModalAdapter — no extra dependencies # For ReactNativeModalAdapter yarn add react-native-modal @@ -123,3 +132,18 @@ function MyComponent() { return