From c07b39a070f59545f47d08f11bba4d8dfb5696dd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:19:18 +0200 Subject: [PATCH] fix: keep device sync free of expo by registering device locales from i18n #75 --- src/i18n/index.ts | 15 ++++++++++++ src/lib/__tests__/intl-locale.test.ts | 5 ++-- src/lib/intl-locale.ts | 33 +++++++++++++++------------ 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 0b889b3b..ed0a57d0 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -7,6 +7,7 @@ import { I18nManager } from 'react-native'; import { getLocales } from 'expo-localization'; import { formatMessage, type MessageParams } from './format'; +import { setDeviceLocales } from '../lib/intl-locale'; // English is the fallback for every lookup, so it is loaded eagerly. The other // catalogs are loaded on first use; see CATALOG_LOADERS below. @@ -212,6 +213,20 @@ export function detectDeviceLocale(): LocaleCode { return 'en'; } +/** + * Hand the device locales to the date formatters (lib/intl-locale), so an + * en-AU phone formats dates as en-AU while reading the English catalog. + */ +export function registerDeviceLocales(): void { + try { + setDeviceLocales(getLocales().map((l) => ({ appLocale: resolveLocaleTag(l), languageTag: l.languageTag }))); + } catch { + setDeviceLocales([]); + } +} + +registerDeviceLocales(); + function lookup(dict: Dictionary, key: string): string | undefined { let current: unknown = dict; for (const part of key.split('.')) { diff --git a/src/lib/__tests__/intl-locale.test.ts b/src/lib/__tests__/intl-locale.test.ts index bb5bee65..7ad5dc58 100644 --- a/src/lib/__tests__/intl-locale.test.ts +++ b/src/lib/__tests__/intl-locale.test.ts @@ -7,13 +7,14 @@ vi.mock('expo-localization', () => ({ getCalendars: () => [{ timeZone: 'UTC', firstWeekday: 1 }], })); -import { intlLocale, resetIntlLocaleCache } from '../intl-locale'; +import { intlLocale } from '../intl-locale'; +import { registerDeviceLocales } from '../../i18n'; import { formatCalendarDate, formatListDate } from '../date-format'; import { formatHeaderDate } from '../email-date'; function setDevice(...tags: string[]) { device.locales = tags.map((t) => ({ languageTag: t, languageCode: t.split('-')[0] })); - resetIntlLocaleCache(); + registerDeviceLocales(); } describe('intlLocale', () => { diff --git a/src/lib/intl-locale.ts b/src/lib/intl-locale.ts index c768559a..f1f2e359 100644 --- a/src/lib/intl-locale.ts +++ b/src/lib/intl-locale.ts @@ -3,11 +3,25 @@ // order: en-AU writes 15/10/2026, en-US 10/15/2026. intlLocale() hands the // date formatters the device's full tag when its language matches the app // language, so an Australian or British phone gets its own dates (#75). -import { getLocales } from 'expo-localization'; -import { resolveLocaleTag } from '../i18n'; +// +// This module stays free of react-native and expo because the device sync +// engine reaches it through quote-header (see device-sync purity test). The +// i18n module reads the device locales and registers them here on load. +export interface DeviceLocale { + /** The app language this device locale selects, from resolveLocaleTag. */ + appLocale: string | null; + languageTag: string; +} + +let deviceLocales: DeviceLocale[] = []; const cache = new Map(); +export function setDeviceLocales(locales: DeviceLocale[]): void { + deviceLocales = locales; + cache.clear(); +} + // Android 14 appends regional preferences such as `-u-fw-mon-mu-celsius`. // The app has its own time-format and week-start settings, so keep only the // language, script and region. @@ -25,14 +39,8 @@ function isUsable(tag: string): boolean { } function deviceTagFor(locale: string): string | null { - let devices: ReturnType; - try { - devices = getLocales(); - } catch { - return null; - } - for (const l of devices) { - if (!l.languageTag || resolveLocaleTag(l) !== locale) continue; + for (const l of deviceLocales) { + if (!l.languageTag || l.appLocale !== locale) continue; const tag = baseTag(l.languageTag); // A tag without a region adds nothing over the language subtag. if (tag.includes('-') && isUsable(tag)) return tag; @@ -52,8 +60,3 @@ export function intlLocale(locale: string | null | undefined): string { cache.set(lang, resolved); return resolved; } - -/** Test hook: forget the resolved tags so a changed device locale is seen. */ -export function resetIntlLocaleCache(): void { - cache.clear(); -}