fix(nav): switching between two pinned web-app tabs crashed onto the wrong one

With two web apps pinned to the bottom bar, tapping the second one crashed:

    IllegalArgumentException: No destination with route
      Route.WebApp/https%3A%2F%2Fbrainstorm.world%2F%3Fq%3DVitor
      is on the NavController's back stack.
      The current destination is route=Route.WebApp/{url}

`saveState`/`restoreState` are keyed by DESTINATION, and every pinned tab of one
kind shares a single destination — every web app is `Route.WebApp/{url}`. So the
`restoreState = true` on the navigate restored the *sibling* tab's saved entry
instead of creating the one that was asked for, and `getBackStackEntry(route)`
then threw on a route that was never pushed.

The crash was the visible half. Instrumented on device, the navigate itself
already landed wrong:

    asked=WebApp(url=https://brainstorm.world/?q=Vitor)
    landed url=http://localhost:8765/keyboard.html

— the second web-app tab selected and rendered the first one's site. Anything
that only stopped the throw would have left that in place.

So: when the entry we asked for isn't on the stack after the navigate, take the
tab fresh — no `restoreState` (that is what handed back the sibling) and no
`launchSingleTop` (the top *is* the sibling, and reusing it is the bug). That
tab's saved scroll/ViewModel state is unrecoverable in the colliding case, but
the user lands on the tab they tapped. Tabs whose destination nothing else
shares still save and restore exactly as before, which is what that behavior is
there for.

Reproduced and verified on a tablet (SM-T220, Android 14): two web apps pinned,
switching either direction now loads the right site with its query string
applied and no crash; a fresh launch straight into either tab was already fine
and still is; and all six bottom-nav tabs cycle twice with no fatals. Same
collision applies to any two pinned tabs sharing a destination (chats, Concord
channels), which this covers too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-08-11 10:05:05 -04:00
co-authored by Claude Opus 5
parent c43339e92b
commit 112504205c
@@ -121,7 +121,28 @@ class Nav(
}
// Mark this entry as a tab root: hides the back arrow in canPop
// and skips the horizontal slide in composableFromEnd.
controller.getBackStackEntry(route).savedStateHandle[BOTTOM_NAV_ROOT_KEY] = true
// saveState/restoreState are keyed by DESTINATION, and every pinned tab of one kind shares a
// single destination — all web apps are `Route.WebApp/{url}`, all pinned chats their own one
// pattern. So the restore above can hand back a *sibling* tab's saved entry: with two web apps
// pinned, tapping the second one landed on the first one's URL, and the lookup below then threw
// `No destination with route …WebApp/<url> is on the NavController's back stack`.
//
// When the entry we asked for isn't there, take the tab fresh (no restoreState, and no
// launchSingleTop — the top is the sibling we do not want to reuse). Its saved scroll/ViewModel
// state is not recoverable in that case, but the user lands on the tab they tapped. Tabs whose
// destination nothing else shares still restore normally, which is what this is here for.
val entry =
runCatching { controller.getBackStackEntry(route) }.getOrNull()
?: run {
controller.navigate(route) {
popUpTo(Route.Home) {
inclusive = false
saveState = true
}
}
runCatching { controller.getBackStackEntry(route) }.getOrNull()
}
entry?.savedStateHandle?.set(BOTTOM_NAV_ROOT_KEY, true)
}
}