fix: load only the active language's catalog at startup

All 27 locale catalogs were imported statically by `src/i18n/index.ts`,
so every cold start evaluated about 4.3 MB of JSON before the first
frame, although only English (the fallback) and the active language are
ever read.

Each catalog is now required from a per-locale loader the first time
`translate()` or `getDictionary()` asks for it. `t()` stays synchronous;
after a language switch the next render loads the new catalog. English
stays a static import because every lookup falls back to it, and the RN
overlay is merged when a catalog is first built, as before.

Measured with `npx expo export --platform android` (unminified bundle,
counting modules reached through top-level requires from the entry):
- catalogs evaluated at startup: 28 modules, 4,287 KB -> 2 modules,
  193 KB (English and the RN overlay; a non-English user also builds
  their own catalog on the first render)
- modules evaluated at startup: 3,590 -> 3,564; their code: 15,219 KB
  -> 11,122 KB
- the Hermes bundle keeps its size (9.34 MB); the catalogs are still in
  it, they are just not evaluated until used
This commit is contained in:
Linus Rath
2026-09-24 19:35:47 +02:00
parent 6c31c8d0c6
commit 604eb39340
2 changed files with 72 additions and 39 deletions
+20
View File
@@ -57,3 +57,23 @@ describe('translate', () => {
expect(translate('fr', 'email_list.no_trash_folder')).toContain('Trash');
});
});
describe('catalog loading', () => {
it('builds only the catalogs that are used, on first use', async () => {
vi.resetModules();
const i18n = await import('../index');
expect(i18n.loadedLocales()).toEqual([]);
expect(i18n.translate('de', 'settings.title')).not.toBe('settings.title');
expect(i18n.loadedLocales().sort()).toEqual(['de']);
// A key German lacks falls through to English, which is built then.
i18n.translate('de', 'email_list.no_trash_folder');
expect(i18n.loadedLocales().sort()).toEqual(['de', 'en']);
// Switching language loads the new catalog synchronously.
const title = i18n.translate('ja', 'settings.title');
expect(i18n.loadedLocales().sort()).toEqual(['de', 'en', 'ja']);
expect(title).toBe((i18n.getDictionary('ja') as { settings: { title: string } }).settings.title);
});
});
+52 -39
View File
@@ -8,33 +8,9 @@ import { I18nManager } from 'react-native';
import { getLocales } from 'expo-localization';
import { formatMessage, type MessageParams } from './format';
import ar from '../../locales/ar/common.json';
import ca from '../../locales/ca/common.json';
import cs from '../../locales/cs/common.json';
import da from '../../locales/da/common.json';
import de from '../../locales/de/common.json';
// English is the fallback for every lookup, so it is loaded eagerly. The other
// catalogs are loaded on first use; see CATALOG_LOADERS below.
import en from '../../locales/en/common.json';
import es from '../../locales/es/common.json';
import fa from '../../locales/fa/common.json';
import fr from '../../locales/fr/common.json';
import he from '../../locales/he/common.json';
import hu from '../../locales/hu/common.json';
import it from '../../locales/it/common.json';
import ja from '../../locales/ja/common.json';
import ko from '../../locales/ko/common.json';
import lv from '../../locales/lv/common.json';
import mn from '../../locales/mn/common.json';
import nb from '../../locales/nb/common.json';
import nl from '../../locales/nl/common.json';
import pl from '../../locales/pl/common.json';
import pt from '../../locales/pt/common.json';
import ro from '../../locales/ro/common.json';
import ru from '../../locales/ru/common.json';
import sk from '../../locales/sk/common.json';
import tr from '../../locales/tr/common.json';
import uk from '../../locales/uk/common.json';
import zh from '../../locales/zh/common.json';
import zhTW from '../../locales/zh-TW/common.json';
// Keys the native app needs that the webmail catalog does not carry. Only an
// English overlay exists today; other languages fall through to it via the
@@ -99,21 +75,58 @@ const RN_OVERLAYS: Partial<Record<LocaleCode, Dictionary>> = {
en: rnEn as Dictionary,
};
const BASE_DICTIONARIES: Record<LocaleCode, Dictionary> = {
ar, ca, cs, da, de, en, es, fa, fr, he, hu, it, ja, ko, lv, mn, nb, nl, pl,
pt, ro, ru, sk, tr, uk, zh, 'zh-TW': zhTW,
// A catalog is a few hundred KB of JSON. Metro only evaluates a module when it
// is first required, so keeping each require inside its loader means startup
// builds English plus the active language instead of all 27, and switching
// language loads the new catalog synchronously on the next t() call. The
// require paths must stay string literals for Metro to bundle them.
const CATALOG_LOADERS: Record<LocaleCode, () => Dictionary> = {
ar: () => require('../../locales/ar/common.json'),
ca: () => require('../../locales/ca/common.json'),
cs: () => require('../../locales/cs/common.json'),
da: () => require('../../locales/da/common.json'),
de: () => require('../../locales/de/common.json'),
en: () => en as Dictionary,
es: () => require('../../locales/es/common.json'),
fa: () => require('../../locales/fa/common.json'),
fr: () => require('../../locales/fr/common.json'),
he: () => require('../../locales/he/common.json'),
hu: () => require('../../locales/hu/common.json'),
it: () => require('../../locales/it/common.json'),
ja: () => require('../../locales/ja/common.json'),
ko: () => require('../../locales/ko/common.json'),
lv: () => require('../../locales/lv/common.json'),
mn: () => require('../../locales/mn/common.json'),
nb: () => require('../../locales/nb/common.json'),
nl: () => require('../../locales/nl/common.json'),
pl: () => require('../../locales/pl/common.json'),
pt: () => require('../../locales/pt/common.json'),
ro: () => require('../../locales/ro/common.json'),
ru: () => require('../../locales/ru/common.json'),
sk: () => require('../../locales/sk/common.json'),
tr: () => require('../../locales/tr/common.json'),
uk: () => require('../../locales/uk/common.json'),
zh: () => require('../../locales/zh/common.json'),
'zh-TW': () => require('../../locales/zh-TW/common.json'),
};
const dictionaries: Record<LocaleCode, Dictionary> = Object.fromEntries(
(Object.keys(BASE_DICTIONARIES) as LocaleCode[]).map((code) => {
const overlay = RN_OVERLAYS[code];
return [code, overlay ? deepMerge(BASE_DICTIONARIES[code], overlay) : BASE_DICTIONARIES[code]];
}),
) as Record<LocaleCode, Dictionary>;
const dictionaries: Partial<Record<LocaleCode, Dictionary>> = {};
// Test/tooling hook: the fully merged catalog for a locale.
/** The merged catalog (vendored + RN overlay) for a locale, loaded on first use. */
export function getDictionary(locale: LocaleCode): Dictionary {
return dictionaries[locale];
let dict = dictionaries[locale];
if (!dict) {
const base = CATALOG_LOADERS[locale]();
const overlay = RN_OVERLAYS[locale];
dict = overlay ? deepMerge(base, overlay) : base;
dictionaries[locale] = dict;
}
return dict;
}
/** Which catalogs have been loaded so far (for tests). */
export function loadedLocales(): LocaleCode[] {
return Object.keys(dictionaries) as LocaleCode[];
}
export function isSupportedLocale(code: string): code is LocaleCode {
@@ -192,8 +205,8 @@ export function translate(
fallback?: string,
params?: MessageParams,
): string {
const message = lookup(dictionaries[locale], key)
?? lookup(dictionaries.en, key)
const message = lookup(getDictionary(locale), key)
?? lookup(getDictionary('en'), key)
?? fallback
?? key;
return formatMessage(message, params, locale);