From 788e9c249b79e7ddba663a1214c165d65f9ce793 Mon Sep 17 00:00:00 2001 From: Rob Jacobson Date: Mon, 12 Oct 2026 18:40:55 -0700 Subject: [PATCH 1/5] fix(android): defer initial camera events until defaults are applied - Hold startup camera notifications until attachment callbacks finish applying the initial camera - Emit the current camera once after initialization and preserve subsequent camera events - Keep maps without a Camera and valid zero-coordinate camera positions working --- .../rnmbx/components/mapview/RNMBXMapView.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 7f92fe61b..6199cef2d 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -202,6 +202,8 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie private var wasGestureActive = false private var isGestureActive = false + private var cameraEventsReady = false + private var hasPendingCameraChange = false var mapViewImpl: String? = null @@ -260,8 +262,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie val _this = this map.addOnCameraChangeListener(OnCameraChangeListener { cameraChangedEventData -> - handleMapChangedEvent(EventTypes.REGION_IS_CHANGING) - handleMapChangedEvent(EventTypes.CAMERA_CHANGED) + sendCameraChangedEvents() }) map.addOnMapIdleListener(OnMapIdleListener { mapIdleEventData -> @@ -856,6 +857,16 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie // region Events + private fun sendCameraChangedEvents() { + if (!cameraEventsReady) { + hasPendingCameraChange = true + return + } + hasPendingCameraChange = false + handleMapChangedEvent(EventTypes.REGION_IS_CHANGING) + handleMapChangedEvent(EventTypes.CAMERA_CHANGED) + } + fun sendRegionDidChangeEvent() { handleMapChangedEvent(EventTypes.REGION_DID_CHANGE) mCameraChangeTracker.setReason(CameraChangeReason.NONE) @@ -1622,6 +1633,13 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie super.onAttachedToWindow() Handler(Looper.getMainLooper()).post { lifecycle.afterAttachFromLooper() + // Camera.addToMap applies defaultSettings in these attachment callbacks. + // Forward the current camera only after they finish, rather than the + // SDK's earlier startup positions. Maps without a Camera also become ready. + cameraEventsReady = true + if (hasPendingCameraChange) { + sendCameraChangedEvents() + } } } From cd0f67cda8321dc35863c6dfe128e8822e3ed1e9 Mon Sep 17 00:00:00 2001 From: Rob Jacobson Date: Tue, 13 Oct 2026 00:25:46 -0700 Subject: [PATCH 2/5] fix(android): gate initial camera change events - Buffer startup camera notifications until camera setup is ready - Coalesce startup notifications and preserve later camera changes --- .../mapview/CameraChangeEventGate.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 android/src/main/java/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGate.kt diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGate.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGate.kt new file mode 100644 index 000000000..1144c6065 --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGate.kt @@ -0,0 +1,29 @@ +package com.rnmapbox.rnmbx.components.mapview + +/** + * Holds camera-change notifications until the map's attachment callbacks have + * applied the initial camera settings. + */ +internal class CameraChangeEventGate( + private val dispatchCameraChanged: () -> Unit, +) { + private var isReady = false + private var hasPendingChange = false + + fun onCameraChanged() { + if (!isReady) { + hasPendingChange = true + return + } + dispatchCameraChanged() + } + + fun markReady() { + if (isReady) return + isReady = true + if (hasPendingChange) { + hasPendingChange = false + dispatchCameraChanged() + } + } +} From ccbd91987b1a2a660aa147809293a2076d8c2cb3 Mon Sep 17 00:00:00 2001 From: Rob Jacobson Date: Tue, 13 Oct 2026 00:26:02 -0700 Subject: [PATCH 3/5] refactor(android): isolate initial camera event gating - Route native camera notifications through a focused readiness gate - Dispatch the latest camera once attachment callbacks finish --- .../rnmbx/components/mapview/RNMBXMapView.kt | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 6199cef2d..07f60794a 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -202,8 +202,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie private var wasGestureActive = false private var isGestureActive = false - private var cameraEventsReady = false - private var hasPendingCameraChange = false + private val cameraChangeEventGate = CameraChangeEventGate { dispatchCameraChangedEvents() } var mapViewImpl: String? = null @@ -858,11 +857,10 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie // region Events private fun sendCameraChangedEvents() { - if (!cameraEventsReady) { - hasPendingCameraChange = true - return - } - hasPendingCameraChange = false + cameraChangeEventGate.onCameraChanged() + } + + private fun dispatchCameraChangedEvents() { handleMapChangedEvent(EventTypes.REGION_IS_CHANGING) handleMapChangedEvent(EventTypes.CAMERA_CHANGED) } @@ -1636,10 +1634,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie // Camera.addToMap applies defaultSettings in these attachment callbacks. // Forward the current camera only after they finish, rather than the // SDK's earlier startup positions. Maps without a Camera also become ready. - cameraEventsReady = true - if (hasPendingCameraChange) { - sendCameraChangedEvents() - } + cameraChangeEventGate.markReady() } } From edb1099bcc83a59ab4153e065a889f85f9cdd20a Mon Sep 17 00:00:00 2001 From: Rob Jacobson Date: Tue, 13 Oct 2026 00:26:17 -0700 Subject: [PATCH 4/5] test(android): cover camera change event readiness - Verify startup notifications coalesce until camera setup is ready - Verify subsequent notifications and empty startup behave correctly --- .../mapview/CameraChangeEventGateTest.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 android/src/test/kotlin/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGateTest.kt diff --git a/android/src/test/kotlin/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGateTest.kt b/android/src/test/kotlin/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGateTest.kt new file mode 100644 index 000000000..ac99dda88 --- /dev/null +++ b/android/src/test/kotlin/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGateTest.kt @@ -0,0 +1,55 @@ +package com.rnmapbox.rnmbx.components.mapview + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CameraChangeEventGateTest { + @Test + fun coalescesStartupChangesAndDispatchesOnceWhenReady() { + var dispatchCount = 0 + val gate = CameraChangeEventGate { dispatchCount++ } + + gate.onCameraChanged() + gate.onCameraChanged() + + assertEquals(0, dispatchCount) + + gate.markReady() + + assertEquals(1, dispatchCount) + } + + @Test + fun dispatchesEachCameraChangeAfterReady() { + var dispatchCount = 0 + val gate = CameraChangeEventGate { dispatchCount++ } + + gate.markReady() + gate.onCameraChanged() + gate.onCameraChanged() + + assertEquals(2, dispatchCount) + } + + @Test + fun markingReadyWithoutAStartupChangeDoesNotEmitAnEvent() { + var dispatchCount = 0 + val gate = CameraChangeEventGate { dispatchCount++ } + + gate.markReady() + + assertEquals(0, dispatchCount) + } + + @Test + fun markingReadyMoreThanOnceDoesNotReplayStartupEvents() { + var dispatchCount = 0 + val gate = CameraChangeEventGate { dispatchCount++ } + + gate.onCameraChanged() + gate.markReady() + gate.markReady() + + assertEquals(1, dispatchCount) + } +} From aa3161ffce7939721eaaf3fa8b54512a30ee9516 Mon Sep 17 00:00:00 2001 From: Rob Jacobson Date: Wed, 14 Oct 2026 18:51:01 -0700 Subject: [PATCH 5/5] refactor(android): inline startup camera event buffering - Keep startup camera buffering in a private anonymous MapView object - Remove the standalone event gate and its unit test - Dispatch camera events directly and shorten the attachment comment --- .../mapview/CameraChangeEventGate.kt | 29 ---------- .../rnmbx/components/mapview/RNMBXMapView.kt | 34 ++++++++---- .../mapview/CameraChangeEventGateTest.kt | 55 ------------------- 3 files changed, 24 insertions(+), 94 deletions(-) delete mode 100644 android/src/main/java/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGate.kt delete mode 100644 android/src/test/kotlin/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGateTest.kt diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGate.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGate.kt deleted file mode 100644 index 1144c6065..000000000 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGate.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.rnmapbox.rnmbx.components.mapview - -/** - * Holds camera-change notifications until the map's attachment callbacks have - * applied the initial camera settings. - */ -internal class CameraChangeEventGate( - private val dispatchCameraChanged: () -> Unit, -) { - private var isReady = false - private var hasPendingChange = false - - fun onCameraChanged() { - if (!isReady) { - hasPendingChange = true - return - } - dispatchCameraChanged() - } - - fun markReady() { - if (isReady) return - isReady = true - if (hasPendingChange) { - hasPendingChange = false - dispatchCameraChanged() - } - } -} diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 07f60794a..7a07f1b09 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -202,7 +202,27 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie private var wasGestureActive = false private var isGestureActive = false - private val cameraChangeEventGate = CameraChangeEventGate { dispatchCameraChangedEvents() } + private val startupCameraEvents = object { + private var isReady = false + private var hasPendingChange = false + + fun onCameraChanged() { + if (!isReady) { + hasPendingChange = true + return + } + sendCameraChangedEvents() + } + + fun markReady() { + if (isReady) return + isReady = true + if (hasPendingChange) { + hasPendingChange = false + sendCameraChangedEvents() + } + } + } var mapViewImpl: String? = null @@ -261,7 +281,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie val _this = this map.addOnCameraChangeListener(OnCameraChangeListener { cameraChangedEventData -> - sendCameraChangedEvents() + startupCameraEvents.onCameraChanged() }) map.addOnMapIdleListener(OnMapIdleListener { mapIdleEventData -> @@ -857,10 +877,6 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie // region Events private fun sendCameraChangedEvents() { - cameraChangeEventGate.onCameraChanged() - } - - private fun dispatchCameraChangedEvents() { handleMapChangedEvent(EventTypes.REGION_IS_CHANGING) handleMapChangedEvent(EventTypes.CAMERA_CHANGED) } @@ -1631,10 +1647,8 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie super.onAttachedToWindow() Handler(Looper.getMainLooper()).post { lifecycle.afterAttachFromLooper() - // Camera.addToMap applies defaultSettings in these attachment callbacks. - // Forward the current camera only after they finish, rather than the - // SDK's earlier startup positions. Maps without a Camera also become ready. - cameraChangeEventGate.markReady() + // Camera.addToMap applies defaultSettings in the attach callbacks (#4213). + startupCameraEvents.markReady() } } diff --git a/android/src/test/kotlin/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGateTest.kt b/android/src/test/kotlin/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGateTest.kt deleted file mode 100644 index ac99dda88..000000000 --- a/android/src/test/kotlin/com/rnmapbox/rnmbx/components/mapview/CameraChangeEventGateTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.rnmapbox.rnmbx.components.mapview - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class CameraChangeEventGateTest { - @Test - fun coalescesStartupChangesAndDispatchesOnceWhenReady() { - var dispatchCount = 0 - val gate = CameraChangeEventGate { dispatchCount++ } - - gate.onCameraChanged() - gate.onCameraChanged() - - assertEquals(0, dispatchCount) - - gate.markReady() - - assertEquals(1, dispatchCount) - } - - @Test - fun dispatchesEachCameraChangeAfterReady() { - var dispatchCount = 0 - val gate = CameraChangeEventGate { dispatchCount++ } - - gate.markReady() - gate.onCameraChanged() - gate.onCameraChanged() - - assertEquals(2, dispatchCount) - } - - @Test - fun markingReadyWithoutAStartupChangeDoesNotEmitAnEvent() { - var dispatchCount = 0 - val gate = CameraChangeEventGate { dispatchCount++ } - - gate.markReady() - - assertEquals(0, dispatchCount) - } - - @Test - fun markingReadyMoreThanOnceDoesNotReplayStartupEvents() { - var dispatchCount = 0 - val gate = CameraChangeEventGate { dispatchCount++ } - - gate.onCameraChanged() - gate.markReady() - gate.markReady() - - assertEquals(1, dispatchCount) - } -}