Skip to content

fix(ios): remove main.sync from HybridMapView and async camera APIs#45

Merged
jkasprzyk17 merged 4 commits into
mainfrom
fix/ios-mapview-main-thread-ownership
Jul 15, 2026
Merged

fix(ios): remove main.sync from HybridMapView and async camera APIs#45
jkasprzyk17 merged 4 commits into
mainfrom
fix/ios-mapview-main-thread-ownership

Conversation

@jkasprzyk17

@jkasprzyk17 jkasprzyk17 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Remove DispatchQueue.main.sync from HybridMapView — property getters read backing fields directly; setters and lifecycle hooks use non-blocking runOnMain (async dispatch).
  • Convert applyCamera, animateCamera, and fitToCoordinates to Promise<void> in the Nitro spec and native implementations, matching the existing promiseOnMain pattern used by fetchCamera / getVisibleRegion.
  • Update MapViewRef and the React wrapper to propagate promises and reject when the view is not mounted.

This addresses deadlock risk when imperative camera calls from the JS thread block on main while main is waiting on JS/Fabric, and removes unnecessary per-getter main round-trips.

Follow-up to #43 — that PR did not remove the onMain / .sync pattern.

Test plan

  • bun run typecheck in package/
  • bun run nitrogen in package/ (codegen required after spec change)
  • iOS example: animateCamera, setCamera, fitToCoordinates from ref work without UI freeze
  • iOS example: rapid prop updates (markers, mapType) render correctly
  • Android example: camera ref methods still work (Promise.resolved on UI thread)

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

React Doctor found 7 issues in 6 files · 2 errors & 5 warnings · score 55 / 100 (Critical) · full project

Errors

5 warnings

examples/advancedFeatures.ts

  • ⚠️ L115 unused-export

src/hooks/index.ts

  • ⚠️ L0 unused-file

src/hooks/useCollectedOverlays.ts

  • ⚠️ L204 Ref initializer runs on every render rerender-lazy-ref-init

src/providers.ts

  • ⚠️ L11 unused-export

src/utils/enteringAnimation.ts

  • ⚠️ L33 unused-export

Reviewed by React Doctor for commit 1814dca. See inline comments for fixes.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cdf80623-f601-4aeb-82fc-a4e116b93c15

📥 Commits

Reviewing files that changed from the base of the PR and between 6f1a89f and 1814dca.

📒 Files selected for processing (1)
  • example/App.tsx

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Camera/viewport actions (setCamera, applyCamera, animateCamera, fitToCoordinates) are now promise-based across the imperative API, allowing callers to await completion.
  • Bug Fixes
    • Imperative camera methods now reject with a clear “MapView is not mounted” error when called before the view is ready, avoiding silent no-ops.
    • Improved adapter state synchronization and lifecycle/main-thread handling during recycling and provider changes.

Walkthrough

Camera methods now return promises across the public TypeScript surface, the JS wrapper, Android, and iOS. iOS also adds locked state backing, main-thread dispatch helpers, and lifecycle-aware promise execution.

Changes

Promise-based camera API

Layer / File(s) Summary
TypeScript contracts
package/src/native/specs/MapView.nitro.ts, package/src/types/ref.ts
applyCamera, animateCamera, and fitToCoordinates now return Promise<void> in the public TypeScript contracts.
Ref plumbing and imperative methods
package/src/components/MapView.tsx, example/App.tsx
MapView rejects missing refs with MapView is not mounted, returns native results for camera methods, and the example scene passes ref through the new component shape.
Android camera promises
package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt
applyCamera, animateCamera, and fitToCoordinates delegate to currentAdapter() and return resolved promises.
iOS state backing and dispatch helpers
package/ios/MapViewState.swift, package/ios/HybridMapView.swift
Adds MapViewState, centralizes backed state access under stateLock, updates provider and googleMapId handling, and adds runOnMain plus promiseOnMainVoid.
iOS async camera and lifecycle
package/ios/HybridMapView.swift
Camera methods now return Promise<Void> through promiseOnMainVoid, and lifecycle activation/recycle paths use runOnMain with the existing reset flow.

Estimated code review effort: 4 (Complex) | ~50 minutes

Possibly related PRs

Suggested labels: review

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Security Check ❓ Inconclusive Repository clone failed, so this custom check could not run with code access. Retry the review run. If this persists, inspect pre-merge custom-check logs for infrastructure or agent runtime failures.
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is clearly related and uses the fix prefix, though it is longer than the ideal length.
Description check ✅ Passed The description matches the PR changes, covering async camera APIs, non-blocking main-thread dispatch, and ref updates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@jkasprzyk17
jkasprzyk17 force-pushed the fix/ios-mapview-main-thread-ownership branch from 68720d0 to 24e11cf Compare July 6, 2026 12:53
Replace blocking DispatchQueue.main.sync hops in property accessors and
imperative methods with main-thread-owned state and non-blocking dispatch.
Camera commands now return Promise<void> to avoid JS/main deadlocks.
@jkasprzyk17
jkasprzyk17 force-pushed the fix/ios-mapview-main-thread-ownership branch from 24e11cf to ba8156c Compare July 6, 2026 12:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
package/ios/HybridMapView.swift (1)

50-67: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep backing ivars on one thread
runOnMain moves setter writes for _provider, _camera, _region, etc. onto the main queue, but the getters still read those same plain vars on the caller/JS thread. Nitro property access is synchronous on the current runtime thread, so this is a cross-thread read/write race on non-atomic storage and can return stale values. Either make both access paths use the same queue/actor or add proper synchronization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package/ios/HybridMapView.swift` around lines 50 - 67, The HybridMapView
backing storage is being written on the main queue via runOnMain while the
property getters still read plain ivars like _provider synchronously on the
caller thread, creating a cross-thread race. Update the provider access pattern
so both get and set go through the same synchronization mechanism used by
HybridMapView (for example, a shared main-thread/actor boundary or explicit
locking), and apply the same approach to the other backing properties such as
_camera and _region.
🧹 Nitpick comments (2)
package/ios/HybridMapView.swift (1)

763-790: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

The helper is correct; the copy-paste tax on the setters is not. promiseOnMainVoid itself is fine — weak-self reject, lifecycle snapshot, sync/async dispatch parity with promiseOnMain, all good. But zoom out: you've stamped the identical runOnMain { [weak self] in guard let self else { return }; _x = newValue; adapter?.x = newValue } block roughly two dozen times (lines 69–535). That's a wall of near-duplicate code where a single typo in one setter (wrong ivar, wrong adapter key) would be nearly invisible in review. A small generic helper — e.g. setOnMain(_ store: ReferenceWritableKeyPath, _ adapterKey: ReferenceWritableKeyPath, _ value:) — would collapse each setter to one line and make divergence impossible. Optional given Swift's keypath ergonomics, but the current form is a maintenance liability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package/ios/HybridMapView.swift` around lines 763 - 790, The repeated setter
blocks in HybridMapView are duplicating the same main-thread state update and
adapter sync logic, which makes copy-paste mistakes easy to miss. Refactor the
setter methods that currently use the repeated runOnMain pattern into a shared
helper (for example, a generic setOnMain-style method) so each setter only
passes the stored property and matching adapter property/keypath. Keep
promiseOnMainVoid as-is; the change should target the duplicated setter
implementations and preserve the same weak-self and main-thread behavior.
package/src/components/MapView.tsx (1)

170-199: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: extract the repeated "not mounted" guard.

The hybridRef.current == null ? reject : ... block is duplicated 5 times now (getCamera, setCamera, animateCamera, getVisibleRegion, fitToCoordinates). A small helper (e.g. withHybridRef(fn)) would remove the repetition and centralize the error message.

♻️ Sketch of a shared helper
function withHybridRef<T>(
  hybridRef: React.RefObject<NativeMapViewHybrid | null>,
  fn: (hybrid: NativeMapViewHybrid) => Promise<T>,
): Promise<T> {
  if (hybridRef.current == null) {
    return Promise.reject(new Error('MapView is not mounted'));
  }
  return fn(hybridRef.current);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package/src/components/MapView.tsx` around lines 170 - 199, The mounted-check
logic is duplicated across MapView methods and should be centralized. Extract
the repeated `hybridRef.current == null` guard in the `MapView` imperative
handle methods (`getCamera`, `setCamera`, `animateCamera`, `getVisibleRegion`,
`fitToCoordinates`) into a shared helper such as `withHybridRef`, and have each
method call it so the “MapView is not mounted” error message stays in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@package/ios/HybridMapView.swift`:
- Around line 50-67: The HybridMapView backing storage is being written on the
main queue via runOnMain while the property getters still read plain ivars like
_provider synchronously on the caller thread, creating a cross-thread race.
Update the provider access pattern so both get and set go through the same
synchronization mechanism used by HybridMapView (for example, a shared
main-thread/actor boundary or explicit locking), and apply the same approach to
the other backing properties such as _camera and _region.

---

Nitpick comments:
In `@package/ios/HybridMapView.swift`:
- Around line 763-790: The repeated setter blocks in HybridMapView are
duplicating the same main-thread state update and adapter sync logic, which
makes copy-paste mistakes easy to miss. Refactor the setter methods that
currently use the repeated runOnMain pattern into a shared helper (for example,
a generic setOnMain-style method) so each setter only passes the stored property
and matching adapter property/keypath. Keep promiseOnMainVoid as-is; the change
should target the duplicated setter implementations and preserve the same
weak-self and main-thread behavior.

In `@package/src/components/MapView.tsx`:
- Around line 170-199: The mounted-check logic is duplicated across MapView
methods and should be centralized. Extract the repeated `hybridRef.current ==
null` guard in the `MapView` imperative handle methods (`getCamera`,
`setCamera`, `animateCamera`, `getVisibleRegion`, `fitToCoordinates`) into a
shared helper such as `withHybridRef`, and have each method call it so the
“MapView is not mounted” error message stays in one place.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b4dd470a-e579-42ae-b6ad-db6194ddfa04

📥 Commits

Reviewing files that changed from the base of the PR and between 36f8472 and 68720d0.

📒 Files selected for processing (5)
  • package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt
  • package/ios/HybridMapView.swift
  • package/src/components/MapView.tsx
  • package/src/native/specs/MapView.nitro.ts
  • package/src/types/ref.ts

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 6, 2026
Comment thread package/src/components/MapView.tsx Outdated
@coderabbitai
coderabbitai Bot requested a review from piotr-graczyk-dev July 6, 2026 13:29
@coderabbitai coderabbitai Bot added the review Waiting for review by colleagues label Jul 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
package/ios/MapViewState.swift (1)

3-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Hand-mirrored struct — one day it'll bite you.

You've cloned every single property of MapProviderAdapter into this struct by hand. Nothing links them at compile time, so the moment someone adds a property to the protocol (and they will) and forgets to mirror it here and in apply(to:), that field silently never reaches the adapter. No error, no warning, just a quietly broken map feature and a very confused engineer three weeks from now.

Consider at least leaving a comment on both MapProviderAdapter and this struct pointing at each other so the next person doesn't have to discover this the hard way.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package/ios/MapViewState.swift` around lines 3 - 37, The MapViewState struct
is manually mirroring MapProviderAdapter properties, so updates can silently
drift. Add clear cross-reference comments in MapViewState and MapProviderAdapter
pointing to each other, and ensure apply(to:) stays in sync with the same
symbols so future property additions are handled deliberately.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@package/ios/MapViewState.swift`:
- Around line 3-37: The MapViewState struct is manually mirroring
MapProviderAdapter properties, so updates can silently drift. Add clear
cross-reference comments in MapViewState and MapProviderAdapter pointing to each
other, and ensure apply(to:) stays in sync with the same symbols so future
property additions are handled deliberately.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aa9219b2-82d8-4397-9f4e-cefa509073d8

📥 Commits

Reviewing files that changed from the base of the PR and between ba8156c and a767acb.

📒 Files selected for processing (3)
  • package/ios/HybridMapView.swift
  • package/ios/MapViewState.swift
  • package/src/components/MapView.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • package/src/components/MapView.tsx
  • package/ios/HybridMapView.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
example/App.tsx (1)

670-683: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

You made every camera method a rejectable promise and then blindly ignored the rejections. Bold.

The whole point of this PR is that animateCamera/fitToCoordinates/getCamera now return Promise<void> that rejects when the view isn't mounted. Your mapRef.current?. only guards a null current — it does nothing for a promise that resolves the ?. chain and then rejects because the native view was recycled. Result: unhandled promise rejection warnings (or a redbox) in the exact "documented usage pattern" you're shipping in the README.

Same rot lives in handleMapReady at Line 788 (fitToCoordinates) and handleGetCamera at Line 685 (await with no try/catch). Attach a .catch (or wrap the await) at each fire-and-forget site.

🧯 Proposed fix for the fire-and-forget call sites
   const handleAnimateCamera = useCallback(() => {
-    mapRef.current?.animateCamera(
-      {
-        center: {
-          latitude: scenario.region.latitude,
-          longitude: scenario.region.longitude,
-        },
-        zoom: 13,
-        heading: 0,
-        pitch: 0,
-      },
-      1,
-    );
+    mapRef.current
+      ?.animateCamera(
+        {
+          center: {
+            latitude: scenario.region.latitude,
+            longitude: scenario.region.longitude,
+          },
+          zoom: 13,
+          heading: 0,
+          pitch: 0,
+        },
+        1,
+      )
+      .catch(() => {
+        // view unmounted before the camera update landed
+      });
   }, [scenario]);

Apply the equivalent .catch(...) to the fitToCoordinates call in handleMapReady (Line 788) and a try/catch around the await in handleGetCamera (Line 685).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@example/App.tsx` around lines 670 - 683, The fire-and-forget camera calls in
handleAnimateCamera, handleMapReady, and handleGetCamera are not handling
rejected Promise results from animateCamera, fitToCoordinates, and getCamera.
Update each call site to catch rejections explicitly: attach a .catch handler
for the non-awaited calls in handleAnimateCamera and handleMapReady, and wrap
the awaited getCamera call in handleGetCamera with try/catch so rejected
promises from an unmounted view do not surface as unhandled rejections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@example/App.tsx`:
- Around line 670-683: The fire-and-forget camera calls in handleAnimateCamera,
handleMapReady, and handleGetCamera are not handling rejected Promise results
from animateCamera, fitToCoordinates, and getCamera. Update each call site to
catch rejections explicitly: attach a .catch handler for the non-awaited calls
in handleAnimateCamera and handleMapReady, and wrap the awaited getCamera call
in handleGetCamera with try/catch so rejected promises from an unmounted view do
not surface as unhandled rejections.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d04b4a41-5141-47bb-80f9-89fd85383cc2

📥 Commits

Reviewing files that changed from the base of the PR and between a767acb and 6f1a89f.

📒 Files selected for processing (2)
  • example/App.tsx
  • package/src/components/MapView.tsx

@jkasprzyk17
jkasprzyk17 merged commit bf4184c into main Jul 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review Waiting for review by colleagues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants