Skip to content

Commit 48c6860

Browse files
lyydikoiCopilot
authored andcommitted
Lazy column compose crash workaround 4 (#11841)
**Ticket:** [MAPSAND-2502](https://mapbox.atlassian.net/browse/MAPSAND-2502?focusedCommentId=658117&page=com.atlassian.jira.plugin.system.issuetabpanels%3Acomment-tabpanel#comment-658117) Two crashes occur when `MapboxMap` is used inside `LazyColumn` with Compose 1.7.x (BOM 2025+): - **Crash 1** — `ComposePausableCompositionException`: `LazyListPrefetchStrategy` (new in 1.7) uses `PausableComposition` to pre-compose upcoming items between frames. If the slot is deactivated while a prefetch is paused, resuming it crashes on the deactivated nodes. - **Crash 2** — `place is called on a deactivated node`: during fast scroll, `measureLazyList` deactivates a slot and then calls `place()` on its root node in the same synchronous layout pass — before composition has a chance to clean it up. ### Fix A — `onStop()` before `onDestroy()` in `MapViewLifecycle` `onDispose` was calling `onDestroy()` directly with no prior `onStop()`. When a map item scrolls off in a `LazyColumn` the Activity lifecycle never changes, so the GL render thread was destroyed without being paused first. Added `mapView.onStop()` before `mapView.onDestroy()`. Safe — `MapController` guards duplicate calls. ### Fix B — outer `Box(modifier)` as stable slot root (Crash 2) `Box(modifier)` was inside the `key(composeMapInitOptions)` block. During fast scroll, `measureLazyList` deactivates the slot and calls `place()` on the slot root Box (not itself deactivated), which then runs `placeInBox` on its direct child — the `AndroidView` holder — which IS deactivated → crash. Moved `Box(modifier)` outside `key()` so the outer Box is the stable slot root that persists across slot reuse — `key()` resets the map-specific content while the outer Box LayoutNode remains the same instance, so SubcomposeLayout has no reason to set `deactivated = true` on it during the reuse transition. The `AndroidView` holder is no longer a direct child of the slot root at placement time. ### Customer-side workaround for Crash 1 (PausableComposition) `ComposePausableCompositionException: Apply is called on deactivated node` requires the customer to disable `LazyColumn` prefetch with a no-op `LazyListPrefetchStrategy`. No prefetch = no `PausableComposition` = crash path doesn't exist. Verified: zero crashes in two test runs (ornaments on and off) with all three fixes active. ```kotlin val listState = rememberLazyListState( prefetchStrategy = object : LazyListPrefetchStrategy { override fun LazyListPrefetchScope.onScroll(delta: Float, layoutInfo: LazyListLayoutInfo) {} override fun LazyListPrefetchScope.onVisibleItemsUpdated(layoutInfo: LazyListLayoutInfo) {} override fun NestedPrefetchScope.onNestedPrefetch(firstVisibleItemIndex: Int) {} } ) ``` See `agents/specs/lazy_column_compose_crash.md` for full analysis, log evidence, and stacktrace breakdown. **Proper fix (after Compose upgrade to 1.4+):** use `onReset`/`onRelease` callbacks on `AndroidView` to pause/resume the GL thread instead of destroy/create on each scroll cycle. This also eliminates the need for the no-op prefetch workaround — Compose 1.4+ handles `AndroidView` deactivation natively. [MAPSAND-2502]: https://mapbox.atlassian.net/browse/MAPSAND-2502?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ cc @mapbox/maps-android cc @mapbox/sdk-platform --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> GitOrigin-RevId: 8c1f3499dc54e66608d47399bcd849c8c4c81871
1 parent 246b9e8 commit 48c6860

7 files changed

Lines changed: 124 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Mapbox welcomes participation and contributions from everyone.
1111
## Bug fixes 🐞
1212
* Fix native memory leak in `AnnotationManager` where bitmap style images were not removed when annotations were deleted.
1313
* Fix feature ID format mismatch in JNI marshaling where whole-number `double` feature IDs (e.g. `12345.0`) were incorrectly serialized as `"12345.000000"` instead of `"12345"`, causing `setFeatureState` to fail when using IDs obtained from `queryRenderedFeatures`.
14+
* [compose] Fix `MapboxMap` crash (`place is called on a deactivated node`) when used inside a `LazyColumn`.
15+
* [compose] **Known limitation:** on Compose Foundation 1.7+ a secondary crash (`Apply is called on deactivated node`) may still occur when `MapboxMap` is used inside a `LazyColumn` during fast scrolling/item reuse because of `LazyColumn` prefetch behavior. Workaround: pass a no-op `LazyListPrefetchStrategy` to `rememberLazyListState()`; see `LazyColumnMapActivity` for an example.
1416

1517
# 11.21.0 April 02, 2026
1618
## Dependencies
@@ -34,7 +36,6 @@ Mapbox welcomes participation and contributions from everyone.
3436
## Dependencies
3537
* Update gl-native to [v11.21.0-rc.1](https://github.com/mapbox/mapbox-maps-android/releases/tag/v11.21.0-rc.1), common to [v24.21.0-rc.1](https://github.com/mapbox/mapbox-maps-android/releases/tag/v11.21.0-rc.1).
3638

37-
3839
# 11.20.1 March 17, 2026
3940
## Bug fixes 🐞
4041
* Internal fixes and performance improvements.

compose-app/src/main/AndroidManifest.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@
3232
android:name="@string/category"
3333
android:value="@string/category_basic" />
3434
</activity>
35+
<activity
36+
android:name=".examples.basic.LazyColumnMapActivity"
37+
android:configChanges="orientation|screenSize|screenLayout"
38+
android:description="@string/description_lazy_column_map"
39+
android:exported="true"
40+
android:label="@string/activity_lazy_column_map"
41+
android:parentActivityName=".ExampleOverviewActivity">
42+
<meta-data
43+
android:name="@string/category"
44+
android:value="@string/category_basic" />
45+
</activity>
3546
<activity
3647
android:name=".examples.basic.DebugModeActivity"
3748
android:configChanges="orientation|screenSize|screenLayout"
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.mapbox.maps.compose.testapp.examples.basic
2+
3+
import android.os.Bundle
4+
import androidx.activity.ComponentActivity
5+
import androidx.activity.compose.setContent
6+
import androidx.compose.foundation.layout.height
7+
import androidx.compose.foundation.layout.padding
8+
import androidx.compose.foundation.lazy.LazyColumn
9+
import androidx.compose.foundation.lazy.items
10+
import androidx.compose.ui.Modifier
11+
import androidx.compose.ui.unit.dp
12+
import com.mapbox.maps.compose.testapp.ExampleScaffold
13+
import com.mapbox.maps.compose.testapp.ui.theme.MapboxMapComposeTheme
14+
import com.mapbox.maps.extension.compose.MapboxMap
15+
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState
16+
17+
/**
18+
* Example showcasing [MapboxMap] inside a [LazyColumn].
19+
*/
20+
public class LazyColumnMapActivity : ComponentActivity() {
21+
override fun onCreate(savedInstanceState: Bundle?) {
22+
super.onCreate(savedInstanceState)
23+
setContent {
24+
MapboxMapComposeTheme {
25+
ExampleScaffold {
26+
// Known issue on Compose Foundation 1.7.x (e.g. composeBom 2025.12.01):
27+
// LazyColumn prefetch uses PausableComposition, which can apply changes to a deactivated
28+
// AndroidView-backed slot and crash with "Apply is called on a deactivated node".
29+
// Workaround: disable prefetch via a no-op LazyListPrefetchStrategy:
30+
//
31+
// val listState = rememberLazyListState(
32+
// prefetchStrategy = object : LazyListPrefetchStrategy {
33+
// override fun LazyListPrefetchScope.onScroll(delta: Float, layoutInfo: LazyListLayoutInfo) {}
34+
// override fun LazyListPrefetchScope.onVisibleItemsUpdated(layoutInfo: LazyListLayoutInfo) {}
35+
// override fun NestedPrefetchScope.onNestedPrefetch(firstVisibleItemIndex: Int) {}
36+
// }
37+
// )
38+
// LazyColumn(state = listState, modifier = Modifier.padding(it)) {
39+
LazyColumn(modifier = Modifier.padding(it)) {
40+
repeat(10) {
41+
item {
42+
MapboxMap(modifier = Modifier.height(MAP_ITEM_HEIGHT_DP.dp))
43+
}
44+
}
45+
}
46+
}
47+
}
48+
}
49+
}
50+
51+
private companion object {
52+
const val MAP_ITEM_HEIGHT_DP = 200
53+
}
54+
}

compose-app/src/main/res/values/example_descriptions.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@
3838
<string name="description_animated_3d_model">Animate a 3D airplane model along a flight path with animated propellers, landing gear, and lights using feature state.</string>
3939
<string name="description_accessibility_scale">Automatic map symbol scaling based on system font size preferences</string>
4040
<string name="description_edge_to_edge">Showcase edge-to-edge layout using Jetpack Compose with proper handling of system insets (system bars, navigation bars, and display cutouts).</string>
41+
<string name="description_lazy_column_map">Showcase MapboxMap inside a LazyColumn with correct scroll lifecycle management.</string>
4142
</resources>

compose-app/src/main/res/values/example_titles.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@
3838
<string name="activity_animated_3d_model">Animated 3D airplane model</string>
3939
<string name="activity_accessibility_scale">Accessibility scale</string>
4040
<string name="activity_edge_to_edge">Edge-to-Edge layout</string>
41+
<string name="activity_lazy_column_map">Map in LazyColumn</string>
4142
</resources>

extension-compose/src/main/java/com/mapbox/maps/extension/compose/MapboxMap.kt

Lines changed: 54 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -75,57 +75,64 @@ public fun MapboxMap(
7575
return
7676
}
7777

78-
// Re-create the map every time the init options change
79-
key(composeMapInitOptions) {
80-
val context = LocalContext.current
81-
val mapView = remember {
82-
ComposeTelemetryEvents.map.increment()
83-
MapView(
84-
context,
85-
mapInitOptions = composeMapInitOptions.getMapInitOptions(context)
86-
)
87-
}
88-
MapViewLifecycle(mapView = mapView)
78+
// Workaround for "place is called on a deactivated node" crash in LazyColumn.
79+
// During fast scroll, LazyColumn can deactivate a slot mid-layout-pass. At that point the slot
80+
// root's MeasurePolicy calls place() on its direct children — crashing if AndroidView's holder
81+
// node is one of them. Box acts as a stable intermediate LayoutNode: it stays as the direct
82+
// child of the slot root, while key() safely resets the map content inside it.
83+
Box(modifier = modifier) {
84+
// Re-create the map every time the init options change
85+
key(composeMapInitOptions) {
86+
val context = LocalContext.current
87+
val mapView = remember {
88+
ComposeTelemetryEvents.map.increment()
89+
MapView(
90+
context,
91+
mapInitOptions = composeMapInitOptions.getMapInitOptions(context)
92+
)
93+
}
94+
MapViewLifecycle(mapView = mapView)
8995

90-
Box(modifier = modifier) {
91-
AndroidView(
92-
factory = { mapView },
93-
modifier = Modifier.fillMaxSize(),
94-
)
95-
MapCompassScope(mapView, this).compass()
96-
MapScaleBarScope(mapView, this).scaleBar()
97-
MapLogoScope(this).logo()
98-
MapAttributionScope(mapView, this).attribution()
99-
}
96+
Box(modifier = Modifier.fillMaxSize()) {
97+
AndroidView(
98+
factory = { mapView },
99+
modifier = Modifier.fillMaxSize(),
100+
)
101+
MapCompassScope(mapView, this).compass()
102+
MapScaleBarScope(mapView, this).scaleBar()
103+
MapLogoScope(this).logo()
104+
MapAttributionScope(mapView, this).attribution()
105+
}
100106

101-
key(mapViewportState) {
102-
mapViewportState.BindToMap(mapView = mapView)
103-
}
104-
key(mapState) {
105-
mapState.BindToMap(mapboxMap = mapView.mapboxMap)
106-
}
107+
key(mapViewportState) {
108+
mapViewportState.BindToMap(mapView = mapView)
109+
}
110+
key(mapState) {
111+
mapState.BindToMap(mapboxMap = mapView.mapboxMap)
112+
}
107113

108-
val parentComposition = rememberCompositionContext()
109-
val currentOnMapClickListener by rememberUpdatedState(onMapClickListener)
110-
val currentOnMapLongClickListener by rememberUpdatedState(onMapLongClickListener)
111-
val currentContent by rememberUpdatedState(content)
112-
val currentStyle by rememberUpdatedState(style)
113-
LaunchedEffect(Unit) {
114-
disposingComposition(
115-
Composition(
116-
MapApplier(mapView), parentComposition
117-
).apply {
118-
setContent {
119-
MapboxMapComposeNode(
120-
currentOnMapClickListener,
121-
currentOnMapLongClickListener,
122-
)
123-
// add Style node with the styleUri
124-
currentStyle.invoke()
125-
currentContent?.let { MapboxMapScope.it() }
114+
val parentComposition = rememberCompositionContext()
115+
val currentOnMapClickListener by rememberUpdatedState(onMapClickListener)
116+
val currentOnMapLongClickListener by rememberUpdatedState(onMapLongClickListener)
117+
val currentContent by rememberUpdatedState(content)
118+
val currentStyle by rememberUpdatedState(style)
119+
LaunchedEffect(Unit) {
120+
disposingComposition(
121+
Composition(
122+
MapApplier(mapView), parentComposition
123+
).apply {
124+
setContent {
125+
MapboxMapComposeNode(
126+
currentOnMapClickListener,
127+
currentOnMapLongClickListener,
128+
)
129+
// add Style node with the styleUri
130+
currentStyle.invoke()
131+
currentContent?.let { MapboxMapScope.it() }
132+
}
126133
}
127-
}
128-
)
134+
)
135+
}
129136
}
130137
}
131138
}

extension-compose/src/main/java/com/mapbox/maps/extension/compose/internal/MapViewLifecycle.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ internal fun MapViewLifecycle(mapView: MapView) {
4343
}
4444
DisposableEffect(mapView) {
4545
onDispose {
46+
mapView.onStop()
4647
mapView.onDestroy()
4748
mapView.removeAllViews()
4849
}

0 commit comments

Comments
 (0)