Skip to content

[Bug]: Camera.setCamera is ignored after tab navigation unless wrapped in setTimeout(0) (possible race condition) #4090

@hlog2e

Description

@hlog2e

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 === true and a non-null targetPosition,
  • It calls cameraRef.current.setCamera({ centerCoordinate: ..., zoomLevel: ..., animationMode: 'flyTo', ... }).

On some devices / runs, the camera simply does not move, even though:

  • isFocused is true,
  • targetPosition is correct,
  • cameraRef.current is 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)

  1. Render BugReportCameraRaceExample in an app where RNMapbox is configured.
  2. Make sure the problematic effect (without timeout) is enabled and the “working” one is commented out.
  3. Tap “Set FOCUSED” to simulate that the map screen has become active (like navigating to a map tab).
  4. Immediately tap “Move camera to B” (and/or A → B → A several times).
  5. On some runs you will see that the camera does not move for the first update after becoming focused.
  6. Now comment out the problematic useEffect and enable the timeout-based effect (with setTimeout(..., 0)), keep the same interaction pattern.
  7. 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 Camera component is mounted inside a visible MapView,
  • cameraRef.current is non-null,
  • isFocused === true (map screen is active),
  • A valid centerCoordinate and zoomLevel are passed to setCamera,

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 Camera JS component setting up its nativeCamera ref and binding NativeCommands via commands.setNativeRef(...), and
    • Userland code calling cameraRef.current.setCamera(...) on the same tick that the map becomes visible / focused.

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:

  1. cameraRef.current exists from React’s perspective, but
  2. 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 a setTimeout, 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug 🪲Something isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions