From 1b661739e346f6d64ff06731ee2ac0bc80501d19 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:43:22 -0500 Subject: [PATCH] fix(map): scope cluster-renderer ViewTreeLifecycleOwner to map host view (#5708) Co-authored-by: Claude Opus 4.8 (1M context) --- .../app/map/component/NodeClusterMarkers.kt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt index 567e146cc6..6d38e176af 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt @@ -17,7 +17,18 @@ package org.meshtastic.app.map.component import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner +import androidx.savedstate.findViewTreeSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.view.DefaultClusterRenderer import com.google.maps.android.compose.Circle @@ -36,6 +47,40 @@ fun NodeClusterMarkers( navigateToNodeDetails: (Int) -> Unit, onClusterClick: (Cluster) -> Boolean, ) { + val view = LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current + val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current + val lifecycleState by lifecycleOwner.lifecycle.currentStateAsState() + + // maps-compose renders each non-clustered item to a bitmap through an off-screen ComposeView that + // it attaches under the MapView (see ComposeUiClusterRenderer + NoDrawContainerView in + // MapComposeViewRender). That ComposeView walks up the view tree for a ViewTreeLifecycleOwner and, + // when it finds none, crashes with "Composed into the View which doesn't propagate + // ViewTreeLifecycleOwner!" (googlemaps/android-maps-compose#875 / #325) — a FATAL on the map screen. + // + // Propagate the owners onto this map screen's host view (LocalView.current), which is an ancestor + // of the internally-created MapView, so the renderer's ComposeView can resolve them. We deliberately + // do NOT touch view.rootView (the activity root): attaching a transient NavEntry lifecycle there is + // what caused the node-list popup regression (#5684), which is why #5704 removed the prior, broader + // workaround entirely. Scoping to the map host view and restoring the previous owners on dispose + // keeps the fix local to the map and leaves Popups/DropdownMenus untouched. + DisposableEffect(view, lifecycleOwner, savedStateRegistryOwner) { + val prevLifecycleOwner = view.findViewTreeLifecycleOwner() + val prevSavedStateRegistryOwner = view.findViewTreeSavedStateRegistryOwner() + view.setViewTreeLifecycleOwner(lifecycleOwner) + view.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) + onDispose { + view.setViewTreeLifecycleOwner(prevLifecycleOwner) + view.setViewTreeSavedStateRegistryOwner(prevSavedStateRegistryOwner) + } + } + + // The cluster renderer drives marker rendering from an async Handler (DefaultClusterRenderer's + // MarkerModifier), which can fire after this screen has stopped and the internal ComposeView is + // detached — at which point no owner is reachable regardless of the above. Skip rendering once the + // lifecycle is no longer at least STARTED to close most of that race. + if (!lifecycleState.isAtLeast(Lifecycle.State.STARTED)) return + Clustering( items = nodeClusterItems, onClusterClick = onClusterClick,