From 112504205c230fd32dee72fa7b56e09f86184e73 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 11 Aug 2026 10:05:05 -0400 Subject: [PATCH] fix(nav): switching between two pinned web-app tabs crashed onto the wrong one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../amethyst/ui/navigation/navs/Nav.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index d937109b10..ac0adde7bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -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/ 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) } }