refactor!: API review — group isolation, ref leak, and a three-stage cleanup - #40
Merged
Conversation
Read-through of the whole surface — public exports, store, coordinator, registries, contexts, hooks and all five adapters — looking for inconsistencies and maintenance hazards. Nine bugs found, the notable ones being group isolation (three store operations reach into the global stackOrder without filtering by groupId, so 'switch'/'replace' in one group can hide or close a sheet in another), a ref leak in sheetRefsMap whenever open() is silently rejected, and a conditional hook call in useOnBeforeClose. Also documents API-level inconsistencies: close() drops the interceptor result that closeAll() returns, clear() bypasses onBeforeClose despite its innocuous name, animatedIndex is driven continuously by two adapters and binarily by three (so their backdrops snap instead of fading), and useSetBackdrop is used by every built-in adapter but never exported for custom ones.
Addresses B1, B2, B4, B6, B7, B8 and B9 from API-REVIEW.md. Group isolation (B1). The store kept one global stackOrder, and three operations walked it without filtering by groupId: applyModeToTopSheet, so 'switch'/'replace' in one group hid or closed a sheet in another; getTopSheetId in finishClosing, which auto-restored a hidden sheet from a foreign group; and getSheetBelowId in startClosing, same. Replaces it with stackOrderByGroup, so no operation can reach another group's sheets — it never holds their stack. Consumers (coordinator, render data, scale depth, back handler) now read their own group's stack directly and stop filtering. Ref leak (B2). open() registered the adapter ref in the module-global map before the store could reject the call. cleanupSheetRef only runs from QueueItem's unmount, so a rejected open leaked an unreclaimable entry — once per call, since inline IDs are random. The ref is now registered only after the store accepts the sheet, which required open() to report its outcome (OpenResult). Conditional hooks (B4). useOnBeforeClose threw before useEvent and useEffect, so losing the context mid-unmount dropped the hook count from 4 to 2 and React reported "rendered fewer hooks than expected" instead of the real problem. Every hook now runs, then the guard throws. Selector purity (B6). useSheetScaleDepth wrote to a ref inside a Zustand selector, making the result depend on how many times the selector ran (twice per render under StrictMode). The exit-animation hold now lives in an effect; the selector returns null when the sheet has left the stack. Stuck sheets (B7). The coordinator read ref.current once, up front, then called close() on it — a silent no-op when the adapter had not mounted yet (realistic for a portal, whose content must teleport first). Nothing then called handleClosed(), so the sheet sat in 'closing' forever and blocked every later open in its group. Both transitions now go through one retry helper that re-reads the ref and re-checks the status each frame, with a dev warning when it gives up. Also: closeAllAnimated used indexOf over the array it was iterating (B8), and requestClose returned true for sheets it never touched (B9).
BREAKING CHANGE: removes the deprecated aliases, renames clear() to destroyAll(), narrows isOpen, and moves test helpers to a subpath. See below for the full list. Stage 2 — behavioural consistency (B3, B5, P1, P5, P7, P10, dead code): Backdrops that snapped (B5). animatedIndex was driven continuously by gorhom and swmansion but discretely by the other three adapters, so their backdrops jumped to full opacity on the first frame while the sheet itself was still animating — the same symptom reported for swmansion, baked into the adapters. CustomModalAdapter now derives it from the `progress` it already animates; ReactNativeModalAdapter fades over the modal's own animationInTiming/animationOutTiming; ActionsSheetAdapter springs with the sheet's own open/close spring config. open() reports its outcome (B3). Two guards could silently drop an open — re-opening an active sheet, or opening while another sheet in the group is mid-animation. The caller got an id back either way. open() now returns OpenResult and useBottomSheetManager().open() returns `string | null`, with a __DEV__ warning naming the reason. This is also what lets the ref be registered only on success (B2, stage 1). close() reports whether it closed (P1). requestClose already returned Promise<boolean> and closeAll returned its promise, but the three main close() calls dropped it — so an onBeforeClose block was invisible unless you reached for the adapter-level API. Adapter authors get the full toolkit (P7). Every built-in adapter uses useSetBackdrop to suppress the shared backdrop; it was never exported, so custom adapters could not match them. Now exported alongside useSheetPreventDismiss. Typed adapters (P10). react-native-modal and react-native-actions-sheet were typed as `[key: string]: unknown`, which disables checking entirely — a prop typo passed silently. Both ship types; they are now devDependencies (as gorhom and swmansion already were) and both adapters extend the real prop types. Also: useBottomSheetStatus accepts BottomSheetPortalId for completion (P5), and the dead code in section 4 of API-REVIEW.md is gone. Stage 3 — API cleanup (P2, P4, P6, P8, P9, W1-W8): - clear() -> destroyAll(). The old name read as tidying up while actually bypassing onBeforeClose and skipping the exit animation. - isOpen now means open, not "open or opening". Added isOpening, isClosing and isVisible for the states it used to conflate. - useBottomSheetStore and the store's state shape are marked @internal; the exported BottomSheetState is narrowed to the fields that are stable. - Test helpers moved to the /testing subpath, with a single resetBottomSheetRegistries() that cannot go stale as registries are added. - Removed: openBottomSheet, clearAll, closeBottomSheet, useBottomSheetState, ModalAdapter, BottomSheetManaged, BottomSheetManagedProps, and the unmarked BottomSheetRef alias. - Local useEvent renamed to useStableCallback, so it no longer collides with reanimated's unrelated useEvent (both are imported by the swmansion adapter); the bottomSheet.store re-export layer is collapsed into ./store; mount() now uses TriggerState like open(); MODE_STATUS_MAP no longer uses null as "no action"; shallow dropped from selectors returning primitives.
Updates the docs site, README and CLAUDE.md for the stage 2 and 3 changes: the group-keyed stack, open() returning null on rejection, close() returning Promise<boolean>, destroyAll() replacing clear(), the narrowed isOpen plus the new isOpening/isClosing/isVisible flags, the narrowed public BottomSheetState, and the /testing subpath. Removes the deprecated-alias tables and the BottomSheetManaged/ModalAdapter re-export notes, since those aliases are gone. Adds a section to custom-adapters.md covering useSetBackdrop and useSheetPreventDismiss, which are now exported — the built-in adapters have always used them, so the guide was describing an adapter you could not actually write. CLAUDE.md gains an explicit warning that the stack is keyed by group and that flattening it inside a store action or selector reintroduces the isolation bug the shape exists to prevent.
The debug monitor called useBottomSheetStore() with no selector, so it re-rendered on every store write regardless of what changed, and then flattened stackOrderByGroup in the component body — allocating a new array each render. Both are now selectors with `shallow`, which the flatten actually requires: under the default reference check a freshly allocated array reads as changed on every store write, so an unguarded selector would be worse than the whole-store subscription it replaces. Worth noting for anything else added here: the example app is not compiled with React Compiler. The plugin is declared in the root babel.config.js, which covers src/ only — example/babel.config.js does not include it, and builder-bob's getConfig does not add it. Nothing in the example memoizes itself.
The status claimed W1-W8 were done. W1 (context hook naming) and W4 (the discriminated open() payload) were not touched, so the claim was wrong. Also records what this work introduced rather than fixed: open() reports rejection through useBottomSheetManager but not useBottomSheetControl, close() now returns false for four different outcomes, and closeAll still cannot say whether an interceptor stopped the cascade.
BREAKING CHANGE: close() and closeAll() resolve to result objects instead of booleans/void; useBottomSheetControl().open() returns boolean; useBottomSheetRefContext is now useMaybeBottomSheetRef. W4 — open() takes a discriminated OpenPayload (`kind: 'inline'` carrying content, or `kind: 'portal'` without) and mount() takes MountPayload. The old shape encoded the mode across usePortal + keepMounted + content, which can express eight combinations of which three are real, and made callers pass `content: null` purely to signal "not inline". The store maps `kind` onto its internal flags in one place, so no caller knows the encoding. W1 — one naming rule for context hooks: `useMaybe*` may return null/undefined and leaves handling it to the caller; plain `use*` either throws or resolves to a documented default. useBottomSheetRefContext became useMaybeBottomSheetRef accordingly, and the manager hooks moved out of the provider file into BottomSheetManager.context.tsx, where the context lives. The three asymmetries the review introduced: - useBottomSheetControl().open() dropped the OpenResult it consumed internally, so a rejection was visible through useBottomSheetManager and invisible here. It now returns boolean — each hook reports rejection in the currency that is useful there, an ID or a yes/no. - close() returned false for four distinct outcomes. It now resolves to a CloseResult carrying a reason: 'blocked', 'interceptor-error' or 'not-closable'. Callers can tell a refusal from nothing-to-close. - closeAll() returned Promise<void>, so a cascade stopped by an interceptor looked exactly like one that closed everything. It now resolves to a CloseAllResult with what closed and which sheet stopped it. While adding that reason, a related bug surfaced: closeAllAnimated treated 'nothing to close' as a refusal and broke out of the loop, stranding every sheet below a sheet that had settled or vanished mid-cascade. Only a real refusal stops it now.
A four-way review of this PR and the swmansion bump found bugs that the original work missed. Each behavioural fix below was reproduced before and verified after. Store ----- open() could push a duplicate ID onto a group stack: switch mode leaves the previous top hidden but still on the stack, and a persistent sheet in that state satisfies the activatable guard, so re-opening it appended a second entry (['p','q','p']) — duplicate React keys and a QueueItem at the wrong z-index. The push now dedupes. Re-opening a persistent sheet from a different group desynced its record from the stack it landed in: the existing-sheet branch never applied the payload's groupId while the stack write did, leaving the sheet unremovable from the second group forever. Rejected now, with a new 'group-mismatch' reason. startClosing restored the sheet below even when the closing sheet was not topmost, so a switched-away sheet animated back in underneath the current top and — because the busy guard keys on 'opening' — blocked the group. Gated on being the group top, matching finishClosing. unmount removed a sheet without restoring the one below, unlike finishClosing. BottomSheetPersistent calls unmount on every component unmount, so navigating away left a hidden sheet rendered as active. Both paths now share detachFromGroup(). Coordinator ----------- An interceptor returning literal false hung requestClose() forever: the truthiness gate dropped it, and closeAllAnimated stalled awaiting a promise that never settled. Discriminated on type now. (This shipped broken in this PR; the tests branch had already fixed it independently.) requestClose branched on a status captured before awaiting the interceptor, so a sheet removed during a long Alert.alert still reported success. Re-read after the await. A sheet whose ref never arrived wedged its whole group unrecoverably — driveSheetRef gave up but left it 'opening', and the busy guard then rejected every later open. It now forces the sheet closed. Adapters -------- detached rendered square bottom corners in every case except detached+fullHeight. The native surface is sized to the whole container and translated down, so its rounded bottom edge sat below the frame and the rectangular clip cut it off. The clipping frame now carries the bottom radii. Removed scrollableNegotiation: 0.16.2 does not know the prop and BottomSheet destructures known props rather than spreading a rest object, so it was provably inert on every version the peer range allows. It returns for free, correctly typed by the library, when the peer moves to 0.17. ActionsSheetAdapter painted its own 0.3 overlay over the manager's backdrop (the library renders one regardless of isModal), and its preventDismiss wiring disabled back and backdrop entirely, so an interceptor never ran and the sheet became undismissable. BottomSheetHost subscribed in an effect without reconciling, so any status transition from an earlier sibling's mount effect was lost and the sheet stayed 'opening' forever. Also: raised the reanimated peer floor to >=4.0.0 (adapters call SharedValue.set and the core-entry adapter imports scheduleOnRN from worklets, so >=3.0.0 installed cleanly and crashed at runtime), deleted the unused src/adapters/index.ts barrel that would have pulled every optional peer into the main entry, dropped memo() from QueueItem per the project's own React Compiler rule, removed dead code, and trimmed comments that restated their code.
A doc audit compared every page against src/ and found samples that no longer compile and tables describing props that do not exist. Broken samples removed or fixed: adapters.md still documented importing the deleted BottomSheetManaged alias; api/types.md carried a whole BottomSheetRef section for a removed export; several pages destructured `clear` from useBottomSheetManager (now destroyAll) or passed open()'s `string | null` straight into APIs that reject null; persistent-sheets.md called open() without params for a registry entry that declares them, so it failed against its own page; type-safe-ids.md and api/hooks.md accessed `params.x` without optional chaining, which cannot typecheck since BottomSheetPortalParams always unions undefined. context-preservation.md branched on isOpen to choose between updating params and opening — with isOpen narrowed to 'open', a second tap during the opening animation took the else branch and was rejected as already-active, silently dropping the update. Uses isVisible now. Corrected against the source: the store diagram still showed a flat stackOrder; intro.md counted four adapters; api/types.md listed a preventDismiss row four lines after saying it had been removed, and typed UseBottomSheetControlReturn's close/closeAll as void; the react-native-modal page documented a backdropOpacity default that does not exist and could not apply since hasBackdrop is forced off; both that page and actions-sheet.md claimed all library props pass through when several are omitted. Added: the five result types (OpenResult, CloseResult, CloseAllResult and their reason unions) that were referenced but never defined, the adapter hooks missing from the hook inventory, `params` in the open() options table, and the /testing subpath, which shipped as a public entry point documented nowhere user-facing.
…ex JSDoc Completes the doc pass started in 34c21e8. custom-adapters.md was teaching the anti-pattern this release removed. Its "Binary strategy" section recommended setting animatedIndex discretely and credited three shipped adapters for doing so — all three were changed away from exactly that because it snaps the backdrop to full opacity a whole animation ahead of the sheet. Replaced with the three real strategies, each matched to the adapter that uses it. The same guide hand-rolled a BackHandler listener that fires for non-topmost sheets; it now uses useBackHandler, and the adapter contract gained the preventDismiss obligation every shipped adapter honours. useAnimatedIndex's own JSDoc carried the same recommendation, so the advice reached adapter authors through editor tooltips even after the docs were fixed. Rewritten around driving the value so it travels with the sheet. close-interception.md's adapter table was wrong in every row it described: CustomModalAdapter has no preventDismiss handling at all, ActionsSheetAdapter never used a `closable` prop, and the swmansion row omitted two of the three things it actually does. Rewritten from source, and the page now documents that a blocked close is observable through CloseResult/CloseAllResult. CLAUDE.md accumulated a lot of drift: stale file and hook names, a z-index sample missing its base offset, a registries section listing two of four, a refsMap type that named a gorhom symbol appearing nowhere in that file, and a background-scale rule that contradicted the code. Also records the one sanctioned useCallback exception, now that memo() is gone from QueueItem. The gorhom and react-native-modal pages claimed all library props pass through; both omit several, and rn-modal documented a backdropOpacity default that does not exist and could not apply since hasBackdrop is forced off.
arekkubaczkowski
force-pushed
the
claude/api-review-yywpjl
branch
from
August 5, 2026 09:34
90d79a4 to
ca271c3
Compare
arekkubaczkowski
changed the base branch from
claude/swmansion-adapter-update-yywpjl
to
main
August 5, 2026 09:34
…sumes The example resolves the library to src/ through a module-resolver alias, so it was transformed by the example's Babel config — which had no react-compiler plugin. Everything the example exercised ran unmemoized, unlike the compiled lib/ a consumer installs. Profiling the same swmansion open+push path both ways: 520 fiber renders and one 34ms commit without the plugin, 349 renders and no commit over 16ms with it. The numbers the example produced described a build that ships to nobody. No panicThreshold here. The library's own build sets all_errors and is the place that enforces it; the example must not fail to bundle over a pattern in demo code.
BottomSheetHost builds its children with .map(), so every host render hands React fresh element references and React calls each QueueItem to discover the output is identical. That includes persistent sheets, which an unrelated sheet opening does not touch. The compiler cannot close this: it memoizes work inside a component, it does not wrap one in memo. The two are complementary, and the build carries both — memo(function QueueItem alongside _c(22). Measured on device, opening one sheet with three persistent sheets mounted: three QueueItem bodies ran for nothing before, none now. It grows linearly with the persistent sheets in a group. Verified it skips only what should be skipped: on push, the existing sheet's QueueItem stays put while its ScaleWrapper still re-renders twice for the new scale depth, through its own store subscription. CLAUDE.md records this as a sanctioned exception, next to useStableCallback, so it is not removed in the name of the no-manual-memoization rule.
The peer range moved to >=4.0.0 in package.json without the lockfile following, so `yarn install --immutable` refused the install and every CI job failed before running anything.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Targets the swmansion 0.16.2 branch (#39), not
main— stack this after it.A full read-through of the API surface (public and internal), written up in
API-REVIEW.md, then applied in three stages. Nine bugs, ten API inconsistencies, eight internal ones, six dead symbols.Stage 1 — bugs, no API change
Group isolation was broken in three places.
BottomSheetManagerProviderpromises independent groups, but the store kept one globalstackOrderand three operations walked it without filtering bygroupId:applyModeToTopSheet—mode: 'switch'/'replace'in group B hid or closed a sheet in group AgetTopSheetIdinfinishClosing— closing in group B auto-restored a hidden sheet from group AgetSheetBelowIdinstartClosing— sameThe coordinator,
useSheetRenderData,closeAllAnimatedandclearGroupall filtered correctly, so the invariant was known and just applied inconsistently. Rather than adding a fourth, fifth and sixth filter, the stack is now keyed by group (stackOrderByGroup): an operation cannot reach another group's sheets because it never holds their stack.CLAUDE.mdgains an explicit warning that flattening the map inside a store action or selector reintroduces exactly this bug.Ref leak.
open()wrote the adapter ref into the module-global map before the store could reject the call.cleanupSheetRefonly runs fromQueueItem's unmount, so a rejected open leaked an entry nothing could reclaim — once per call, since inline IDs are random. The ref is now registered only on success, which is what forcedopen()to report its outcome.Conditional hook call.
useOnBeforeClosethrew beforeuseEventanduseEffect, so losing the context mid-unmount dropped the hook count from 4 to 2 and React reported "rendered fewer hooks than expected" instead of the real problem.Sheets stuck in
'closing'. The coordinator had two strategies in one switch:expandre-read the ref inside rAF,closeused a value captured at subscription time. If the adapter had not mounted yet — realistic for a portal, whose content must teleport first —ref?.close()was a silent no-op, nothing calledhandleClosed(), and the sheet sat in'closing'forever while blocking every later open in its group. Both transitions now go through one retry helper that re-reads the ref and re-checks the status each frame, with a dev warning when it gives up.Plus: a ref mutated inside a Zustand selector (
useSheetScaleDepth),indexOfover the array being iterated incloseAllAnimated, andrequestClosereturningtruefor sheets it never touched.Stage 2 — behavioural consistency
Backdrops that snapped.
animatedIndexwas driven continuously by gorhom and swmansion but discretely by the other three, so their backdrops jumped to full opacity on the first frame while the sheet was still animating.CustomModalAdapterwas the clearest case — it animatesprogressover 300 ms and setanimatedIndexdiscretely in the sameexpand(). This is the same symptom reported for swmansion in #39, baked into the adapters instead of the backdrop.Each now follows its own animation:
CustomModalAdapterderives from theprogressit already has,ReactNativeModalAdapterfades overanimationInTiming/animationOutTiming,ActionsSheetAdaptersprings with the sheet's own spring config (openAnimationConfigis aWithSpringConfig— there is no duration to borrow).Typed adapters.
[key: string]: unknownin two adapters turned out to be a consequence of the libraries not being devDependencies, not an oversight. Added both (as gorhom and swmansion already were) and typed the adapters against the real prop types.ModalPropsneededPartial<>— react-native-modal declares most props required and fills them fromdefaultProps.Adapter authors get the full toolkit. Every built-in adapter calls
useSetBackdropto suppress the shared backdrop; it was never exported, so a custom adapter written againstdocs/custom-adapters.mdcould not match them. Now exported, and documented.Also:
open()returnsstring | nullwith a dev warning naming the rejection reason,close()returnsPromise<boolean>(it already existed onrequestCloseandcloseAll— the three mainclose()calls just dropped it),useBottomSheetStatusacceptsBottomSheetPortalIdfor completion, and six dead symbols are gone.Stage 3 — cleanup (breaking)
clear()→destroyAll(). The old name read as tidying up while bypassingonBeforeCloseand skipping the exit animation.isOpennow means open, not "open or opening". AddedisOpening/isClosing/isVisible.useBottomSheetStoremarked@internal; publicBottomSheetStatenarrowed to the stable fields./testingsubpath, with oneresetBottomSheetRegistries()that cannot go stale as registries are added.useEvent→useStableCallback(it collided with reanimated's unrelateduseEvent, and the swmansion adapter imports both);bottomSheet.store.tsre-export layer collapsed into./store.Verification
yarn typecheck,yarn lintandyarn prepare(bob + React Compiler atpanicThreshold: 'all_errors') pass. Confirmed the/testingsubpath builds and that the removed symbols are gone from the publishedindex.d.ts.Nothing was run on a device. The repo still has no test suite (
jestreports 0 matches), so none of these fixes is covered by a test. The changes most worth exercising on hardware are the three adapters whose backdrop timing changed and the coordinator's close path — both alter animation behaviour that static analysis cannot confirm.Not fixed
The native detent cap derives from
getLocationInWindow/convert(to: window), which account for transforms. Sheets render insideScaleWrapper, so a sheet scaled by another above it may recompute its cap and twitch. Left alone — separate concern from anything here, and it touches the core scale animation.Generated by Claude Code