Skip to content

refactor!: API review — group isolation, ref leak, and a three-stage cleanup - #40

Merged
arekkubaczkowski merged 13 commits into
mainfrom
claude/api-review-yywpjl
Aug 5, 2026
Merged

refactor!: API review — group isolation, ref leak, and a three-stage cleanup#40
arekkubaczkowski merged 13 commits into
mainfrom
claude/api-review-yywpjl

Conversation

@arekkubaczkowski

Copy link
Copy Markdown
Owner

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. BottomSheetManagerProvider promises independent groups, but the store kept one global stackOrder and three operations walked it without filtering by groupId:

  • applyModeToTopSheetmode: 'switch'/'replace' in group B hid or closed a sheet in group A
  • getTopSheetId in finishClosing — closing in group B auto-restored a hidden sheet from group A
  • getSheetBelowId in startClosing — same

The coordinator, useSheetRenderData, closeAllAnimated and clearGroup all 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.md gains 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. cleanupSheetRef only runs from QueueItem'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 forced open() to report its outcome.

Conditional hook call. 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.

Sheets stuck in 'closing'. The coordinator had two strategies in one switch: expand re-read the ref inside rAF, close used 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 called handleClosed(), 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), indexOf over the array being iterated in closeAllAnimated, and requestClose returning true for sheets it never touched.

Stage 2 — behavioural consistency

Backdrops that snapped. animatedIndex was 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. CustomModalAdapter was the clearest case — it animates progress over 300 ms and set animatedIndex discretely in the same expand(). This is the same symptom reported for swmansion in #39, baked into the adapters instead of the backdrop.

Each now follows its own animation: CustomModalAdapter derives from the progress it already has, ReactNativeModalAdapter fades over animationInTiming/animationOutTiming, ActionsSheetAdapter springs with the sheet's own spring config (openAnimationConfig is a WithSpringConfig — there is no duration to borrow).

Typed adapters. [key: string]: unknown in 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. ModalProps needed Partial<> — react-native-modal declares most props required and fills them from defaultProps.

Adapter authors get the full toolkit. Every built-in adapter calls useSetBackdrop to suppress the shared backdrop; it was never exported, so a custom adapter written against docs/custom-adapters.md could not match them. Now exported, and documented.

Also: open() returns string | null with a dev warning naming the rejection reason, close() returns Promise<boolean> (it already existed on requestClose and closeAll — the three main close() calls just dropped it), useBottomSheetStatus accepts BottomSheetPortalId for completion, and six dead symbols are gone.

Stage 3 — cleanup (breaking)

  • clear()destroyAll(). The old name read as tidying up while bypassing onBeforeClose and skipping the exit animation.
  • isOpen now means open, not "open or opening". Added isOpening / isClosing / isVisible.
  • useBottomSheetStore marked @internal; public BottomSheetState narrowed to the stable fields.
  • Test helpers moved to a /testing subpath, with one resetBottomSheetRegistries() that cannot go stale as registries are added.
  • Removed all eight deprecated aliases.
  • Local useEventuseStableCallback (it collided with reanimated's unrelated useEvent, and the swmansion adapter imports both); bottomSheet.store.ts re-export layer collapsed into ./store.

Verification

yarn typecheck, yarn lint and yarn prepare (bob + React Compiler at panicThreshold: 'all_errors') pass. Confirmed the /testing subpath builds and that the removed symbols are gone from the published index.d.ts.

Nothing was run on a device. The repo still has no test suite (jest reports 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 inside ScaleWrapper, 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

claude added 10 commits August 5, 2026 11:30
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
arekkubaczkowski force-pushed the claude/api-review-yywpjl branch from 90d79a4 to ca271c3 Compare August 5, 2026 09:34
@arekkubaczkowski
arekkubaczkowski changed the base branch from claude/swmansion-adapter-update-yywpjl to main August 5, 2026 09:34
claude added 3 commits August 5, 2026 11:43
…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.
@arekkubaczkowski
arekkubaczkowski merged commit bf7ce65 into main Aug 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants