From 9139065e1dccba92d0a23d7cbcea3a9767838a61 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Mon, 24 Aug 2026 22:23:28 +0200 Subject: [PATCH 1/3] fix(android): keep MapView alive when it leaves the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detaching the map from the window destroyed the underlying Google MapView and latched `isDestroyed` permanently, so the view could never be resumed again. Any parent reparenting, `removeClippedSubviews`, or a navigator detaching an inactive screen left a blank, dead map for the rest of the session. The lifecycle listener was unregistered on that same detach, so `onHostResume`/`onHostPause` stopped working even if the view came back. Replace the one-way latch with an ordered lifecycle state machine that tolerates re-attach, and derive the target state from whether the view is in the window and whether the host is in the foreground. Detaching now pauses and stops the map instead of destroying it. Destruction moves to `prepareForRecycle()` and `onHostDestroy()` — the two points where the adapter is actually discarded — so detaching no longer being the disposal path does not leak MapViews. Also forward `onStart`, `onStop` and `onLowMemory`, which were never passed to the SDK. `onSaveInstanceState` is intentionally left out: a Fabric view has no host to hand it a Bundle and no restore path, so it would be dead code. --- .../nitromaps/GoogleMapProviderAdapter.kt | 100 ++++++++++++------ .../nitro/nitromaps/MapProviderAdapter.kt | 5 + .../nitro/nitromaps/MapViewLifecycleOwner.kt | 94 ++++++++++++++++ .../nitro/nitromaps/MapViewLifecycleState.kt | 12 +++ 4 files changed, 178 insertions(+), 33 deletions(-) create mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt create mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleState.kt diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt index 15067f2..671d47a 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt @@ -1,7 +1,9 @@ package com.margelo.nitro.nitromaps import android.Manifest +import android.content.ComponentCallbacks import android.content.pm.PackageManager +import android.content.res.Configuration import android.os.Handler import android.os.Looper import android.view.View @@ -32,7 +34,6 @@ class GoogleMapProviderAdapter( private var googleMap: GoogleMap? = null private var isUserGesture = false private var hasFiredMapReady = false - private var isDestroyed = false private val overlayController = MapOverlayController(null, context) private var pendingMarkers: Array? = null private var pendingPolylines: Array? = null @@ -49,32 +50,46 @@ class GoogleMapProviderAdapter( mapId(mapId) } }, - ).also { mapView -> - mapView.onCreate(null) - context.addLifecycleEventListener(this@GoogleMapProviderAdapter) - - mapView.addOnAttachStateChangeListener( - object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) { - if (!isDestroyed) { - mapView.onResume() - } - } + ) - override fun onViewDetachedFromWindow(v: View) { - context.removeLifecycleEventListener(this@GoogleMapProviderAdapter) - mapView.onPause() - destroyMapViewIfNeeded(mapView) - } - }, - ) + private val lifecycle = MapViewLifecycleOwner(view) + + private var isAttachedToWindow = false + + /** React only mounts views while the host runs; [onHostPause] corrects this. */ + private var isHostResumed = true + + private val attachStateListener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + isAttachedToWindow = true + syncLifecycleState() + } + + override fun onViewDetachedFromWindow(v: View) { + isAttachedToWindow = false + syncLifecycleState() + } + } + + private val memoryCallbacks = object : ComponentCallbacks { + override fun onConfigurationChanged(newConfig: Configuration) = Unit - mapView.getMapAsync { map -> + override fun onLowMemory() { + lifecycle.onLowMemory() + } + } + + init { + context.addLifecycleEventListener(this) + context.registerComponentCallbacks(memoryCallbacks) + view.addOnAttachStateChangeListener(attachStateListener) + + view.getMapAsync { map -> googleMap = map configureMap(map) } - installViewportSizeListener(mapView) + installViewportSizeListener(view) } private var _mapType = MapType.STANDARD @@ -376,20 +391,31 @@ class GoogleMapProviderAdapter( } override fun onHostResume() { - if (!isDestroyed) { - view.onResume() - } + isHostResumed = true + syncLifecycleState() } override fun onHostPause() { - if (!isDestroyed) { - view.onPause() - } + isHostResumed = false + syncLifecycleState() } override fun onHostDestroy() { - context.removeLifecycleEventListener(this) - destroyMapViewIfNeeded(view) + destroyMapView() + } + + /** + * Brings the map to the state implied by whether it is on screen and whether the + * host is in the foreground. Leaving the window stops the map, never destroys it. + */ + private fun syncLifecycleState() { + val target = when { + !isAttachedToWindow -> MapViewLifecycleState.CREATED + isHostResumed -> MapViewLifecycleState.RESUMED + else -> MapViewLifecycleState.STARTED + } + + lifecycle.moveTo(target) } private fun configureMap(map: GoogleMap) { @@ -769,15 +795,23 @@ class GoogleMapProviderAdapter( googleMap?.setMapStyle(null) googleMap?.setPadding(0, 0, 0, 0) applyUiSettings() + destroyMapView() } - private fun destroyMapViewIfNeeded(mapView: MapView) { - if (isDestroyed) { + /** + * Tears the map down for good. Both call sites discard the adapter afterwards; + * detaching from the window deliberately does not come here. + */ + private fun destroyMapView() { + if (lifecycle.isDestroyed) { return } - mapView.onDestroy() - isDestroyed = true + context.removeLifecycleEventListener(this) + context.unregisterComponentCallbacks(memoryCallbacks) + view.removeOnAttachStateChangeListener(attachStateListener) + lifecycle.moveTo(MapViewLifecycleState.DESTROYED) + googleMap = null } } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt index 162f420..3f4d6f8 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt @@ -48,5 +48,10 @@ interface MapProviderAdapter { fun animateCamera(camera: Camera, duration: Double?) fun getVisibleRegion(): Promise fun fitToCoordinates(coordinates: Array, padding: EdgePadding?, animated: Boolean?) + + /** + * Resets adapter state and releases the underlying native map. Every caller + * discards the adapter afterwards; leaving the window does not trigger it. + */ fun prepareForRecycle() } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt new file mode 100644 index 0000000..a7503a9 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt @@ -0,0 +1,94 @@ +package com.margelo.nitro.nitromaps + +import com.google.android.gms.maps.MapView + +/** + * Drives a [MapView] through the lifecycle callbacks the Maps SDK requires. + * + * The SDK only tolerates ordered transitions, so [moveTo] walks one state at a time. + * Detaching never destroys the map — only an explicit move to + * [MapViewLifecycleState.DESTROYED] does — because a destroyed map cannot be resumed. + */ +internal class MapViewLifecycleOwner(private val mapView: MapView) { + private var state = MapViewLifecycleState.CREATED + + val isDestroyed: Boolean + get() = state == MapViewLifecycleState.DESTROYED + + init { + mapView.onCreate(null) + } + + /** Moves the map to [target], emitting every intermediate callback along the way. */ + fun moveTo(target: MapViewLifecycleState) { + if (isDestroyed) { + return + } + + if (target == MapViewLifecycleState.DESTROYED) { + destroy() + return + } + + while (state.ordinal < target.ordinal) { + stepUp() + } + + while (state.ordinal > target.ordinal) { + stepDown() + } + } + + fun onLowMemory() { + if (isDestroyed) { + return + } + + mapView.onLowMemory() + } + + private fun destroy() { + while (state.ordinal > MapViewLifecycleState.CREATED.ordinal) { + stepDown() + } + + mapView.onDestroy() + state = MapViewLifecycleState.DESTROYED + } + + private fun stepUp() { + when (state) { + MapViewLifecycleState.CREATED -> { + mapView.onStart() + state = MapViewLifecycleState.STARTED + } + + MapViewLifecycleState.STARTED -> { + mapView.onResume() + state = MapViewLifecycleState.RESUMED + } + + MapViewLifecycleState.RESUMED, + MapViewLifecycleState.DESTROYED, + -> Unit + } + } + + private fun stepDown() { + when (state) { + MapViewLifecycleState.RESUMED -> { + mapView.onPause() + state = MapViewLifecycleState.STARTED + } + + MapViewLifecycleState.STARTED -> { + mapView.onStop() + state = MapViewLifecycleState.CREATED + } + + MapViewLifecycleState.CREATED, + MapViewLifecycleState.DESTROYED, + -> Unit + } + } +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleState.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleState.kt new file mode 100644 index 0000000..93f46b0 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleState.kt @@ -0,0 +1,12 @@ +package com.margelo.nitro.nitromaps + +/** + * Ordered lifecycle states a Google `MapView` can be driven through. [CREATED], + * [STARTED] and [RESUMED] are reversible; [DESTROYED] is terminal. + */ +internal enum class MapViewLifecycleState { + CREATED, + STARTED, + RESUMED, + DESTROYED, +} From 027adc1ba9c18b7a23e14a3b69ccc4dc9562f960 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Mon, 24 Aug 2026 23:13:40 +0200 Subject: [PATCH 2/3] fix(android): destroy the map on unmount, not only on recycle Moving onDestroy() out of onViewDetachedFromWindow left prepareForRecycle() as the only per-view disposal path, but ViewManager.setupViewRecycling() is gated on ReactNativeFeatureFlags.enableViewRecycling(), which defaults to false. With the flag off the recyclable-view stack is never created, so prepareToRecycleView() -- and therefore prepareForRecycle() -- never runs, and every unmounted map kept its MapView, GoogleMap, LifecycleEventListener and ComponentCallbacks alive until the Activity went away. Tear the adapter down from HybridView.onDropView() instead. The generated HybridMapViewManager calls it from onDropViewInstance() unconditionally, and SurfaceMountingManager calls that on every view delete, so it fires with or without the feature flag. prepareForRecycle() delegates to the same helper and stays responsible for resetting the props. Releasing the adapter on every unmount also made currentAdapter() reachable with a null adapter, where it used to rebuild a GoogleMapProviderAdapter -- constructing an Android View on the JS thread and registering listeners that nothing would ever unregister. The imperative methods now reject with "MapView is not mounted", matching HybridMapView.swift, and the adapter field is volatile because the UI thread writes it while the JS thread reads it. --- .../margelo/nitro/nitromaps/HybridMapView.kt | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt index debd57e..aa0753f 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt @@ -8,12 +8,16 @@ import com.facebook.react.uimanager.ThemedReactContext import com.margelo.nitro.core.Promise import com.margelo.nitro.views.RecyclableView +private const val MAP_VIEW_NOT_MOUNTED_MESSAGE = "MapView is not mounted" + @Keep @DoNotStrip class HybridMapView(private val context: ThemedReactContext) : HybridMapViewSpec(), RecyclableView { + /** Written on the UI thread, read from the JS thread by the imperative methods. */ + @Volatile private var adapter: MapProviderAdapter? = null private var _provider = MapProvider.GOOGLE @@ -269,33 +273,44 @@ class HybridMapView(private val context: ThemedReactContext) : adapter?.onClusterPress = value } - override fun fetchCamera(): Promise = currentAdapter().fetchCamera() + override fun fetchCamera(): Promise { + val mounted = adapter ?: return notMountedRejection() + return mounted.fetchCamera() + } override fun applyCamera(camera: Camera): Promise { - currentAdapter().applyCamera(camera) + val mounted = adapter ?: return notMountedRejection() + mounted.applyCamera(camera) return Promise.resolved(Unit) } override fun animateCamera(camera: Camera, duration: Double?): Promise { - currentAdapter().animateCamera(camera, duration) + val mounted = adapter ?: return notMountedRejection() + mounted.animateCamera(camera, duration) return Promise.resolved(Unit) } - override fun getVisibleRegion(): Promise = currentAdapter().getVisibleRegion() + override fun getVisibleRegion(): Promise { + val mounted = adapter ?: return notMountedRejection() + return mounted.getVisibleRegion() + } override fun fitToCoordinates( coordinates: Array, padding: EdgePadding?, animated: Boolean?, ): Promise { - currentAdapter().fitToCoordinates(coordinates, padding, animated) + val mounted = adapter ?: return notMountedRejection() + mounted.fitToCoordinates(coordinates, padding, animated) return Promise.resolved(Unit) } + override fun onDropView() { + releaseAdapter() + } + override fun prepareForRecycle() { - adapter?.prepareForRecycle() - adapter?.view?.let(view::removeView) - adapter = null + releaseAdapter() _provider = MapProvider.GOOGLE _mapType = MapType.STANDARD _region = null @@ -332,18 +347,25 @@ class HybridMapView(private val context: ThemedReactContext) : onClusterPress = null } - private fun currentAdapter(): MapProviderAdapter { - adapter?.let { return it } - installAdapter(_provider) - return requireNotNull(adapter) + private fun notMountedRejection(): Promise = + Promise.rejected(IllegalStateException(MAP_VIEW_NOT_MOUNTED_MESSAGE)) + + /** + * Detaches and destroys the installed adapter. Both teardown paths land here: + * [onDropView] fires on every unmount, while [prepareForRecycle] only fires when + * React Native has view recycling enabled. + */ + private fun releaseAdapter() { + adapter?.prepareForRecycle() + adapter?.view?.let(view::removeView) + adapter = null } private fun installAdapter(provider: MapProvider) { + // Built before the teardown so an unsupported provider leaves the current map intact. val nextAdapter = makeAdapter(provider) - val previousAdapter = adapter - previousAdapter?.prepareForRecycle() - previousAdapter?.view?.let(view::removeView) + releaseAdapter() adapter = nextAdapter attach(nextAdapter.view) syncState(nextAdapter) From 18e71f7e6c5b1ce7af6f00f97e82144ca35c16aa Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Mon, 24 Aug 2026 23:36:12 +0200 Subject: [PATCH 3/3] refactor(android): name the adapter teardown release(), read the map on main Two loose ends from moving onDestroy() into the teardown path. MapProviderAdapter.prepareForRecycle() no longer prepares anything for recycle -- since it destroys the MapView, the adapter can never be reused. The name also collided with Nitro's RecyclableView.prepareForRecycle(), which means the opposite ("reset this view for reuse"), and one was calling the other. Rename it to release(), and drop the body that only reset fields on an object about to be discarded -- the prop defaults, plus the GoogleMap mutations (mapType, myLocation, style, padding, uiSettings) issued moments before onDestroy(). What is left is what release actually has to do: unsubscribe the JS callbacks so no in-flight map event reaches a released view, clear the overlays, destroy the map. fetchCamera() and getVisibleRegion() read googleMap on the calling thread and then hopped to main with that reference captured, so an unmount landing in between let the main-thread block touch a GoogleMap whose MapView had already been destroyed. Read the field inside the hop instead; destroyMapView() nulls it on main, so the block now sees the null and falls back. Verified on an emulator alongside the previous commit: mount/unmount cycles balance onCreate against onDestroy, getCamera still resolves real coordinates while mounted, and rejects with "MapView is not mounted" once unmounted. --- .../nitromaps/GoogleMapProviderAdapter.kt | 91 ++++++------------- .../margelo/nitro/nitromaps/HybridMapView.kt | 2 +- .../nitro/nitromaps/MapProviderAdapter.kt | 8 +- 3 files changed, 34 insertions(+), 67 deletions(-) diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt index 671d47a..06f555f 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt @@ -313,23 +313,26 @@ class GoogleMapProviderAdapter( syncMarkerPressHandlers() } - override fun fetchCamera(): Promise { - val map = googleMap - if (map != null) { - return promiseOnMain { map.cameraPosition.toCamera() } + override fun fetchCamera(): Promise = promiseOnMain { + googleMap?.cameraPosition?.toCamera() ?: fallbackCamera() + } + + /** The camera the caller last asked for, used until the map itself can answer. */ + private fun fallbackCamera(): Camera { + val camera = _camera + if (camera != null) { + return camera } - return Promise.resolved( - _camera ?: Camera( - center = Coordinate( - latitude = _region?.latitude ?: 0.0, - longitude = _region?.longitude ?: 0.0, - ), - zoom = 10.0, - heading = null, - pitch = null, - altitude = null, + return Camera( + center = Coordinate( + latitude = _region?.latitude ?: 0.0, + longitude = _region?.longitude ?: 0.0, ), + zoom = 10.0, + heading = null, + pitch = null, + altitude = null, ) } @@ -342,21 +345,8 @@ class GoogleMapProviderAdapter( updateMapCamera(camera, animated = true, durationMs = (animationDuration * 1000).toInt()) } - override fun getVisibleRegion(): Promise { - val map = googleMap - if (map != null) { - return promiseOnMain { map.projection.toNitroVisibleRegion() } - } - - val zero = Coordinate(latitude = 0.0, longitude = 0.0) - return Promise.resolved( - VisibleRegion( - nearLeft = zero, - nearRight = zero, - farLeft = zero, - farRight = zero, - ), - ) + override fun getVisibleRegion(): Promise = promiseOnMain { + googleMap?.projection?.toNitroVisibleRegion() ?: emptyVisibleRegion() } override fun fitToCoordinates( @@ -748,9 +738,9 @@ class GoogleMapProviderAdapter( onMapReady?.invoke() } - override fun prepareForRecycle() { - isUserGesture = false - hasFiredMapReady = false + override fun release() { + // Drop the JS callbacks first: a map event still in flight must not reach a + // view that is already gone. onRegionChange = null onRegionChangeComplete = null onMapReady = null @@ -763,38 +753,8 @@ class GoogleMapProviderAdapter( onPolygonPress = null onCirclePress = null onClusterPress = null - _markers = null - _polylines = null - _polygons = null - _circles = null - pendingMarkers = null - pendingPolylines = null - pendingPolygons = null - pendingCircles = null + overlayController.clear() - _mapType = MapType.STANDARD - _region = null - _camera = null - scrollEnabled = true - zoomEnabled = true - rotateEnabled = true - pitchEnabled = true - _showsUserLocation = null - _followsUserLocation = null - _showsCompass = null - _showsScale = null - _customMapStyle = null - _clusteringEnabled = null - _mapPadding = null - _markerEnteringAnimation = null - _clusterEnteringAnimation = null - overlayController.markerEnteringAnimation = null - overlayController.clusterEnteringAnimation = null - googleMap?.mapType = MapType.STANDARD.toGoogleMapType() - googleMap?.isMyLocationEnabled = false - googleMap?.setMapStyle(null) - googleMap?.setPadding(0, 0, 0, 0) - applyUiSettings() destroyMapView() } @@ -816,3 +776,8 @@ class GoogleMapProviderAdapter( } private fun normalizeGoogleMapId(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() } + +private fun emptyVisibleRegion(): VisibleRegion { + val zero = Coordinate(latitude = 0.0, longitude = 0.0) + return VisibleRegion(nearLeft = zero, nearRight = zero, farLeft = zero, farRight = zero) +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt index aa0753f..8eab2bf 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt @@ -356,7 +356,7 @@ class HybridMapView(private val context: ThemedReactContext) : * React Native has view recycling enabled. */ private fun releaseAdapter() { - adapter?.prepareForRecycle() + adapter?.release() adapter?.view?.let(view::removeView) adapter = null } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt index 3f4d6f8..06f7e14 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt @@ -50,8 +50,10 @@ interface MapProviderAdapter { fun fitToCoordinates(coordinates: Array, padding: EdgePadding?, animated: Boolean?) /** - * Resets adapter state and releases the underlying native map. Every caller - * discards the adapter afterwards; leaving the window does not trigger it. + * Destroys the underlying native map and unregisters everything the adapter owns. + * Every caller discards the adapter afterwards, so this is a one-way transition -- + * it is not the Nitro `RecyclableView.prepareForRecycle` reset. Leaving the window + * does not trigger it; a detached map is only stopped. */ - fun prepareForRecycle() + fun release() }