Skip to content

[Android][Fabric] IndexOutOfBoundsException in RNMBXSource.getChildAt when ShapeSource+Layer pair is conditionally mounted/unmounted in one commit #4251

Description

@kanzilouagie99

Environment

  • @rnmapbox/maps: 10.3.1
  • React Native: 0.86.0 (New Architecture / Fabric — mandatory at this RN version)
  • Expo SDK 57 (dev client), Android emulator (API 34, arm64)
  • Mapbox implementation: mapbox (v11 SDK)

Bug

java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0 thrown from RNMBXSource.getChildAt (RNMBXSource.kt:211) via SurfaceMountingManager.removeViewAt, crashing the whole surface.

java.lang.IndexOutOfBoundsException: Index 0 out of bounds for length 0
  at java.util.ArrayList.get(ArrayList.java:434)
  at com.rnmapbox.rnmbx.components.styles.sources.RNMBXSource.getChildAt(RNMBXSource.kt:211)
  at com.rnmapbox.rnmbx.components.styles.sources.RNMBXShapeSourceManager.getChildAt(RNMBXShapeSourceManager.kt:38)
  at com.facebook.react.fabric.mounting.SurfaceMountingManager.removeViewAt(SurfaceMountingManager.kt:456)
  at com.facebook.react.fabric.mounting.mountitems.IntBufferBatchMountItem.execute(IntBufferBatchMountItem.kt:125)
  ...

Reproduction

Conditionally mount/unmount a ShapeSource + its first child Layer together in a single React commit, on a screen with multiple ShapeSources:

{shape ? (
  <ShapeSource id="fog" shape={shape}>
    <FillLayer id="fog-fill" style={{ fillColor: "#C9C4D4" }} />
  </ShapeSource>
) : null}

When shape flips between null and non-null (or the screen with several such pairs unmounts in one commit), Fabric's view hierarchy and RNMBXSource.mSubFeatures desync: Fabric issues removeViewAt/getChildAt for child indices the source's own bookkeeping no longer has. Also reproduced with Index 1 out of bounds for length 0 during multi-layer screen teardown.

Root cause (from reading RNMBXSource.kt)

mSubFeatures is mutated by addLayer/removeLayer using the child positions Fabric passes in, but getChildAt/removeLayer index into it without bounds checks. During batched mount/unmount (IntBufferBatchMountItem) the two child models can briefly disagree, and the unchecked ArrayList.get turns a transient desync into a fatal crash of the entire react surface.

Workaround (app side)

Never conditionally mount the pair; always render ShapeSource + Layer and swap an empty featureCollection([]) in/out via the shape prop only.

Suggested fix

Bounds-check the three touch points (clamp insert position in addLayer, no-op out-of-range removeLayer, safe fallback in getChildAt). Patch we're shipping via patch-package:

diff --git a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXSource.kt b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXSource.kt
index 3dec5bb..c87ed36 100644
--- a/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXSource.kt
+++ b/node_modules/@rnmapbox/maps/android/src/main/java/com/rnmapbox/rnmbx/components/styles/sources/RNMBXSource.kt
@@ -189,10 +189,21 @@ abstract class RNMBXSource<T : Source?>(context: Context?) : AbstractMapFeature(
         } else {
             false
         }
-        mSubFeatures.add(childPosition, FeatureInfo(feature, added))
+        // Fabric's shadow tree and this class's own mSubFeatures bookkeeping can
+        // momentarily disagree on child count during rapid mount/unmount batches
+        // (observed on Android when a MapView with multiple ShapeSource/Layer
+        // pairs tears down in a single commit). Clamping here avoids an
+        // IndexOutOfBoundsException from an out-of-range insert index rather
+        // than crashing the whole surface.
+        val safePosition = childPosition.coerceIn(0, mSubFeatures.size)
+        mSubFeatures.add(safePosition, FeatureInfo(feature, added))
     }
 
     fun removeLayer(childPosition: Int) {
+        if (childPosition < 0 || childPosition >= mSubFeatures.size) {
+            Logger.w(LOG_TAG, "removeLayer: index $childPosition out of bounds for size ${mSubFeatures.size} on source $iD, ignoring")
+            return
+        }
         var featureInfo = mSubFeatures[childPosition]
         if (featureInfo.added) {
             val mapView = mMapView
@@ -208,7 +219,9 @@ abstract class RNMBXSource<T : Source?>(context: Context?) : AbstractMapFeature(
         get() = mSubFeatures.map { it.feature }.filterNotNull()
 
     override fun getChildAt(childPosition: Int): View {
-        return childViews[childPosition]
+        childViews.getOrNull(childPosition)?.let { return it }
+        Logger.w(LOG_TAG, "getChildAt: index $childPosition out of bounds for size ${childViews.size} on source $iD, returning placeholder")
+        return View(context)
     }
 
     override fun getChildCount(): Int {

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions