npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jacques_gordon/expo-mapbox-navigation

v5.0.3

Published

Expo module for Mapbox Navigation SDK with 16KB page size support, NDK27, and Mapbox Maps v11.11.0+

Readme

@jacques_gordon/expo-mapbox-navigation

npm version

Full-featured Expo module for Mapbox Navigation SDK v3 — Android and iOS.


Features

  • Android — Waze-style navigation UI built from scratch: maneuver banner, lane guidance, speed limit, ETA bar, voice instructions, mute/overview/recenter buttons, day/night auto-switch
  • iOS — Drop-in NavigationViewController from Mapbox Navigation SDK v3 (lane guidance, speed limit, voice, day/night all built-in)
  • Both platforms — 7 events, 19 props, full feature and API parity
  • NDK 27 + 16 KB page size compliant (Android 15+)

✅ Tested on iOS: real EAS production build, real device - launch crash confirmed gone. ✅ Tested on Android: no regressions from the iOS-only changes in 4.0.0 (that release touched iOS exclusively - ios/fetch-xcframeworks.sh, ios/patch-mapbox-library-evolution.sh, README/changelog only).


Installation

npx expo install @jacques_gordon/expo-mapbox-navigation @rnmapbox/maps

1. Setup @rnmapbox/maps first

["@rnmapbox/maps", {
  "RNMapboxMapsImpl": "mapbox",
  "RNMapboxMapsVersion": "11.11.0",-- Android (editable), 11.20.2 -- IOS (fixed)
  "RNMapboxMapsDownloadToken": "sk.your_secret_token"
}]

2. Add this plugin

["@jacques_gordon/expo-mapbox-navigation", {
  "accessToken": "pk.your_public_token",
  "downloadsToken": "sk.your_secret_token",
  "mapboxMapsVersion": "11.11.0"-- Android (editable), 11.20.2 -- IOS (fixed)
}]

3. iOS only — enable static frameworks

["expo-build-properties", {
  "ios": {
    "useFrameworks": "static"
  }
}]

On the DYLD 4 Symbol missing: GestureType.singleTap launch crash: this project previously documented forceStaticLinking here as the confirmed fix for that crash. That claim has been retracted — direct binary analysis of a crashing build and a non-crashing build (both real production IPAs) showed identical dynamic linking for MapboxMaps/MapboxCommon/MapboxCoreMaps/Turf in both builds, meaning forceStaticLinking produced no observable difference either way. The actual difference found was that the two builds' MapboxMaps binaries were genuinely different files (different size, different hash) despite both reporting the identical version 11.20.2 — most likely because CocoaPods-trunk-resolved MapboxMaps (installed by @rnmapbox/maps, not vendored by this package) served different binary content at different times under the same version tag. See the changelog's 4.0.3 entry for the full retraction and the 4.0.0 entry (left intact) for the original, now-superseded reasoning. There is currently no confirmed, actionable fix for this crash to document here — if you hit it, treat version pinning/resolution of MapboxMaps via @rnmapbox/maps as the area to investigate, not this package's own config.

Confirmed working version set (interlocked — lift from the matching build-artifacts tag on bump):

nav 3.20.1 / MapboxNavigationNative 324.20.2 (bucket dash-native) /
MapboxCommon 24.20.2 / MapboxMaps 11.20.2 (matches @rnmapbox/[email protected]).

Plugin Options

| Option | Required | Default | Description | |--------|----------|---------|-------------| | accessToken | ✅ | — | Public Mapbox token (pk.*). Used for map tiles and routing. | | downloadsToken | ✅ | — | Secret Mapbox token (sk.*) with Downloads:Read scope. Same token as RNMapboxMapsDownloadToken. Kept for backward compatibility with app.json configs from earlier versions — as of 2.3.0 it's no longer used at app-build time (the iOS SDK is vendored as prebuilt binaries; nothing downloads from api.mapbox.com during your pod install/EAS build anymore). | | mapboxMapsVersion | ✅ | "11.11.0" — iOS fixed at 11.20.2 | Must exactly match RNMapboxMapsVersion in @rnmapbox/maps. Android only as of 2.3.0. As of 2.3.7, this genuinely drives the com.mapbox.maps:android and (indirectly, via mapboxNavigationVersion's auto-calculation) com.mapbox.navigationcore:* Gradle dependency versions — previously this option was silently ignored for that purpose and those versions were hardcoded. iOS SDK version is fixed per npm package release — see iOS Architecture. | | mapboxNavigationVersion | — | auto-calculated (Android only) | Android only (reactivated in 2.3.7 — was deprecated/unused after 2.3.0's iOS rewrite). If set, used exactly as given for com.mapbox.navigationcore:* Gradle dependencies — no recalculation. If omitted, derived from mapboxMapsVersion via the Phase 1/Phase 2 formula (see iOS Architecture history) — a best-effort approximation, not a guarantee. Has no effect on iOS; the iOS SDK version is fixed by which npm package version you install. | | androidColorOverrides | — | {} | Override Mapbox native resource colors on Android. |


iOS Architecture

Mapbox Navigation SDK v3 for iOS is distributed via Swift Package Manager only — Mapbox has not shipped CocoaPods support for it. This package works around that by vendoring the SDK's binaries directly. Mapbox officially publishes MapboxNavigationCore/MapboxNavigationUIKit/MapboxDirections (and their transitive binary dependencies) as precompiled .xcframework downloads via a dedicated repository, mapbox-navigation-ios-build-artifactsios/fetch-xcframeworks.sh fetches these directly into the consuming app's node_modules on its own first pod install.

What this means for you:

  • No network access to api.mapbox.com needed during your pod install or EAS build for the Navigation-specific frameworks themselves — only your own downloadsToken at fetch time.
  • No SPM package resolution happens in your project for this SDK at all.
  • useFrameworks: "static" (see step 3 of Installation) is required. forceStaticLinking, previously documented here as the fix for the DYLD 4 Symbol missing: GestureType.singleTap launch crash, has been retracted as of 4.0.3 — real A/B binary evidence showed it made no observable difference to linking type in either a crashing or non-crashing build. See the 4.0.3 changelog entry for the retraction and why the crash's real cause is currently unconfirmed.
  • The iOS SDK version is fixed by which version of this npm package you install (matching a specific mapboxMapsVersion), not something you configure per-app.

Why MapboxMaps/MapboxCommon/MapboxCoreMaps/Turf are not vendored here: @rnmapbox/maps already installs those via CocoaPods. Vendoring a second copy of the same libraries would cause duplicate-symbol link errors. Only the Navigation-specific frameworks that @rnmapbox/maps doesn't already provide are vendored by this package.

Upgrading the vendored iOS SDK version (maintainers)

The iOS binaries are tied to a specific Navigation SDK version, matched to a specific MapboxMaps version. ios/Frameworks/*.xcframework is not committed to this repo (see Why iOS binaries aren't committed to this repo below) — ios/fetch-xcframeworks.sh fetches the Navigation-specific frameworks fresh each time they're needed. To bump the version:

  1. Confirm the target Navigation version's matching Maps version from that release's own "Packaging" changelog section at https://github.com/mapbox/mapbox-navigation-ios/releases/tag/vX.Y.Z — don't assume a pattern holds, verify the exact release notes every time.
  2. Update MAPBOX_NAV_VERSION at the top of ios/fetch-xcframeworks.sh.
  3. Update ExpoMapboxNavigation.podspec's s.dependency 'MapboxMaps', '...' line to the exact same Maps version — this is intentionally hardcoded here, not read from any option or environment variable (see the comment directly above that line in the podspec for why).
  4. Update s.platforms/IPHONEOS_DEPLOYMENT_TARGET in the same podspec if the new MapboxMaps version raises its own minimum deployment target.
  5. Also confirm ios/ExpoMapboxNavigationModule.swift/ExpoMapboxNavigationView.swift still compile against the new SDK version's actual API — this project's own custom Swift source was built against a specific API generation once before and broke silently on a version downgrade (see the 3.1.9 changelog entry) without any warning until a real archive build. There is no automated check for this yet.
  6. Run the "Verify Mapbox Navigation xcframeworks fetch" GitHub Actions workflow with the new version tag — this is a CI sanity check only (confirms ios/fetch-xcframeworks.sh still works end-to-end against the new version), it does not publish or commit anything.
  7. npm version + npm publish as usual — the published tarball does not include ios/Frameworks/*.xcframework (see below); consumers fetch it themselves on their own first pod install.

Why iOS binaries aren't committed to this repo

Mapbox's own Product Terms ("1.10. No Redistribution") prohibit redistributing their SDK binaries to third parties who haven't authenticated with their own Mapbox account/token. Since this repository and npm package are public, committing ios/Frameworks/*.xcframework (fetched using this project's own Mapbox DOWNLOADS:READ token) would make them freely downloadable by anyone via git clone or npm install, without their own token — a real compliance risk, not just a repo-size concern.

Instead, ios/Frameworks/*.xcframework is excluded from both this repo (.gitignore) and the published npm tarball (package.json's files), and ExpoMapboxNavigation.podspec's s.prepare_command fetches these binaries directly into the consuming app's node_modules/@jacques_gordon/expo-mapbox-navigation/ios/Frameworks/ the first time that app runs pod install — using that app's own Mapbox downloadsToken (see the downloadsToken plugin option, required for iOS). This matches the approach used by other public Mapbox Navigation + React Native packages (e.g. pawan-pk/react-native-mapbox-navigation).

Practical implications for consumers of this package:

  • The first pod install after installing/upgrading this package will be noticeably slower (fetching ~250MB of binaries) — subsequent installs are fast as long as node_modules isn't wiped between them.
  • Requires network access and a valid Mapbox DOWNLOADS:READ token (via the downloadsToken plugin option) at pod install time, not just at publish time. If that token is missing or invalid, the consuming app's own build fails with a clear error (not a cryptic downstream one), pointing back to the downloadsToken option.

Usage

import { MapboxNavigationView } from '@jacques_gordon/expo-mapbox-navigation';

export default function Navigation() {
  return (
    <MapboxNavigationView
      style={{ flex: 1 }}
      coordinates={[
        { latitude: 50.8503, longitude: 4.3517 },  // Brussels
        { latitude: 51.2194, longitude: 4.4025 },  // Antwerp
      ]}
      voiceUnits="metric"
      language="fr"
      navigationProfile="driving-traffic"
      onRoutesReady={({ nativeEvent }) =>
        console.log('Route ready:', nativeEvent.distanceMeters, 'm')
      }
      onRouteProgressChanged={({ nativeEvent }) =>
        console.log('Remaining:', nativeEvent.distanceRemaining, 'm')
      }
      onManeuverBannerPressed={({ nativeEvent }) => {
        // Open a bottom sheet showing all upcoming steps
        console.log('Steps:', nativeEvent.steps);
      }}
      onArrival={() => console.log('Arrived!')}
      onNavigationCancelled={() => console.log('Cancelled')}
      onRoutesFailed={({ nativeEvent }) =>
      //nativeEvent.code === 'same_location' = 'navigation.alreadyAtDestination' (<= 25m)
        console.error('Failed:', nativeEvent.code, nativeEvent.message)
      }
    />
  );
}

Props

Navigation

Route recalculation on prop change (5.0.0+, both platforms): changing any of coordinates, waypointIndices, navigationProfile, language, voiceUnits, excludeTypes, maxHeight or maxWidth automatically recalculates the route with the new value (multiple changes in the same render batch coalesce into a single request; re-setting an identical value does nothing). Before 5.0.0, only coordinates triggered recalculation — changing e.g. language mid-session had no effect until the next coordinates change. mapStyle and the color/UI props never trigger recalculation.

| Prop | Type | Default | Description | |------|------|---------|-------------| | coordinates | { latitude: number; longitude: number }[] | required | Waypoints. Minimum 2. | | waypointIndices | number[] | all | Which coordinates are true waypoints (vs. route shape points). | | navigationProfile | string | "driving-traffic" | "driving-traffic", "driving", "walking", "cycling". Android: omit the "mapbox/" prefix. | | language | string | device locale | BCP-47 tag (e.g. "fr", "nl", "en-US"). | | voiceUnits | "metric" \| "imperial" | auto by locale | Overrides automatic unit detection. | | excludeTypes | string[] | — | Road types to avoid (e.g. ["toll", "ferry"]). | | mapStyle | string | Mapbox Navigation Day | Map style URL. | | mute | boolean | false | Silence voice instructions. | | maxHeight | number | — | Max vehicle height in metres. | | maxWidth | number | — | Max vehicle width in metres. | | useMapMatching | boolean | false | Use Map Matching API instead of routing. | | customRasterTileUrl | string | — | Custom raster tile URL with {x}/{y}/{z}. | | customRasterAboveLayerId | string | — | Layer ID to insert custom raster tiles above. | | showEndOfRouteFeedback | boolean | true | iOS only (5.0.1+). Whether the built-in end-of-route screen ("You have arrived" + trip rating stars) appears on final arrival. Set false to arrive silently — onArrival fires either way. No-op on Android (its custom UI has no such screen). |

Color Customization (Android)

All color props are optional — defaults are applied when omitted.

| Prop | Default | Description | |------|---------|-------------| | maneuverBackgroundColorDay | Mapbox default | Background of the turn-by-turn instruction banner (day/light mode). Sets ManeuverViewOptions.maneuverBackgroundColor at runtime (Android/iOS). Android: has no visible effect from this prop alone — must also be set as a plugin option in app.json for the real on-screen color to change. See the callout right below, and priority vs. androidColorOverrides. | | maneuverBackgroundColorNight | Mapbox default | Same, for night mode — the component switches automatically based on time of day (6am–8pm = day). Android: same app.json plugin-option requirement as maneuverBackgroundColorDay above. | | maneuverTurnIconColor | Mapbox default | Color of the turn-direction icon inside the instruction banner. Sets ManeuverViewOptions.turnIconManeuver at runtime. Android: has no visible effect from this prop alone — Mapbox's SDK has no runtime API for icon color, only a build-time XML style chain. Must also be set as a plugin option in app.json. See the callout right below. | | navigationPuckColor | Mapbox default | Tints Mapbox's own default location puck icon (the arrow showing your position/heading on the map — distinct from maneuverTurnIconColor, which is inside the banner, not on the map). Ignored if navigationPuckImagePath or navigationPuck3DModelPath is set. | | navigationPuckImagePath | — | Replaces the puck icon entirely with a local image (file:// URI or absolute path). Never tinted, even if navigationPuckColor is also set — avoids any risk of a tint operation failing on an arbitrary custom image. Falls back to the color/default icon if the file can't be loaded. | | navigationPuck3DModelPath | — | Replaces the 2D puck with a 3D model (.glb/.gltf) — a local path, asset://name.glb (Android's bundled assets), or a full URL. Takes priority over both puck props above when set and valid. Falls back to the 2D puck if the model fails to load. | | speedLimitPosition | "bottomLeft" | Position of the speed limit panel: "bottomLeft", "bottomRight", "topLeft", or "topRight". "topRight" uses a wider margin to clear the mute/overview/recenter buttons, which occupy that corner already — verify visually on your target devices before relying on it. | | showEta | true | Whether the ETA/duration/distance bar is shown once navigation starts. Android only. | | etaBarBackgroundColor | "#1E2433" | Background of the bottom ETA/duration/distance bar. | | etaTextColor | "#FFFFFF" | Text color for ETA time and duration. | | iconButtonColor | "#1A73E8" | Color of the mute/overview/recenter buttons (default state). | | iconButtonMutedColor | "#EA4335" | Color of the mute button when voice is muted. |

<MapboxNavigationView
  maneuverBackgroundColorDay="#1E2433"
  maneuverBackgroundColorNight="#0B0E14"
  maneuverTurnIconColor="#1A73E8"
  navigationPuckColor="#1A73E8"
  speedLimitPosition="bottomLeft"
  etaBarBackgroundColor="#1E2433"
  etaTextColor="#FFFFFF"
  iconButtonColor="#1A73E8"
  iconButtonMutedColor="#EA4335"
/>

Android: maneuverBackgroundColorDay, maneuverBackgroundColorNight, and maneuverTurnIconColor alone are not enough.

All three set a matching ManeuverViewOptions field at runtime — confirmed, via diagnostic logging, to correctly receive and parse the right value every time — but on Android, none of them visibly changes anything on their own. The maneuver banner's actual on-screen appearance (background and icon color) is controlled by a build-time Android resource style (MapboxCustomManeuverStyle / MapboxCustomManeuverTurnIconStyle), generated by this package's config plugin from its own app.json options — not from view props on your React component. This isn't a bug to work around: config plugins run at expo prebuild time, before your JS ever executes, so they have no way to read a runtime prop value — and Mapbox's SDK resolves the banner's actual colors from that compiled resource, not from the runtime API. See Mapbox Native Colors below for the full mechanism.

You need the same three values in two places: as app.json plugin options (this is what actually changes the color on screen) and, if you like, also as view props on your component (harmless — kept for iOS parity and forward-compatibility, but currently has no visible effect of its own on Android). To avoid maintaining the same hex values twice, put them in one shared file and spread it into both:

// maneuverColors.js — single source of truth
module.exports = {
  maneuverBackgroundColorDay: "#1E2433",
  maneuverBackgroundColorNight: "#0B0E14",
  maneuverTurnIconColor: "#1A73E8",
};
// app.config.js — the JS config format, so it can require() the file above
// (a static app.json cannot)
const maneuverColors = require('./maneuverColors');

module.exports = {
  expo: {
    plugins: [
      ["@jacques_gordon/expo-mapbox-navigation", {
        accessToken: "pk.xxx",
        downloadsToken: "sk.xxx",
        ...maneuverColors,
      }],
    ],
  },
};
// Your component — spread the same object, so there's exactly one place
// to ever change one of these three colors
import maneuverColors from '../maneuverColors';

<MapboxNavigationView {...maneuverColors} /* ...other props */ />

iOS parity, as of this release:

  • maneuverBackgroundColorDay/maneuverBackgroundColorNight — implemented via a custom StandardDayStyle/StandardNightStyle subclass pair, styling InstructionsBannerView through UIAppearance (Mapbox's own confirmed, documented v3 mechanism for this). Read once, at the time a route is presented — unlike Android, NavigationOptions.styles is a construction-time configuration, so changing these colors while navigation is already active takes effect on the next route, not instantly. This is an architectural difference from Android's live-updating maneuver view, not an oversight.
  • navigationPuckColor/navigationPuckImagePath/navigationPuck3DModelPath — implemented via NavigationMapView.puckType (Puck2DConfiguration/Puck3DConfiguration), the confirmed official v3 API — same precedence as Android (3D model > custom image, never tinted > color tint > default). Unlike the maneuver colors above, this can be updated live while navigation is active. navigationPuckColor tints a standard system symbol rather than Mapbox's own default icon — there's no confirmed public API for the exact built-in asset name to tint directly on iOS, unlike Android.
  • maneuverTurnIconColor and speedLimitPosition are not implemented on iOS and remain no-ops (stored, no effect). maneuverTurnIconColor would require referencing a specific internal SDK view class for the turn-direction icon that we could not confirm still exists under an unchanged name in the current v3 MapboxNavigationUIKit — guessing wrong would fail to compile the entire iOS build, not just silently not work, so this was left out deliberately rather than risked. speedLimitPosition has no confirmed repositioning API — SpeedLimitView's position is fixed as part of NavigationViewController's drop-in layout. Both would require building a custom top/bottom banner (see Mapbox's "Build a custom UI" guide) rather than using the drop-in NavigationViewController, a substantially larger undertaking than styling one already outside this package's current architecture.
  • etaBarBackgroundColor, etaTextColor, iconButtonColor, iconButtonMutedColor remain stored-only for the same reason as maneuverTurnIconColor — no confirmed v3 class name found for these specific sub-elements.

Mapbox Native Colors (Android, via plugin)

Override Mapbox's built-in resource colors (route line, etc.) via androidColorOverrides in app.json:

["@jacques_gordon/expo-mapbox-navigation", {
  "accessToken": "pk.xxx",
  "downloadsToken": "sk.xxx",
  "mapboxMapsVersion": "11.11.0",
  "androidColorOverrides": {
    "mapbox_primary_route_color": "#0055FF",
    "mapbox_main_maneuver_background_color": "#FF5500"
  }
}]

Priority between androidColorOverrides.mapbox_main_maneuver_background_color and the maneuverBackgroundColorDay prop. These are two independent mechanisms that both affect the maneuver banner's background — androidColorOverrides is a build-time Android resource override (baked into the APK/AAB at expo prebuild), while maneuverBackgroundColorDay is a runtime ManeuverViewOptions API call. Setting both to different values means only one visibly wins, which looks like the other one "isn't working." As of 3.1.2, this package resolves it with a clear, fixed priority:

  1. androidColorOverrides.mapbox_main_maneuver_background_color, if you set it explicitly — always wins, exactly as you set it.
  2. Otherwise, maneuverBackgroundColorDay, if set — automatically written into the same underlying resource for you. You don't need to set both; setting only maneuverBackgroundColorDay is enough.
  3. Otherwise, Mapbox's own default.

In short: if you're only using one of the two, just use maneuverBackgroundColorDay — it's kept in sync automatically. Reach for androidColorOverrides.mapbox_main_maneuver_background_color directly only if you specifically need to pin an exact value regardless of what any prop says (e.g. a design system constant that shouldn't be app-configurable).

maneuverTurnIconColor — plugin option only, no priority conflict. Unlike the background color above, there's no androidColorOverrides key for the turn icon's color, so there's nothing to prioritize against — just set maneuverTurnIconColor as a plugin option and it's used directly to generate MapboxCustomManeuverTurnIconStyle, referenced from MapboxCustomManeuverStyle via maneuverViewIconStyle/laneGuidanceManeuverIconStyle (Mapbox's own official mechanism for this — see their "Change the color of maneuver turn icons" guide). Same single build-time XML style file as the background color, generated in both res/values/ and res/values-night/ — Mapbox ships its own values-night/ default for this too, which would otherwise silently win in dark mode the same way it originally did for the background color.


Events

| Event | Payload | Description | |-------|---------|-------------| | onRoutesReady | { routeCount, distanceMeters, durationSeconds } | Fired when routes are calculated and navigation starts. | | onRouteProgressChanged | { distanceRemaining, durationRemaining, distanceTraveled, fractionTraveled, currentStepDistanceRemaining } | Fired on every GPS update during navigation. | | onArrival | {} | User reached the destination. | | onNavigationCancelled | {} | User tapped the cancel (✕) button. | | onNavigationFinished | {} | Navigation session ended normally. | | onRoutesFailed | { message: string; code?: string } | Route calculation failed or was rejected. message is English-only debugging text — don't show it to users. When code is present, switch on it for your own localized UI: "same_location" = all waypoints within 25 m of each other (driver already at destination), no route requested. code is absent for ordinary SDK routing failures. | | onManeuverBannerPressed | { steps: RouteStep[] } | Fired when user taps the instruction banner. Use to open a bottom sheet with the full steps list. |

RouteStep type

interface RouteStep {
  instruction: string;       // "Turn left onto Main St"
  distanceMeters: number;
  durationSeconds: number;
  maneuverType: string;      // "turn", "merge", "roundabout", etc.
  maneuverModifier: string;  // "left", "right", "straight", etc.
  roadName: string;
  laneInstructions: {
    active: boolean;         // true = recommended lane
    directions: string[];    // ["straight"], ["left", "straight"]
  }[];
}

16 KB Page Size (Android 15+)

Google Play requires new apps/updates targeting Android 15+ (API 35+) to support 16 KB memory pages. This package addresses this via two independent mechanisms, covering two different Mapbox artifact groups — worth understanding both, since they're gated differently.

Already enabled by default, no configuration needed:

  • NDK 27 (27.0.12077973) — first NDK version with full 16 KB support
  • jniLibs.useLegacyPackaging = false — prevents .so compression, enables proper alignment
  • 64-bit ABI filters (arm64-v8a, x86_64) — the 16 KB requirement applies to 64-bit only

Mechanism 1 — com.mapbox.maps:*/com.mapbox.common:* (Maps/plugin/module/extension groups): always on, no flag.

The config plugin's addAndroidConfig unconditionally substitutes these to their -ndk27 variants via resolutionStrategy.dependencySubstitution (17 modules). MAPS_VER comes from mapboxMapsVersion (default "11.11.0"); COMMON_VER is auto-derived from it (Mapbox's synchronized versioning: 11.x.y24.x.y, same minor/patch — verified against Mapbox's own compatibility table). This has been on since before androidUseNdk27 existed and isn't gated by it — it runs every time regardless.

Mechanism 2 — com.mapbox.navigationcore:*: opt-in — androidUseNdk27.

Mapbox publishes a separate -ndk27 artifact variant of each com.mapbox.navigationcore:* dependency (the one this package itself declares in android/build.gradle) — but only starting from Navigation SDK 3.11.0 (this package defaults to 3.8.1, which predates it; confirmed from Mapbox's own changelog). Since switching to a nonexistent -ndk27 artifact fails the build outright (Could not resolve), this one is opt-in rather than default:

["@jacques_gordon/expo-mapbox-navigation", {
  "accessToken": "pk.xxx",
  "downloadsToken": "sk.xxx",
  "mapboxMapsVersion": "11.14.0",
  "mapboxNavigationVersion": "3.11.0",
  "androidUseNdk27": true
}]

Requirements when enabling this:

  • Pass both mapboxMapsVersion and mapboxNavigationVersion explicitly — don't rely on this package's Phase 1/Phase 2 auto-calculation for a version pair you haven't verified. Mapbox's own docs note that pairing Navigation/Maps versions incorrectly is easy to get wrong for anything below Navigation 3.16.0.
  • Use a mapboxNavigationVersion of 3.11.0 or later (and a correspondingly compatible mapboxMapsVersion — check Mapbox's Navigation SDK release notes for the exact tested pairing at your target version).

If you enable androidUseNdk27 with an unsupported version pair, Gradle will fail clearly with Could not resolve com.mapbox.navigationcore:...-ndk27:... — a hard failure, not a silent fallback, so you'll know immediately if the pairing is wrong.

In short: leaving everything at defaults gets you Mechanism 1 automatically (Maps/Common covered). Google Play flagging only libnavigator-android.so specifically means Mechanism 2 is what you still need — set androidUseNdk27: true with a verified >= 3.11.0 pairing.

See Android's 16 KB page size guide.


Changelog

5.0.3

Android: fixes the destination flag (and waypoint badges) rendering visibly offset from the point where the route line actually ends — confirmed by a real device screenshot. Two independent causes, both fixed.

  • Cause 1 — wrong coordinate source (the big one): markers were placed at the RAW request coordinates (the geocoded address the app passes in), but Mapbox snaps routes to the road network — the drawn route line ends at the SNAPPED point, which can sit tens of meters away from the raw geocode. Markers now use the Directions RESPONSE waypoints (per Mapbox's own docs: "input coordinates snapped to the road and path network, in the order of the input coordinates") — i.e. exactly where the route line starts/ends. Bonus correctness: response waypoints only contain leg-separating stops (the API itself excludes silent via-points), so intermediate badge numbering is right by construction. If a response carries no waypoints, markers fall back to the raw coordinates (the pre-5.0.3 placement) rather than disappearing. iOS needs no change: its markers go through the SDK's own waypoint pipeline, which is already route-derived.
  • Cause 2 — bitmap anchor geometry: the flag bitmap drew its pole at the LEFT edge with a 2dp empty margin below, while IconAnchor.BOTTOM anchors the bitmap's bottom-CENTER on the map point — so the pole base landed ~11dp right of and 2dp above the true point even when the coordinate was correct. The pole is now horizontally centered with its base flush against the bitmap's bottom edge (the exact anchor pixel), with the banner waving right of the pole top.

5.0.2

Two changes: (1) BOTH platforms — when the degenerate-route guard fires (driver already at the destination), the view now switches to a live free-drive map centered on the driver, instead of a whole-world view on Android and a permanently black surface on iOS; (2) build-tooling fix — the published build/index.js in 5.0.0/5.0.1 contained raw, uncompiled JSX, restored to proper compiled createElement output, byte-identical to the production-proven 4.0.2 artifact.

  • The problem, observed on real devices: 5.0.1's guard correctly rejects same-location trips (onRoutesFailed, code: "same_location") without starting navigation — but then nothing owns the screen. Android's always-present map stayed at world view (no trip session → no enhanced locations → the first-location camera centering never triggers); iOS was worse: its architecture creates NO view at all before a successful route, so the surface stayed permanently black — and honestly, the same was true for ANY route failure on iOS, this case just made it easy to reproduce.

  • The fix — Mapbox free-drive mode (passive session: live map + directional puck + following camera, no turn-by-turn) on both platforms:

    • Android: the guard branch immediately centers the camera on the trip's first coordinate (instant feedback, no GPS dependency), then starts a free-drive session (setNavigationRoutes(emptyList) + startTripSession() — the SDK's documented passive mode). Enhanced locations then animate the puck and the existing first-location logic switches the camera to live following. If a real trip is requested later, the normal route flow transitions free-drive → active guidance seamlessly (the standard SDK flow this package already uses).
    • iOS: a standalone NavigationMapView fed by the shared navigation's Combine publishers, plus tripSession().startFreeDrive() and a .following camera — the construction pattern is verified verbatim against Mapbox's own v3.20.1 example (Examples/AdditionalExamples/Examples/Custom-Navigation-Camera.swift), and all three APIs are confirmed public at the exact vendored tag (SessionController.startFreeDrive(), NavigationMapView.init(location:routeProgress:...), NavigationController's locationMatching/routeProgress publishers). The fallback map is torn down automatically when a real route later presents the full drop-in UI (the SDK's own examples perform this exact free-drive → active-guidance transition), and its passive session is set to idle if the view is removed while the fallback is up — scoped so active-guidance teardown behavior is untouched. Default directional-arrow puck; custom puck props intentionally don't apply to the fallback.
  • onRoutesFailed still fires exactly as in 5.0.1 (code: "same_location") — the free-drive map is what the USER sees; the event is what the APP reacts to (e.g. its own localized "you have arrived" banner over the map). Reminder: always handle onRoutesFailed — this whole case was initially mistaken for a regression on a real device precisely because no handler was wired.

  • Third change — professional destination and waypoint markers (BOTH platforms): the final destination now shows a checkered "finish" flag on a pole, and intermediate stops show numbered circular badges (1, 2, 3…) — the Waze-style clarity, drawn entirely in code (no bundled image assets, matching how every icon in this package's Android UI is already drawn; same dark-slate/white palette on both platforms).

    • iOS: rendered through the SDK's OWN waypoint pipeline via the three NavigationViewControllerDelegate customization hooks (shapeFor waypoints:legIndex:, waypointCircleLayerWithIdentifier:, waypointSymbolLayerWithIdentifier:) — every signature and the whole approach verified verbatim against Mapbox's own v3.20.1 Custom-Final-Waypoint example. Using the SDK pipeline means markers automatically follow rerouting, and already-passed stops fade out via the SDK-maintained legIndex/completed state. The flag image is registered with the map style and automatically RE-registered after every style load (style changes wipe registered images — including this package's own optional mapStyle load), with the image-registration observer cancelled on teardown. Style operations use try? (the SDK example's own try! deliberately not copied — this package never crashes on style ops).
    • Android: rendered with the Maps SDK's annotation plugin (bundled with the existing com.mapbox.maps:android dependency — no new artifact): flag bitmap at the final coordinate (bottom-anchored so the pole base sits on the point), numbered badge bitmaps at intermediate leg-separating stops (respecting waypointIndices semantics exactly as fetchRoutes sends them — silent via-points get no badge). Markers are (re)built when routes become active and fully cleared on navigation teardown; the whole marker path is wrapped so a rendering failure can never break navigation itself (cosmetic-only guarantee).
  • What happened: the tsconfig.json restored from git history in 4.0.3 (after being missing since 2.1.3, which had silently broken npm run build) carried "jsx": "react-native" — a mode that PRESERVES JSX syntax in the emitted .js, expecting a later Babel pass. Every rebuild from 4.0.3 onward therefore shipped build/index.js with a raw <NativeView .../> in it. The original pre-2.1.3 artifact had been generated with the classic transform (plain React.createElement calls) — meaning the restored tsconfig never matched whatever originally produced the known-good output.

  • Why nothing visibly broke: Metro/babel-preset-expo transpiles JSX in node_modules .js files, so Expo consumers (including the real iOS device tests that succeeded on 5.0.0/5.0.1) parsed it fine. But shipping preserved JSX in a package's compiled entry point is wrong: any consumer whose bundler does not run a full JSX transform over node_modules would fail to parse the package outright.

  • Fix: tsconfig.json now uses "jsx": "react" (classic createElement transform; src/index.tsx already imports React). Verified in the strongest way available: the regenerated build/index.js is byte-for-byte identical to the build/index.js shipped in 4.0.2 (the artifact that had been proven in production since 2.1.3-era builds) — confirmed by direct file comparison against the immutable npm tarball.

  • No source, native, or API changes of any kind in this release.

5.0.1

iOS: fixes an intermittent hard crash (EXC_BREAKPOINT/SIGTRAP) when the navigation screen is closed and reopened — the Navigation SDK enforces a single MapboxNavigationProvider per process, and this package was creating one per view instance. Found from a real production crash log after 5.0.0's on-device success (the map now renders and navigates — first confirmed working iOS navigation of this package).

  • The crash, from a real .ips crash report: main-thread stack reads, verbatim: closure #1 in static MapboxNavigationProvider.checkInstanceIsUnique()Locked.withLockMapboxNavigationProvider.init(coreConfig:) ← this package's init block. checkInstanceIsUnique() is the SDK's own internal guard: it deliberately traps the process when a second provider instance is created while another is still alive. Since this package created a provider per view instance, closing and quickly reopening the navigation screen (remount before the previous view deallocated) crashed the app — intermittently, because it depended on deallocation timing. This is also, in hindsight, exactly why reference implementations keep their provider in a static let.
  • Fix: one static let sharedNavigationProvider, lazily created on first use, shared by every view instance. Teardown (removeFromSuperview) intentionally does NOT touch it — it lives for the process lifetime, which is precisely what the SDK's uniqueness rule wants.
  • Structural bonus — the 4.0.8 black-screen race fix becomes obsolete by construction: the shared provider is available synchronously, so mapboxNavigation is assigned in init() before any prop setter can possibly run. The DispatchQueue.main.async deferral and its provider-ready catch-up call are removed entirely; 5.0.0's coalesced prop-setter scheduler is now the single trigger path into fetchRoutes(), in every ordering.
  • Second crash report, honestly triaged as NOT yet attributable: a same-day SIGSEGV (CFStringFind on a NULL string during CALayer drawing, drawLayer:inContext:_firstCommitBlock) has all its meaningful frames in unsymbolicated app-binary offsets — it cannot be attributed to this package or ruled out without the matching build's binary/dSYM. It may or may not be a downstream effect of the double-provider state this release fixes. To be reassessed after this fix ships: if it recurs, symbolication against that exact build will be needed.
  • Second fix in this release — navigation arrow instead of a plain blue dot (iOS): during active navigation the location puck appeared as the basic blue location dot with no heading indication (confirmed by a real device screenshot). Root cause, verified verbatim at v3.20.1: NavigationMapView.puckType's own SDK default is .puck3D(.navigationDefault) — the standard Mapbox directional-arrow puck (PuckConfigurations.swift defines the navigationDefault statics) — but this package's applyPuckSettings() fallback branch actively REPLACED that default with .puck2D(Puck2DConfiguration()), the plain dot, whenever no custom puck prop was set. The fallback now explicitly sets .puck3D(.navigationDefault) — restoring the SDK's arrow, and also correctly resetting to it when a custom puck prop is later cleared. Custom navigationPuckColor/navigationPuckImagePath/navigationPuck3DModelPath behavior is unchanged.
  • Third change in this release — new prop showEndOfRouteFeedback (iOS-effective): NavigationViewController's built-in end-of-route screen ("You have arrived" + trip-rating stars) can now be disabled by passing showEndOfRouteFeedback={false}. Default is true — the SDK's own behavior, so nothing changes for existing consumers unless they opt out. Verified verbatim at v3.20.1: NavigationViewController.showsEndOfRouteFeedback is a public settable property (forwards to arrivalController.showsEndOfRoute); note from the same source that assigning showsReportFeedback would overwrite it — this package never assigns that. Applied at controller construction AND live on an already-presented controller (the setter is real, so toggling mid-navigation works). onArrival fires regardless of this setting. Declared as a stored no-op on Android for cross-platform prop parity (its custom-built UI has no equivalent screen).
  • Fourth fix in this release — camera now always starts centered on the driver (iOS): a real device test surfaced a case where the map presented over a default world region (mid-ocean) instead of the driver's position: a degenerate route whose destination equals the driver's current location (0 m, instant arrival) — active guidance never entered its follow-the-user camera state. presentNavigationViewController now explicitly calls navigationCamera.update(cameraState: .following) right after presentation — verified verbatim at v3.20.1 (NavigationCamera.update(cameraState:) is public; .following is "the camera is following user position"), and it is the exact call the reference implementation uses for its own recenter button, so it is harmless in the normal flow where the SDK enters following on its own.
  • Fifth change in this release — degenerate-route guard (BOTH platforms): requesting a trip whose destination is where the driver already is produced a 0 m route, a 1-second navigation session, an instant arrival screen, and (on iOS) the mispositioned camera fixed above. fetchRoutes() on both platforms now detects this case up front and emits a clear, catchable onRoutesFailed (message: "Origin and destination are the same location (all waypoints within 25 m) - nothing to navigate", byte-identical on both platforms) instead of starting navigation — the consuming app can show its own "you are already at your destination" UI from that event. Deliberately checks ALL coordinates against the first, not just origin vs destination: a legitimate round trip (start → distant via point → back to start) also has origin == destination and is NOT blocked. Distance math uses each platform's own standard API (CLLocation.distance(from:) / android.location.Location.distanceBetween) — no new dependency. Localization: the event carries a stable machine-readable code: "same_location" alongside the English-only message — the consuming app should switch on code to display its own localized text (this package's own strings are never translated; the language prop only localizes what the Mapbox API generates).
  • Sixth change in this release — native upcoming-steps panel on Android: on iOS, tapping the maneuver banner opens the SDK's own built-in list of all upcoming steps; Android's custom UI had no native equivalent (the tap only emitted onManeuverBannerPressed). Android now shows a native bottom panel (scrim + scrollable list of each step's instruction and distance), toggled by the same banner tap. Coexistence preserved on both platforms: the onManeuverBannerPressed event still fires on every tap exactly as before — apps rendering their own JS sheet from the payload keep working unchanged. Panel details: hard-capped at 86% of the view height (custom onMeasure clamp — wraps short lists, scrolls long ones); dismissed by tapping the scrim, tapping the banner again, or any navigation teardown; all visible text comes from the Directions API response (already localized via language) plus numeric-only distance labels (unit system follows voiceUnits) — this package still hardcodes no user-facing prose. Built from framework widgets only, no new dependency. iOS's built-in list deliberately NOT capped at 86%: its pin-to-screen-bottom constraint is internal to the SDK and required-priority (verified verbatim at v3.20.1, TopBannerViewController.stepsContainerShowConstraints) — adding a competing external constraint would create an unsatisfiable-constraints conflict that UIKit resolves by breaking an arbitrary constraint. Not safely possible without replacing the SDK's steps UI wholesale; documented rather than risked.
  • Seventh change — navigationPuck3DModelPath crash hardening (Android): a crash was reported with this prop on Android even with a verified-valid .glb file. Prior hardening (file existence + glb magic-number validation for file:///absolute paths, try/catch around every SDK call, 2D fallback) already covers the Kotlin-reachable failure modes — but asset:// paths were passed through with no validation at all; that gap is now closed (asset existence + the same glb magic-number check, via the standard AssetManager API). Honest status: if the crash persists with a valid model after this, the failure is inside Mapbox's native model loader/renderer (not catchable from Kotlin) and needs the device's crash log (logcat/tombstone) to pinpoint — please capture one. Note that as of this release both platforms show a proper directional-arrow puck by default, with no custom model needed (iOS: SDK's .puck3D(.navigationDefault); Android: SDK's default bearing icon), so the custom 3D model is now purely optional for the arrow use case.
  • Android otherwise unchanged: the Android SDK's MapboxNavigationProvider.create() already manages a process-wide instance internally; no equivalent crash exists there, and Android's default puck already shows its own directional icon.
  • Verification status: crash mechanism and stack are from a real production crash log (strongest possible evidence); the fix pattern is the SDK's own documented usage. As always, no local Swift toolchain — syntax balance checked, and the change uses only constructs already compiled in prior builds (static let, same initializer call). Real device retest (open/close/reopen the navigation screen repeatedly) is the confirmation step.

5.0.0

Both platforms: route-affecting props now re-trigger route calculation when their value changes. Previously, only coordinates did — changing navigationProfile, language, voiceUnits, excludeTypes, waypointIndices, maxHeight or maxWidth after mount was silently ignored until the next coordinates change. Major version bump because this is a real behavior change consumers can observe (a route may now recalculate in situations where it previously wouldn't).

  • Concrete bug this fixes: changing the app language mid-session left voice guidance and banner instructions in the OLD language — the Directions API returns instructions already translated inside the route response itself, so without a new route request, the new language value was stored but never used. Same mechanism affected all the other route-affecting props (e.g. switching navigationProfile from driving to walking did nothing).
  • Props that now re-trigger fetchRoutes() on change (each one verified to be actually read by both platforms' route-request builders): coordinates (as before), waypointIndices, navigationProfile, language, voiceUnits, excludeTypes, maxHeight, maxWidth.
  • Props deliberately EXCLUDED from re-triggering (verified to have no link to the route request): mapStyle (map appearance only — applied at presentation time, unchanged behavior), mute (live-applied already, no route impact), useMapMatching/customRasterTileUrl/customRasterAboveLayerId (stored-but-unused on BOTH platforms since before 4.0.2 — wiring dead props to refetch would be pure noise), and all color/puck/UI props.
  • Two safeguards against regressions, implemented identically on both platforms:
    1. Equality guard in every affected setter — re-delivering an identical value never triggers a refetch.
    2. Coalescing — all triggers within one main-loop turn merge into a single deferred fetchRoutes() call. This matters most at initial mount, where React Native delivers every prop in one batch: without coalescing, mounting the view would fire up to 8 simultaneous route requests. With it, exactly one. On iOS this also means the initial fetch runs after init()'s provider-creation block (queued earlier on the same main queue), closing the 4.0.8 startup-ordering gap from a second, independent angle — and 4.0.8's own provider-ready catch-up call now goes through the same scheduler rather than calling fetchRoutes() directly (caught in this release's own pre-publish audit: a direct call would have fired a first request at mount that the props' scheduled fetch immediately cancelled and re-issued — two network requests per mount, one wasted). On Android the scheduler uses a main-Looper Handler rather than View.post() — deliberate: View.post() defers queued work until the view is attached, and props are delivered before attachment.
  • Cross-platform parity preserved by construction: the exact same prop list, guards, and coalescing semantics ship on iOS and Android in this same release — at no point does one platform refetch where the other doesn't. (This was the explicit reason this change was previously rejected as an iOS-only fix — see the analysis that led to it.)
  • What did NOT change: fetchRoutes()'s own internal guards (needs ≥2 coordinates and an initialized navigation instance) still make any scheduled fetch a harmless no-op when prerequisites aren't met; Android's mapboxNavigation is still created synchronously in init{}; iOS keeps the 4.0.8 provider-ready catch-up call.
  • Verification status: Kotlin/Swift both syntax-checked (brace/paren balance), all call sites of fetchRoutes() audited on both platforms (it is now reachable only via the scheduler, plus iOS's init catch-up), setter wiring re-verified against both Module definition files, and equality semantics confirmed against both languages' structural-equality rules for the exact declared property types. As always: no local native toolchain available — a real build on each platform is the final confirmation step.

4.0.8

iOS: fixes the black-screen-after-successful-build failure — a startup race condition that silently prevented the route request (and therefore the entire navigation UI) from ever starting. Found via a full line-by-line audit of ios/ExpoMapboxNavigationView.swift prompted by a real on-device test: the 4.0.7 build compiles, launches, and shows a permanently black view where the map should be, with a Console.app capture showing zero Mapbox activity of any kind.

  • Root cause (race condition, silent): init() deferred the creation of MapboxNavigationProvider to a DispatchQueue.main.async block, but React Native/Fabric delivers initial props — including coordinates — synchronously, in the same main-runloop turn that creates the view, i.e. before that async block runs. The initial setCoordinates() call therefore reached fetchRoutes() while mapboxNavigation was still nil, and fetchRoutes()'s guard bailed out silently. Nothing ever re-triggered the fetch, so no route request was ever made, NavigationViewController was never constructed, and the view stayed permanently empty. This exactly matches the observed evidence: no crash, no error, no Mapbox log output at all — the SDK was never asked to do anything. Fix: the async init block now calls fetchRoutes() after the provider is ready — a no-op unless valid coordinates already arrived, covering both orderings (props-before-provider and provider-before-props) deterministically. No new SDK API involved; pure sequencing fix.
  • Second fix (stale-task guard): Swift Task cancellation is cooperative — cancel() only sets a flag; the task body keeps running unless it checks. fetchRoutes() cancels any in-flight request before starting a new one (and removeFromSuperview cancels too), but the task body never checked the flag — so a superseded/cancelled request could still fire stale onRoutesReady/onRoutesFailed events and present a NavigationViewController for an outdated route (or on a torn-down view). Now checks Task.isCancelled after the route result arrives and bails out if cancelled. Confirmed standard Swift concurrency semantics, not SDK-specific.
  • Third fix (Apple containment contract): child view-controller embedding used a non-canonical call order (addSubview before addChild, didMove last, containment skipped entirely when no parent VC found at the moment of embedding). Restructured to Apple's documented canonical order from "Creating a custom container view controller": addChildaddSubview → constraints → didMove(toParent:). Same conditional-parent fallback behavior as before; only the ordering contract changed.
  • Audit scope, for the record: every remaining SDK call in the file was already source-verified against mapbox-navigation-ios v3.20.1 during the 4.0.7 pass (RoutingProvider.calculateRoutes signature/return type, NavigationRoutes.mainRoute/alternativeRoutes, NavigationRoute.route, Route.distance/expectedTravelTime, RouteOptions.maximumHeight/maximumWidth types, RoadClasses init semantics, all three NavigationViewControllerDelegate signatures). This pass additionally reviewed every function for runtime-safety issues (force unwraps, optional handling, event-thread correctness) — the three items above were the only confirmed defects found. Known non-issues left deliberately untouched: Locale.regionCode is deprecated (iOS 16+) but functional — cosmetic warning only; one MapboxNavigationProvider is created per view instance (fine for this package's one-navigation-view-at-a-time usage; would need a shared provider only if multiple simultaneous navigation views ever became a requirement).
  • Verification status, honestly stated: the race-condition mechanism is established from the code path plus the real device evidence (black screen + zero Mapbox log output), and the fix logic is deterministic — but as with every iOS change in this package, no local Swift compilation is possible (no Mac); brace/paren balance and non-ASCII scans were run, and the changed lines use no API not already compiled successfully in the 4.0.7 build. A real build + on-device test is still the final confirmation step.
  • Companion audit of every other iOS-side file, same pass — no confirmed defects found, nothing changed: ios/ExpoMapboxNavigationModule.swift (all 7 Events(...) names match the view's EventDispatcher properties exactly; every Prop closure type matches the TS prop types; Name("ExpoMapboxNavigation") matches both requireNativeViewManager and expo-module.config.json), ExpoMapboxNavigation.podspec (version/deployment-target/source-file declarations all consistent), ios/patch-mapbox-library-evolution.sh (defensive, isolation-tested per its own 3.4.6 entry; note that after 4.0.3's retraction its actual usefulness against the original crash is unconfirmed — kept because it is harmless and was part of the only combination verified crash-free in production), and ios/fetch-xcframeworks.sh (production-proven — two theoretical-only smells noted for the record, deliberately NOT changed since the script is verified working in real EAS builds: it assigns to TMPDIR, a variable macOS exports globally, which child processes then inherit — harmless here because the directory exists for the script's whole lifetime; and found=$(find ... | head -1) under set -o pipefail could in principle propagate a SIGPIPE exit status — never observed in practice with these small artifact listings).

4.0.7

Two iOS fixes: the one remaining Swift compile error from the 2026-07-15 EAS build (RoadClasses? optional unwrap, line 243), plus a silent-failure bug found by a full-file audit done to make sure no further compile errors are hiding behind it: the didArriveAt delegate method had the v2-era -> Bool signature, which compiles but never fires under v3 — onArrival would never have been emitted.

  • Context: the previous build (first with 4.0.6's fixes) failed with exactly ONE remaining error - value of optional type 'RoadClasses?' must be unwrapped at ExpoMapboxNavigationView.swift:243. Notably, this line sits in the same fetchRoutes() function as 4.0.6's six errors, and the compiler only reported it AFTER those were fixed - meaning everything after line 243 in that function had still never been type-checked. Rather than fix one line and risk a third failed build revealing the next hidden error, this release was preceded by a full audit of both Swift files against the exact vendored SDK sources (mapbox-navigation-ios tag v3.20.1, which vendors MapboxDirections inside Sources/MapboxDirections/ - not main, not assumed).
  • Fix 1 - RoadClasses (the compile error): RoadClasses(descriptions:) is a failable initializer - public init?(descriptions: [String]), quoted verbatim from v3.20.1's RoadClasses.swift, whose default: case is return nil (one unrecognized string nils the whole thing; recognized values: toll, restricted, motorway, ferry, tunnel, hov2, hov3, hot, unpaved, cash_only_tolls). The 4.0.2-era code assigned it directly to the non-optional roadClassesToAvoid (open var roadClassesToAvoid: RoadClasses = [], verified at the same tag). Fixed with optional binding: valid list applied, any invalid entry means no exclusions (graceful no-op, never a crash) - consistent with this package's existing bad-prop-input convention. The 4.0.2 changelog claimed this initializer was "confirmed" from source - the initializer's existence was confirmed, its failability was missed. That gap is exactly what this release's audit process was designed to catch.
  • Fix 2 - didArriveAt (the silent-failure bug, no compiler diagnostic): the audit's delegate-signature diff against v3.20.1's NavigationViewControllerDelegate.swift found our navigationViewController(_:didArriveAt:) declared -> Bool (the SDK v2 shape). The v3 requirement returns Void (both the protocol requirement and its default implementation, quoted verbatim). A mismatched signature still compiles - it's just an extra unrelated method - but the SDK dispatches to its own default no-op, so onArrival would never have fired even once the app built and ran. Removed -> Bool/return true (multi-leg continuation is SDK-handled in v3; the v2 return value has no v3 equivalent).
  • Audit coverage, stated precisely: everything before line 243 and every other function was already validated by the real compiler in the last build (its single-error output proves the checker got that far). The never-type-checked zone (lines 244-285) was verified API-by-API against v3.20.1 sources: maximumHeight/maximumWidth (Measurement<UnitLength>?), routingProvider() (@MainActor protocol, returns RoutingProvider), calculateRoutes(options:) (-> Task<NavigationRoutes, Error>, accepts NavigationRouteOptions via its RouteOptions superclass - all verified at-tag), NavigationRoutes.mainRoute/.alternativeRoutes/NavigationRoute.route, Route.distance/expectedTravelTime. The remaining two delegate signatures (didUpdate progress and navigationViewControllerDidDismiss) match v3.20.1 exactly. No other mismatch found.
  • Still not verified: an actual successful build + on-device render. This is compile-correctness and callback-wiring work verified against source; the map rendering end-to-end remains to be confirmed by the next EAS build.

4.0.6

Fixes 3 real Swift compile errors in ios/ExpoMapboxNavigationView.swift - the first actual Xcode compilation this file has ever gone through (no Mac/Xcode toolchain was available for any of 4.0.0-4.0.5; 4.0.5 fixed autolinking discovery, which let a real archive build reach this file's compiler pass for the first time). Confirmed from a real EAS Build fastlane log, not guessed.

  • Context: with 4.0.5 installed, the consuming app's iOS archive build got past autolinking (the ExpoMapboxNavigation pod was found and CocoaPods started compiling it - direct confirmation the 4.0.5 fix works) and failed for a new, different reason: ** ARCHIVE FAILED ** / CompileSwift normal arm64 ... ExpoMapboxNavigationView.swift, with 7 real compiler errors.
  • wp.separatesLegs = ... - "cannot assign to property: 'wp' is a 'let' constant" (line 218, introduced in 4.0.2's waypointIndices fix): verified against mapbox-navigation-ios's own current documentation and examples that in Navigation SDK v3 (via MapboxNavigationCore), Waypoint is used as a struct (e.g. var userWaypoint = Waypoint(location:); userWaypoint.heading = ... in Mapbox's own v3 examples) - not the class from the standalone mapbox-directions-swift package that 4.0.2's own changelog entry had assumed and never got to compile-check. Fixed by declaring var wp instead of let wp in the waypoint-mapping closure - a one-word fix once the real type's value semantics were confirmed.
  • options.distanceUnit = ... ? .mile : .kilometer - "cannot infer contextual base in reference to member 'mile'" (line 225, also from 4.0.2): distanceUnit does not exist as a property on RouteOptions/NavigationRouteOptions at all - confirmed directly from mapbox-directions-swift's own DirectionsOptions.swift source. The real property is distanceMeasurementSystem: MeasurementSystem, with cases .imperial/.metric (not .mile/.kilometer, which don't exist anywhere on this type). Fixed to options.distanceMeasurementSystem = resolveVoiceUnits() == "imperial" ? .imperial : .metric.
  • options.profileIdentifier = .automobileAvoidingTraffic (and .automobile/.walking/.cycling) - "cannot infer contextual base in reference to member '...'" (lines 228-232, also from 4.0.2): these 4 errors were not independent bugs - confirmed directly from mapbox-directions-swift's own ProfileIdentifier.swift source that automobile, automobileAvoidingTraffic, cycling, and walking are all real, correctly-named static members (backed by raw values "mapbox/driving", "mapbox/driving-traffic", "mapbox/cycling", "mapbox/walking"). These 4 lines were left completely unchanged - they were cascading/spurious diagnostics produced by the real, preceding distanceUnit error confusing the type-checker for the rest of that scope, a well-known Swift compiler behavior. Fixing the one real error above was expected to (and, per this same build's compiler behavior in wholemodule mode reporting every real error in one pass, is believed to) resolve all 4.
  • Audited, not touched: the rest of this file (mapStyle/StyleURI, RoadClasses, maximumHeight/maximumWidth as Measurement<UnitLength>, Puck2DConfiguration/Puck3DConfiguration puck setup) was cross-checked against the same real Mapbox sources and left alone - no errors were reported against any of it in the same compiler pass (SWIFT_COMPILATION_MODE = wholemodule means the whole file is type-checked in one pass, so an error there would have surfaced in the same log if one existed).
  • **Verification method, honestl