diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2ff1e761e4..7b9c15327c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -30,6 +30,7 @@ "@formatjs/intl-listformat": "^8.3.13", "@formatjs/intl-locale": "^5.3.10", "@formatjs/intl-numberformat": "^9.4.0", + "@formatjs/intl-pluralrules": "^6.3.13", "@formatjs/intl-relativetimeformat": "^12.3.13", "@formatjs/intl-segmenter": "^12.2.13", "@kilocode/app-shared": "workspace:*", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index fc7247844e..e77995f840 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -369,11 +369,13 @@ function RootLayoutNav() { router.replace('/(app)/(tabs)/(3_profile)/preferences'); } try { - await i18n.changeLanguage(resolved); + // The plural-rules polyfill must be in place before the first render in the new language. prewarmIntl(resolved); + await i18n.changeLanguage(resolved); } catch { - await i18n.changeLanguage('en'); + // The plural-rules polyfill must be in place before the first render in the new language. prewarmIntl('en'); + await i18n.changeLanguage('en'); } void renameAndroidNotificationChannels(); if (!cancelled) { diff --git a/apps/mobile/src/i18n/apply-language.ts b/apps/mobile/src/i18n/apply-language.ts index 3c111aa3d3..18c48cc63f 100644 --- a/apps/mobile/src/i18n/apply-language.ts +++ b/apps/mobile/src/i18n/apply-language.ts @@ -66,8 +66,9 @@ export async function applyLanguagePreference( const previousLanguage = i18n.language; try { - await i18n.changeLanguage(resolved); + // The plural-rules polyfill must be in place before the first render in the new language. prewarmIntl(resolved); + await i18n.changeLanguage(resolved); } catch { return { kind: 'catalog-failed' }; } @@ -75,8 +76,9 @@ export async function applyLanguagePreference( const persisted = await setLanguagePreferenceAsync(preference, previousLanguage); if (!persisted) { try { - await i18n.changeLanguage(previousLanguage); + // The plural-rules polyfill must be in place before the first render in the new language. prewarmIntl(previousLanguage); + await i18n.changeLanguage(previousLanguage); } catch { // Ignore: the rollback is best-effort; the persist failure already surfaced. } diff --git a/apps/mobile/src/lib/formatjs-locale-data.ts b/apps/mobile/src/lib/formatjs-locale-data.ts index b808aa3926..5e36c7f699 100644 --- a/apps/mobile/src/lib/formatjs-locale-data.ts +++ b/apps/mobile/src/lib/formatjs-locale-data.ts @@ -182,6 +182,96 @@ const LIST_FORMAT_LOCALE_LOADERS = { zu: () => void require('@formatjs/intl-listformat/locale-data/zu.js'), } satisfies Record void>; +const PLURAL_RULES_LOCALE_LOADERS = { + af: () => void require('@formatjs/intl-pluralrules/locale-data/af.js'), + sq: () => void require('@formatjs/intl-pluralrules/locale-data/sq.js'), + am: () => void require('@formatjs/intl-pluralrules/locale-data/am.js'), + ar: () => void require('@formatjs/intl-pluralrules/locale-data/ar.js'), + hy: () => void require('@formatjs/intl-pluralrules/locale-data/hy.js'), + az: () => void require('@formatjs/intl-pluralrules/locale-data/az.js'), + eu: () => void require('@formatjs/intl-pluralrules/locale-data/eu.js'), + be: () => void require('@formatjs/intl-pluralrules/locale-data/be.js'), + bn: () => void require('@formatjs/intl-pluralrules/locale-data/bn.js'), + bs: () => void require('@formatjs/intl-pluralrules/locale-data/bs.js'), + bg: () => void require('@formatjs/intl-pluralrules/locale-data/bg.js'), + my: () => void require('@formatjs/intl-pluralrules/locale-data/my.js'), + ca: () => void require('@formatjs/intl-pluralrules/locale-data/ca.js'), + 'zh-Hans': () => void require('@formatjs/intl-pluralrules/locale-data/zh.js'), + 'zh-Hant': () => void require('@formatjs/intl-pluralrules/locale-data/zh.js'), + hr: () => void require('@formatjs/intl-pluralrules/locale-data/hr.js'), + cs: () => void require('@formatjs/intl-pluralrules/locale-data/cs.js'), + da: () => void require('@formatjs/intl-pluralrules/locale-data/da.js'), + nl: () => void require('@formatjs/intl-pluralrules/locale-data/nl.js'), + en: () => void require('@formatjs/intl-pluralrules/locale-data/en.js'), + et: () => void require('@formatjs/intl-pluralrules/locale-data/et.js'), + fil: () => void require('@formatjs/intl-pluralrules/locale-data/fil.js'), + fi: () => void require('@formatjs/intl-pluralrules/locale-data/fi.js'), + fr: () => void require('@formatjs/intl-pluralrules/locale-data/fr.js'), + gl: () => void require('@formatjs/intl-pluralrules/locale-data/gl.js'), + ka: () => void require('@formatjs/intl-pluralrules/locale-data/ka.js'), + de: () => void require('@formatjs/intl-pluralrules/locale-data/de.js'), + el: () => void require('@formatjs/intl-pluralrules/locale-data/el.js'), + gu: () => void require('@formatjs/intl-pluralrules/locale-data/gu.js'), + ht: () => void require('@formatjs/intl-pluralrules/locale-data/en.js'), + ha: () => void require('@formatjs/intl-pluralrules/locale-data/ha.js'), + he: () => void require('@formatjs/intl-pluralrules/locale-data/he.js'), + hi: () => void require('@formatjs/intl-pluralrules/locale-data/hi.js'), + hu: () => void require('@formatjs/intl-pluralrules/locale-data/hu.js'), + is: () => void require('@formatjs/intl-pluralrules/locale-data/is.js'), + ig: () => void require('@formatjs/intl-pluralrules/locale-data/ig.js'), + id: () => void require('@formatjs/intl-pluralrules/locale-data/id.js'), + ga: () => void require('@formatjs/intl-pluralrules/locale-data/ga.js'), + it: () => void require('@formatjs/intl-pluralrules/locale-data/it.js'), + ja: () => void require('@formatjs/intl-pluralrules/locale-data/ja.js'), + kn: () => void require('@formatjs/intl-pluralrules/locale-data/kn.js'), + kk: () => void require('@formatjs/intl-pluralrules/locale-data/kk.js'), + km: () => void require('@formatjs/intl-pluralrules/locale-data/km.js'), + ko: () => void require('@formatjs/intl-pluralrules/locale-data/ko.js'), + ckb: () => void require('@formatjs/intl-pluralrules/locale-data/ckb.js'), + lo: () => void require('@formatjs/intl-pluralrules/locale-data/lo.js'), + lv: () => void require('@formatjs/intl-pluralrules/locale-data/lv.js'), + lt: () => void require('@formatjs/intl-pluralrules/locale-data/lt.js'), + mk: () => void require('@formatjs/intl-pluralrules/locale-data/mk.js'), + mg: () => void require('@formatjs/intl-pluralrules/locale-data/mg.js'), + ms: () => void require('@formatjs/intl-pluralrules/locale-data/ms.js'), + ml: () => void require('@formatjs/intl-pluralrules/locale-data/ml.js'), + mt: () => void require('@formatjs/intl-pluralrules/locale-data/mt.js'), + mi: () => void require('@formatjs/intl-pluralrules/locale-data/en.js'), + mr: () => void require('@formatjs/intl-pluralrules/locale-data/mr.js'), + mn: () => void require('@formatjs/intl-pluralrules/locale-data/mn.js'), + ne: () => void require('@formatjs/intl-pluralrules/locale-data/ne.js'), + nb: () => void require('@formatjs/intl-pluralrules/locale-data/nb.js'), + or: () => void require('@formatjs/intl-pluralrules/locale-data/or.js'), + om: () => void require('@formatjs/intl-pluralrules/locale-data/om.js'), + ps: () => void require('@formatjs/intl-pluralrules/locale-data/ps.js'), + fa: () => void require('@formatjs/intl-pluralrules/locale-data/fa.js'), + pl: () => void require('@formatjs/intl-pluralrules/locale-data/pl.js'), + pt: () => void require('@formatjs/intl-pluralrules/locale-data/pt.js'), + 'pt-BR': () => void require('@formatjs/intl-pluralrules/locale-data/pt.js'), + pa: () => void require('@formatjs/intl-pluralrules/locale-data/pa.js'), + ro: () => void require('@formatjs/intl-pluralrules/locale-data/ro.js'), + ru: () => void require('@formatjs/intl-pluralrules/locale-data/ru.js'), + sr: () => void require('@formatjs/intl-pluralrules/locale-data/sr.js'), + si: () => void require('@formatjs/intl-pluralrules/locale-data/si.js'), + sk: () => void require('@formatjs/intl-pluralrules/locale-data/sk.js'), + sl: () => void require('@formatjs/intl-pluralrules/locale-data/sl.js'), + so: () => void require('@formatjs/intl-pluralrules/locale-data/so.js'), + es: () => void require('@formatjs/intl-pluralrules/locale-data/es.js'), + sw: () => void require('@formatjs/intl-pluralrules/locale-data/sw.js'), + sv: () => void require('@formatjs/intl-pluralrules/locale-data/sv.js'), + ta: () => void require('@formatjs/intl-pluralrules/locale-data/ta.js'), + te: () => void require('@formatjs/intl-pluralrules/locale-data/te.js'), + th: () => void require('@formatjs/intl-pluralrules/locale-data/th.js'), + tr: () => void require('@formatjs/intl-pluralrules/locale-data/tr.js'), + uk: () => void require('@formatjs/intl-pluralrules/locale-data/uk.js'), + ur: () => void require('@formatjs/intl-pluralrules/locale-data/ur.js'), + uz: () => void require('@formatjs/intl-pluralrules/locale-data/uz.js'), + vi: () => void require('@formatjs/intl-pluralrules/locale-data/vi.js'), + cy: () => void require('@formatjs/intl-pluralrules/locale-data/cy.js'), + yo: () => void require('@formatjs/intl-pluralrules/locale-data/yo.js'), + zu: () => void require('@formatjs/intl-pluralrules/locale-data/zu.js'), +} satisfies Record void>; + export function loadNumberFormatLocaleData(language: SupportedLanguage): void { NUMBER_FORMAT_LOCALE_LOADERS[language](); } @@ -189,3 +279,7 @@ export function loadNumberFormatLocaleData(language: SupportedLanguage): void { export function loadListFormatLocaleData(language: SupportedLanguage): void { LIST_FORMAT_LOCALE_LOADERS[language](); } + +export function loadPluralRulesLocaleData(language: SupportedLanguage): void { + PLURAL_RULES_LOCALE_LOADERS[language](); +} diff --git a/apps/mobile/src/lib/intl-cache-hermes-numberformat.test.ts b/apps/mobile/src/lib/intl-cache-hermes-numberformat.test.ts index de044b5671..8bd5ecfbb5 100644 --- a/apps/mobile/src/lib/intl-cache-hermes-numberformat.test.ts +++ b/apps/mobile/src/lib/intl-cache-hermes-numberformat.test.ts @@ -6,13 +6,12 @@ describe('Hermes NumberFormat compatibility', () => { vi.resetModules(); }); - it('polyfills currency parts and unit formatting when NumberFormat is missing', async () => { + it('polyfills currency parts and unit formatting when NumberFormat and PluralRules are missing', async () => { const nativeIntl = Intl; vi.stubGlobal('Intl', { Collator: nativeIntl.Collator, DateTimeFormat: nativeIntl.DateTimeFormat, getCanonicalLocales: nativeIntl.getCanonicalLocales, - PluralRules: nativeIntl.PluralRules, }); const { prewarmIntl } = await import('./intl-cache'); diff --git a/apps/mobile/src/lib/intl-cache-hermes-surface.test.ts b/apps/mobile/src/lib/intl-cache-hermes-surface.test.ts new file mode 100644 index 0000000000..269e7fce01 --- /dev/null +++ b/apps/mobile/src/lib/intl-cache-hermes-surface.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Hermes ships only these Intl members. Everything else must come from a polyfill. +function stubHermesIntl(): void { + const nativeIntl = Intl; + vi.stubGlobal('Intl', { + Collator: nativeIntl.Collator, + DateTimeFormat: nativeIntl.DateTimeFormat, + getCanonicalLocales: nativeIntl.getCanonicalLocales, + }); +} + +describe('Hermes Intl surface', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it('formats every supported language and pluralizes for i18next', async () => { + stubHermesIntl(); + const { SUPPORTED_LANGUAGES } = await import('@/i18n/languages'); + const { prewarmIntl, collator, dateTimeFormat } = await import('./intl-cache'); + const format = await import('./format'); + const { timeAgo } = await import('./utils'); + + for (const language of SUPPORTED_LANGUAGES) { + prewarmIntl(language); + expect(format.formatMoney(1234.5, language)).toBeTruthy(); + expect(format.formatNumber(3, language)).toBeTruthy(); + expect(format.formatPercent(0.5, language)).toBeTruthy(); + expect(format.formatList(['a', 'b', 'c'], language)).toBeTruthy(); + expect(format.formatDuration(3725, language)).toBeTruthy(); + expect(format.formatFileSize(2048, language)).toBeTruthy(); + expect(format.firstGrapheme('👍🏽x', language)).toBe('👍🏽'); + expect(format.formatDate(new Date(0), language)).toBeTruthy(); + expect(collator(language).compare('a', 'b')).toBeLessThan(0); + expect(dateTimeFormat(language, { hour: 'numeric' }).format(new Date(0))).toBeTruthy(); + expect(timeAgo(new Date(Date.now() - 3 * 86_400_000), language)).toBeTruthy(); + } + + // i18next builds its own Intl.PluralRules; without the polyfill it silently + // falls back to English one/other and caches that per language. + const { i18n } = await import('@/i18n'); + prewarmIntl('ru'); + await i18n.changeLanguage('ru'); + const few = i18n.t('prReview.hunkRows.fileLoadedCount', { count: 2, displayCount: '2' }); + const many = i18n.t('prReview.hunkRows.fileLoadedCount', { count: 5, displayCount: '5' }); + expect(few).not.toBe(many); + }); +}); diff --git a/apps/mobile/src/lib/intl-cache.ts b/apps/mobile/src/lib/intl-cache.ts index 093dc85aca..ab5accde66 100644 --- a/apps/mobile/src/lib/intl-cache.ts +++ b/apps/mobile/src/lib/intl-cache.ts @@ -2,11 +2,16 @@ import { shouldPolyfill as shouldPolyfillDurationFormat } from '@formatjs/intl-d import { shouldPolyfill as shouldPolyfillListFormat } from '@formatjs/intl-listformat/should-polyfill.js'; import { shouldPolyfill as shouldPolyfillLocale } from '@formatjs/intl-locale/should-polyfill.js'; import { shouldPolyfill as shouldPolyfillNumberFormat } from '@formatjs/intl-numberformat/should-polyfill.js'; +import { shouldPolyfill as shouldPolyfillPluralRules } from '@formatjs/intl-pluralrules/should-polyfill.js'; import { shouldPolyfill as shouldPolyfillRelativeTimeFormat } from '@formatjs/intl-relativetimeformat/should-polyfill.js'; import { shouldPolyfill as shouldPolyfillSegmenter } from '@formatjs/intl-segmenter/should-polyfill.js'; import { isSupportedLanguage, type SupportedLanguage } from '@/i18n/languages'; -import { loadListFormatLocaleData, loadNumberFormatLocaleData } from '@/lib/formatjs-locale-data'; +import { + loadListFormatLocaleData, + loadNumberFormatLocaleData, + loadPluralRulesLocaleData, +} from '@/lib/formatjs-locale-data'; import { RELATIVE_TIME_LOCALE_LOADERS } from '@/lib/relative-time-locales'; /** @@ -26,6 +31,7 @@ const durationFormats = new Map(); const collators = new Map(); const segmenters = new Map(); let usesNumberFormatPolyfill = false; +let usesPluralRulesPolyfill = false; let usesListFormatPolyfill = false; let usesRelativeTimePolyfill = false; let usesDurationFormatPolyfill = false; @@ -54,8 +60,22 @@ function ensureLocale(): void { } } +// Hermes ships without Intl.PluralRules, and the NumberFormat and +// RelativeTimeFormat polyfills construct one. +function ensurePluralRules(language: SupportedLanguage): void { + if (!usesPluralRulesPolyfill && shouldPolyfillPluralRules(language)) { + require('@formatjs/intl-pluralrules/polyfill-force.js'); + usesPluralRulesPolyfill = true; + } + if (usesPluralRulesPolyfill) { + ensureLocale(); + loadPluralRulesLocaleData(language); + } +} + function ensureNumberFormat(locale: string): void { const language = localeDataLanguage(locale); + ensurePluralRules(language); if (!usesNumberFormatPolyfill && shouldPolyfillNumberFormat(locale)) { require('@formatjs/intl-numberformat/polyfill-force.js'); usesNumberFormatPolyfill = true; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dc3e9738f..61505eb1b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,6 +339,9 @@ importers: '@formatjs/intl-numberformat': specifier: ^9.4.0 version: 9.4.0 + '@formatjs/intl-pluralrules': + specifier: ^6.3.13 + version: 6.3.13 '@formatjs/intl-relativetimeformat': specifier: ^12.3.13 version: 12.3.13 @@ -1359,7 +1362,7 @@ importers: version: 7.0.0-dev.20260514.1 jest: specifier: 30.3.0 - version: 30.3.0(@types/node@24.12.4)(node-notifier@10.0.1) + version: 30.3.0(@types/node@25.5.2)(node-notifier@10.0.1) typescript: specifier: 'catalog:' version: 5.9.3 @@ -5234,6 +5237,9 @@ packages: '@formatjs/intl-numberformat@9.4.0': resolution: {integrity: sha512-j7H3YoHOdfYjESj3yU9Aw/SApIBd3H91D87HpplkPWs0aIR7MOs9wh6GN8GSLKJQy6zXuK4d0Bgng5h+3JkrJg==} + '@formatjs/intl-pluralrules@6.3.13': + resolution: {integrity: sha512-78dxY4nu4BbB9J7WqqIFzr1EbhrbB6VE4KOGw4qlmX+IpS6YPM7tVYLLhDq7qOQCzq9+741iDMrgB1JlhHUM/Q==} + '@formatjs/intl-relativetimeformat@12.3.13': resolution: {integrity: sha512-qgH6XQugzCrFyZIMvhi5xlk6JcJxyF87kcWzH8XT6X8oJQpdljt4xQSsF55TjaRHhi+iK1Nu7YAI9QkNGF7L2g==} @@ -21893,6 +21899,11 @@ snapshots: '@formatjs/bigdecimal': 0.2.7 '@formatjs/intl-localematcher': 0.8.13 + '@formatjs/intl-pluralrules@6.3.13': + dependencies: + '@formatjs/bigdecimal': 0.2.7 + '@formatjs/intl-localematcher': 0.8.13 + '@formatjs/intl-relativetimeformat@12.3.13': dependencies: '@formatjs/intl-localematcher': 0.8.13 @@ -27292,7 +27303,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/expect@3.2.4': dependencies: @@ -27370,7 +27381,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/utils@3.2.4': dependencies: