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.1.7

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, 33 props, full feature and API parity
  • NDK 27 + 16 KB page size compliant (Android 15+)

✅ Tested on iOS: real EAS production build, real device

✅ Tested on Android: real EAS production build, real device


Installation

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

1. Setup @rnmapbox/maps first

["@rnmapbox/maps", {
  "RNMapboxMapsImpl": "mapbox",
  "RNMapboxMapsVersion": "11.11.0"
}]

RNMapboxMapsVersion must exactly match this package's mapboxMapsVersion (Android) / iosMapboxMapsVersion (iOS, 5.0.5+) — see Plugin Options below.

Don't set RNMapboxMapsDownloadToken here — @rnmapbox/maps' own plugin marks it deprecated (its own source prints a warning if you set it) in favor of the RNMAPBOX_MAPS_DOWNLOAD_TOKEN environment variable, which its Maven repo block already reads as a fallback natively. You don't need to set that env var either: this package's own downloadsToken (step 2 below) already provisions the same MAPBOX_DOWNLOADS_TOKEN Gradle property that @rnmapbox/maps' downloads also authenticate through (5.0.7+) — one token, one place to set it.

2. Add this plugin

["@jacques_gordon/expo-mapbox-navigation", {
  "accessToken": "pk.your_public_token",
  "downloadsToken": "sk.your_secret_token",
  "mapboxMapsVersion": "11.11.0"
}]

mapboxMapsVersion is Android-only and editable. iOS's SDK versions default to 3.20.1/11.20.2 but are also editable (5.0.5+) via the separate iosMapboxNavigationVersion/iosMapboxMapsVersion options — most apps won't need to touch these; see Plugin Options.

3. iOS only — enable static frameworks

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

Hit the DYLD 4 Symbol missing: GestureType.singleTap launch crash? See The GestureType.singleTap launch crash in iOS Architecture below — there is currently no confirmed, actionable fix to apply here at setup time.

This package's default, confirmed-working iOS SDK version set is nav 3.20.1 / MapboxNavigationNative 324.20.2 / MapboxCommon 24.20.2 / MapboxMaps 11.20.2 (interlocked — see the iosMapboxNavigationVersion/iosMapboxMapsVersion rows in Plugin Options below to override).


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. Actively used on BOTH platforms: written to ~/.netrc at prebuild so pod install can fetch the vendored iOS Navigation binaries, and (5.0.6+) written to android/gradle.properties as MAPBOX_DOWNLOADS_TOKEN so the api.mapbox.com Maven repo authenticates — making this package independent of @rnmapbox/maps' deprecated RNMapboxMapsDownloadToken option (a non-empty token already in gradle.properties is respected; the RNMAPBOX_MAPS_DOWNLOAD_TOKEN env var serves as fallback if this option were ever absent). | | mapboxMapsVersion | ✅ | "11.11.0" | Android only. Drives the com.mapbox.maps:android and (indirectly, via mapboxNavigationVersion's auto-calculation) com.mapbox.navigationcore:* Gradle dependency versions. For iOS, use iosMapboxMapsVersion instead (5.0.5+) — the two platforms' correct pairings differ, so they are deliberately independent options. | | mapboxNavigationVersion | — | auto-calculated (Android only) | Android only (reactivated in 2.3.7). 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 — a best-effort approximation, not a guarantee. For iOS, use iosMapboxNavigationVersion instead (5.0.5+). | | iosMapboxNavigationVersion | — | "3.20.1" | iOS only (5.0.5+). Git tag of Mapbox's mapbox-navigation-ios-build-artifacts repo that the fetch script clones at pod install (the vendored Navigation binaries). Used EXACTLY as given — no auto-calculation on iOS, ever: you must verify yourself that it pairs with iosMapboxMapsVersion (check mapbox-navigation-ios release notes for the MapboxMaps version each release was built against). A bad pair = link errors or runtime ABI crashes. Must be exact x.y.z — anything else fails the prebuild loudly. | | iosMapboxMapsVersion | — | "11.20.2" | iOS only (5.0.5+). Exact MapboxMaps version the podspec declares (resolved from CocoaPods trunk — not vendored; it arrives via @rnmapbox/maps). MUST equal the RNMapboxMapsVersion you set in @rnmapbox/maps' plugin config — a mismatch fails pod install loudly with a version conflict (a protection, not a bug). Must pair with iosMapboxNavigationVersion (see above). The defaults together are the confirmed-working set: nav 3.20.1 / MapboxNavigationNative 324.20.2 / MapboxCommon 24.20.2 / MapboxMaps 11.20.2. | | androidColorOverrides | — | {} | Override Mapbox native resource colors on Android. |


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, maxWidth, or useMapMatching (5.0.4+) 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, navigationViewportPadding*, 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. | | navigationViewportPaddingTop / Left / Bottom / Right | number | Android: 180/40/300/40 dp — iOS: 20/20/40/20 pt (each platform's own pre-5.1.0 default, unchanged unless set) | Padding around the camera's framed viewport, applied live (no route recalculation). Bottom is the one most apps touch — a larger value pushes the navigation icon further up from the screen's true bottom edge (this is what gives the Waze-style ~30%-from-bottom position). Maps directly to EdgeInsets (Android) / NavigationMapView.viewportPadding (iOS) — both native SDK mechanisms, not custom. | | useMapMatching | boolean | false | Implemented on both platforms as of 5.0.4. Uses the Map Matching API instead of Directions — for routes that exactly follow the given coordinates. Changing it re-triggers the route request. excludeTypes/maxHeight/maxWidth are Directions-only parameters and are ignored in this mode (API limitation, not a bug). | | customRasterTileUrl | string | — | Implemented on both platforms as of 5.0.4. Custom raster tile URL with {x}/{y}/{z} placeholders, injected into the navigation map's style and automatically re-applied on style changes. Clear it to remove the overlay. iOS: applies to the drop-in navigation map (not the pre-route free-drive fallback). | | customRasterAboveLayerId | string | — | Style layer ID above which the raster layer is inserted. If omitted or not present in the current style, the raster goes on top. | | 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. 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. iOS (5.0.4+): fully functional as a view prop — applied via the SDK's ManeuverView appearance (primary + secondary arrow colors) at route presentation time. | | 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" | Android only — 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. Not available on iOS: the drop-in's SpeedLimitView is positioned by internal, required-priority SDK constraints with no public repositioning API at the pinned SDK version — see the 5.0.4 changelog for the full rationale. | | showEta | true | Whether the ETA/duration/distance bottom bar is shown once navigation starts. Android: applied live. iOS (5.0.4+): swaps the drop-in's bottom banner (ETA bar + cancel button) for an empty one — construction-time, changes apply on the next route presentation. | | 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 implementation notes, beyond what's already in the tables above:

  • 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.
  • maneuverTurnIconColor, etaBarBackgroundColor, etaTextColor, iconButtonColor, iconButtonMutedColor (5.0.4+) — same construction-time caveat as the maneuver background colors above (read once, at route presentation). Applied via ManeuverView/BottomBannerView/TimeRemainingLabel/DistanceRemainingLabel/ArrivalTimeLabel/FloatingButton UIAppearance targets — every one verified verbatim against Mapbox's own DayStyle.swift at v3.20.1, not guessed.
  • 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/ETA/button 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.
  • speedLimitPosition is the only color/UI prop still not implemented on iOS (no-op, stored only) — SpeedLimitView's position is fixed as part of NavigationViewController's drop-in layout, with no confirmed public repositioning API. Would require building a custom top/bottom banner (see Mapbox's "Build a custom UI" guide) rather than the drop-in NavigationViewController — a substantially larger undertaking than styling, outside this package's current architecture.

Route loading screen

By default, the map appears first and the route line pops in a moment later once the Directions API responds. To cover that gap:

| Prop | Default | Description | |---|---|---| | showRouteLoadingOverlay | false | Covers the view with a native loading screen (opaque background + spinner) from mount until the first route is drawn. Fades out on the first success or failure — one-shot, never re-shown for later refetches on the same mounted view. Ignored when loadingScreen is set. | | loadingOverlayColor | "#1E2433" | Background color of the native loading screen. Only relevant with showRouteLoadingOverlay. | | loadingScreen | — | Your own React Native component/element to show instead of the built-in native screen — e.g. your app's branded splash. Same one-shot timing (mount → first onRoutesReady/onRoutesFailed). Takes precedence over showRouteLoadingOverlay. Implemented entirely in JS (renders above the native view) — works identically on both platforms with no native code involved. |

// Built-in native screen
<MapboxNavigationView
  showRouteLoadingOverlay
  loadingOverlayColor="#1E2433"
  {/* ...other props */}
/>

// Your own component instead
<MapboxNavigationView
  loadingScreen={<MyBrandedSplash />}
  {/* ...other props */}
/>

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"]
  }[];
}

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-artifacts — ios/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. See The GestureType.singleTap launch crash below for the retracted forceStaticLinking claim previously documented here.
  • The iOS SDK version defaults to whatever this npm package version ships, but is configurable per-app (5.0.5+) via the iosMapboxNavigationVersion/iosMapboxMapsVersion plugin options — see Upgrading the vendored iOS SDK version below.

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

As of 5.0.5, you likely don't need this section at all. If you just want to try a newer (or older) Navigation/Maps SDK pairing in your own app, set the iosMapboxNavigationVersion and iosMapboxMapsVersion plugin options in your app.json — see the Plugin Options table above. No new npm release of this package required; the versions are resolved dynamically at your own expo prebuild / pod install. You are still responsible for verifying the pairing yourself — there is no auto-calculation on iOS (unlike Android's mapboxNavigationVersion), and a bad pair means link errors or runtime ABI crashes.

This section is for maintainers of this package bumping the shipped defaults — the versions a consumer gets if they set neither plugin option. 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 default:

  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 the default fallback in all three places that read the (optional, prebuild-generated) .mapbox-ios-versions.json — they must move together:
  3. Update s.platforms/IPHONEOS_DEPLOYMENT_TARGET in the podspec if the new MapboxMaps version raises its own minimum deployment target.
  4. 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.
  5. 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.
  6. 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.

CocoaPods sunset (Dec 2, 2026) — contingency plan

Confirmed fact (verified directly against Mapbox's own release notes and install guide, not a secondary summary): "Mapbox will end support for CocoaPods in December 2026. After that date, new versions of the Mapbox Maps SDK for iOS will not be published to CocoaPods." — mapbox-maps-ios release notes, install guide. CocoaPods trunk itself also goes fully read-only around the same date (no new versions of ANY pod, from any maintainer).

What is NOT at risk: the vendored Navigation frameworks (ios/fetch-xcframeworks.sh) never went through CocoaPods trunk at all — they're fetched by git clone-ing Mapbox's own mapbox-navigation-ios-build-artifacts repo directly. That mechanism is unaffected by the sunset.

What IS at risk: ExpoMapboxNavigation.podspec's s.dependency 'MapboxMaps', mapbox_maps_version line. It resolves MapboxMaps from CocoaPods trunk — via @rnmapbox/maps's own CocoaPods dependency on it, deduplicated by pod name. After Mapbox stops publishing to trunk, whatever MapboxMaps version is last available there becomes a hard ceiling for iosMapboxMapsVersion — existing trunk-published versions stay installable forever (read-only, not deleted), but nothing newer will ever appear via this path.

The actual trigger to watch is NOT Mapbox's own December 2026 date — it's whether @rnmapbox/maps itself migrates off CocoaPods. As of this writing, @rnmapbox/maps's own install docs (ios/install.md) make no mention of Swift Package Manager, and no migration announcement has been found. As long as @rnmapbox/maps keeps resolving MapboxMaps via CocoaPods, our current mechanism keeps working exactly as-is (just capped at whatever version was last on trunk when Mapbox stops publishing).

Why we can't preemptively vendor MapboxMaps ourselves today: this was already tried once (see the "REVERTED" section at the top of ios/fetch-xcframeworks.sh) and confirmed to break CocoaPods' automatic "[CP] Copy XCFrameworks" build phase generation when overriding @rnmapbox/maps's own MapboxMaps dependency via a :podspec => Podfile override — while @rnmapbox/maps ALSO still pulls its own copy from trunk, that's two copies of the same symbols, guaranteed duplicate-symbol link errors.

The concrete plan for when the trigger fires (@rnmapbox/maps drops CocoaPods, or Mapbox's trunk freeze date arrives and we need a version newer than what's frozen there): the two-copies conflict above disappears on its own once @rnmapbox/maps no longer also provides MapboxMaps via CocoaPods — at that point, re-enable direct MapboxMaps.xcframework vendoring, extending ios/fetch-xcframeworks.sh's existing git-clone-and-vendor pattern (it already does exactly this for the Navigation frameworks) to also cover MapboxMaps itself, sourced from mapbox-maps-ios-binary or Mapbox's SPM binary distribution. Verified as of this writing: a lower-level bridge, spm_dependency (React Native >= 0.75), lets a podspec declare an SPM dependency directly — but as of Expo SDK ~53/RN 0.79 (Navio's current versions) it has a confirmed open, unresolved bug causing duplicate-symbol errors specifically in Expo modules (expo/expo#37813), and forces dynamic frameworks project-wide — which conflicts with this project's useFrameworks: "static" requirement (tied to the GestureType crash investigation). Re-verify that issue's status before relying on spm_dependency for this.

Until the trigger fires: no code changes are justified. Keep iosMapboxMapsVersion/iosMapboxNavigationVersion reasonably current when convenient (see "Upgrading the vendored iOS SDK version" above) so whichever version ends up frozen is as recent as practical, rather than stale from neglect.

The DYLD 4 Symbol missing: GestureType.singleTap launch crash

This project previously documented forceStaticLinking as the confirmed fix for this 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.

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.

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.y ↔ 24.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 (with the config plugin applied and no mapboxNavigationVersion override, the default mapboxMapsVersion: "11.11.0" auto-calculates to 3.8.0, 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.1.7

iOS (Xcode 26.2): the 5.1.6 fix now also applies to the app target. A real EAS build with 5.1.6 (nav 3.27.3 → vendored frameworks built with Swift 6.3.2, EAS compiler Swift 6.2.3) proved the 5.1.6 mechanism correct: every pod — ExpoMapboxNavigation included — now compiles. The build then failed one step later, in the app target (SwiftCompile … (in target 'Navio')), with the identical cannot load underlying module for 'MapboxMaps' inside MapboxNavigationCore/MapboxNavigationUIKit's .private.swiftinterface.

Cause: the app's generated ExpoModulesProvider.swift does import ExpoMapboxNavigation, which transitively loads MapboxNavigationCore/UIKit, so the app target runs the same interface rebuild — and CocoaPods passes it the same -Xcc -fmodule-map-file=…/MapboxMaps.modulemap (via Pods-<App>.xcconfig), which the sub-invocation drops for the same reason. 5.1.6 had put the flag in pod_target_xcconfig only, which never reaches the app target.

Fixed by also adding OTHER_SWIFT_FLAGS = $(inherited) -Xfrontend -strict-implicit-module-context to user_target_xcconfig. OTHER_SWIFT_FLAGS is a CocoaPods plural setting, so it is merged with other pods' flags rather than overriding them, and the Expo app template does not set OTHER_SWIFT_FLAGS itself, so the app target inherits it. Requires pod install / a fresh expo prebuild (EAS does this automatically). Source-verified (CocoaPods build_settings.rb, build log); not compile-verified locally (no Mac).

5.1.6

iOS build failure on Xcode 26.2 / Expo SDK 55: failed to build module 'MapboxNavigationCore'; this SDK is not supported by the compiler — root-caused from a real EAS build log, then confirmed verbatim in the failing toolchain's own compiler source (swiftlang/swift, branch release/6.2, lib/Frontend/ModuleInterfaceLoader.cpp).

The visible error blames a Swift version mismatch: the vendored MapboxNavigationCore.xcframework (nav 3.20.1) was precompiled by Mapbox with Swift 6.1.2 (Xcode 16.4), while Expo SDK 55 mandates Xcode ≥ 26.2 → Swift 6.2.3 (verified in Expo's own sdk-compatibility.json: SDK 55 → xcodeVersionRange: ">=26.2.0", so pinning an older EAS image is not an option). A binary .swiftmodule is only loadable by the exact compiler that produced it, so that rejection is normal and expected — it is not the actual failure. Mapbox ships these frameworks with BUILD_LIBRARY_FOR_DISTRIBUTION, so Swift's designed fallback is to rebuild the module from its textual .swiftinterface using the current compiler. That fallback is what actually broke, reporting (one line earlier in the log, from inside MapboxNavigationCore's own interface): cannot load underlying module for 'MapboxMaps'.

Mechanism, from the compiler source at release/6.2 (lines ~1950-1973): the interface rebuild runs in a sub-invocation, and that sub-invocation inherits the parent compile's -Xcc flags only when strictImplicitModuleContext / disableImplicitSwiftModule / DirectClangCC1ModuleBuild is set — otherwise the inherited clang-arg list is deliberately reduced, dropping search-path flags so modules stay shareable across targets. MapboxMaps is built from source by CocoaPods, so the only route to its Clang module is the explicit -Xcc -fmodule-map-file=<build-products>/MapboxMaps/MapboxMaps.modulemap that Xcode passes to this pod target (confirmed present in the failing log; explicit modules were confirmed OFF, so none of the other three conditions applied). Dropped in the sub-invocation, import MapboxMaps inside MapboxNavigationCore's interface becomes unresolvable, the rebuild aborts, and the compiler surfaces the original version mismatch as the top-level error.

Fixed by adding OTHER_SWIFT_FLAGS = $(inherited) -Xfrontend -strict-implicit-module-context to this pod's pod_target_xcconfig, so the interface rebuild sees the same modulemap flags as the target that triggered it. This fixes the class of problem rather than one instance: no particular vendored-SDK ↔ Xcode pairing is required anymore, which is exactly what library evolution is meant to provide. Scoped to this pod target only — nothing else in the consuming app's build is touched, and no version bump of Mapbox Navigation / MapboxMaps / RNMapboxMapsVersion is needed.

Also adds a toolchain diagnostic to ios/fetch-xcframeworks.sh: after fetching, it prints the swift-compiler-version baked into each vendored framework's .swiftinterface. Mapbox does not document which Xcode built each release, so this is the only reliable way to see which Swift a given iosMapboxNavigationVersion was built with (visible in EAS logs via the eas-build-post-install hook that cats .fetch-xcframeworks.log).

Verified: root cause traced end-to-end in the build log and confirmed against the exact compiler branch's source. Not verified by a real iOS build — this project has no Mac/Xcode toolchain available, so the fix is source-verified only, not compile-verified. If it does not resolve the build, the fallback is to align toolchains instead: bump iosMapboxNavigationVersion to the newest tag published in mapbox-navigation-ios-build-artifacts (v3.27.3 at time of writing — note 3.28+ releases exist upstream but have no prebuilt binary artifacts), with iosMapboxMapsVersion and the app's RNMapboxMapsVersion both set to the matching 11.27.3.

loadingScreen (5.1.5) could crash the app with "This instance of MapboxNavigation is destroyed" + a Fabric "Cannot remove child ... childCount may be incorrect", or simply never appear — root-caused via a real-device adb logcat capture with identity-hash lifecycle logging temporarily added to init{}/setupNavigation()/onDetachedFromWindow(). The trace showed three separate native ExpoMapboxNavigationView instances constructed within ~300ms of each other during a single logical mount — one still fully attached and actively registered when the next one's init{} ran a moment later.

Root cause, in src/index.tsx: the returned tree's shape depended on loadingScreen's nullness — a bare <NativeView> when null/undefined, a <View><NativeView/>…</View> wrapper otherwise. If a consumer's loadingScreen value differs in nullness between this component's own first few renders (e.g. computed from state that settles a moment after mount — the same class of instability that commonly delays coordinates too), the root element type changes between renders, and React fully unmounts the old native view and mounts a brand new one to match — even though nothing about the feature actually changed. MapboxNavigationProvider — Mapbox's own global, process-wide singleton (verified verbatim in its source: create() unconditionally calls onDestroy() on whatever MapboxNavigation instance existed before, with no regard for whether some other still-attached view is still using it) — then destroys the still-attached OLD view's instance out from under it, before that view's own onDetachedFromWindow() ever gets a chance to run. Calling unregisterXxxObserver()/stopTripSession() on that now-destroyed instance is exactly what threw "This instance of MapboxNavigation is destroyed"; the resulting corrupted internal view-hierarchy state produced the accompanying Fabric child-count error. Depending on exactly which of the three rapid mounts "won" and settled, the net effect ranged from an outright crash to the loading screen never appearing at all (the final settled render happening to be the plain, no-loadingScreen tree shape).

Fixed in two layers:

  • src/index.tsx (the actual root-cause fix): the component now always returns the same wrapping <View> — only the conditionally-rendered loading-screen sibling changes based on loadingScreen, an ordinary, safe React update that never touches the native view's own position or type. loadingScreen is now genuinely safe to compute conditionally / derive from state that isn't ready on the very first render, exactly as one would expect. The extra <View> is a single flex:1 passthrough — no visible or behavioral difference for consumers who never set loadingScreen at all.
  • Android onDetachedFromWindow() (defense in depth): every teardown call (unregisterXxxObserver, stopTripSession, MapboxNavigationProvider.destroy()) is now skipped entirely once mapboxNavigation.isDestroyed is already true, and MapboxNavigationProvider.destroy() additionally only runs when this view's instance is still the current global one — otherwise it would destroy whatever newer view's instance actually replaced it. This covers any other trigger capable of momentarily overlapping two view instances that the JS-side fix above doesn't reach — a Fast Refresh remount during development being the main one, since it happens outside normal React reconciliation entirely.

Verified: real gradlew compileDebugKotlin (BUILD SUCCESSFUL) and a real tsc --noEmit pass, both clean. Not yet re-confirmed crash-free on a real device after this specific fix (the investigation that found the root cause used a diagnostic-only build; this changelog entry describes the fix that build's evidence led to, verified by compilation, not yet by a fresh on-device repro attempt).

5.1.5

New: route loading screen (showRouteLoadingOverlay, loadingOverlayColor, loadingScreen) — covers the "bare map appears first, route line pops in a moment later" gap with either a built-in native screen (opaque background + spinner, both platforms) or your own React Native component. One-shot per mount: shown from mount until the first onRoutesReady/onRoutesFailed, never re-shown for later refetches on the same view. Opt-in (all three default to off/unset) — zero behavior change for existing consumers. Android: the overlay view is created INVISIBLE (not GONE) at mount and given the highest elevation in the view tree, so it's correctly measured on the initial pass (see the 5.1.3 entry on why GONE-at-mount matters under RN Fabric) and always draws on top. iOS: added first (at init), with an explicit bringSubviewToFront both when revealed and right after the free-drive fallback map / drop-in NavigationViewController's view are added later — UIKit has no elevation concept, subviews added afterward would otherwise cover it since it's created before either.

Fixes an incomplete 5.1.4 publish: package.json's "main"/"types" point at the pre-compiled build/index.js/build/index.d.ts — what npm consumers and editor IntelliSense actually read — not src/index.tsx directly. The route loading screen feature above was added to src/index.tsx and to both native platforms, but npm run build was never re-run before 5.1.4 was published: the native Kotlin/Swift changes made it out (confirmed present in the published tarball), but build/'s compiled output was stale from before any 5.1.4 work, so showRouteLoadingOverlay/loadingOverlayColor were entirely missing from the published TypeScript types (Kotlin/Swift-only effectively, no type safety) and loadingScreen — whose logic lives entirely in the JS wrapper component, not in native code — didn't work at all in the published 5.1.4. Fixed by actually running npm run build and verifying the new symbols are present in the output before this publish.

5.1.4

Android: ALL routes failing with onRoutesFailed({"message": "Not Found"}), navigation never starting — confirmed via a real-device adb logcat capture using 5.1.3's new RouterFailure.url diagnostic logging: the actual failing request was https://api.mapbox.com/directions/v5/mapbox/mapbox/driving-traffic/<coords> — the mapbox vendor segment duplicated in the path, a structurally malformed URL the Directions API correctly rejects with a 404. Root cause: the Directions API SDK's own URL builder already prepends the mapbox/ vendor segment ahead of whatever RouteOptions.profile is set to — this file's navigationProfile?.let { builder.profile(if (it.startsWith("mapbox/")) it else "mapbox/$it") } manually added its own mapbox/ prefix on top, producing the duplicate whenever navigationProfile was set (Navio always sets it to "driving-traffic", so this fired on every single route request — matching the "fails for every ride" report exactly). The prop's own JSDoc already correctly documented the intended contract ("Android: omit the mapbox/ prefix (handled internally)") — the bug was purely in this file not honoring it. iOS was never affected: it maps navigationProfile through a switch to the SDK's typed ProfileIdentifier enum, no string concatenation involved. Fixed by passing the profile through as-is (it.removePrefix("mapbox/") — defensively strips a stray prefix if a caller still includes one, without reintroducing the duplicate). Same fix applied to the (currently unused by Navio) map-matching request path for consistency. Confirmed resolved on a real device: post-fix adb logcat showed a route request succeeding with zero onRoutesFailed events and navigation starting normally.

5.1.3

Android: the speed-limit panel STILL never appeared on screen even with valid data and the SDK's own render() being called — root-caused and fixed after one disproven hypothesis, with every step verified by live adb logcat diagnostics on a real device. (Note: the npm-published 5.1.2 was cut from an intermediate state that predates this fix and the diagnostics below — it contains only the build fix and 5.1.1's data-side fallback. This release, 5.1.3, is the one that actually makes the panel visible.)

Throttled diagnostic logging added (adb logcat -s ExpoMapboxNavigation, transitions only — a handful of lines per drive, not thousands): every change of posted=<speed> unit=<unit> sign=<sign> currentSpeedAvailable=<bool> rendered=<sdk-render|posted-only-fallback|hidden> is logged, so on-device tests can distinguish "the posted limit is absent from the data for this road" (a coverage limitation — verified against OSM: only ~30% of roads around the Belgian test area carry an explicit maxspeed tag, and iOS's own SpeedLimitView.canDraw hides the sign on exactly the same roads) from "data present but not rendered" (which would be a real code bug).

The investigation: a live adb logcat capture on a Samsung Galaxy S24 Ultra showed posted=50/20, currentSpeedAvailable=true, rendered=sdk-render repeatedly — the SDK's own unmodified MapboxSpeedInfoView.render() was genuinely invoked with valid data (confirmed down to the innermost TextView's own text, "50") — yet nothing appeared on screen. A ViewTreeObserver.OnGlobalLayoutListener-based diagnostic confirmed the widget and every nested ConstraintLayout inside it measured w=0 h=0 despite correct VISIBLE visibility and correct text at every level, while the parent container was correctly sized (1080×2340). Dependency conflicts were ruled out (constraintlayout uniformly 2.1.4, appcompat uniformly 1.7.0 app-wide). A first hypothesis — androidx/constraintlayout#838, a GONE → VISIBLE re-measure bug — was implemented (forced requestLayout() on every ConstraintLayout in the subtree) and disproven by a real on-device retest: still w=0 h=0. The decisive evidence came from extracting the SDK's actual mapbox_speed_info_view.xml out of the ui-components AAR in the Gradle cache: postedSpeedLayoutVienna has a hard-coded 70dp×70dp size — a fixed-size view measuring 0 after "layout passes" is only possible if measure() never actually ran on it. Actual root cause: React Native (Fabric) swallows requestLayout() for native subtrees — RN only sizes views it manages through Yoga, so a dirtied native subtree below an RN-managed view never receives a new measure/layout traversal. Every other element of this package's UI (maneuver banner, ETA bar, side buttons) uses INVISIBLE when hidden — measured normally during the initial mount pass, so flipping to VISIBLE needs no re-measure and they all display fine. speedInfoView is the only child created GONE (measured 0×0 at mount) and flipped to VISIBLE later — the only lifecycle requiring a post-mount re-measure, and therefore the only invisible widget. Fixed with the standard, widely-deployed React Native pattern for exactly this (same as react-native-maps): override requestLayout() on the view to post a manual measure(EXACTLY current size)+layout() traversal — it can never change the view's own RN-assigned size, only propagate real measurement down to children; idempotent, at most once per frame, and covers any future dynamically-shown native child too. The earlier per-ConstraintLayout requestLayout() calls are kept as cache-invalidation so the forced traversal genuinely re-measures instead of short-circuiting through ConstraintLayout's internal measurement caches. Confirmed resolved on real devices: post-fix adb logcat showed the widget measuring exactly its XML-intended size for the first time (w=184 h=184 = 70dp × 2.625 density, previously stuck at 0×0), and the panel now displays correctly on both test devices (Galaxy S20+ and Galaxy S24 Ultra). The throttled diagnostic logs are intentionally kept in the release — they cost a handful of Log.d lines per drive and make any future speed-limit report diagnosable via adb logcat -s ExpoMapboxNavigation without a special build.

5.1.2

Android build failure introduced by 5.1.1: compileDebugKotlin failed with Cannot access class 'androidx.appcompat.widget.AppCompatTextView'. Check your module classpath for missing or conflicting dependencies. — confirmed by a real consumer app build (gradlew assembleDebug), not just source review. Root cause: 5.1.1's speed-limit-parity fix reads MapboxSpeedInfoView's public sub-view properties (speedInfoPostedSpeedMutcd, speedInfoUnitTextMutcd, etc.), which are typed androidx.appcompat.widget.AppCompatTextView. This package's android/build.gradle never declared androidx.appcompat:appcompat as a direct dependency — it's only pulled in transitively through ui-components, which isn't enough for Kotlin to resolve the type from this module's own classpath (the exact same class of issue androidx.constraintlayout:constraintlayout was already added to solve for MapboxManeuverView, which extends ConstraintLayout). Fixed by adding implementation 'androidx.appcompat:appcompat:1.7.0' alongside the existing ConstraintLayout dependency. Verified with a real compile this time: patched a local npm-installed copy of 5.1.1 and ran gradlew :jacques_gordon-expo-mapbox-navigation:compileDebugKotlin directly against a real consumer app — BUILD SUCCESSFUL, no errors (only pre-existing, unrelated deprecation warnings). No behavior changes in this release — 5.1.1's speed-limit logic itself is unchanged, this only fixes the missing dependency that broke the build. (Note: the panel still does not actually appear on screen in this version — that requires 5.1.3's React Native measure/layout fix, root-caused after this version was published.)

5.1.1

Android: the posted speed limit almost never displayed, while iOS displayed it reliably — confirmed by real-device testing on both platforms side by side. Root cause, verified verbatim in mapbox-navigation-android's MapboxSpeedInfoApi.kt/SpeedLimitProcessor.kt at v3.20.x: updatePostedAndCurrentSpeed() returns null — discarding a perfectly valid posted limit in the process — whenever LocationMatcherResult.enhancedLocation.speed (the GPS-derived current speed, a nullable Double?) is unavailable, which happens right after guidance starts, with weak GPS, or with certain location providers. iOS's own SpeedLimitView.canDraw (verified verbatim in mapbox-navigation-ios's SpeedLimitView.swift at v3.20.1) has no such dependency — !isAlwaysHidden && signStandard != nil && (speedLimit != nil || shouldShowUnknownSpeedLimit) — it shows the sign whenever a posted limit is known, full stop, never reading current speed at all. Both platforms source the posted limit from the same underlying native NavigationStatus.speedLimit field (confirmed: iOS's MapboxNavigator.swift reads status.speedLimit directly; Android's LocationMatcherResult.speedLimitInfo is populated from the same shared native Navigator) — so this was never a data-coverage difference between the platforms, only a platform code-path gap unique to Android's speed-info helper. Fixed by rendering the posted-limit-only case ourselves whenever updatePostedAndCurrentSpeed() returns null but LocationMatcherResult.speedLimitInfo.speed is actually available — driving MapboxSpeedInfoView's own public child views directly (SpeedInfoValue/LocationMatcherResult both have an internal constructor, so this package can't build one to hand to .render() directly; the child views, e.g. speedInfoPostedSpeedMutcd/speedInfoMutcdLayout, are public vals and the only supported way to reach this state). The unit-conversion math is a verbatim port of the SDK's own (private, hence unreachable) PostedAndCurrentSpeedFormatter.getPostedSpeedLimit(), so the displayed number never differs from what the normal (both-values-available) render path would show for the same road. The existing "both values available" path is completely untouched — zero behavior change there.

5.1.0

Android: navigation icon (puck) was visibly rotating independently of the map during turns, instead of staying fixed like Waze/Google Maps — confirmed by frame-by-frame analysis of two real device screen recordings (same pattern on both, independently). The puck's on-screen angle is literally (puck bearing − camera bearing), since the location-indicator layer's bearing is a geographic property rendered through the same camera transform as the rest of the map (verified verbatim in mapbox-maps-android's LocationIndicatorLayerRenderer.kt). This package had explicitly set puckBearing = PuckBearing.COURSE, feeding the puck the RAW, instantaneous GPS course on every location update — while the camera's own bearing comes from MapboxNavigationViewportDataSource's bearingSmoothing (capped at 45°/update, computed from an anticipatory look-ahead window of upcoming route points, deliberately not raw instantaneous course). Two independently-timed bearing sources drift apart mid-turn; that drift is exactly what appeared as "the puck rotating." Fixed by switching to PuckBearing.HEADING — which is also the Mapbox Maps SDK's own default (@Default("PuckBearing.HEADING"), verified verbatim in LocationComponentSettingsData.kt) and exactly what Mapbox's own official reference app uses (TurnByTurnExperienceActivity.kt, mapbox-navigation-android-examples — never overrides puckBearing at all). HEADING mode drives puck rotation from device compass data, which this package never feeds through changePosition(...) — so there's no second bearing source left to disagree with the camera, and 100% of the visible rotation now comes from the camera turning beneath a visually fixed puck. iOS was not affected (verified: no equivalent explicit override exists there — the drop-in NavigationViewController manages puck/camera bearing sync entirely internally).

Refinement over a fixed HEADING value, found during audit: a static HEADING setting has one edge case — this package's own Overview mode (toggleOverview(), camera goes north-up/non-rotating) gets no direction at all from HEADING (no compass feed), whereas the OLD COURSE mode showed the correct absolute direction there just fine (a static, non-rotating map has no camera-bearing-vs-course drift to begin with — COURSE was never actually buggy in Overview, only in Following). Fixed by switching puckBearing WITH the camera state instead of fixing it to one value: HEADING only while FOLLOWING/TRANSITION_TO_FOLLOWING (the mode that was actually broken), COURSE everywhere else (OVERVIEW/TRANSITION_TO_OVERVIEW/IDLE, where it was always correct) — via navigationCamera.registerNavigationCameraStateChangeObserver, which also fires immediately with the current state on registration (verified verbatim in NavigationCamera.kt), so the correct initial value is set with no separate explicit assignment needed.

New prop, both platforms: navigationViewportPaddingTop/Left/Bottom/Right — lets consumers reposition the navigation icon within the frame without touching this package's own defaults. Maps directly to each platform's own native mechanism (Android's EdgeInsets fed to MapboxNavigationViewportDataSource.followingPadding; iOS's NavigationMapView.viewportPadding, verified verbatim at v3.20.1 — the SDK's own public property for this, previously never set by this package at all). Defaults are each platform's exact pre-5.1.0 value (Android: 180/40/300/40 dp, the existing Waze-style ~30%-from-bottom position, unchanged; iOS: 20/20/40/20 pt, the SDK's own untouched default) — so leaving these unset changes nothing on either platform. Applied live on both platforms; never triggers a route recalculation. Caught during a pre-publish audit: the iOS setter originally passed navigationViewportPaddingTop ?? 20 (etc.) directly to UIEdgeInsets, which expects CGFloat — but ??'s both sides resolve against the `Double