Skip to content

[Bug]: Android: initial camera from Camera defaultSettings lands at latitude 0 on a cold style cache #4273

Description

@krisgerhard

Follow-up to #3818 as requested there. #3818 is closed by #4040, but a related failure is still present on 10.3.5: the initial camera keeps the longitude and ends up at latitude 0 whenever the Mapbox style cache is cold.

Reproducer, evidence and the patch I am running: https://github.com/krisgerhard/rnmapbox-bounds-initial-camera-repro

Mapbox Version

11.24.2

React Native Version

0.86.2

Platform

Android

@rnmapbox/maps version

10.3.5

Standalone component to reproduce

BugReportExample.js
import React, { useRef, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Camera, MapView } from '@rnmapbox/maps';

const BOUNDS = { ne: [137.9, 35.6], sw: [136.8, 34.8] };
const EXPECTED = [
  (BOUNDS.ne[0] + BOUNDS.sw[0]) / 2,
  (BOUNDS.ne[1] + BOUNDS.sw[1]) / 2,
];

export default function BugReportExample() {
  const mountedAt = useRef(Date.now());
  const [center, setCenter] = useState(null);
  const [log, setLog] = useState([]);

  const push = (label) => {
    const entry = `+${Date.now() - mountedAt.current}ms ${label}`;
    console.log(`[repro] ${entry}`);
    setLog((prev) => (prev.length >= 8 ? prev : [...prev, entry]));
  };

  const failed =
    center !== null &&
    (Math.abs(center[0] - EXPECTED[0]) > 0.5 || Math.abs(center[1] - EXPECTED[1]) > 0.5);

  return (
    <View style={styles.root}>
      <MapView
        style={styles.map}
        onDidFinishLoadingStyle={() => push('onDidFinishLoadingStyle')}
        onDidFinishLoadingMap={() => push('onDidFinishLoadingMap')}
        onMapIdle={(state) => {
          setCenter(state.properties.center);
          push(
            `onMapIdle center=[${state.properties.center
              .map((n) => n.toFixed(3))
              .join(', ')}]`,
          );
        }}
      >
        <Camera defaultSettings={{ bounds: BOUNDS }} />
      </MapView>

      <View style={styles.overlay}>
        <Text style={styles.line}>
          expected center [{EXPECTED.map((n) => n.toFixed(3)).join(', ')}]
        </Text>
        <Text style={styles.line}>
          actual center{' '}
          {center === null ? '—' : `[${center.map((n) => n.toFixed(3)).join(', ')}]`}
        </Text>
        {center !== null && (
          <Text style={failed ? styles.fail : styles.pass}>{failed ? 'FAIL' : 'PASS'}</Text>
        )}
        {log.map((line, i) => (
          <Text key={i} style={styles.logLine}>
            {line}
          </Text>
        ))}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1 },
  map: { flex: 1 },
  overlay: {
    position: 'absolute',
    top: 12,
    left: 12,
    right: 12,
    padding: 12,
    borderRadius: 8,
    backgroundColor: 'rgba(0, 0, 0, 0.78)',
  },
  line: { color: '#ddd', fontSize: 12 },
  pass: { color: '#5cff9d', fontWeight: '700', marginVertical: 4 },
  fail: { color: '#ff6b6b', fontWeight: '700', marginVertical: 4 },
  logLine: { color: '#bbb', fontSize: 11 },
});

Observed behavior and steps to reproduce

The camera lands at [137.350, 0.000] instead of [137.350, 35.200] — right longitude, latitude exactly 0, so the map opens on the ocean.

  1. Install the app fresh (adb uninstall <pkg> first — this is what clears the Mapbox style cache).
  2. Launch it. The Camera has no props other than defaultSettings, so after stop & defaultStop race stop #4040 no empty stop is sent (nativeStop === null).
  3. Read the center from onMapIdle / the overlay.

Only the first launch after an install is wrong; every later launch of the same install is correct, which is why this looks flaky in the wild.

build defaultSettings fresh installs landed at latitude 0
10.3.5 { bounds } 4 4
10.3.5 + patch below { bounds } 4 0
10.3.5 { centerCoordinate, zoomLevel } 8 6
10.3.5 + patch below { centerCoordinate, zoomLevel } 4 0
10.3.5, warm relaunch (no reinstall) { bounds } 1 0

So bounds fails every time and centerCoordinate fails most of the time — #4213 (onCameraChanged firing [0, 0] / [lng, 0] on launch) looks like the same root cause seen through the event stream.

Reproduced on a physical OnePlus NE2213 (Android 16, API 36) and on the Medium_Phone_API_36.0 AVD, both New Architecture, debug builds.

Unpatched (emulator, fresh install) vs. patched:

I ReactNativeJS: [repro +681ms] onDidFinishLoadingStyle
I ReactNativeJS: [repro +730ms] onDidFinishLoadingMap
I ReactNativeJS: [repro +1018ms] onMapIdle center=[137.350, 0.000] zoom=7.85

Expected behavior

<Camera defaultSettings={{ bounds }} /> should put the initial camera inside the requested bounds on the first launch too, as it does on every subsequent launch.

Notes / preliminary analysis

RNMBXCamera declares requiresStyleLoad = false, so RNMBXMapView.addFeature adds it immediately and addToMap()setInitialCamera() runs before the style is loaded. setInitialCamera applies the stop through a zero-duration flyTo (CameraUpdateItem.run()); applied that early the latitude does not stick while the longitude does. This matches @Elter71's observation in #3818 that "during the first flyTo method call, Mapbox doesn't center the map on the latitude".

Instrumenting RNMBXCamera in our production app (with Log.eLogger.w is level-gated below WARN and silently dropped the output) showed:

  • the map view is already fully laid out (1080×2412) — not a layout-size race;
  • CameraStop.cameraForCoordinateBounds(...) returns the correct camera (center = 35.205, 137.357, zoom = 8.87);
  • setInitialCamera runs ~270 ms before onDidFinishLoadingMap on a cold start; on a warm start the cached style is loaded before the camera is added, which is exactly the difference between a failing and a passing launch.

Deferring the default stop until the style is loaded fixes it in all configurations I measured. getStyle invokes the callback immediately when the style is already loaded, so the warm path is unchanged and only the cold-start initial camera is delayed by one style load (~150–200 ms):

     private fun setInitialCamera(mapView: RNMBXMapView) {
-        mDefaultStop?.let {
-            val map = mapView.getMapboxMap()
-
-            it.setDuration(0)
-            it.setMode(CameraMode.NONE)
-            val item = it.toCameraUpdate(mapView)
-            item.run()
-        }
+        val defaultStop = mDefaultStop ?: return
+        mapView.getMapboxMap().getStyle(object : Style.OnStyleLoaded {
+            override fun onStyleLoaded(style: Style) {
+                defaultStop.setDuration(0)
+                defaultStop.setMode(CameraMode.NONE)
+                defaultStop.toCameraUpdate(mapView).run()
+            }
+        })
     }

We have been running this patch in production for a while with no side effects. I don't know whether the deferral or requiresStyleLoad = true is the direction you'd prefer (the latter looked riskier to me).

Additional links and references

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions