Repository navigation
MapViewRef methods reject with "MapView is not mounted" when called from a mount effect #131
Description
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:
- React commits.
useImperativeHandlepublishes the handle and the consumer's effects run — all on the
JS thread, before any native view exists. - Fabric applies the mount transaction on the UI thread. The generated component invokes
hybridRef
last, afterafterUpdate()—nitrogen/generated/ios/c++/views/HybridMapViewComponent.mm(step 3 of
updateProps) andnitrogen/generated/android/c++/views/JHybridMapViewStateUpdater.cpp(end of
updateViewProps). Both files are generated;bun run nitrogenreproduces them. hybridRefreturnsvoid, so Nitro converts it to anAsyncJSCallback
(JSIConverter+Function.hpp:30→isAsync), whoseoperator()callscallAndForget→
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. MapViewRefJSDoc (package/src/types/ref.ts) says nothing about when the handle becomes usable.onMapReadyis not mentioned in the README at all — only indocs/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-nativeimports, so it stays unit-testable
(see Testing);handleHybridRefdrains it in call order; - reject anything still pending at unmount, and clear
hybridRef.currentthere — 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/animateCameraissued 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
MapViewRefJSDoc 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
fix: buffer MapViewRef calls until the native map exists
fix: buffer MapViewRef calls until the native map exists