Skip to content

fix(Android): clamp unmapped sheet states instead of throwing - #4530

Open
Bram-dc wants to merge 1 commit into
software-mansion:mainfrom
Bram-dc:fix/sheet-detent-index-crash
Open

fix(Android): clamp unmapped sheet states instead of throwing#4530
Bram-dc wants to merge 1 commit into
software-mansion:mainfrom
Bram-dc:fix/sheet-detent-index-crash

Conversation

@Bram-dc

@Bram-dc Bram-dc commented Aug 20, 2026

Copy link
Copy Markdown

Description

SheetUtils.detentIndexFromSheetState throws IllegalArgumentException when a stable BottomSheetBehavior state has no slot in the current detents array. Because it is called from a BottomSheetBehavior callback on the main thread, the throw takes the whole app down.

java.lang.IllegalArgumentException: [RNScreens] Invalid state 4 for detentCount 1
  com.swmansion.rnscreens.bottomsheet.SheetUtils.detentIndexFromSheetState(SheetUtils.kt:87)
  com.swmansion.rnscreens.ScreenStackFragment$bottomSheetStateCallback$1.onStateChanged(ScreenStackFragment.kt:170)
  com.google.android.material.bottomsheet.BottomSheetBehavior.setStateInternal(BottomSheetBehavior.java:1305)
  ...

This is not a transient-state problem. onSheetStateChanged already filters with SheetUtils.isStateStable(newState), and STATE_COLLAPSED is stable. The mismatch is between the state Material still holds and a detents array that has since shrunk:

  • sheetAllowedDetents goes from [0.5, 1] to [1] while the sheet is resting collapsed
  • Material keeps reporting STATE_COLLAPSED until the sheet is re-settled
  • the detentCount == 1 branch only maps STATE_HIDDEN and STATE_EXPANDED, so it throws

Updating detents in response to content height is a normal thing to do, which makes this reachable without the user doing anything unusual.

Worth noting: the rewritten form sheet has already reached the same conclusion. FormSheetBehaviorController.mapStateToDetentIndex returns a FORM_SHEET_UNKNOWN_DETENT_INDEX sentinel for exactly these combinations rather than throwing. This PR brings the legacy path in line with that.

Reported (against the react-navigation repo, but the stack trace is this function) in react-navigation/react-navigation#12374.

Changes

  • detentIndexFromSheetState now routes every unmapped state through a new private unmappedDetentIndex, which logs a warning and clamps to the top-most valid detent instead of throwing.
  • Updated the KDoc, which documented the @throws behaviour.

I did not reuse -1 the way the form sheet controller does, because this mapping already spends -1 on STATE_HIDDEN. Returning it would report the sheet as dismissed, which is worse than reporting the nearest detent. Clamping to detentCount - 1 keeps the sheet visible and matches where it physically is: with a single detent the only detent is the expanded one, and STATE_HALF_EXPANDED against two detents is likewise closest to the top.

sheetStateFromDetentIndex is left alone. Its throws are fed by our own index arithmetic rather than by Material's state machine, so they are genuine programming errors.

Before & after - visual documentation

No visual changes. The sheet renders the same; it stops crashing.

Test plan

There are no Kotlin unit tests in the repo, so this was verified by hand.

Repro, from the linked issue:

useEffect(() => {
  navigation.setOptions({
    sheetAllowedDetents: needExpand ? [1] : [0.5, 1],
  })
}, [navigation, needExpand])

Open the form sheet, leave it at the collapsed detent, then let needExpand flip so the detents array shrinks to [1]. On main the app dies with the IllegalArgumentException above. With this change the sheet stays up, reports detent index 0, and logs:

W/SheetUtils: [RNScreens] State 4 has no detent in an array of 1, clamping to the top detent

I have been running this patched into 4.26.2 in production for several weeks. It removed the crash with no behavioural fallout, which matters more than usual for us because the app hosts an AccessibilityService in the same process, so this exception was taking that down with it.

Formatting checked with ktlint 1.3.0 (the version android/spotless.gradle pins) against the changed file.

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • For API changes, updated relevant public types.
  • Ensured that CI passes

detentIndexFromSheetState throws IllegalArgumentException when a stable
BottomSheetBehavior state has no slot in the current detents array. That is
reachable from ordinary prop updates: shrinking sheetAllowedDetents while the
sheet rests in a detent that no longer exists leaves Material reporting the
old state until the sheet is re-settled, so [0.5, 1] -> [1] while collapsed
delivers STATE_COLLAPSED with a detentCount of 1.

The throw happens on the main thread inside a BottomSheetBehavior callback,
so it takes down the whole app.

Clamp to the top-most valid detent and log a warning instead. The rewritten
form sheet already treats an unmapped state as non-fatal via
FORM_SHEET_UNKNOWN_DETENT_INDEX; -1 cannot be reused in this mapping because
it already means STATE_HIDDEN, and reporting the sheet as dismissed would be
worse than reporting the nearest detent.
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.

1 participant