Skip to content

Maps Compose: MarkerInfoWindow crashes with "Composed into the View which doesn't propagate ViewTreeLifecycleOwner!" on compose-ui 1.11+ when the map is detached #971

Description

@firefinchdev

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.

  1. Put a GoogleMap containing a MarkerInfoWindow inside a LazyColumn item.
  2. Call markerState.showInfoWindow() — the SDK posts an info-window render.
  3. Immediately, with no frame in between, scroll the map item out of the viewport. AndroidViewHolder.onDeactivate() runs removeAllViewsInLayout(), unparenting the MapView.
  4. 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.handleCallbackev.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.0ensureCompositionCreated() 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, ... */ }

Metadata

Metadata

Assignees

No one assigned

    Labels

    triage meI really want to be triaged.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions