fix: keep device sync free of expo by registering device locales from i18n #75

This commit is contained in:
Linus Rath
2026-10-15 15:19:18 +02:00
parent 7e5e296271
commit c07b39a070
3 changed files with 36 additions and 17 deletions
+15
View File
@@ -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('.')) {
+3 -2
View File
@@ -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', () => {
+18 -15
View File
@@ -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<string, string>();
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<typeof getLocales>;
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();
}