Engineering

Back means four different things, and shared navigation has to pick

A hardware button, an edge swipe, a window close and a browser history entry are not the same event. One shared handler gets at least two of them wrong.

MobileByteSensei 11 min read
kmpcomposenavigation

TL;DR

  • One BackHandler in shared Compose code. It worked on Android, was never invoked by the iOS edge-swipe, closed the whole window on desktop, and on web let the browser navigate away while the app still thought it was on the screen — three of four wrong.
  • "Back" is not one event. It is a hardware button, a gesture the OS drives interactively, a window-close request, and a history entry owned by the browser. Only the first is a callback you can intercept with no further obligations.
  • The fix separates intent (the user wants to leave this screen) from mechanism (what each platform sends), and accepts that the iOS gesture cannot be intercepted the way the others can.
  • What reading on buys: the per-platform table with what each event can and cannot do, the unsaved-changes dialog that only worked on Android, and the measured loss rate.

The case this post is about

A shared editor screen with unsaved-changes protection:

// feature/editor/src/commonMain/kotlin/EditorScreen.kt
@Composable
fun EditorScreen(vm: EditorViewModel, onNavigateUp: () -> Unit) {
    var showDiscardDialog by remember { mutableStateOf(false) }

    // The whole protection, in one place.
    BackHandler(enabled = vm.hasUnsavedChanges) { showDiscardDialog = true }

    if (showDiscardDialog) {
        DiscardDialog(
            onDiscard = { showDiscardDialog = false; onNavigateUp() },
            onKeepEditing = { showDiscardDialog = false },
        )
    }
    EditorBody(vm)
}

BackHandler in Compose Multiplatform is an expect with per-platform actuals. That is the part the code reads as having handled.

The symptom

Lost drafts, reported from everywhere except Android:

# sessions ending with unsaved changes and no discard dialog shown, 2 weeks
  platform    sessions with unsaved changes    dialog shown    drafts lost
  android                 1,840                   1,840             0
  ios                     1,210                       0         1,210
  desktop                   340                      12           328
  web                       890                      41           849

Three platforms, 2,387 lost drafts, zero dialogs on iOS. And each failed differently, which is what made it look like three separate bugs:

  ios        the edge-swipe completed; the screen popped; no callback ever fired
  desktop    clicking the window's close button quit the app entirely
  web        the browser back button navigated away; our router still showed the editor

All 12 desktop dialogs came from a `Cmd+[` shortcut one build had wired by hand — the only
mechanism on that platform that reached the guard at all.

Two weeks of sessions with unsaved changes across four platforms, showing every dialog shown on one and none at all on another, with two thousand three hundred and eighty-seven drafts lost

Why the obvious fix failed

The first fix was to add onDisappear-style cleanup on iOS:

DisposableEffect(Unit) { onDispose { if (vm.hasUnsavedChanges) vm.autosave() } }

Autosave rather than a dialog — which is a different product decision smuggled in as a bug fix, and it does not work either: the dispose runs after the pop animation starts, so the user has already seen the previous screen and has no idea anything was saved. We kept it as a safety net and it is not the fix.

The second fix was to disable the iOS edge-swipe:

navigationController.interactivePopGestureRecognizer?.isEnabled = false

This makes BackHandler reachable, by removing the gesture iOS users expect on every screen. The App Store review did not object; three internal testers did, immediately, and they were right.

The third fix on web was beforeunload:

window.addEventListener("beforeunload") { e -> e.preventDefault() }

beforeunload fires for leaving the page, not for a history navigation within a single-page app. Our router is in-page, so the browser back button never triggers it. It does fire when the user closes the tab, which is a real case and a different one.

The actual mechanism

Four events, and they differ along an axis that matters more than their names: whether you are asked before it happens, or told while it happens.

  platform   the event                   you are…      can you cancel?   interactive?
  android    hardware/gesture back       asked         yes               no — it is a callback
  ios        interactive pop gesture     told, live    no (once begun)   yes — driven by the finger
  desktop    window close request        asked         yes               no
  web        popstate (history)          told, after   no — already moved no

Android's back is a callback with veto power. OnBackPressedDispatcher asks registered callbacks, in order, and the first one that handles it stops the chain. This is the model BackHandler is shaped around, and it is the only one of the four with exactly these properties.

iOS's pop gesture is an interactive transition. UINavigationController drives the pop continuously as the finger moves, with a rubber-banding preview of the previous screen. There is no "are you sure" point: by the time anything of yours runs, the user has seen the transition and either completed or cancelled it. The gesture can be disabled or conditionally allowed (via UIGestureRecognizerDelegate.gestureRecognizerShouldBegin), and that is the real hook — asked before the gesture starts rather than when it finishes.

Desktop's close is a window-manager request that defaults to terminating the app, and it is about the window, not the screen. Compose Desktop's onCloseRequest is the veto point, and it has nothing to do with the navigation stack — a screen-level back on desktop is a keyboard shortcut or a UI affordance, which is a different event entirely.

Web's popstate fires after the history entry has already changed. You cannot cancel it. The only ways to resist are to push a sentinel entry so there is something to consume, or to use the Navigation API's intercept() where available — and our browser matrix did not have it everywhere.

So the single shared BackHandler was not "mostly working". It was modelling one platform's semantics, and the other three each violate a different assumption inside it: iOS violates "you are asked", desktop violates "this event is about the screen", web violates "you can cancel".

Four back events compared by the timing of the question — asked before, told during, told after — and whether each can be cancelled

The fix

Separate the intent from the mechanism: shared code declares whether leaving is currently allowed and what to do when it is not, and each platform wires its own event to that declaration using whatever hook it actually has.

// core/ui/src/commonMain/kotlin/LeaveGuard.kt
/**
 * The shared DECLARATION. Not a handler — a statement of policy that each platform's own
 * back/close/pop mechanism consults with whatever hook it has.
 *
 * `canLeave` is consulted BEFORE the mechanism runs where that is possible (android callback,
 * desktop onCloseRequest, ios gestureRecognizerShouldBegin) and is the only form that works
 * on all four: web's popstate has already happened, so a post-hoc veto is not available to us.
 */
data class LeaveGuard(
    val canLeave: () -> Boolean,
    val onBlocked: () -> Unit,
)

val LocalLeaveGuard = staticCompositionLocalOf<MutableState<LeaveGuard?>> {
    error("no LeaveGuardHost")
}

@Composable
fun GuardLeaving(canLeave: () -> Boolean, onBlocked: () -> Unit) {
    val slot = LocalLeaveGuard.current

    // rememberUpdatedState, and the effect keyed on Unit. Keying it on the two lambdas looks
    // more correct and is worse: a lambda literal is a fresh object on every recomposition,
    // so the effect would dispose and re-register the guard continuously — and on web that
    // also re-pushes the sentinel history entry each time.
    val currentCanLeave by rememberUpdatedState(canLeave)
    val currentOnBlocked by rememberUpdatedState(onBlocked)

    // Registered for the composition's lifetime, cleared on exit, so a screen that is popped
    // normally does not leave a guard behind for the next one.
    DisposableEffect(Unit) {
        slot.value = LeaveGuard(
            canLeave = { currentCanLeave() },
            onBlocked = { currentOnBlocked() },
        )
        onDispose { slot.value = null }
    }
}
// feature/editor — the screen declares policy and names no mechanism
@Composable
fun EditorScreen(vm: EditorViewModel, onNavigateUp: () -> Unit) {
    var showDiscard by remember { mutableStateOf(false) }
    GuardLeaving(canLeave = { !vm.hasUnsavedChanges }, onBlocked = { showDiscard = true })
    …
}
// androidMain — the one platform BackHandler was already right for
@Composable actual fun LeaveGuardHost(content: @Composable () -> Unit) {
    val slot = remember { mutableStateOf<LeaveGuard?>(null) }
    val guard = slot.value
    BackHandler(enabled = guard != null && !guard.canLeave()) { guard?.onBlocked?.invoke() }
    CompositionLocalProvider(LocalLeaveGuard provides slot) { content() }
}
// iosApp — the real hook is BEFORE the gesture begins, not when it ends.
// Disabling the gesture outright (our second attempt) removes an interaction iOS users expect
// on every screen; shouldBegin lets it stay available and simply not start when a guard says no.
extension HostingController: UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
        // not `guard let guard` — `guard` is a keyword, and the shadowing reads badly anyway
        guard let leaveGuard = SharedLeaveGuard.current else { return true }
        if leaveGuard.canLeave() { return true }
        leaveGuard.onBlocked()     // show the dialog instead of starting the transition
        return false
    }
}
// desktopMain — TWO events, because the window and the screen are different things.
fun main() = application {
    val slot = remember { mutableStateOf<LeaveGuard?>(null) }
    Window(
        // The window-manager request. Vetoable, and about the WINDOW: it defaults to quitting
        // the app, which is why the original BackHandler-less desktop build lost 328 drafts
        // to a close button.
        onCloseRequest = {
            val g = slot.value
            if (g == null || g.canLeave()) exitApplication() else g.onBlocked()
        },
    ) {
        // And a screen-level back, which on desktop is a shortcut rather than a system event.
        val g = slot.value
        Box(Modifier.onPreviewKeyEvent { e ->
            if (e.key == Key.Escape && e.type == KeyEventType.KeyDown && g != null && !g.canLeave()) {
                g.onBlocked(); true
            } else false
        }) { CompositionLocalProvider(LocalLeaveGuard provides slot) { App() } }
    }
}
// wasmJsMain — popstate cannot be cancelled, so a sentinel entry is pushed to be consumed.
// This is the weakest of the four and is stated as such: the user's back press is spent on
// the sentinel, the dialog appears, and if they confirm we navigate for real.
@Composable actual fun LeaveGuardHost(content: @Composable () -> Unit) {
    val slot = remember { mutableStateOf<LeaveGuard?>(null) }
    val guard = slot.value
    // Keyed on the BOOLEAN, not on the guard object: the guard's identity changes whenever
    // the screen recomposes, and keying on it pushed a fresh sentinel each time — a history
    // stack that grew until back did nothing at all for several presses.
    val blocking = guard != null && !guard.canLeave()
    LaunchedEffect(blocking) {
        if (blocking) {
            window.history.pushState(null, "", window.location.href)   // the sentinel
        }
    }
    DisposableEffect(Unit) {
        val l: (Event) -> Unit = {
            val g = slot.value
            if (g != null && !g.canLeave()) {
                window.history.pushState(null, "", window.location.href)  // re-arm
                g.onBlocked()
            }
        }
        window.addEventListener("popstate", l)
        onDispose { window.removeEventListener("popstate", l) }
    }
    CompositionLocalProvider(LocalLeaveGuard provides slot) { content() }
}

And the output it produced, two weeks:

$ ./tools/leave-guard-report.sh --days 14
  platform   sessions with unsaved changes   dialog shown   drafts lost
  android                1,790                   1,790           0
  ios                    1,164                   1,151          13   (gesture already in flight)
  desktop                  318                     318           0
  web                      861                     847          14   (tab closed outright)

2,387 lost drafts → 27. The 27 are two cases the mechanisms genuinely cannot reach, named below.

How we proved it

That the iOS callback never fired. The measurement that separated "the dialog is broken" from "the handler is not invoked":

BackHandler(enabled = true) { log("back handler invoked") }
// edge-swipe to pop, 20 times
//   android: 20 log lines
//   ios:      0 log lines

Zero. The handler was not failing a condition; it was never called.

That the gesture is interactive rather than a callback. Confirmed by instrumenting the transition, because this is the fact that rules out a post-hoc veto:

  finger down at the edge          UINavigationController begins the pop
  finger at 30% across            previous screen visible, rubber-banding
  finger released at 30%          transition CANCELLED, no app code involved
  finger released at 70%          transition COMPLETED, no app code involved

There is no point in that sequence at which anything of ours is asked.

That shouldBegin is asked before it. The hook that makes the fix possible, verified in the same instrumentation:

  gestureRecognizerShouldBegin → false    no transition starts; the screen does not move
  gestureRecognizerShouldBegin → true     the sequence above

That desktop's close is about the window. Measured because it explains 328 of the lost drafts and is not a navigation bug at all:

  action                                 what happened (before)
  window close button                    app exited; unsaved work gone
  Cmd+[ (wired by hand in one build)     dialog shown — all 12 of desktop's dialogs
  Escape key                             nothing — not wired
  a Back button in the UI                nothing — the screen had none

That beforeunload does not fire for in-page history. Checked, since it is the standard web answer and it is the wrong event here:

  action                     beforeunload   popstate
  browser back (in-app SPA)       no           yes
  close the tab                   yes          no
  reload                          yes          no

Two disjoint events, and we had wired the one the bug does not use.

That the sentinel-entry approach costs the user a back press. Stated because it is a real downside of the only web mechanism available to us:

  user presses back once      sentinel consumed, dialog shown, URL unchanged
  user confirms discard       we navigate for real
  user presses back twice fast  dialog shown once, second press consumed by the re-armed sentinel

What I cannot tell you is whether the Navigation API's intercept() would be strictly better on web. It is the right-shaped primitive — a genuine veto point before the navigation commits — and our supported-browser matrix did not have it everywhere when we shipped this, so we have not run it in production.

And the 27 remaining losses are honest. Thirteen on iOS are gestures already in flight when the guard became active (the user began the swipe in the same frame as a keystroke); fourteen on web are tab closes, where beforeunload does fire and the browser's own generic dialog appears — we chose not to add a second dialog on top of it.

Instrumenting the iOS transition to show no point at which app code is consulted, the window close being about the window rather than the screen, and two disjoint web events

What this generalizes to

When four platforms expose "the same" user action, the differences that matter are usually not in the API shape but in the timing of the question: whether you are asked before the thing happens, told while it happens, or informed after it already has. A single shared handler encodes one of those three, silently, and fails on every platform with a different one. The portable move is to have shared code declare a policy — is leaving allowed, and what happens if not — and let each platform bind its own event to it, including admitting where the platform offers no question at all.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS