From 378d4cc63b3b913041900b653d34d9e751dae2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Wed, 10 Jun 2026 19:28:01 +0200 Subject: [PATCH] fix: prevent ComposeUiClusterRenderer crash on fast back gesture with compose-ui 1.10+ Addresses issue #875: IllegalStateException "Composed into the View which doesn't propagate ViewTreeLifecycleOwner!" when navigating back while DefaultClusterRenderer's MarkerModifier handler is still processing. - Add scope.isActive guards in getDescriptorForCluster and onBeforeClusterItemRendered so that after the Compose scope is cancelled (e.g. on back navigation), render callbacks fall through to the default super implementation instead of attempting to create new ComposeViews with a disposed CompositionContext. - Set ViewTreeSavedStateRegistryOwner alongside ViewTreeLifecycleOwner on off-screen views for full compose-ui 1.10+ compatibility. --- .../compose/clustering/ClusterRenderer.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt index 9d8f2342..4dfb9548 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt @@ -42,6 +42,7 @@ import com.google.maps.android.clustering.view.DefaultClusterRenderer import com.google.maps.android.compose.ComposeUiViewRenderer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.android.awaitFrame +import kotlinx.coroutines.isActive import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collectLatest @@ -50,6 +51,10 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner internal interface ClusterRendererItemState { val unclusteredItems: State> @@ -90,6 +95,15 @@ internal class ComposeUiClusterRenderer( override val lifecycle: Lifecycle get() = lifecycleRegistry } + private val fakeSavedStateRegistryOwner = object : SavedStateRegistryOwner { + private val controller = SavedStateRegistryController.create(this).apply { + performAttach() + performRestore(null) + } + override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry + override val lifecycle: Lifecycle get() = fakeLifecycleOwner.lifecycle + } + override fun onClustersChanged(clusters: Set>) { super.onClustersChanged(clusters) unclusteredItems.value = clusters.filter { !shouldRenderAsCluster(it) } @@ -149,6 +163,7 @@ internal class ComposeUiClusterRenderer( } ) view.setViewTreeLifecycleOwner(fakeLifecycleOwner) + view.setViewTreeSavedStateRegistryOwner(fakeSavedStateRegistryOwner) val renderHandle = viewRendererState.value.startRenderingView(view) val rerenderJob = scope.launch { collectInvalidationsAndRerender(key, view) @@ -212,6 +227,7 @@ internal class ComposeUiClusterRenderer( } override fun getDescriptorForCluster(cluster: Cluster): BitmapDescriptor { + if (!scope.isActive) return super.getDescriptorForCluster(cluster) return if (clusterContentState.value != null) { val viewInfo = keysToViews.entries .firstOrNull { (key, _) -> (key as? ViewKey.Cluster)?.cluster == cluster } @@ -232,6 +248,8 @@ internal class ComposeUiClusterRenderer( override fun onBeforeClusterItemRendered(item: T, markerOptions: MarkerOptions) { super.onBeforeClusterItemRendered(item, markerOptions) + if (!scope.isActive) return + if (clusterItemContentState.value != null) { val viewInfo = keysToViews.entries .firstOrNull { (key, _) -> (key as? ViewKey.Item)?.item == item }