Skip to content

MapViewRef methods reject with "MapView is not mounted" when called from a mount effect #131

Closed

Description

@jkasprzyk17

Problem

Every MapViewRef method rejects when it is called from the consumer's mount effect:

MapView is not mounted

This is not a race that sometimes works — the ref cannot be populated that early. useImperativeHandle
hands out a non-null handle during the commit, so mapRef.current?.fitToCoordinates(...) is not
short-circuited by optional chaining: the call happens, and returns an already-rejected promise. A
consumer who does not attach a .catch gets either an unhandled rejection warning or, more commonly,
nothing at all — the camera silently never moves.

Measured against v1.2.1 in a fresh Expo SDK 57 consumer app on 2026-09-17:

when the call is made iOS Android
mount effect, t = 0 ms rejected rejected
t = 50 ms after mount rejected resolved
t = 500 ms after mount resolved resolved

Both providers, simulator and emulator.

Reproduction

function Map() {
  const mapRef = useRef<MapViewRef>(null);

  useEffect(() => {
    // rejects — nothing happens, no error surfaces
    mapRef.current?.fitToCoordinates(points);
  }, []);

  return <MapView ref={mapRef} style={{ flex: 1 }} markers={markers} />;
}

useLayoutEffect fails the same way: both run on the JS thread inside the commit, before the native
view exists.

Root cause

withHybridRef rejects immediately whenever the Nitro HybridObject has not arrived yet —
package/src/components/MapView.tsx:34:

const hybrid = hybridRef.current;
if (hybrid == null) {
  return Promise.reject(new Error(MAP_VIEW_NOT_MOUNTED_ERROR)) as T;
}

and hybridRef.current is only assigned from the hybridRef view prop (MapView.tsx:148), which Nitro
delivers asynchronously by construction:

  1. React commits. useImperativeHandle publishes the handle and the consumer's effects run — all on the
    JS thread, before any native view exists.
  2. Fabric applies the mount transaction on the UI thread. The generated component invokes hybridRef
    last, after afterUpdate() — nitrogen/generated/ios/c++/views/HybridMapViewComponent.mm (step 3 of
    updateProps) and nitrogen/generated/android/c++/views/JHybridMapViewStateUpdater.cpp (end of
    updateViewProps). Both files are generated; bun run nitrogen reproduces them.
  3. hybridRef returns void, so Nitro converts it to an AsyncJSCallback
    (JSIConverter+Function.hpp:30 → isAsync), whose operator() calls callAndForget →
    Dispatcher::runAsync → CallInvoker::invokeAsync (JSCallback.hpp:105,
    CallInvokerDispatcher.hpp:24). There is no "already on the JS thread, run inline" path — it is
    always queued as a separate JS task.

So the earliest possible moment hybridRef.current can be set is one JS→UI→JS round trip after the
commit that mounted the view. A mount effect is structurally too early, on both platforms. The Android
t = 50 ms column passing is just that round trip completing sooner there, not a different guarantee.

The same asynchrony is why the library's own example never calls the ref from an effect: example/App.tsx
calls fitToCoordinates from handleMapReady and swallows failures with ?.catch(() => {}) on every
call site. The constraint is already baked into our own usage — it is simply not written down anywhere.

Documentation gap

  • The README "Imperative camera API" example (README.md:235) shows a button press and states no timing
    rule.
  • MapViewRef JSDoc (package/src/types/ref.ts) says nothing about when the handle becomes usable.
  • onMapReady is not mentioned in the README at all — only in docs/architecture.md:73.

Suggested fix

Buffer imperative calls until the hybrid ref arrives, then flush them in order:

  • keep the queue in its own module, free of React and react-native imports, so it stays unit-testable
    (see Testing); handleHybridRef drains it in call order;
  • reject anything still pending at unmount, and clear hybridRef.current there — today there is no
    cleanup at all, so after unmount the ref keeps pointing at a dead HybridObject and calls reject from
    native instead of from JS;
  • no timers, sleeps or retries (Nitro's own guidance: model readiness as a state transition, not a
    delay).

Android caveat — queueing on hybridRef alone is not enough. In the window between the hybrid ref
arriving and getMapAsync delivering the GoogleMap, the camera calls are already reachable but do
nothing: GoogleMapProviderAdapter.fitToCoordinates (L359) and updateMapCamera (L621) return early on
googleMap == null, while HybridMapView.kt:287-309 returns Promise.resolved(Unit). Flushing the JS
queue there would turn a loud rejection into a silent success — worse than the current bug. So either
drain the queue on map-ready, or make the adapter defer the camera update until the map exists (there is
already a pendingMarkers + runWhenMapViewLaidOut pattern in that file to follow). iOS has no such
window: MKMapView exists as soon as the adapter is installed.

Whichever is chosen, document the resulting guarantee in the README imperative section and in the
MapViewRef JSDoc.

Acceptance criteria

  • fitToCoordinates / animateCamera issued from a mount effect resolve and visibly move the
    camera, on both platforms and both providers
  • Several calls issued before the ref arrives run in the order they were made
  • Calls still pending at unmount reject with a clear message and never touch a released native view
  • Calls made after unmount still reject
  • No setTimeout, sleep or retry in the fix
  • No new test runner or renderer dependency (see Testing)
  • README and MapViewRef JSDoc state when the handle is usable

Testing

Unit, under the existing bun test: enqueue order is preserved, the queue flushes once the ref arrives,
and pending calls reject at unmount.

Manual, on a simulator and an emulator: an example-app scenario option that calls fitToCoordinates from
a mount effect and reports whether the promise resolved — both providers. A Google Maps API key is
required, see the README.

Rendering MapView in a test is out of scope: react-native's Flow source does not parse under bun,
so it would mean running jest alongside bun test in CI.

Environment

react-native-better-maps 1.2.1 (ccbc2a8) · react-native-nitro-modules 0.35.10 · react-native 0.86.0 ·
Expo SDK 57 · iOS simulator + Android emulator · verified 2026-09-17

Activity

  1. added
    bugSomething isn't working
    kotlinThe Kotlin / Android native layer (package/android)
    typescriptThe TypeScript layer (package/src)
    on Sep 17, 2026
  2. self-assigned this
    on Sep 18, 2026
  3. added 2 commits that reference this issue on Sep 22, 2026
    088a0e3
    edc195f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingkotlinThe Kotlin / Android native layer (package/android)platform: androidAffects Androidplatform: iosAffects iOStypescriptThe TypeScript layer (package/src)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions