maplibre / maplibre/maplibre-react-native
Android: stale Layer/Source reference after style reload crashes removeFromMap (SIGSEGV, fault addr 0x8)
- Dominant language
- TypeScript
- Stars
- 661
- Forks
- 124
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 30
Description
## Summary
`MLRNLayer.removeFromMap` / `MLRNSource.removeFromMap` (Android) call `Style.removeLayer` / `Style.removeSource` on a Kotlin reference (`mLayer` / `source`) that the live `Style` no longer holds — dereferencing a freed native peer. Deterministic `SIGSEGV`, fault addr `0x8`, on the first EXPLORE→GUIDANCE-style screen transition after a style reload in a Fabric (New Architecture) app.
## Environment
- `@maplibre/maplibre-react-native` **11.3.6**
- React Native New Architecture (Fabric) enabled
- Android emulator `sdk_gphone64_arm64` / API 36 (Android 16), `arm64`
- `libmaplibre.so` BuildId `8df5d66091478ff63255664d9348d0939f07517a`
## Repro shape
1. A screen renders `` with a runtime-added `fill-extrusion` `` (declared as a JSX child, not baked into the style JSON).
2. The map's `mapStyle` prop changes at some point after mount (in our case: an app-level fallback-style → resolved-style swap a few seconds after a cold launch), causing MapLibre to reload the native `Style`.
3. Sometime later, the screen unmounts (in our case: a same-commit transition to a different full-screen `` on another screen — e.g. Explore → Preview/Guidance in a typical nav app).
4. React/Fabric issues `removeViewAt` for the layer child, which reaches `MLRNLayer.removeFromMap` → `Style.removeLayer(mLayer)`.
5. **Crash**, `SEGV_MAPERR` at fault addr `0x8`.
## Root cause (confirmed live, not guessed)
We added temporary `Log.d` instrumentation in `MLRNMapView`/`MLRNLayer` to get ground truth rather than speculate. At the moment of the crash:
- The `MLRNMapView` was **not** destroyed (`destroyed == false`, `mapLibreMap` non-null). A sibling `MLRNCamera` child removed from the *same* view moments earlier in the *same* Fabric mount-item batch succeeded cleanly — so the map/style were genuinely alive, not mid-teardown. (We first suspected `MapShadowNode.dispose()`'s `UiThreadUtil.runOnUiThread`-posted early dispose racing Fabric's own child-removal mount items — this instrumentation ruled that out.)
- `mLayer` was still the exact object captured at mount (`addToMap`, fresh `FillExtrusionLayer`, `existingLayer == null` at insert time).
- `mMap`'s style was still the *same* style object `mLayer` was originally inserted into.
- But re-querying that live style for a layer under `mLayer`'s own id (`style.getLayer(mID)`) returned **null** — the style no longer contained this layer. `mLayer` (the Kotlin reference) was never invalidated to reflect that.
- Calling `style.removeLayer(mLayer)` on this now-orphaned reference is what dereferences the freed native peer: `mbgl::android::Layer::get()` inside `NativeMapView::removeLayer`.
Best-evidenced trigger (not independently proven beyond the state above, but consistent with everything observed): a style reload tears down and rebuilds the native `Style`. The library's own intent — re-attaching sources/layers that are still tracked as children when a new style finishes loading (`addAllSourcesToMap()`) — does not appear to reliably refresh a runtime `fill-extrusion` layer's Kotlin-side reference in every case, leaving `mLayer` pointing at a layer object the *new* style never actually created.
## Tombstone
```
Build fingerprint: 'google/sdk_gphone64_arm64/emu64a:16/BE2A.250530.026.D1/13818094:user/release-keys'
ABI: 'arm64'
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000008
Cause: null pointer dereference
backtrace:
#00 pc 0000000000782d90 libmaplibre.so (BuildId: 8df5d66091478ff63255664d9348d0939f07517a)
#01 pc 000000000059bcbc libmaplibre.so (mbgl::android::Layer::get()+64)
#02 pc 0000000000535bb8 libmaplibre.so (mbgl::android::NativeMapView::removeLayer(_JNIEnv&, long)+52)
...
#19 pc 000000000023c090 (org.maplibre.reactnative.components.layer.MLRNLayer.removeFromMap+0)
...
#24 pc 00000000002496f8 (org.maplibre.reactnative.components.mapview.MLRNMapView.removeFeature+0)
...
#29 pc 0000000000245cdc (org.maplibre.reactnative.components.mapview.MLRNMapViewManager.removeViewAt+0)
...
#34 pc 0000000000245c9c (org.maplibre.reactnative.components.mapview.MLRNMapViewManager.removeViewAt+0)
...
```
Fault addr is exactly `0x8` on every reproduction (4/4 attempts before the fix) — a fixed small-offset member access on a null base pointer, not a random/flaky address.
## Proposed fix
We shipped this downstream as a `patch-package` patch (verified live on-device: crash gone across 10+ repeated Explore↔Guidance-style transitions and a full simulated drive, zero regressions in our own test suite). Three changes, diff inline:
1. **The actual fix** — `MLRNLayer.removeFromMap` re-confirms the live style still holds *this exact object* under its own id before calling `removeLayer`:
```diff
--- a/android/src/main/java/org/maplibre/reactnative/components/layer/MLRNLayer.kt
+++ b/android/src/main/java/org/maplibre/reactnative/components/layer/MLRNLayer.kt
@@ -366,7 +366,15 @@
override fun removeFromMap(mapView: MLRNMapView) {
val layer = mLayer ?: return
- this.style?.removeLayer(layer)
+ val style = this.style ?: return
+
+ // `mLayer` can go stale WITHOUT this feature's own `removeFromMap`
+ // ever running (e.g. a style reload not reliably re-attaching a
+ // runtime-added layer). Re-confirming the live style still holds
+ // this exact object under its own id before touching it is the
+ // only way to know its native peer is still live.
+ if (style.getLayer(mID ?: return) !== layer) {
+ return
+ }
+
+ style.removeLayer(layer)
}
```
2. **Same defect class, same fix shape** — `MLRNSource.removeFromMap`:
```diff
--- a/android/src/main/java/org/maplibre/reactnative/components/sources/MLRNSource.kt
+++ b/android/src/main/java/org/maplibre/reactnative/components/sources/MLRNSource.kt
@@ -107,7 +107,11 @@
if (mQueuedLayers != null) {
mQueuedLayers!!.clear()
}
- if (mMap != null && source != null && mMap!!.style != null) {
+ if (mMap != null && source != null && mMap!!.style != null && mMap!!.style!!.getSourceAs(mID!!) === source) {
try {
mMap!!.style!!.removeSource(source!!)
} catch (ex: Throwable) {
```
(Not independently reproduced for sources — no crash observed there — but the risk is structurally identical and the guard is free.)
3. **Defensive-only, different (unproven) failure shape** — a `!destroyed` guard in `MLRNMapView.removeFeature`, in case a child removal ever does race the view's own `dispose()` (the hypothesis this investigation ruled out for our specific repro, but which `MapShadowNode.dispose()`'s own KDoc suggests was a real concern when it was written):
```diff
--- a/android/src/main/java/org/maplibre/reactnative/components/mapview/MLRNMapView.kt
+++ b/android/src/main/java/org/maplibre/reactnative/components/mapview/MLRNMapView.kt
@@ -298,7 +298,9 @@
}
}
- child.feature.removeFromMap(this)
+ if (!destroyed) {
+ child.feature.removeFromMap(this)
+ }
}
is MapChild.ViewChild -> {
```
Happy to open a PR with these three changes plus a short write-up if that's useful — flagging here first since I don't have a minimal standalone repro repo handy (ours reproduced inside a full app) and wanted to share the tombstone/instrumentation evidence while it's fresh.
Contributor guide
Research direction
Start with removeFromMap in android/src/main/java/org/maplibre/reactnative/components/layer/MLRNLayer.kt and android/src/main/java/org/maplibre/reactnative/components/sources/MLRNSource.kt, then inspect removeFeature in MLRNMapView.kt. Reproduce a style reload followed by child removal, and verify that orphaned layer or source references no longer reach native removal and the transition remains crash-free.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, kotlin, react-native
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100