From be6f2cfb71c3a37ba1a1e5bbd92211a36dbe96b2 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:36:03 -0500 Subject: [PATCH] fix(map): remove manual ViewTree lifecycle owner workarounds (#5704) --- .../app/map/component/NodeClusterMarkers.kt | 55 ------------------- .../app/node/component/InlineMap.kt | 33 ----------- 2 files changed, 88 deletions(-) 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 a7ae3b8e20..567e146cc6 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,18 +17,7 @@ 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 @@ -47,50 +36,6 @@ 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() - - // Workaround for https://github.com/googlemaps/android-maps-compose/issues/858 - // and https://github.com/googlemaps/android-maps-compose/issues/875 - // The maps clustering library creates an internal ComposeView to snapshot markers. - // If that view is not attached to the hierarchy (which it often isn't during rendering), - // it fails to find the Lifecycle and SavedState owners. We propagate them to the root view - // so the internal snapshot view can find them when walking up the tree. - // DisposableEffect runs at composition time (not post-composition like SideEffect), - // ensuring owners are set before the Clustering composable triggers marker rendering. - // Capture and restore the previous owners on dispose. The owners here are NavEntry-scoped - // (transient) lifecycles; leaving one attached to the activity root view after this screen is - // destroyed makes subsequently opened Popups/DropdownMenus inherit a DESTROYED lifecycle and - // render at 0x0 (invisible). See the node-list popup regression and InlineMap. - DisposableEffect(lifecycleOwner, savedStateRegistryOwner) { - val root = view.rootView - val prevRootLifecycleOwner = root.findViewTreeLifecycleOwner() - val prevRootSavedStateRegistryOwner = root.findViewTreeSavedStateRegistryOwner() - root.setViewTreeLifecycleOwner(lifecycleOwner) - root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - // Also set on the view itself in case the internal renderer walks from a child - val prevViewLifecycleOwner = view.findViewTreeLifecycleOwner() - val prevViewSavedStateRegistryOwner = view.findViewTreeSavedStateRegistryOwner() - if (view !== root) { - view.setViewTreeLifecycleOwner(lifecycleOwner) - view.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - } - onDispose { - root.setViewTreeLifecycleOwner(prevRootLifecycleOwner) - root.setViewTreeSavedStateRegistryOwner(prevRootSavedStateRegistryOwner) - if (view !== root) { - view.setViewTreeLifecycleOwner(prevViewLifecycleOwner) - view.setViewTreeSavedStateRegistryOwner(prevViewSavedStateRegistryOwner) - } - } - } - - // Guard against the cluster renderer's async Handler trying to render markers - // after the lifecycle has stopped — the internal ComposeView requires an active lifecycle. - if (!lifecycleState.isAtLeast(Lifecycle.State.STARTED)) return - Clustering( items = nodeClusterItems, onClusterClick = onClusterClick, diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt index 53af32bb01..f77f8aec18 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt @@ -18,17 +18,9 @@ package org.meshtastic.app.node.component import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.key import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.savedstate.compose.LocalSavedStateRegistryOwner -import androidx.savedstate.findViewTreeSavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng import com.google.maps.android.compose.Circle @@ -52,31 +44,6 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { true -> ComposeMapColorScheme.DARK else -> ComposeMapColorScheme.LIGHT } - - // Defensive workaround: propagate ViewTreeLifecycleOwner to the root view so that - // any internal maps-compose ComposeView (e.g., info windows) can find the lifecycle - // when walking up the view tree. - // - // IMPORTANT: capture and restore the previous owners on dispose. This InlineMap is hosted inside the - // node-detail NavEntry, whose LocalLifecycleOwner is a transient, entry-scoped lifecycle. Leaving it - // attached to the activity root view after the entry is destroyed (e.g. navigating back to the node - // list) would make subsequently opened Popups/DropdownMenus inherit a DESTROYED lifecycle and - // render at 0x0 (invisible). See the node-list popup regression. - val view = LocalView.current - val lifecycleOwner = LocalLifecycleOwner.current - val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current - DisposableEffect(lifecycleOwner, savedStateRegistryOwner) { - val root = view.rootView - val prevRootLifecycleOwner = root.findViewTreeLifecycleOwner() - val prevRootSavedStateRegistryOwner = root.findViewTreeSavedStateRegistryOwner() - root.setViewTreeLifecycleOwner(lifecycleOwner) - root.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner) - onDispose { - root.setViewTreeLifecycleOwner(prevRootLifecycleOwner) - root.setViewTreeSavedStateRegistryOwner(prevRootSavedStateRegistryOwner) - } - } - key(node.num) { val location = LatLng(node.latitude, node.longitude) val cameraState = rememberCameraPositionState {