Skip to content

Localize Mapbox map labels to the user's preferred app locale - #99304

Draft
MelvinBot wants to merge 5 commits into
mainfrom
claude-mapboxMapLocale
Draft

MelvinBot wants to merge 5 commits into
mainfrom
claude-mapboxMapLocale

Conversation

@MelvinBot

Copy link
Copy Markdown
Contributor

Explanation of Change

The Mapbox map never told Mapbox which language to render, so map labels (place names, roads) stayed in the default language and ignored the app locale. This wires the user's preferredLocale (from useLocalize()) into the map on both platforms:

  • Web (MapViewImpl.web.tsx): calls the underlying mapbox-gl map.setLanguage(...) once the style is loaded, reapplying whenever the map or locale changes.
  • Native (MapView.tsx): passes the localizeLabels={{locale}} prop to <Mapbox.MapView> (supported by @rnmapbox/maps).
  • A shared getMapboxLanguage helper in utils.ts maps app locales to the BCP-47 codes Mapbox expects (pt-BR → pt, zh-hans → zh-Hans; the rest pass through). Unsupported codes fall back to each label's local language on the Mapbox side.

Unit tests were added for getMapboxLanguage.

Note: on web the app uses a classic Mapbox Studio style; setLanguage() localizes labels that have name_{lang} fields in the vector tiles. Locales not present in the classic tiles gracefully fall back to the local language.

Fixed Issues

$ #97752
PROPOSAL: #97752 (comment)

Tests

  1. Open a screen that shows the Mapbox map (e.g. create a Distance expense: FAB → Create expense → Distance).
  2. With the app language set to English, note the map labels (country/city/road names).
  3. Change the app language (Settings → Preferences → Language) to another language, e.g. Spanish.
  4. Return to the map and verify the labels now render in the selected language (for locales supported by the Mapbox style).
  5. Switch back to English and verify the labels revert.
  • Verify that no errors appear in the JS console

Offline tests

QA Steps

// TODO: These must be filled out, or the issue title must include "[No QA]."

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

Co-authored-by: Rory Abraham <roryabraham@users.noreply.github.com>
@melvin-bot melvin-bot Bot added Melvin-Test-Android Melvin-Test-Web Triggers Melvin to run the testing steps of the PR on web labels Aug 24, 2026
@MelvinBot
MelvinBot requested a review from a team August 24, 2026 07:48
@codecov

codecov Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/components/MapView/MapView.tsx 56.86% <100.00%> (+0.86%) ⬆️
src/components/MapView/utils.ts 90.08% <100.00%> (+0.89%) ⬆️
src/components/MapView/MapViewImpl.web.tsx 0.00% <0.00%> (ø)
... and 1041 files with indirect coverage changes

@MelvinBot

This comment was marked as low quality.

@MelvinBot

This comment was marked as low quality.

@github-actions

Copy link
Copy Markdown
Contributor

🚧 roryabraham has triggered a test Expensify/App build. You can view the workflow run here.

@linhvovan29546

Copy link
Copy Markdown
Contributor

@MelvinBot This doesn’t work on web, and it looks like the change only supports two languages.
https://github.com/Expensify/App/pull/99304/changes#diff-a7f8ac7902cdbf14643da46189b14ee847a5169165fbf0f8fbfb9248fb96f62dR6

@MelvinBot

Copy link
Copy Markdown
Contributor Author

You're right on both counts. I reproduced the web failure and traced why.

1. "Doesn't work on web" — confirmed. I tested on web: switched the app to Japanese, and the map's place/road labels stayed entirely English while the rest of the UI localized. So the setLanguage() wiring runs but has no visible effect.

Root cause: In mapbox-gl v3, map.setLanguage() does not rewrite any layer's text-field. All it does is reload the vector sources with a language= query param and let Mapbox's tile server localize the generic name field. That only shows up if the style's symbol layers actually render {name}. Our classic Studio style (mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq) was authored in Mapbox Studio, which bakes the label language in at authoring time — its symbol layers almost certainly pin text-field to {name_en}, so the server-side localization is ignored and labels stay English. The code path is correct; the style is the blocker.

To confirm the last step, open the map on web and check map.getStyle().layers (the symbol layers' layout['text-field']) — if it reads name_en, that's it.

Fix options (roughly increasing effort):

  • Edit the Mapbox Studio style so label layers use {name} instead of {name_en}. This is a change in the Expensify Mapbox account, not in this repo — needs whoever owns it. This is the smallest fix and makes the current code work as-is.
  • Migrate the map to the newer Mapbox Standard style, which exposes a first-class language config that drives labels dynamically.
  • Rewrite the symbol layers' text-field in code after style load (what the old @mapbox/mapbox-gl-language plugin did) — brittle, least preferred.

2. "Only supports two languages" — it's a bit different, but there is a real gap. getMapboxLanguage only remaps two locales (pt-BR→pt, zh-hans→zh-Hans); the other 9 pass through unchanged because they're already valid codes — that part is fine. The real limitation is that Mapbox Streets' classic tileset only has name_* translations for a subset (ar, en, es, fr, de, it, ja, ko, pt, ru, vi, zh-Hans, zh-Hant). So of our 11 app locales, el (Greek), nl (Dutch), and pl (Polish) will never localize even after the style is fixed — they pass through and silently fall back to each label's local name. Worth calling out in the PR notes so it's not mistaken for a bug later.

I'm not pushing a fix here since the most likely real fix is a Mapbox Studio style change outside this repo, and I'd want roryabraham to weigh in on which of the three approaches to take.

Test result: fail — map labels did not localize on web after switching to Japanese.

Step Status Observation
Baseline: Distance map (NY→Boston) labels in English while app is English ✅ pass Labels render in English (Massachusetts, Connecticut, Hartford, etc.)
Switch app language to 日本語 (Japanese) ✅ pass All app UI chrome switched to Japanese immediately
Re-open Distance map — expect Japanese map labels ❌ fail UI is Japanese but map labels are the identical English strings — no Japanese anywhere on the tiles
Console/network for mapbox errors on map screen ✅ pass No 4xx/5xx to api.mapbox.com; no error banners (direct JS console not queryable on web harness)
Evidence (2)

Distance map with app language English — all place/road labels in English

Distance map with app language English — all place/road labels in English

Same route after switching app language to Japanese — UI is Japanese but map labels remain English

Same route after switching app language to Japanese — UI is Japanese but map labels remain English

view run

@roryabraham

Copy link
Copy Markdown
Contributor

@linhvovan29546 @MelvinBot I can confirm I see toggles to have language and worldview "Configured in SDK":

Image

But I don't want to enable that without confirming first that we are configuring Mapbox language and worldview in the Expensify app in production.

Do we need to merge and deploy this PR first, then enable those settings?

The real limitation is that Mapbox Streets' classic tileset only has name_* translations for a subset (ar, en, es, fr, de, it, ja, ko, pt, ru, vi, zh-Hans, zh-Hant). So of our 11 app locales, el (Greek), nl (Dutch), and pl (Polish) will never localize even after the style is fixed

Regarding this: I understand it could be a larger refactor, but would switching to Google Maps provide more complete localization? I don't recall the context for why we used Mapbox in the first place, but I recall that it being closed-source comes with some extra config burden in this repo. Is there a ready-to-go Google Maps library for React Native?

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Short answer: No — production configures neither today. Deploy this PR first, then flip Language only. Do not flip Worldview. And no, we don't need Google Maps: I was wrong about the 3 missing locales.


1. Are we configuring language/worldview in production today?

No, neither. origin/main has zero references to setLanguage, localizeLabels, setWorldview, or worldview anywhere in src/. This PR adds language only — it never sets worldview on either platform.

2. Ordering: deploy first, then flip Language

Deploy first is the safe order. Merging is a no-op today: the style pins labels to English, so setLanguage() has no visible effect — that's exactly what linhvovan29546 hit. Nothing regresses in the gap.

Flipping first is the risky order. The style is server-side, so it takes effect instantly for every client — current production, old native builds, cached web sessions. Those clients set no language, and Mapbox's documented default is the browser/device locale, not English. A user with a German browser and an English app would suddenly get German map labels, with no deploy to roll back through.

Leave Worldview pinned. It controls disputed-boundary rendering, defaults to US, and this PR never sets it. Flipping it hands border rendering to device locale — a policy call unrelated to this PR.

3. Google Maps? No — and I owe you a correction

My "el/nl/pl will never localize" claim was wrong. That was the old streets-v8 name_* field list. Mapbox now supports 40+ label languages server-side, and all 11 of our locales are covered — including el, nl, and pl. Google's ~74 languages is a longer list, but it covers nothing extra that we actually ship.

The real gap is native, not Mapbox. Web and native use different mechanisms:

Mechanism Languages Needs the Studio toggle?
Web map.setLanguage() — server-side tile request 40+, incl. el/nl/pl Yes
Native localizeLabels — client-side name_{code} rewrite ~12 tile fields, no el/nl/pl No

So after the flip, web gets all 11 locales and native still misses 3. The fix is moving native to the same server-side path (Mapbox's SettingsService LANGUAGE key) — but @rnmapbox/maps v10.3.2 doesn't expose it, so that needs a small native module or an upstream PR. Worth a follow-up issue, not a provider swap.

Why Google Maps would cost far more than it returns
  • Routing is server-side and Mapbox-based. getRoute calls the GetRoute API command; the backend computes the route and distance. Swapping the display map doesn't swap routing, and Google's ToS restricts rendering third-party routing data on a Google map — so you'd likely have to migrate distance calculation too, which changes billed mileage. That's a much bigger blast radius than a UI swap.
  • No single library covers our platforms. react-native-maps handles iOS/Android but has no first-party web support; web would need @vis.gl/react-google-maps. You'd own two map implementations instead of one.
  • The config burden doesn't go away. Google needs platform-restricted API keys per platform, same as the Mapbox token setup.
  • Pricing. Google Maps Platform bills per map load and is generally more expensive than Mapbox at scale.
  • We already use Google Places for address autocomplete (AddressSearch), so the account exists — that lowers setup cost only, not migration cost.
Verifying before you publish the style

Studio keeps a draft version of the style. If the token scope allows it, point a local build at mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq/draft and confirm labels follow app locale before publishing. Caveat I couldn't verify: our Mapbox token comes from the backend via GetMapboxAccessToken and probably lacks the styles:draft scope, so this may need a temporary dev token.

Style URL is at src/CONST/index.ts:6402. PR wiring: MapViewImpl.web.tsx:201-221 (web), MapView.tsx:264-275 (native), utils.ts:5-21 (locale mapping — pt-BR→pt and zh-hans→zh-Hans are the only remaps needed, and both are valid Mapbox codes).

Recommended sequence: merge + deploy → flip Language to "Configured in SDK" (leave Worldview) → verify on web with a locale like Greek or Polish, which only work through the server-side path → file a follow-up for native server-side language.

Sources: Mapbox maps internationalization, Android localization plugin


view run

@roryabraham

Copy link
Copy Markdown
Contributor

Ok:

  • I think we should update this PR to set worldview in addition to language (might as well?)
  • Can we find or create an upstream issue for the https://github.com/rnmapbox/maps limitation Melvin is referencing, then fix it with an upstream PR and patch?

@linhvovan29546

Copy link
Copy Markdown
Contributor

@MelvinBot Please check #99304 (comment)

MelvinBot and others added 2 commits September 4, 2026 03:27
Mapbox draws disputed borders from the perspective of the worldview it is
given. The map never set one, so every user saw the style's default. Web now
sends the worldview matching the user's country alongside the label language.

Co-authored-by: Rory Abraham <roryabraham@users.noreply.github.com>
Co-authored-by: Linh Vo <linhvovan29546@users.noreply.github.com>
Co-authored-by: Rory Abraham <roryabraham@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Fixed the failing check — pushed e73ea1d3.

Oxfmt check was the only real failure. oxfmt wants a blank line between the @src/... and relative import groups in src/components/MapView/utils.ts:3. Running npm run fmt added it; that's the entire diff (1 line).

The other failing check, Check independent approval, just needs a human reviewer — nothing to fix in code.

Verification
  • npm run fmt is now idempotent and reports no diff.
  • npx eslint src/components/MapView/utils.ts → clean.
  • npm test tests/unit/MapViewUtilsTest.ts → 23/23 passed.
  • Local oxfmt pin (^0.55.0) matches main, so the local run reproduces CI.
  • Skipped typecheck locally (tsgo isn't installed in this environment); it passed on CI for this exact code, and a blank line can't affect it.

view run

MelvinBot and others added 2 commits September 4, 2026 03:33
Mapbox falls back to the style's default worldview for country codes it has no
worldview for, so an allowlist only adds a list that goes stale as Mapbox adds
worldviews. Only values that aren't country codes at all still need dropping,
since those raise an error.

Co-authored-by: Rory Abraham <roryabraham@users.noreply.github.com>
Co-authored-by: Linh Vo <linhvovan29546@users.noreply.github.com>
…ude-mapboxMapLocale

Co-authored-by: Linh Vo <linhvovan29546@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Worldview is wired up on web and pushed (611f1a4). The upstream ask is half done: I searched, there's no existing report, and I drafted one — but my token can't post to third-party repos, so someone needs to paste it. Details below.


1. Worldview

Web now sends worldview alongside language, from the user's country by IP:

Two things worth your call, roryabraham:

The country source. Worldview is a country, not a language, so it can't come from preferredLocale. I used ONYXKEYS.COUNTRY (country by IP) — already loaded app-wide, no extra request, and it matches "where the user is", which is what worldview is for. The alternative is their address country from PRIVATE_PERSONAL_DETAILS, which is more deliberate but often unset. Easy to swap.

This is a policy change, not just plumbing. Today every user sees the style's default worldview. After this, an Indian user sees Indian borders and a Chinese user sees Chinese borders. That's the point of the feature, but it's a decision about disputed territory, so flag it if you'd rather pin it.

I pass any well-formed country code straight through rather than allowlisting US/CN/IN/JP. Mapbox's own docs say unsupported ISO codes fall back to the TileJSON default and only invalid codes error, so an allowlist would just go stale as Mapbox adds worldviews.

Heads-up: setLanguage and setWorldview are marked @private in mapbox-gl 3.24

Both are exported in the type definitions, so TypeScript is happy and they work — but mapbox-gl-dev.js annotates both with @private. They aren't part of the public API surface and could change without a major bump. This applies to the setLanguage call that was already in this PR too. Worth knowing before we depend on them.

Native gets no worldview. @rnmapbox/maps has zero worldview support — the string doesn't appear anywhere in its src/, android/ or ios/. So after this, web honors worldview and native doesn't. That's the same gap as the upstream item below.

2. Upstream

Found: nothing tracking this. Closest matches are rnmapbox/maps#4172 (someone hitting the identical wall in March, still unanswered) and issues #1325 / #3302 / #284 / #740, all closed and all about bugs in the client-side path rather than the missing server-side one.

Venue: not an issue. rnmapbox sets blank_issues_enabled: false and their only template is a bug report; their config routes feature requests to Discussions → Ideas. A feature request filed as an issue gets auto-closed by their label bot as "rather a question than an issue" — that's literally what happened on #897.

Blocked: createDiscussion returns FORBIDDEN — Resource not accessible by personal access token. My token is scoped to Expensify repos, so I can't post it. The draft is ready to paste as-is into Ideas:

Ready-to-post draft — title + body

Title: Expose server-side language and worldview (MapboxMapsOptions) in addition to localizeLabels


Summary

@rnmapbox/maps can localize map labels only through the client-side localization extension (localizeLabels). It has no binding for the server-side language and worldview settings that the Mapbox Maps SDK v11 exposes via MapboxMapsOptions. That leaves two gaps that can't be worked around from JS:

  1. Languages. localizeLabels rewrites each symbol layer's text-field to name_{code}, so it only reaches the ~12 languages that exist as name_* fields in the Mapbox Streets tiles. The server-side path covers 40+ languages. Greek, Dutch and Polish are examples that work server-side and cannot work client-side.
  2. Worldview. There is no worldview support at all — worldview does not appear anywhere in src/, android/ or ios/. Apps can't choose how disputed borders are drawn.

Both are already available on the native SDK version this library pins (11.23.1, and the API landed in v11.8.0), and both are already available on web through mapbox-gl's map.setLanguage() / map.setWorldview() — so today the same app localizes differently on web and on native.

Where it stands today

localizeLabels bottoms out in the localization extension on both platforms:

  • Android — android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt: savedStyle?.localizeLabels(locale, mLocaleLayerIds) (from com.mapbox.maps.extension.localization.localizeLabels)
  • iOS — ios/RNMBX/RNMBXMapView.swift: try self.mapboxMap.style.localizeLabels(into: locale.locale, forLayerIds: locale.layerIds)

A search for MapboxMapsOptions across the repo returns nothing, so there's no existing path to the server-side setting.

Proposed API

These are process-wide settings on the Mapbox SDK rather than per-map properties, so they fit the existing static Mapbox.* module alongside setWellKnownTileServer / setTelemetryEnabled rather than becoming MapView props:

Mapbox.setLanguage('el');   // BCP-47; null/undefined resets to the SDK default
Mapbox.setWorldview('IN');  // ISO 3166-1 alpha-2; null/undefined resets to the style default

Sketch of the change:

  • src/specs/NativeMapboxModule.ts — add setLanguage(language: string | null): void; and setWorldview(worldview: string | null): void;
  • src/RNMBXModule.ts, src/Mapbox.ts / src/Mapbox.native.ts — re-export both
  • Android android/src/main/java/com/rnmapbox/rnmbx/modules/RNMBXModule.kt — @ReactMethod wrappers assigning com.mapbox.maps.MapboxMapsOptions.language / .worldview
  • iOS ios/RNMBX/RNMBXModule.swift + RNMBXModule.m — @objc wrappers assigning MapboxMapsOptions.language / .worldview

localizeLabels would keep working exactly as it does now; this is purely additive.

Related, but not the same thing

Context

Raised from #99304, where we're localizing map labels to the user's app language. Web gets every language we ship plus worldview; native is capped at what the tiles carry. Happy to put up a PR if maintainers are open to the API shape above.

Versions: @rnmapbox/maps 10.3.2, Mapbox Maps SDK 11.23.1 (iOS + Android).

3. Upstream PR + patch — not in this PR

The change set is the four bullets in the draft above, and it's small. But it's Kotlin and Swift, and I can't compile either here — this runner has no macOS for the iOS half and no working Android native build for the Kotlin half. Shipping a patch-package patch for native code that has never once been compiled would risk breaking the Android and iOS builds for a feature that is dormant until you flip the Studio toggle anyway.

My recommendation: land this PR and the Studio Language flip first (that's the whole win on web, and most of it on native), then do the upstream PR + patch as a follow-up on a machine that can build both platforms. It buys native el/nl/pl plus native worldview — real, but bounded, and not worth blocking the rest on.

Testing

Unit tests, lint, typecheck and cspell all pass — getMapboxWorldview has coverage for supported codes, unsupported-but-valid codes, and malformed input (23/23 in MapViewUtilsTest.ts). The 668 typecheck errors in this environment are all pre-existing and confined to four files under scripts/ and tests/unit/TSCompilerUtilsTest.ts; none are in MapView.

I could not verify at runtime. The web test session went blank after the branch checkout — 0 DOM nodes, blank screenshots — and recovering it needs a CI-owned session restart the test agent isn't allowed to perform. The dev server itself compiled both changed files cleanly, so this looks like test infra rather than the change, but treat the browser behavior as unverified. Since the Studio toggle isn't flipped yet, there's nothing visible to confirm regardless; what's worth re-checking once a session is healthy is simply that the map still draws and the console stays clean.


view run

@MelvinBot

Copy link
Copy Markdown
Contributor Author

⚠️ MelvinBot CI Fix Loop Detected

MelvinBot has made 5 consecutive commits trying to fix CI failures. Automatic CI fix dispatches are now paused for this PR.

Next steps: A human should review the recent commits and resolve the conflicting CI errors manually.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Melvin-Test-Android Melvin-Test-Web Triggers Melvin to run the testing steps of the PR on web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants