Summary
On compose-ui 1.11.x, MarkerInfoWindow / MarkerInfoWindowContent crash the app with:
java.lang.IllegalStateException: Composed into the View which doesn't propagate ViewTreeLifecycleOwner!
The Maps SDK measures the info-window View from its own Handler, asynchronously. If that measure lands after Compose has unparented the MapView, the ComposeView created by ComposeInfoWindowAdapter can no longer resolve a ViewTreeLifecycleOwner, and AbstractComposeView.onMeasure throws.
This is a latent race that compose-ui 1.11 promoted from silent no-op to fatal (see Why this is new). We first hit it in production; it is now reproducible on demand in ~8 seconds with the sample below, and the proposed one-line-per-owner fix is verified to eliminate it.
Consumers cannot fix this within the documented API surface except by abusing mapViewFactory, because the offending ComposeView and its container are both internal to the library.
Environment details
|
|
| Library |
com.google.maps.android:maps-compose:8.4.0 |
| Maps SDK |
com.google.android.gms:play-services-maps:20.0.0 |
| Compose UI |
androidx.compose.ui:ui:1.11.4 (via compose-bom:2026.06.01) |
| Repro device |
OnePlus CPH2649, Android 16 / API 36 |
| Google Play services |
26.32.32 (260400-964721966) |
| Renderer |
maps_core: 263020504 (phoenix / LATEST) |
| Original production reports |
dynamite policy_maps_core_dynamite@260830213, same phoenix renderer |
Renderer matters for reproducing. On an emulator whose GMS only offers the legacy renderer (renderer=LEGACY, GMS 24.23.35), the same app did not crash in 33 cycles. Please reproduce on a device with a current GMS / phoenix renderer.
Steps to reproduce
The crash needs getInfoWindow() + measure to land after Compose has unparented the MapView, so showInfoWindow() and the detach must happen in the same beat, before the SDK's Handler drains.
- Put a
GoogleMap containing a MarkerInfoWindow inside a LazyColumn item.
- Call
markerState.showInfoWindow() — the SDK posts an info-window render.
- Immediately, with no frame in between, scroll the map item out of the viewport.
AndroidViewHolder.onDeactivate() runs removeAllViewsInLayout(), unparenting the MapView.
- Scroll back, repeat.
Crashes within ~20 cycles, consistently under 10 seconds. Full runnable sample below.
Note this is the onDeactivate / recycling path — no back press, no lifecycle transition anywhere. Outright removal from composition (onDetach) is the same mechanism and is what we see in production, but recycling reproduces far more readily because the MapView is never destroyed.
Code example
private val TARGET = LatLng(12.9716, 77.5946)
@Composable
private fun ReuseRepro(fix: Boolean) {
val listState = rememberLazyListState()
val markerState = rememberUpdatedMarkerState(TARGET)
var loaded by remember { mutableStateOf(false) }
// Post an info-window render, then deactivate the map item in the same beat, so the SDK's
// Handler callback lands after AndroidViewHolder has unparented the MapView.
LaunchedEffect(loaded) {
if (!loaded) return@LaunchedEffect
while (true) {
markerState.showInfoWindow()
listState.scrollToItem(6) // -> onDeactivate -> removeAllViewsInLayout()
delay(40)
listState.scrollToItem(0) // -> onReuse -> addView()
delay(40)
markerState.hideInfoWindow()
}
}
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
item { MapWithInfoWindow(fix, markerState) { loaded = true } }
items(20) { i -> Text("filler $i", Modifier.fillMaxWidth().height(120.dp)) }
}
}
@Composable
private fun MapWithInfoWindow(
fix: Boolean,
markerState: MarkerState,
onLoaded: () -> Unit,
) {
val pinningFactory = rememberOwnerPinningMapViewFactory() // see Suggested fix
GoogleMap(
modifier = Modifier.fillMaxWidth().height(400.dp),
cameraPositionState = rememberCameraPositionState(),
onMapLoaded = onLoaded,
// fix = false -> crashes in ~8s; fix = true -> survives 875+ cycles
mapViewFactory = if (fix) pinningFactory else { ctx, opts -> MapView(ctx, opts) },
) {
MarkerInfoWindow(state = markerState) {
Text("info window content")
}
}
}
Reproduction results
Same APK, same device, only the fix flag varying:
| Run |
mapViewFactory |
Result |
| 1 |
default (::MapView) |
💥 crash after 8s |
| 2 |
default (::MapView) |
💥 crash after 8s |
| 3 |
default (::MapView) |
💥 crash after 9s |
| 4 |
owner-pinning |
✅ 725 cycles, no crash, process alive |
| 5 |
owner-pinning |
✅ 875 cycles, no crash, process alive |
Isolating the cause
Replaying maps-compose's exact sequence (unparent → getInfoWindow work → measure) against a real MapView, varying only whether the MapView is detached and whether owners are pinned:
| owners pinned |
MapView detached |
mapView.findViewTreeLifecycleOwner() |
composeView.measure(...) |
| ❌ |
✅ |
null |
💥 IllegalStateException |
| ✅ |
✅ |
MainActivity@… |
✅ OK (measured 0×0) |
| ❌ |
❌ |
MainActivity@… |
✅ OK (measured 210×47) |
| ✅ |
❌ |
MainActivity@… |
✅ OK (measured 210×47) |
- Rows 1 vs 3: detachment alone flips the outcome — nothing else changed.
- Rows 1 vs 2: pinning the owners eliminates the throw.
- Rows 3 vs 4: pinning is a no-op while attached — identical 210×47, so normal measurement is untouched.
- Row 2 measures 0×0: in the detached case the info window renders blank instead of crashing. That is the intended trade — the map is being torn down anyway.
Stack trace
Locally reproduced (device above):
FATAL EXCEPTION: main
Process: com.example.mapsrepro
java.lang.IllegalStateException: Composed into the View which doesn't propagate ViewTreeLifecycleOwner!
at androidx.compose.ui.platform.AbstractComposeView.resolveComposeViewContext(ComposeView.android.kt:361)
at androidx.compose.ui.platform.AbstractComposeView.ensureCompositionCreated(ComposeView.android.kt:340)
at androidx.compose.ui.platform.AbstractComposeView.onMeasure(ComposeView.android.kt:475)
at android.view.View.measure(View.java:29204)
at com.google.maps.api.android.lib6.impl.cq.a(:com.google.android.gms.policy_maps_core_dynamite@263020513@263020504025.955878937.955878937:27)
at com.google.maps.api.android.lib6.phoenix.ai.a(:com.google.android.gms.policy_maps_core_dynamite@263020513@263020504025.955878937.955878937:32)
at com.google.maps.api.android.lib6.phoenix.dd.k(:com.google.android.gms.policy_maps_core_dynamite@263020513@263020504025.955878937.955878937:103)
at com.google.maps.api.android.lib6.phoenix.dd.e(:com.google.android.gms.policy_maps_core_dynamite@263020513@263020504025.955878937.955878937:79)
at com.google.maps.api.android.lib6.impl.ew.run(:com.google.android.gms.policy_maps_core_dynamite@263020513@263020504025.955878937.955878937:67)
at android.os.Handler.handleCallback(Handler.java:1001)
at android.os.Handler.dispatchMessage(Handler.java:109)
at android.os.Looper.loopOnce(Looper.java:306)
at android.os.Looper.loop(Looper.java:416)
at android.app.ActivityThread.main(ActivityThread.java:9999)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:613)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1074)
Original production trace (different dynamite build — obfuscated class letters differ, every line offset matches):
Fatal Exception: java.lang.IllegalStateException: Composed into the View which doesn't propagate ViewTreeLifecycleOwner!
at androidx.compose.ui.platform.AbstractComposeView.resolveComposeViewContext(ComposeView.android.kt:361)
at androidx.compose.ui.platform.AbstractComposeView.ensureCompositionCreated(ComposeView.android.kt:340)
at androidx.compose.ui.platform.AbstractComposeView.onMeasure(ComposeView.android.kt:475)
at android.view.View.measure(View.java:28584)
at com.google.maps.api.android.lib6.impl.cp.a(:com.google.android.gms.policy_maps_core_dynamite@260830213@260830204025.951593635.951593635:27)
at com.google.maps.api.android.lib6.phoenix.an.c(:com.google.android.gms.policy_maps_core_dynamite@260830213@260830204025.951593635.951593635:32)
at com.google.maps.api.android.lib6.phoenix.du.q(:com.google.android.gms.policy_maps_core_dynamite@260830213@260830204025.951593635.951593635:34)
at com.google.maps.api.android.lib6.phoenix.dm.b(:com.google.android.gms.policy_maps_core_dynamite@260830213@260830204025.951593635.951593635:68)
at com.google.maps.api.android.lib6.phoenix.dm.g(:com.google.android.gms.policy_maps_core_dynamite@260830213@260830204025.951593635.951593635:79)
at com.google.maps.api.android.lib6.impl.ev.run(:com.google.android.gms.policy_maps_core_dynamite@260830213@260830204025.951593635.951593635:67)
at android.os.Handler.handleCallback(Handler.java:1037)
at android.os.Handler.dispatchMessage(Handler.java:108)
at android.os.Looper.loopOnce(Looper.java:304)
at android.os.Looper.loop(Looper.java:430)
at android.app.ActivityThread.main(ActivityThread.java:9345)
at java.lang.reflect.Method.invoke(Method.java)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:593)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:953)
All three Compose frames are identical, and the Maps frames match at offsets :27, :32, :79, :67. Note Handler.handleCallback → ev.run / ew.run at the bottom: this is the SDK's own posted render, not a Compose layout pass.
Root cause analysis
1. The info-window ComposeView is parented below the MapView
ComposeInfoWindowAdapter.getInfoWindow() creates a ComposeView, sets content, and calls:
renderHandles[marker] = mapView.startRenderingComposeView(view, markerNode.compositionContext)
MapComposeViewRender.startRenderingComposeView():
val containerView = ensureContainerView() // NoDrawContainerView, a child of the MapView
containerView.addView(view)
view.apply { setParentCompositionContext(parentContext) }
Hierarchy: MapView → NoDrawContainerView → ComposeView.
2. The composition is only created eagerly if the view attaches
AbstractComposeView creates its composition in onAttachedToWindow(). If the MapView subtree is not attached when addView runs, that never fires and composition stays null.
3. onMeasure then has to create it — and that is now fatal
final override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
ensureCompositionCreated() // ComposeView.android.kt:475
internalOnMeasure(widthMeasureSpec, heightMeasureSpec)
}
ensureCompositionCreated() → resolveComposeViewContext():
lifecycleOwner =
contextView.findViewTreeLifecycleOwner()
?: existingContext?.lifecycleOwner
?: throw IllegalStateException(
"Composed into the View which doesn't propagate ViewTreeLifecycleOwner!"
),
4. The owner tag is not on the MapView — and Compose cuts the link to where it is
This is the crux. In a Compose host the view_tree_lifecycle_owner tag lives on AndroidViewHolder — the MapView's parent — set from LocalLifecycleOwner.current (compose-ui AndroidViewHolder.android.kt:179).
When the map leaves composition or is deactivated for reuse, Compose unparents the MapView from that holder:
layoutNode.onDetach = { owner ->
(owner as? AndroidComposeView)?.removeAndroidView(this)
removeAllViewsInLayout() // AndroidViewHolder.android.kt:455
}
override fun onDeactivate() {
reset()
removeAllViewsInLayout() // AndroidViewHolder.android.kt:253
}
So at measure time the walk is:
ComposeView → NoDrawContainerView → MapView → parent == null → no owner → throw
The LifecycleOwner object is alive and valid throughout — confirmed empirically, the isolation table above shows the same MainActivity instance resolving fine as soon as it is reachable. The problem is reachability, not lifecycle state.
Why this is new (compose-ui 1.10 → 1.11)
The race predates 1.11; it simply was not fatal.
compose-ui 1.10.0 — ensureCompositionCreated() did no owner resolution:
private fun ensureCompositionCreated() {
if (composition == null) {
try {
creatingComposition = true
composition = setContent(resolveParentCompositionContext()) { Content() }
} finally {
creatingComposition = false
}
}
}
The ViewTreeLifecycleOwner requirement was enforced in AndroidComposeView.onAttachedToWindow() (AndroidComposeView.android.kt:2230) — which never runs for a view that never attaches. The detached info window silently rendered blank.
compose-ui 1.11.4 — owner resolution moved into ensureCompositionCreated() via the new resolveComposeViewContext(), resolved eagerly and synchronously. Since onMeasure calls it unconditionally, measuring a detached ComposeView now throws.
Net effect: every maps-compose consumer using Compose info windows on compose-ui 1.11+ inherits a crash where 1.10 degraded gracefully. 8.4.0 does not help — its NoDrawContainerView change addresses the drawing regression, not this one.
Why this belongs in maps-compose
- The
ComposeView and the NoDrawContainerView are both library-internal. Consumers cannot set owners on either.
- The Maps SDK measures and draws the returned
View on its own schedule, which the library cannot synchronize with Compose teardown. hideInfoWindow() on dispose narrows the window but cannot cancel an already-posted render — we shipped that mitigation first and still crashed.
- It affects every consumer of
MarkerInfoWindow / MarkerInfoWindowContent on a current Compose BOM.
Suggested fix
Pin the ViewTree owners onto a view below the boundary Compose severs, so the lookup terminates inside the map's own subtree.
Preferred: capture them in GoogleMap, where the composition locals are directly available, and set them on the MapView in the AndroidView factory:
// In GoogleMap()
val lifecycleOwner = LocalLifecycleOwner.current
val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current
AndroidView(
factory = { context ->
mapViewFactory(context, options).also { mapView ->
// The Maps SDK measures info-window ComposeViews from its own Handler, which can
// land after Compose has unparented this MapView from its AndroidViewHolder (the
// view that actually carries the ViewTreeLifecycleOwner tag). Pinning here keeps
// the owner lookup terminating inside the map's own subtree.
mapView.setViewTreeLifecycleOwner(lifecycleOwner)
mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
...
}
},
...
)
Alternatively, set them on the NoDrawContainerView in ensureContainerView() — but note the container is created lazily on the first info window, by which time the MapView may already be unparented, so reading the owners off the view tree at that point is not reliable. Capturing from composition locals in GoogleMap is the safer source.
Both lifecycleOwner and savedStateRegistryOwner are required: resolveComposeViewContext() throws a separate IllegalStateException for a missing ViewTreeSavedStateRegistryOwner. viewModelStoreOwner is permitted to be null.
resolveParentCompositionContext() is already satisfied because startRenderingComposeView sets parentContext explicitly, so it never falls through to windowRecomposer (which would throw its own "Cannot locate windowRecomposer; View is not attached").
This does not resurrect a destroyed lifecycle and does not change attached behaviour — these are the same instances AndroidView already sets on the MapView's parent, and the isolation table confirms identical measurement while attached.
Secondary observation (separate, unverified)
renderHandles in ComposeInfoWindowAdapter is only cleared from disposeForMarker(), which is wired solely to setOnInfoWindowCloseListener. If a marker is removed while its info window is open and no close event is delivered, both the map entry and the ComposeView appear to remain in the container indefinitely. Flagging as possibly worth a look while in this file — not confirmed.
Workaround for other affected consumers
The public mapViewFactory parameter on GoogleMap is enough to apply the fix from outside the library. This is what was verified above (875 cycles clean vs. crash in 8s):
@Composable
private fun rememberOwnerPinningMapViewFactory(): (Context, GoogleMapOptions) -> MapView {
val lifecycleOwner = LocalLifecycleOwner.current
val savedStateRegistryOwner = LocalSavedStateRegistryOwner.current
val mapViewRef = remember { arrayOfNulls<MapView>(1) }
// Re-pin if the providing owners ever change identity, so the MapView never holds a stale one.
SideEffect {
mapViewRef[0]?.let { mapView ->
mapView.setViewTreeLifecycleOwner(lifecycleOwner)
mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
}
}
return remember {
{ context, options ->
MapView(context, options).also { mapView ->
mapViewRef[0] = mapView
mapView.setViewTreeLifecycleOwner(lifecycleOwner)
mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
}
}
}
}
// usage
GoogleMap(
/* ... */,
mapViewFactory = rememberOwnerPinningMapViewFactory(),
) { /* markers, MarkerInfoWindow, ... */ }
Summary
On compose-ui 1.11.x,
MarkerInfoWindow/MarkerInfoWindowContentcrash the app with:The Maps SDK measures the info-window
Viewfrom its ownHandler, asynchronously. If that measure lands after Compose has unparented theMapView, theComposeViewcreated byComposeInfoWindowAdaptercan no longer resolve aViewTreeLifecycleOwner, andAbstractComposeView.onMeasurethrows.This is a latent race that compose-ui 1.11 promoted from silent no-op to fatal (see Why this is new). We first hit it in production; it is now reproducible on demand in ~8 seconds with the sample below, and the proposed one-line-per-owner fix is verified to eliminate it.
Consumers cannot fix this within the documented API surface except by abusing
mapViewFactory, because the offendingComposeViewand its container are both internal to the library.Environment details
com.google.maps.android:maps-compose:8.4.0com.google.android.gms:play-services-maps:20.0.0androidx.compose.ui:ui:1.11.4(viacompose-bom:2026.06.01)maps_core: 263020504(phoenix / LATEST)policy_maps_core_dynamite@260830213, same phoenix rendererRenderer matters for reproducing. On an emulator whose GMS only offers the legacy renderer (
renderer=LEGACY, GMS 24.23.35), the same app did not crash in 33 cycles. Please reproduce on a device with a current GMS / phoenix renderer.Steps to reproduce
The crash needs
getInfoWindow()+ measure to land after Compose has unparented theMapView, soshowInfoWindow()and the detach must happen in the same beat, before the SDK's Handler drains.GoogleMapcontaining aMarkerInfoWindowinside aLazyColumnitem.markerState.showInfoWindow()— the SDK posts an info-window render.AndroidViewHolder.onDeactivate()runsremoveAllViewsInLayout(), unparenting theMapView.Crashes within ~20 cycles, consistently under 10 seconds. Full runnable sample below.
Note this is the
onDeactivate/ recycling path — no back press, no lifecycle transition anywhere. Outright removal from composition (onDetach) is the same mechanism and is what we see in production, but recycling reproduces far more readily because theMapViewis never destroyed.Code example
Reproduction results
Same APK, same device, only the
fixflag varying:mapViewFactory::MapView)::MapView)::MapView)Isolating the cause
Replaying maps-compose's exact sequence (unparent →
getInfoWindowwork → measure) against a realMapView, varying only whether theMapViewis detached and whether owners are pinned:mapView.findViewTreeLifecycleOwner()composeView.measure(...)nullMainActivity@…MainActivity@…MainActivity@…Stack trace
Locally reproduced (device above):
Original production trace (different dynamite build — obfuscated class letters differ, every line offset matches):
All three Compose frames are identical, and the Maps frames match at offsets
:27,:32,:79,:67. NoteHandler.handleCallback→ev.run/ew.runat the bottom: this is the SDK's own posted render, not a Compose layout pass.Root cause analysis
1. The info-window
ComposeViewis parented below theMapViewComposeInfoWindowAdapter.getInfoWindow()creates aComposeView, sets content, and calls:renderHandles[marker] = mapView.startRenderingComposeView(view, markerNode.compositionContext)MapComposeViewRender.startRenderingComposeView():Hierarchy:
MapView → NoDrawContainerView → ComposeView.2. The composition is only created eagerly if the view attaches
AbstractComposeViewcreates its composition inonAttachedToWindow(). If theMapViewsubtree is not attached whenaddViewruns, that never fires andcompositionstaysnull.3.
onMeasurethen has to create it — and that is now fatalensureCompositionCreated()→resolveComposeViewContext():4. The owner tag is not on the
MapView— and Compose cuts the link to where it isThis is the crux. In a Compose host the
view_tree_lifecycle_ownertag lives onAndroidViewHolder— theMapView's parent — set fromLocalLifecycleOwner.current(compose-uiAndroidViewHolder.android.kt:179).When the map leaves composition or is deactivated for reuse, Compose unparents the
MapViewfrom that holder:So at measure time the walk is:
The
LifecycleOwnerobject is alive and valid throughout — confirmed empirically, the isolation table above shows the sameMainActivityinstance resolving fine as soon as it is reachable. The problem is reachability, not lifecycle state.Why this is new (compose-ui 1.10 → 1.11)
The race predates 1.11; it simply was not fatal.
compose-ui 1.10.0 —
ensureCompositionCreated()did no owner resolution:The
ViewTreeLifecycleOwnerrequirement was enforced inAndroidComposeView.onAttachedToWindow()(AndroidComposeView.android.kt:2230) — which never runs for a view that never attaches. The detached info window silently rendered blank.compose-ui 1.11.4 — owner resolution moved into
ensureCompositionCreated()via the newresolveComposeViewContext(), resolved eagerly and synchronously. SinceonMeasurecalls it unconditionally, measuring a detachedComposeViewnow throws.Net effect: every maps-compose consumer using Compose info windows on compose-ui 1.11+ inherits a crash where 1.10 degraded gracefully. 8.4.0 does not help — its
NoDrawContainerViewchange addresses the drawing regression, not this one.Why this belongs in maps-compose
ComposeViewand theNoDrawContainerVieware both library-internal. Consumers cannot set owners on either.Viewon its own schedule, which the library cannot synchronize with Compose teardown.hideInfoWindow()on dispose narrows the window but cannot cancel an already-posted render — we shipped that mitigation first and still crashed.MarkerInfoWindow/MarkerInfoWindowContenton a current Compose BOM.Suggested fix
Pin the ViewTree owners onto a view below the boundary Compose severs, so the lookup terminates inside the map's own subtree.
Preferred: capture them in
GoogleMap, where the composition locals are directly available, and set them on theMapViewin theAndroidViewfactory:Alternatively, set them on the
NoDrawContainerViewinensureContainerView()— but note the container is created lazily on the first info window, by which time theMapViewmay already be unparented, so reading the owners off the view tree at that point is not reliable. Capturing from composition locals inGoogleMapis the safer source.Both
lifecycleOwnerandsavedStateRegistryOwnerare required:resolveComposeViewContext()throws a separateIllegalStateExceptionfor a missingViewTreeSavedStateRegistryOwner.viewModelStoreOwneris permitted to be null.resolveParentCompositionContext()is already satisfied becausestartRenderingComposeViewsetsparentContextexplicitly, so it never falls through towindowRecomposer(which would throw its own "Cannot locate windowRecomposer; View is not attached").This does not resurrect a destroyed lifecycle and does not change attached behaviour — these are the same instances
AndroidViewalready sets on theMapView's parent, and the isolation table confirms identical measurement while attached.Secondary observation (separate, unverified)
renderHandlesinComposeInfoWindowAdapteris only cleared fromdisposeForMarker(), which is wired solely tosetOnInfoWindowCloseListener. If a marker is removed while its info window is open and no close event is delivered, both the map entry and theComposeViewappear to remain in the container indefinitely. Flagging as possibly worth a look while in this file — not confirmed.Workaround for other affected consumers
The public
mapViewFactoryparameter onGoogleMapis enough to apply the fix from outside the library. This is what was verified above (875 cycles clean vs. crash in 8s):