Skip to content

Use CLDR locale matching to pick the device's translation - #156

Open
spencerrlongg wants to merge 1 commit into
developfrom
feature/i18n-cldr-locale-matching
Open

Use CLDR locale matching to pick the device's translation#156
spencerrlongg wants to merge 1 commit into
developfrom
feature/i18n-cldr-locale-matching

Conversation

@spencerrlongg

Copy link
Copy Markdown
Collaborator

Summary

Replaces the prefix-based device-language matching from #155 with @formatjs/intl-localematcher (ECMA-402 best-fit ponyfill).

The old logic returned the first resource key sharing a base language. That's wrong whenever we ship multiple variants of one language — and five of our base languages have multiple variants (en, es, zh, pt, de), so it was structural, not exotic. The worst case was a real bug: Traditional Chinese devices (zh-Hant, zh-MO) matched zh-CN and got served Simplified Chinese, a different character set.

Why this over a native module

Bundle.preferredLocalizations(from:) is Apple's blessed answer per TN2418, and it is usable here — the class method takes an arbitrary array, no .lproj needed — via a local Expo module paired with LocaleListCompat.getFirstMatch() on Android. Went with the JS ponyfill instead: same algorithm family, same verified fixes, but identical behavior across platforms instead of trusting two OS implementations to agree, no native code in two languages, and it still ships as an EAS Update rather than changing the native fingerprint and forcing new store builds.

Two edge cases the library forced handling for

  • match() throws RangeError on structurally invalid tags. This runs at module load, so an unguarded throw would take down app startup over a bad device locale — caught, falls back to en-US.
  • match() canonicalizes casing, so Crowdin's informal-German de-if comes back as de-IF and misses our lowercase resource key. Result is mapped back to the real key case-insensitively.

Test plan

Verified by extracting the actual resolveDeviceLanguage source from i18n/index.js and running it against the real installed package and the real 75-key resource map:

requested before after
zh-Hant zh-CN (Simplified) zh-TW
zh-MO zh-CN (Simplified) zh-HK
en-AU en-US en-GB
pt-AO pt-BR pt-PT
de-US de-DE de-DE
zh-Hans zh-CN zh-CN
de-if de-if de-if (case mapping)
de_DE / C / xx / [] en-US, no throw
  • All 12 cases return a string that is a real key in resources (no silent fallback-to-English from a key miss)
  • node --check passes
  • Confirm in the simulator: German still renders (regression check on the case that was verified working in Wire Crowdin-pulled locales into i18next #155)

🤖 Generated with Claude Code

The previous prefix matching returned the first resource key sharing a
base language, which is wrong when we ship several variants of one
language. Five of our base languages have multiple variants (en, es,
zh, pt, de), so this was structural rather than exotic -- most
seriously, Traditional Chinese devices (zh-Hant, zh-MO) matched zh-CN
and were served Simplified Chinese, a different character set.

Replace it with @formatjs/intl-localematcher, a ponyfill of ECMA-402
best-fit matching. Verified against our real 75-locale set:
zh-Hant -> zh-TW, zh-MO -> zh-HK, en-AU -> en-GB, pt-AO -> pt-PT,
all previously resolving to the wrong variant.

Two edge cases the library forced handling for:
- match() throws RangeError on structurally invalid tags. This runs at
  module load, so an unguarded throw would break app startup over a bad
  device locale; catch and fall back to en-US.
- match() canonicalizes casing, so Crowdin's informal-German "de-if"
  comes back as "de-IF" and misses our lowercase resource key. Map the
  result back to the real key case-insensitively.

Chose this over a local Expo module wrapping Bundle.preferredLocalizations
(from:) / LocaleListCompat.getFirstMatch(): same algorithm family and the
same verified fixes, but identical on both platforms, no native code to
maintain, and it still ships as an EAS Update rather than forcing new
store builds via a changed native fingerprint.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant