fix(navigation): defer route cleanup until pop completes

This commit is contained in:
tapframe 2026-07-20 18:27:13 +05:30
parent cc13ee9c9f
commit d514468fef
4 changed files with 87 additions and 9 deletions

View file

@ -74,6 +74,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.serialization.SavedStateConfiguration
@ -761,13 +762,17 @@ private fun MainAppContent(
onSwitchProfile: () -> Unit = {},
) {
val navBackStack = rememberNavBackStack(navigationSavedStateConfiguration, initialRoute)
val routeDisposalDecorator = remember {
RouteDisposalNavEntryDecorator<NavKey> { key ->
if (key is AppRoute) disposeRoute(key)
}
}
val navController = remember(navBackStack, onNavigate, onGoBack, onReplace) {
NuvioNavigator(
backStack = navBackStack,
onExternalNavigate = onNavigate,
onExternalBack = onGoBack,
onExternalReplace = onReplace,
onRouteRemoved = ::disposeRoute,
)
}
val appUpdaterController = rememberAppUpdaterController()
@ -1827,8 +1832,12 @@ private fun MainAppContent(
backStack = navBackStack,
modifier = Modifier.fillMaxSize(),
onBack = { navController.popBackStack() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
routeDisposalDecorator,
),
sharedTransitionScope = this@SharedTransitionLayout,
entryProvider = entryProvider {
entryProvider = entryProvider<NavKey> {
entry<TabsRoute> {
PlatformBackHandler(
enabled = true,
@ -3263,6 +3272,13 @@ private fun MainAppContent(
},
)
}
}.let { provider ->
{ key ->
routeDisposalDecorator.register(
key = key,
entry = provider(key),
)
}
},
)
}

View file

@ -9,7 +9,6 @@ internal class NuvioNavigator(
private val onExternalNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null,
private val onExternalBack: (() -> Unit)? = null,
private val onExternalReplace: ((AppRoute) -> Unit)? = null,
private val onRouteRemoved: (AppRoute) -> Unit = {},
) {
val currentRoute: AppRoute?
get() = backStack.lastOrNull() as? AppRoute
@ -37,12 +36,7 @@ internal class NuvioNavigator(
val firstRemovedIndex = if (resolvedOptions.popUpToInclusive) targetIndex else targetIndex + 1
if (firstRemovedIndex <= backStack.lastIndex) {
val removedRoutes = backStack
.subList(firstRemovedIndex, backStack.size)
.filterIsInstance<AppRoute>()
.toList()
backStack.subList(firstRemovedIndex, backStack.size).clear()
removedRoutes.forEach(onRouteRemoved)
}
}
}
@ -54,7 +48,7 @@ internal class NuvioNavigator(
fun popBackStack(expectedRoute: AppRoute? = null): Boolean {
if (expectedRoute != null && currentRoute != expectedRoute) return false
if (backStack.size > 1) {
(backStack.removeAt(backStack.lastIndex) as? AppRoute)?.let(onRouteRemoved)
backStack.removeAt(backStack.lastIndex)
return true
}
onExternalBack?.invoke()

View file

@ -0,0 +1,35 @@
package com.nuvio.app.navigation
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavEntryDecorator
internal class RouteDisposalNavEntryDecorator<T : Any> private constructor(
private val registry: RouteDisposalRegistry<T>,
) : NavEntryDecorator<T>(
onPop = registry::dispose,
decorate = { entry -> entry.Content() },
) {
constructor(onDispose: (T) -> Unit) : this(RouteDisposalRegistry(onDispose))
fun register(key: T, entry: NavEntry<T>): NavEntry<T> = entry.also {
registry.register(contentKey = entry.contentKey, key = key)
}
}
internal class RouteDisposalRegistry<T : Any>(
private val onDispose: (T) -> Unit,
) {
private val keysByContentKey = mutableMapOf<Any, T>()
fun register(contentKey: Any, key: T) {
val existingKey = keysByContentKey[contentKey]
check(existingKey == null || existingKey == key) {
"Navigation content key $contentKey is already registered to a different route"
}
keysByContentKey[contentKey] = key
}
fun dispose(contentKey: Any) {
keysByContentKey.remove(contentKey)?.let(onDispose)
}
}

View file

@ -0,0 +1,33 @@
package com.nuvio.app.navigation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class RouteDisposalRegistryTest {
@Test
fun disposesRegisteredRouteExactlyOnceWhenNav3ReportsPop() {
val disposedRoutes = mutableListOf<String>()
val registry = RouteDisposalRegistry<String>(disposedRoutes::add)
registry.register(contentKey = "stream-entry", key = "stream-route")
assertTrue(disposedRoutes.isEmpty())
registry.dispose(contentKey = "stream-entry")
registry.dispose(contentKey = "stream-entry")
assertEquals(listOf("stream-route"), disposedRoutes)
}
@Test
fun rejectsAContentKeySharedByDifferentRoutes() {
val registry = RouteDisposalRegistry<String> {}
registry.register(contentKey = "shared-entry", key = "first-route")
assertFailsWith<IllegalStateException> {
registry.register(contentKey = "shared-entry", key = "second-route")
}
}
}