Skip to content

fix: read RNMBXLocationModule on first use, not when the package loads - #4388

Merged
mfazekas merged 1 commit into
rnmapbox:mainfrom
kristojorg:fix/lazy-location-module
Oct 25, 2026
Merged

mfazekas merged 1 commit into
rnmapbox:mainfrom
kristojorg:fix/lazy-location-module

Conversation

@kristojorg

Copy link
Copy Markdown
Contributor

Description

src/modules/location/locationManager.ts reads the native location module at module scope: NativeModules.RNMBXLocationModule on iOS, and on Android the spec module, whose default export calls TurboModuleRegistry.getEnforcing. It also creates the NativeEventEmitter there. So loading @rnmapbox/maps creates the location module, also in an app that only draws a map.

On iOS the module has requiresMainQueueSetup = true, so the first read creates it synchronously on the main thread. Its AppleLocationProvider() property initializer reads CLLocationManager.accuracyAuthorization, which is a synchronous call to locationd. When locationd is slow, the app hangs at startup. In our app, Sentry reports App Hangs of 2–4 s with this main-thread stack:

CLClientGetLocation
CLLocationManager.accuracyAuthorization.getter
AppleLocationProvider.init
RNMBXLocationModule.init
-[RCTTurboModuleManager _provideObjCModule:...]
RCTUnsafeExecuteOnMainQueueSync

This PR reads the module when it is first used:

  • getMapboxLocationManager() reads the native module on first use. On Android it calls TurboModuleRegistry.getEnforcing itself and imports only the Spec type, so importing the spec file no longer creates the module.
  • getLocationModuleEventEmitter() creates the NativeEventEmitter on first use. It replaces the LocationModuleEventEmitter constant, which the module exported but the package did not; only the tests used it.
  • stop() runs on every background transition. It now calls the native stop() only if the module was already read.
  • Tests: loading the package, and going to the background and back, do not read RNMBXLocationModule (iOS and Android); the first getLastKnownLocation() does. Four of the six new tests fail on main.

On iOS the module is still created on the main thread, now on first use, so its CLLocationManager keeps the main run loop.

Not changed: the Mapbox Maps SDK makes the same accuracyAuthorization read in MapView.init (LocationDataModel.createDefault() → AppleLocationProvider.init), so every map view still makes it. That is reported at mapbox/mapbox-maps-ios#2447.

Note on iOS: until JS uses locationManager (directly or through UserLocation), RNMBXLocationModule.shared is nil. RNMBXCamera and RNMBXCustomLocationProvider already check for nil, and the map then uses its own location provider. Camera follow with LocationPuck works with the module never created (tested below). Not tested: a CustomLocationProvider that is installed before JS first uses locationManager. In that case the module starts with an AppleLocationProvider, not the custom provider.

Before and after

Reproducer below, Debug build of /example, React Native 0.84.1 (bridgeless), MapboxMaps 11.23.1, iPhone 17 Pro simulator on iOS 26.5, Xcode 27.1. A temporary NSLog in RNMBXLocationModule.init and a temporary swizzle of -[CLLocationManager accuracyAuthorization] log each read with its thread and stack (second block below, not part of this PR).

On a simulator locationd usually answers in a few ms, so the hang does not reproduce on its own. To show its effect, the swizzle can add 3 s to each read (REPRO_SLOW_LOCATIOND=1), as a stand-in for a slow locationd. Once, on the first launch after installing a new build, the module's AppleLocationProvider.init took 29 s with no added delay; I could not make that happen again (fresh boot, reinstall, clean install).

main this PR
RNMBXLocationModule.init on the main thread while @rnmapbox/maps loads, before the screen's own code runs only when "Use location" is pressed
main-thread accuracyAuthorization reads before the map loads 4 (2 from RNMBXLocationModule, 2 from MapView) 2 (from MapView)
with 3 s per read: app launch → first line of the screen's JS 6.3 s 0.3 s
with 3 s per read: app launch → onDidFinishLoadingMap 12.8 s 6.8 s
first locationManager.getLastKnownLocation() 1 ms (module already exists) 11 ms; 3014 ms with 3 s per read

The main-thread stack of the first read on main:

AppleLocationProvider.init(locationManager:mayRequestWhenInUseAuthorization:locationManagerDelegateProxy:)
AppleLocationProvider.__allocating_init(locationManager:mayRequestWhenInUseAuthorization:locationManagerDelegateProxy:)
AppleLocationProvider.__allocating_init()
RNMBXLocationModule.init()
@objc RNMBXLocationModule.init()
-[RCTTurboModuleManager _getModuleInstanceFromClass:]
-[RCTTurboModuleManager _createAndSetUpObjCModule:moduleName:moduleId:]
__86-[RCTTurboModuleManager _provideObjCModule:moduleHolder:shouldPerfLog:moduleProvider:]_block_invoke
__RCTUnsafeExecuteOnMainQueueSync_block_invoke
_dispatch_client_callout
_dispatch_async_and_wait_invoke

With this PR the same stack appears only after "Use location" is pressed.

Also checked on the fixed build:

  • iOS, User Location Updates: locations arrive, the camera follows, and updates continue after the app goes to the background and back.
  • iOS, Custom Native UserLocation (Camera followUserLocation with LocationPuck, no locationManager): the camera follows the simulated location, and RNMBXLocationModule is never created.
  • Android (Pixel 8 emulator, API 35, arm64): on main the reproducer creates RNMBXLocationModule when the package loads; with this PR, only when "Use location" is pressed. The full example app does not create it at launch. User Location Updates: locations arrive and continue after the app goes to the background and back. getLastKnownLocation() did not resolve on this emulator, on main or with this PR.

Checklist

  • I've read CONTRIBUTING.md
  • I updated the doc/other generated code with running yarn generate in the root folder (no changes)
  • I have tested the new feature on /example app.
    • In V11 mode/ios
    • In New Architecture mode/ios
    • In V11 mode/android
    • In New Architecture mode/android
  • I added/updated a sample - if a new feature was implemented (/example)

The example app runs Mapbox v11 with the bridgeless New Architecture only, so the two iOS items are one configuration, and so are the two Android items. yarn lint, yarn type:check and yarn unittest pass.

Component to reproduce the issue you're fixing

Register it as the root component in example/index.js: AppRegistry.registerComponent(appConfig.expo.name, () => BugReportExample). It does not touch locationManager until "Use location" is pressed.

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

import config from '../utils/config';

const appStart = performance.now();
console.log('[repro] JS: BugReportExample loaded');

Mapbox.setAccessToken(config.get('accessToken'));

const styleJSON = JSON.stringify({
  version: 8,
  sources: {},
  layers: [
    {
      id: 'background',
      type: 'background',
      paint: { 'background-color': '#cfe8f3' },
    },
  ],
});

const BugReportExample = () => {
  const [mapLoaded, setMapLoaded] = useState('not yet');
  const [locationResult, setLocationResult] = useState('not called');

  const onMapLoaded = () => {
    const ms = (performance.now() - appStart).toFixed(0);
    console.log(`[repro] JS: map loaded, ${ms} ms after BugReportExample loaded`);
    setMapLoaded(`${ms} ms`);
  };

  const requestLocation = async () => {
    console.log('[repro] JS: Use location pressed');
    const start = performance.now();
    const location = await Mapbox.locationManager.getLastKnownLocation();
    const ms = (performance.now() - start).toFixed(0);
    console.log(`[repro] JS: getLastKnownLocation returned in ${ms} ms`);
    setLocationResult(`${ms} ms, ${location ? 'a location' : 'no location'}`);
  };

  return (
    <View style={styles.container}>
      <MapView
        style={styles.map}
        styleJSON={styleJSON}
        onDidFinishLoadingMap={onMapLoaded}
      >
        <Camera
          defaultSettings={{
            centerCoordinate: [-87.622088, 41.878781],
            zoomLevel: 10,
          }}
        />
      </MapView>
      <View style={styles.panel}>
        <Text>Map loaded: {mapLoaded}</Text>
        <Text>getLastKnownLocation: {locationResult}</Text>
        <Button title="Use location" onPress={requestLocation} />
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1 },
  map: { flex: 1 },
  panel: { padding: 16, paddingBottom: 48, gap: 8 },
});

export default BugReportExample;
Temporary instrumentation (not part of this PR)

ios/RNMBX/RNMBXLocationModule.swift, first line of init():

NSLog("[repro] RNMBXLocationModule.init start, main thread: %@", Thread.isMainThread ? "yes" : "no")

example/ios/RNMapboxExample/AppDelegate.mm (#import <CoreLocation/CoreLocation.h> and #import <objc/runtime.h>), called first in application:didFinishLaunchingWithOptions:. For 3 s per read: SIMCTL_CHILD_REPRO_SLOW_LOCATIOND=1 xcrun simctl launch <udid> org.reactjs.native.example.RNMapboxExample. Logs: xcrun simctl spawn <udid> log stream --predicate 'eventMessage CONTAINS "[repro]"'.

static void ReproInstrumentAccuracyAuthorization(void)
{
  Method method = class_getInstanceMethod([CLLocationManager class], @selector(accuracyAuthorization));
  IMP original = method_getImplementation(method);
  BOOL slow = [NSProcessInfo.processInfo.environment[@"REPRO_SLOW_LOCATIOND"] isEqualToString:@"1"];
  method_setImplementation(method, imp_implementationWithBlock(^CLAccuracyAuthorization(CLLocationManager *manager) {
    NSArray<NSString *> *stack = [NSThread callStackSymbols];
    BOOL main = NSThread.isMainThread;
    NSLog(@"[repro] accuracyAuthorization read, main thread: %@", main ? @"yes" : @"no");
    if (main) {
      for (NSUInteger i = 3; i < MIN((NSUInteger)24, stack.count); i++) {
        NSLog(@"[repro]   %@", stack[i]);
      }
    }
    if (slow) {
      usleep(3000000);
    }
    CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
    CLAccuracyAuthorization result = ((CLAccuracyAuthorization (*)(id, SEL))original)(manager, @selector(accuracyAuthorization));
    NSLog(@"[repro] accuracyAuthorization returned after %.0f ms, main thread: %@",
          (CFAbsoluteTimeGetCurrent() - start) * 1000, main ? @"yes" : @"no");
    return result;
  }));
  NSLog(@"[repro] accuracyAuthorization instrumented, slow: %@", slow ? @"yes" : @"no");
}

android/src/main/java/com/rnmapbox/rnmbx/modules/RNMBXLocationModule.kt, first in the class body:

init {
    android.util.Log.i("RNMBXRepro", "[repro] RNMBXLocationModule.init, thread: ${Thread.currentThread().name}")
}

locationManager read the native location module, and created its event
emitter, at module scope, so loading @rnmapbox/maps created the module.
On iOS that runs RNMBXLocationModule.init on the main thread, and its
AppleLocationProvider reads CLLocationManager.accuracyAuthorization, a
synchronous call to locationd that can hang an app that only draws a map.

The module and the emitter are now read on first use, and stop(), which
runs on every background transition, skips a module that was never read.
@mfazekas

Copy link
Copy Markdown
Contributor

Thanks, the root cause checks out. I reproduced it on main (the location module is read during package import) and confirmed that with this PR it's only read on first use, including across background/foreground, on an iOS simulator.

One small request: please trim or remove the comment above mapboxLocationManager. The iOS accuracyAuthorization details belong in the PR description. If you keep anything, something like // stop() runs on every background transition, so it must not be the first read. is enough.

@mfazekas
mfazekas merged commit 70a9eb6 into rnmapbox:main Oct 25, 2026
10 checks passed
@mfazekas

Copy link
Copy Markdown
Contributor

Merged, thanks @kristojorg for the fix and the detailed investigation! I'll trim the code comment in a small follow-up PR, so no need to change anything here.

This branch was previously deployed

1 inactive deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants