From f1b7629f987a23d90433129c744471b2692b4567 Mon Sep 17 00:00:00 2001 From: Milan Barta Date: Mon, 12 Oct 2026 13:23:49 +0200 Subject: [PATCH] Load the graph for a NavigatorHost that takes over an existing ID Since #202, a NavigatorHost loads its graph in HotwireActivityDelegate.registerNavigatorHost(), which skips any host whose view ID is already registered. A fragment that hosts a nested NavigatorHost can be replaced by a new instance of itself, and the new nested host registers before the old one is destroyed. The new host never loads its graph, and when the old host unregisters it removes the ID entirely. The next route() on the new host's navigator throws "You must call setGraph() before calling getGraph()". A new host instance now takes over the ID, and a host only removes the entry while it still owns it. --- .../activities/HotwireActivityDelegate.kt | 8 +- .../activities/HotwireActivityDelegateTest.kt | 93 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 navigation-fragments/src/test/kotlin/dev/hotwire/navigation/activities/HotwireActivityDelegateTest.kt diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/activities/HotwireActivityDelegate.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/activities/HotwireActivityDelegate.kt index 843ddbdc..3aa8131e 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/activities/HotwireActivityDelegate.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/activities/HotwireActivityDelegate.kt @@ -86,7 +86,7 @@ class HotwireActivityDelegate(val activity: HotwireActivity) { internal fun registerNavigatorHost(host: NavigatorHost) { logDebug("navigatorRegistered", listOf("navigator" to host.navigator.configuration.name)) - if (navigatorHosts[host.id] == null) { + if (navigatorHosts[host.id] !== host) { navigatorHosts[host.id] = host listenToDestinationChanges(host) @@ -105,7 +105,11 @@ class HotwireActivityDelegate(val activity: HotwireActivity) { internal fun unregisterNavigatorHost(host: NavigatorHost) { logDebug("navigatorUnregistered", listOf("navigator" to host.navigator.configuration.name)) - navigatorHosts.remove(host.id) + + // A newer host instance can take over the same ID before this one is destroyed. + if (navigatorHosts[host.id] === host) { + navigatorHosts.remove(host.id) + } } internal fun onNavigatorHostReady(host: NavigatorHost) { diff --git a/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/activities/HotwireActivityDelegateTest.kt b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/activities/HotwireActivityDelegateTest.kt new file mode 100644 index 00000000..d57e53b1 --- /dev/null +++ b/navigation-fragments/src/test/kotlin/dev/hotwire/navigation/activities/HotwireActivityDelegateTest.kt @@ -0,0 +1,93 @@ +package dev.hotwire.navigation.activities + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.fragment.app.Fragment +import dev.hotwire.navigation.navigator.NavigatorConfiguration +import dev.hotwire.navigation.navigator.NavigatorHost +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +// A screen that hosts a nested NavigatorHost can be replaced by a new instance of itself. The new +// nested host is created before the old one is destroyed, and both use the same view ID. +@RunWith(RobolectricTestRunner::class) +class HotwireActivityDelegateTest { + + private lateinit var activity: TestActivity + + @Before + fun setUp() { + activity = Robolectric.buildActivity(TestActivity::class.java).setup().get() + } + + @Test + fun `new nested host loads its graph while the old host with the same id is still registered`() { + addScreen(CONTAINER_A_ID) + + val newScreen = addScreen(CONTAINER_B_ID) + + assertThat(newScreen.navigatorHost.isGraphInitialized).isTrue() + } + + @Test + fun `destroying the old nested host keeps the new host with the same id registered`() { + val oldScreen = addScreen(CONTAINER_A_ID) + val newScreen = addScreen(CONTAINER_B_ID) + + activity.supportFragmentManager.beginTransaction().remove(oldScreen).commitNow() + + assertThat(activity.delegate.findNavigatorHost(NESTED_HOST_ID)) + .isSameAs(newScreen.navigatorHost) + } + + private fun addScreen(containerId: Int): NestedHostScreen { + val screen = NestedHostScreen() + activity.supportFragmentManager.beginTransaction().add(containerId, screen).commitNow() + return screen + } + + class NestedHostScreen : Fragment() { + val navigatorHost get() = childFragmentManager.findFragmentById(NESTED_HOST_ID) as NavigatorHost + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FrameLayout(requireContext()).apply { id = NESTED_HOST_ID } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + childFragmentManager.beginTransaction().add(NESTED_HOST_ID, NavigatorHost()).commitNow() + } + } + + class TestActivity : HotwireActivity() { + override fun navigatorConfigurations() = listOf( + NavigatorConfiguration( + name = "nested", + startLocation = "https://example.com/start", + navigatorHostId = NESTED_HOST_ID + ) + ) + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(androidx.appcompat.R.style.Theme_AppCompat_Light_NoActionBar) + super.onCreate(savedInstanceState) + setContentView(LinearLayout(this).apply { + addView(FrameLayout(context).apply { id = CONTAINER_A_ID }) + addView(FrameLayout(context).apply { id = CONTAINER_B_ID }) + }) + } + } + + companion object { + private val NESTED_HOST_ID = View.generateViewId() + private val CONTAINER_A_ID = View.generateViewId() + private val CONTAINER_B_ID = View.generateViewId() + } +}