Back to writing

A native sheet swipe cannot be vetoed from JavaScript

Homies, the bilingual React Native app I build privately on the side, presents its money forms (add an expense, settle a debt) as native bottom sheets. Type half an expense, drag the sheet down, and the draft is gone. No "Discard changes?" prompt, although the screen has one.

No confirm dialog can stop that swipe, because the navigation event arrives after the sheet is gone. The lock added later is, by the library's own source, ignored on Android and skipped on iOS, and its tests stay green, because they assert the options object and nothing that acts on it. That last part is the lesson that travels: a test of configuration cannot see the runtime that is supposed to obey it.

The navigation event arrives too late

The discard prompt lives in a hook, useDiscardGuard, that listens for React Navigation's beforeRemove event, calls preventDefault() when the form is dirty, and shows an alert. For the header back button and the Android hardware back it works, because those exits are routed through JavaScript: the event fires, the listener vetoes, the screen stays.

A swipe on a native sheet is not routed through JavaScript. On iOS the sheet is a UISheetPresentationController, on Android a Material bottom sheet behavior, and the drag is handled by the platform. By the time beforeRemove reaches the listener, the native side has already dismissed the sheet. There is nothing left to prevent. The hook's own doc comment says it: beforeRemove "fires too late to veto a native formSheet swipe-down".

So the only control you have over a swipe is whether the platform allows it at all.

First: the OTP sheet

I met this first on the email verification sheet. Swiping it away cost the user their resend cooldown, with no warning. The fix set gestureEnabled: false and hid the grabber, leaving the header back as the only exit, which does go through the guard.

Four days later I found that half of that did not work. The comment I left in the onboarding layout is specific:

Android: react-native-screens 4.25.2 hard-codes isHideable/isDraggable=true in its sheet SheetDelegate and never reads gestureEnabled, so the gesture lock above is a no-op there and any downward drag dismisses past the guard. Present as a plain card instead [...] Remove this fork once RNS honors gestureEnabled for Android sheets.

So on iOS the verification screen is a locked sheet, and on Android it is not a sheet. That fork is still in the code.

The money sheets

A month after that, a commit titled "Lock dirty money sheets on Android" added this helper and spread it into the add-expense and settle-debt screen options, with locked meaning dirty or submitting. Another extended it to three settings sheets the same day.

// condensed from sheetScreenOptions.ts
export function androidSheetDismissLockOptions(
  locked: boolean,
  platform = Platform.OS,
) {
  if (platform !== "android") return {};
  return {
    gestureEnabled: !locked,
    sheetGrabberVisible: !locked,
  };
}

Read that next to the earlier comment. It sets gestureEnabled on Android, the platform where the library ignores it, and returns nothing on iOS, the platform where it works. The react-native-screens version in package.json is still 4.25.2, with no patch applied.

That earlier comment checks out against the installed 4.25.2 source: ScreenViewManager.kt copies the prop into Screen.isGestureEnabled, and nothing else in the Android sources reads that field. SheetDelegate.kt sets isHideable = true and isDraggable = true unconditionally. isSheetGrabberVisible is the same story: set, never read.

All of this is a reading of the installed source, not a run on a device, and the rest of the post rests on it. By that reading, a dirty add-expense sheet can be swiped away on Android exactly as before the fix, and on iOS because the fix opted out.

Why the tests are green

Two test files cover the lock. sheetScreenOptions.test.ts asserts that the helper returns the locked options on Android and an empty object elsewhere. add-expense.sheetLock.test.tsx renders the screen with a mocked navigator, types into the title field, and asserts that the options handed to the header flipped to gestureEnabled: false.

Both are correct tests of what the JavaScript does. Neither can see whether the native layer acts on the options it receives, and that is the only place this bug lives. The repository even has the manual check written down in its QA scenarios: try to swipe down a data-entry sheet, expect the gesture blocked or a confirm shown. A manual row runs when someone remembers it.

One real fix did land in this area. Six days after the lock, a commit moved the beforeRemove listener to install at mount instead of after the form first becomes dirty, closing a window where a fast header Back right after the first keystroke slipped past the guard. That guards the JavaScript-routed exits, which were the ones already covered.

Where that leaves it

The options I can see, none of them shipped:

  • Do on the money sheets what the first fix did for the OTP screen: a locked sheet on iOS, a plain card on Android. It is consistent and it gives up the Android sheet.
  • Drop the platform !== 'android' line, which would at least protect iOS.
  • Stop treating dismissal as loss. Keep the draft in state that outlives the sheet and restore it on reopen, so a swipe costs nothing. This is the only option that does not fight the platform.

And one check, whichever is chosen: a Maestro flow that types into the sheet, swipes down, and asserts the title field still holds its text. It would have failed the day the lock went in.

It is also exhibit 9 in the bug museum, with the diff and the test files.