-
-
Notifications
You must be signed in to change notification settings - Fork 924
Description
Mapbox Implementation
Mapbox
Mapbox Version
default
React Native Version
0.81.5
React Native Architecture
New Architecture (Fabric/TurboModules)
Platform
iOS
@rnmapbox/maps version
10.2.7
Standalone component to reproduce
import React, {useEffect, useRef, useState} from 'react';
import {View, Button, StyleSheet, Text} from 'react-native';
import {MapView, Camera} from '@rnmapbox/maps';
/**
* This component simulates a tab-based navigation scenario where:
* - "Map screen" becomes focused
* - A global-like target position is updated
* - Camera is moved imperatively via ref when the screen is focused
*
* The key part is the `useEffect` that triggers `setCamera` when
* `isFocused` and `target` change.
*
* In my real app this effect runs when navigating from an "Explore" tab
* to a "Map" tab and updating a shared camera store (Zustand), and the
* first camera update after navigation is often ignored unless wrapped
* in `setTimeout(..., 0)`.
*/
const BugReportCameraRaceExample = () => {
const cameraRef = useRef(null);
// Simulate navigation focus of the map screen
const [isFocused, setIsFocused] = useState(false);
// Simulate a global camera target set from another tab/screen
const [target, setTarget] = useState(null);
// Two example coordinates to jump between
const COORD_A = [-74.00597, 40.71427]; // e.g. point A
const COORD_B = [-73.98513, 40.7589]; // e.g. point B
// 🔴 Problematic version (no timeout)
// When this effect runs immediately after toggling "focus" and setting
// `target`, the first `setCamera` call is sometimes ignored.
useEffect(() => {
if (!isFocused) return;
if (!target) return;
if (!cameraRef.current) return;
// In my real app, this is exactly the kind of call that gets dropped
cameraRef.current.setCamera({
centerCoordinate: target,
zoomLevel: 14,
animationMode: 'flyTo',
animationDuration: 1000,
});
}, [isFocused, target]);
// 🟢 Working version (with timeout) for reference:
// Uncomment this effect and comment out the one above to see that
// wrapping in `setTimeout(..., 0)` makes the issue disappear.
/*
useEffect(() => {
if (!isFocused) return;
if (!target) return;
const timer = setTimeout(() => {
if (!cameraRef.current) return;
cameraRef.current.setCamera({
centerCoordinate: target,
zoomLevel: 14,
animationMode: 'flyTo',
animationDuration: 1000,
});
}, 0); // 👈 without this, the first update after "focus" is often ignored
return () => clearTimeout(timer);
}, [isFocused, target]);
*/
return (
<View style={styles.container}>
<View style={styles.controls}>
<Text style={styles.label}>
Focus state: {isFocused ? 'FOCUSED' : 'NOT FOCUSED'}
</Text>
<Button
title={isFocused ? 'Set NOT focused' : 'Set FOCUSED'}
onPress={() => setIsFocused(prev => !prev)}
/>
<View style={styles.spacer} />
<Button
title="Move camera to A"
onPress={() => setTarget(COORD_A)}
/>
<View style={styles.spacer} />
<Button
title="Move camera to B"
onPress={() => setTarget(COORD_B)}
/>
</View>
<View style={styles.mapContainer}>
<MapView style={StyleSheet.absoluteFill}>
<Camera
ref={cameraRef}
defaultSettings={{
centerCoordinate: COORD_A,
zoomLevel: 13,
}}
/>
</MapView>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {flex: 1},
controls: {
paddingTop: 40,
paddingHorizontal: 16,
paddingBottom: 16,
backgroundColor: 'white',
},
label: {
marginBottom: 8,
fontWeight: '600',
},
spacer: {
height: 8,
},
mapContainer: {
flex: 1,
},
});
export default BugReportCameraRaceExample;Observed behavior and steps to reproduce
Observed behavior
When using the imperative Camera ref and calling setCamera in an effect that runs when the “map screen” becomes focused and a new target is set, the first camera update after focus is often ignored.
In my real app this happens when:
- I navigate from an "Explore" tab to a "Map" tab (React Navigation tabs),
- A shared camera store (Zustand) sets a new
targetPosition, - The map screen's effect detects
isFocused === trueand a non-nulltargetPosition, - It calls
cameraRef.current.setCamera({ centerCoordinate: ..., zoomLevel: ..., animationMode: 'flyTo', ... }).
On some devices / runs, the camera simply does not move, even though:
isFocusedistrue,targetPositionis correct,cameraRef.currentis not null,- No error is thrown.
If I wrap the setCamera call in setTimeout(() => { ... }, 0), the issue disappears and the camera always moves to the new position after focus.
Steps to reproduce (using the standalone component above)
- Render
BugReportCameraRaceExamplein an app where RNMapbox is configured. - Make sure the problematic effect (without timeout) is enabled and the “working” one is commented out.
- Tap “Set FOCUSED” to simulate that the map screen has become active (like navigating to a map tab).
- Immediately tap “Move camera to B” (and/or A → B → A several times).
- On some runs you will see that the camera does not move for the first update after becoming focused.
- Now comment out the problematic
useEffectand enable the timeout-based effect (withsetTimeout(..., 0)), keep the same interaction pattern. - The camera now reliably moves to the target after focus.
This matches the behavior I see in my real tab navigation setup.
Expected behavior
Given that:
- The
Cameracomponent is mounted inside a visibleMapView, cameraRef.currentis non-null,isFocused === true(map screen is active),- A valid
centerCoordinateandzoomLevelare passed tosetCamera,
I would expect every call to cameraRef.current.setCamera(...) to result in a corresponding camera update / animation, without needing to defer the call via setTimeout.
In other words, the camera should not silently ignore the first update after the screen becomes focused; it should behave the same whether or not a 0ms timeout is present.
Notes / preliminary analysis
This behavior seems identical to the workaround described in [this comment in #3704 (https://github.com//issues/3704#issuecomment-2910399755), where setCenter only works when wrapped in setTimeout(0).
- The pattern strongly suggests a race condition between:
- The
CameraJS component setting up itsnativeCameraref and bindingNativeCommandsviacommands.setNativeRef(...), and - Userland code calling
cameraRef.current.setCamera(...)on the same tick that the map becomes visible / focused.
- The
From reading Camera.tsx (simplified):
const nativeCamera = useRef<typeof NativeCameraView>(null);
const commands = useMemo(() => new NativeCommands(RNMBXCameraModule), []);
useEffect(() => {
if (nativeCamera.current) {
commands.setNativeRef(nativeCamera.current);
}
}, [commands, nativeCamera.current]);
const _setCamera: CameraRef['setCamera'] = (config) => {
if (!allowUpdates) return;
// ... buildNativeStop
commands.call<void>('updateCameraStop', [
_nativeStop as unknown as NativeArg,
]);
};it looks plausible that there is a small window where:
cameraRef.currentexists from React’s perspective, but- The underlying native camera view / commands binding has not fully completed yet,
so that the first updateCameraStop call can effectively be a no-op.
Deferring the call with setTimeout(..., 0) ensures:
- The map and camera native view are fully ready,
commands.setNativeRef(nativeCamera.current)has run,
and then the updateCameraStop call reliably moves the camera.
Additional links and references
-
Related issue: #3704 – setCenter only works when wrapped in setTimeout
In that issue, the user also reports that camera updates only work when wrapped in asetTimeout, which aligns with what I’m seeing after tab navigation / focus changes. -
My own workaround is currently using the same pattern:
useEffect(() => { if (!isFocused) return; if (!targetPosition) return; const timer = setTimeout(() => { if (!cameraRef.current) return; cameraRef.current.setCamera({ centerCoordinate: [targetPosition.longitude, targetPosition.latitude], zoomLevel, animationDuration: 1000, animationMode: 'flyTo', }); }, 0); return () => clearTimeout(timer); }, [targetPosition, zoomLevel, isFocused]);
but this feels like a fragile hack to have in application code. A library-level solution (or an officially recommended pattern / API) to avoid this race would be very helpful.