From d514468fef262dde8ee589d4cb2fd7bf4f4f1843 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:27:13 +0530 Subject: [PATCH] fix(navigation): defer route cleanup until pop completes --- .../commonMain/kotlin/com/nuvio/app/App.kt | 20 +++++++++-- .../nuvio/app/navigation/NuvioNavigator.kt | 8 +---- .../RouteDisposalNavEntryDecorator.kt | 35 +++++++++++++++++++ .../navigation/RouteDisposalRegistryTest.kt | 33 +++++++++++++++++ 4 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/RouteDisposalNavEntryDecorator.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/navigation/RouteDisposalRegistryTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 94a99115..dd8cc3e7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 { 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(), + routeDisposalDecorator, + ), sharedTransitionScope = this@SharedTransitionLayout, - entryProvider = entryProvider { + entryProvider = entryProvider { entry { PlatformBackHandler( enabled = true, @@ -3263,6 +3272,13 @@ private fun MainAppContent( }, ) } + }.let { provider -> + { key -> + routeDisposalDecorator.register( + key = key, + entry = provider(key), + ) + } }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt index 7320a800..b458d9e1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt @@ -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() - .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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/RouteDisposalNavEntryDecorator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/RouteDisposalNavEntryDecorator.kt new file mode 100644 index 00000000..4ba7d2e8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/RouteDisposalNavEntryDecorator.kt @@ -0,0 +1,35 @@ +package com.nuvio.app.navigation + +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator + +internal class RouteDisposalNavEntryDecorator private constructor( + private val registry: RouteDisposalRegistry, +) : NavEntryDecorator( + onPop = registry::dispose, + decorate = { entry -> entry.Content() }, +) { + constructor(onDispose: (T) -> Unit) : this(RouteDisposalRegistry(onDispose)) + + fun register(key: T, entry: NavEntry): NavEntry = entry.also { + registry.register(contentKey = entry.contentKey, key = key) + } +} + +internal class RouteDisposalRegistry( + private val onDispose: (T) -> Unit, +) { + private val keysByContentKey = mutableMapOf() + + 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) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/navigation/RouteDisposalRegistryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/navigation/RouteDisposalRegistryTest.kt new file mode 100644 index 00000000..766ee7b6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/navigation/RouteDisposalRegistryTest.kt @@ -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() + val registry = RouteDisposalRegistry(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 {} + registry.register(contentKey = "shared-entry", key = "first-route") + + assertFailsWith { + registry.register(contentKey = "shared-entry", key = "second-route") + } + } +}