⚙️ refactor: Lazy load locale resources (#13640)

This commit is contained in:
Ravi Kumar L 2026-06-10 08:48:58 -04:00 committed by GitHub
parent 56608739f8
commit db863e75e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 651 additions and 167 deletions

View file

@ -1,9 +1,12 @@
import { defaultNS, resources } from '~/locales/i18n';
import translationEn from '~/locales/en/translation.json';
import { defaultNS } from '~/locales/i18n';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
resources: typeof resources.en;
resources: {
translation: typeof translationEn;
};
strictKeyChecks: true;
}
}

View file

@ -9,6 +9,7 @@ import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
import WakeLockManager from '~/components/System/WakeLockManager';
import LanguageSync from '~/components/System/LanguageSync';
import { getThemeFromEnv } from './utils/getThemeFromEnv';
import { initializeFontSize } from '~/store/fontSize';
import { LiveAnnouncer } from '~/a11y';
@ -47,6 +48,7 @@ const App = () => {
return (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<LanguageSync />
<LiveAnnouncer>
<ThemeProvider
// Only pass initialTheme and themeRGB if environment theme exists

View file

@ -1,7 +1,7 @@
import React, { useContext, useCallback } from 'react';
import Cookies from 'js-cookie';
import { useRecoilState } from 'recoil';
import { Dropdown, ThemeContext } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { Dropdown, Spinner, ThemeContext } from '@librechat/client';
import ArchivedChats from './ArchivedChats';
import ToggleSwitch from '../ToggleSwitch';
import { useLocalize } from '~/hooks';
@ -85,6 +85,7 @@ export const LangSelector = ({
portal?: boolean;
}) => {
const localize = useLocalize();
const isLanguageLoading = useRecoilValue(store.languageLoading);
const languageOptions = [
{ value: 'auto', label: localize('com_nav_lang_auto') },
@ -137,15 +138,26 @@ export const LangSelector = ({
<div className="flex items-center justify-between">
<div id={labelId}>{localize('com_nav_language')}</div>
<Dropdown
value={langcode}
onChange={onChange}
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
options={languageOptions}
className="z-50"
aria-labelledby={labelId}
portal={portal}
/>
<div className="flex items-center gap-2">
{isLanguageLoading && (
<span
role="status"
aria-label={localize('com_ui_loading')}
className="flex size-5 items-center justify-center text-text-secondary"
>
<Spinner className="size-4" />
</span>
)}
<Dropdown
value={langcode}
onChange={onChange}
sizeClasses="[--anchor-max-height:256px] max-h-[60vh]"
options={languageOptions}
className="z-50"
aria-labelledby={labelId}
portal={portal}
/>
</div>
</div>
);
};
@ -169,9 +181,6 @@ function General() {
userLang = navigator.language || navigator.languages[0];
}
requestAnimationFrame(() => {
document.documentElement.lang = userLang;
});
setLangcode(userLang);
Cookies.set('lang', userLang, { expires: 365 });
},

View file

@ -2,8 +2,9 @@ import 'test/matchMedia.mock';
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { LangSelector } from './General';
import { RecoilRoot } from 'recoil';
import { LangSelector } from './General';
import store from '~/store';
describe('LangSelector', () => {
let mockOnChange;
@ -54,4 +55,19 @@ describe('LangSelector', () => {
expect(mockOnChange).toHaveBeenCalledWith('it-IT');
});
});
it('shows a loading indicator while language resources load', () => {
global.ResizeObserver = class MockedResizeObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
};
const { getByRole } = render(
<RecoilRoot initializeState={({ set }) => set(store.languageLoading, true)}>
<LangSelector langcode="en-US" onChange={mockOnChange} />
</RecoilRoot>,
);
expect(getByRole('status', { name: 'Loading...' })).toBeInTheDocument();
});
});

View file

@ -17,11 +17,11 @@ import {
OGDialogTrigger,
} from '@librechat/client';
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/General';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import { ShareArtifactsContainer } from './ShareArtifacts';
import { useLocalize, useDocumentTitle } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { ShareContext } from '~/Providers';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import MessagesView from './MessagesView';
import Footer from '../Chat/Footer';
import { cn } from '~/utils';
@ -80,10 +80,6 @@ function SharedView() {
: null) ?? 'en-US';
}
requestAnimationFrame(() => {
document.documentElement.lang = userLang;
});
setLangcode(userLang);
Cookies.set('lang', userLang, { expires: 365 });
},

View file

@ -0,0 +1,35 @@
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import i18n, { changeLanguageSafely, normalizeLocale } from '~/locales/i18n';
import store from '~/store';
export default function LanguageSync() {
const lang = useRecoilValue(store.lang);
const setLanguageLoading = useSetRecoilState(store.languageLoading);
useEffect(() => {
if (i18n.language === normalizeLocale(lang)) {
setLanguageLoading(false);
return;
}
let isCurrentRequest = true;
setLanguageLoading(true);
changeLanguageSafely(lang)
.catch((error) => {
console.error('[i18n] Failed to change language', error);
})
.finally(() => {
if (isCurrentRequest) {
setLanguageLoading(false);
}
});
return () => {
isCurrentRequest = false;
};
}, [lang, setLanguageLoading]);
return null;
}

View file

@ -1,21 +1,12 @@
import { useCallback, useEffect } from 'react';
import { useCallback } from 'react';
import { TOptions } from 'i18next';
import { useRecoilValue } from 'recoil';
import { useTranslation } from 'react-i18next';
import { resources } from '~/locales/i18n';
import store from '~/store';
import translationEn from '~/locales/en/translation.json';
export type TranslationKeys = keyof typeof resources.en.translation;
export type TranslationKeys = keyof typeof translationEn;
export default function useLocalize() {
const lang = useRecoilValue(store.lang);
const { t, i18n } = useTranslation();
useEffect(() => {
if (i18n.language !== lang) {
i18n.changeLanguage(lang);
}
}, [lang, i18n]);
const { t } = useTranslation();
return useCallback(
(phraseKey: TranslationKeys, options?: TOptions) => t(phraseKey, options),

View file

@ -1,48 +1,193 @@
import i18n from './i18n';
import type { TranslationResource } from './i18n';
import {
__resetLocaleForTests,
__setLocaleLoaderForTests,
changeLanguageSafely,
ensureLocale,
initializeI18n,
normalizeLocale,
} from './i18n';
import English from './en/translation.json';
import French from './fr/translation.json';
import Spanish from './es/translation.json';
import French from './fr/translation.json';
import { TranslationKeys } from '~/hooks';
import i18n from './i18n';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe('i18next translation tests', () => {
// Ensure i18next is initialized before any tests run
beforeAll(async () => {
if (!i18n.isInitialized) {
await i18n.init();
}
await initializeI18n();
});
it('should return the correct translation for a valid key in English', () => {
i18n.changeLanguage('en');
afterEach(async () => {
await changeLanguageSafely('en');
});
it('should return the correct translation for a valid key in English', async () => {
await changeLanguageSafely('en');
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
});
it('should return the correct translation for a valid key in French', () => {
i18n.changeLanguage('fr');
it('should return the correct translation for a valid key in French', async () => {
await changeLanguageSafely('fr');
expect(i18n.t('com_ui_examples')).toBe(French.com_ui_examples);
});
it('should return the correct translation for a valid key in Spanish', () => {
i18n.changeLanguage('es');
it('should return the correct translation for a valid key in Spanish', async () => {
await changeLanguageSafely('es');
expect(i18n.t('com_ui_examples')).toBe(Spanish.com_ui_examples);
});
it('should fallback to English for an invalid language code', () => {
it('should fallback to English for an invalid language code', async () => {
// When an invalid language is provided, i18next should fallback to English
i18n.changeLanguage('invalid-code');
await changeLanguageSafely('invalid-code');
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
});
it('should return the key itself for an invalid key', () => {
i18n.changeLanguage('en');
it('should return the key itself for an invalid key', async () => {
await changeLanguageSafely('en');
expect(i18n.t('invalid-key' as TranslationKeys)).toBe('invalid-key'); // Returns the key itself
});
it('should correctly format placeholders in the translation', () => {
i18n.changeLanguage('en');
it('should correctly format placeholders in the translation', async () => {
await changeLanguageSafely('en');
expect(i18n.t('com_endpoint_default_with_num', { 0: 'John' })).toBe('default: John');
i18n.changeLanguage('fr');
await changeLanguageSafely('fr');
expect(i18n.t('com_endpoint_default_with_num', { 0: 'Marie' })).toBe('par défaut : Marie');
});
it('should normalize language selector values to locale files', () => {
expect(normalizeLocale('en-US')).toBe('en');
expect(normalizeLocale('de-DE')).toBe('de');
expect(normalizeLocale('fr-FR')).toBe('fr');
expect(normalizeLocale('ar-EG')).toBe('ar');
expect(normalizeLocale('he-IL')).toBe('he');
expect(normalizeLocale('nl-NL')).toBe('nl');
expect(normalizeLocale('pl-PL')).toBe('pl');
expect(normalizeLocale('uk-UA')).toBe('uk');
expect(normalizeLocale('zh-Hans')).toBe('zh-Hans');
expect(normalizeLocale('zh-Hant')).toBe('zh-Hant');
expect(normalizeLocale('pt-BR')).toBe('pt-BR');
expect(normalizeLocale('pt-PT')).toBe('pt-PT');
});
it('should reuse an in-flight locale load', async () => {
__resetLocaleForTests('sv');
const pendingLocale = deferred<{ default: TranslationResource }>();
const loadLocale = jest.fn(() => pendingLocale.promise);
const restoreLoader = __setLocaleLoaderForTests('sv', loadLocale);
const firstLoad = ensureLocale('sv-SE');
const secondLoad = ensureLocale('sv-SE');
expect(loadLocale).toHaveBeenCalledTimes(1);
pendingLocale.resolve({ default: { com_ui_examples: 'svenska exempel' } });
await expect(Promise.all([firstLoad, secondLoad])).resolves.toEqual(['sv', 'sv']);
expect(i18n.getResource('sv', 'translation', 'com_ui_examples')).toBe('svenska exempel');
restoreLoader();
__resetLocaleForTests('sv');
});
it('should retry a locale load after a transient failure', async () => {
__resetLocaleForTests('ka');
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
let callCount = 0;
const restoreLoader = __setLocaleLoaderForTests('ka', async () => {
callCount += 1;
if (callCount === 1) {
throw new Error('temporary chunk failure');
}
return { default: { com_ui_examples: 'ქართული მაგალითები' } };
});
await expect(ensureLocale('ka-GE')).resolves.toBe('en');
await expect(ensureLocale('ka-GE')).resolves.toBe('ka');
expect(callCount).toBe(2);
expect(i18n.getResource('ka', 'translation', 'com_ui_examples')).toBe('ქართული მაგალითები');
restoreLoader();
__resetLocaleForTests('ka');
consoleErrorSpy.mockRestore();
});
it('should only apply the newest rapid language switch', async () => {
__resetLocaleForTests('sv');
__resetLocaleForTests('ka');
__resetLocaleForTests('sl');
const svLocale = deferred<{ default: TranslationResource }>();
const kaLocale = deferred<{ default: TranslationResource }>();
const slLocale = deferred<{ default: TranslationResource }>();
const restoreSv = __setLocaleLoaderForTests('sv', () => svLocale.promise);
const restoreKa = __setLocaleLoaderForTests('ka', () => kaLocale.promise);
const restoreSl = __setLocaleLoaderForTests('sl', () => slLocale.promise);
const firstSwitch = changeLanguageSafely('sv-SE');
const secondSwitch = changeLanguageSafely('ka-GE');
const latestSwitch = changeLanguageSafely('sl');
svLocale.resolve({ default: { com_ui_examples: 'svenska exempel' } });
await firstSwitch;
expect(i18n.language).not.toBe('sv');
kaLocale.resolve({ default: { com_ui_examples: 'ქართული მაგალითები' } });
await secondSwitch;
expect(i18n.language).not.toBe('ka');
slLocale.resolve({ default: { com_ui_examples: 'slovenski primeri' } });
await expect(latestSwitch).resolves.toBe('sl');
expect(i18n.language).toBe('sl');
expect(document.documentElement.lang).toBe('sl');
restoreSv();
restoreKa();
restoreSl();
__resetLocaleForTests('sv');
__resetLocaleForTests('ka');
__resetLocaleForTests('sl');
});
it('should restore the newest language if an older change finishes late', async () => {
__resetLocaleForTests('sv');
__resetLocaleForTests('sl');
const svLocale = deferred<{ default: TranslationResource }>();
const slLocale = deferred<{ default: TranslationResource }>();
const restoreSv = __setLocaleLoaderForTests('sv', () => svLocale.promise);
const restoreSl = __setLocaleLoaderForTests('sl', () => slLocale.promise);
const firstSwitch = changeLanguageSafely('sv-SE');
const latestSwitch = changeLanguageSafely('sl');
slLocale.resolve({ default: { com_ui_examples: 'slovenski primeri' } });
await expect(latestSwitch).resolves.toBe('sl');
expect(i18n.language).toBe('sl');
svLocale.resolve({ default: { com_ui_examples: 'svenska exempel' } });
await firstSwitch;
expect(i18n.language).toBe('sl');
expect(document.documentElement.lang).toBe('sl');
restoreSv();
restoreSl();
__resetLocaleForTests('sv');
__resetLocaleForTests('sl');
});
});

View file

@ -1,112 +1,337 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import your JSON translations
import translationEn from './en/translation.json';
import translationAr from './ar/translation.json';
import translationCa from './ca/translation.json';
import translationCs from './cs/translation.json';
import translationDa from './da/translation.json';
import translationDe from './de/translation.json';
import translationEs from './es/translation.json';
import translationEt from './et/translation.json';
import translationFa from './fa/translation.json';
import translationFr from './fr/translation.json';
import translationIt from './it/translation.json';
import translationPl from './pl/translation.json';
import translationPt_BR from './pt-BR/translation.json';
import translationPt_PT from './pt-PT/translation.json';
import translationRu from './ru/translation.json';
import translationJa from './ja/translation.json';
import translationKa from './ka/translation.json';
import translationSv from './sv/translation.json';
import translationKo from './ko/translation.json';
import translationLt from './lt/translation.json';
import translationLv from './lv/translation.json';
import translationTh from './th/translation.json';
import translationTr from './tr/translation.json';
import translationUg from './ug/translation.json';
import translationVi from './vi/translation.json';
import translationNl from './nl/translation.json';
import translationNn from './nn/translation.json';
import translationId from './id/translation.json';
import translationIs from './is/translation.json';
import translationHe from './he/translation.json';
import translationHu from './hu/translation.json';
import translationHy from './hy/translation.json';
import translationFi from './fi/translation.json';
import translationZh_Hans from './zh-Hans/translation.json';
import translationZh_Hant from './zh-Hant/translation.json';
import translationSk from './sk/translation.json';
import translationBo from './bo/translation.json';
import translationUk from './uk/translation.json';
import translationBs from './bs/translation.json';
import translationNb from './nb/translation.json';
import translationSl from './sl/translation.json';
export const defaultNS = 'translation';
export const supportedLocales = [
'ar',
'bo',
'bs',
'ca',
'cs',
'da',
'de',
'en',
'es',
'et',
'fa',
'fi',
'fr',
'he',
'hu',
'hy',
'id',
'is',
'it',
'ja',
'ka',
'ko',
'lt',
'lv',
'nb',
'nl',
'nn',
'pl',
'pt-BR',
'pt-PT',
'ru',
'sk',
'sl',
'sv',
'th',
'tr',
'ug',
'uk',
'vi',
'zh-Hans',
'zh-Hant',
] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
export type TranslationResource = Record<string, string>;
export const resources = {
en: { translation: translationEn },
ar: { translation: translationAr },
bs: { translation: translationBs },
ca: { translation: translationCa },
cs: { translation: translationCs },
'zh-Hans': { translation: translationZh_Hans },
'zh-Hant': { translation: translationZh_Hant },
da: { translation: translationDa },
de: { translation: translationDe },
es: { translation: translationEs },
et: { translation: translationEt },
fa: { translation: translationFa },
fr: { translation: translationFr },
it: { translation: translationIt },
nb: { translation: translationNb },
pl: { translation: translationPl },
'pt-BR': { translation: translationPt_BR },
'pt-PT': { translation: translationPt_PT },
ru: { translation: translationRu },
ja: { translation: translationJa },
ka: { translation: translationKa },
sv: { translation: translationSv },
ko: { translation: translationKo },
lt: { translation: translationLt },
lv: { translation: translationLv },
th: { translation: translationTh },
tr: { translation: translationTr },
ug: { translation: translationUg },
vi: { translation: translationVi },
nl: { translation: translationNl },
nn: { translation: translationNn },
id: { translation: translationId },
is: { translation: translationIs },
he: { translation: translationHe },
hu: { translation: translationHu },
hy: { translation: translationHy },
fi: { translation: translationFi },
sk: { translation: translationSk },
bo: { translation: translationBo },
sl: { translation: translationSl },
uk: { translation: translationUk },
} as const;
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: {
'zh-TW': ['zh-Hant', 'en'],
'zh-HK': ['zh-Hant', 'en'],
zh: ['zh-Hans', 'en'],
default: ['en'],
},
fallbackNS: 'translation',
ns: ['translation'],
debug: false,
defaultNS,
resources,
interpolation: { escapeValue: false },
});
const localeLoaders: Record<
Exclude<SupportedLocale, 'en'>,
() => Promise<{ default: TranslationResource }>
> = {
ar: () => import('./ar/translation.json'),
bo: () => import('./bo/translation.json'),
bs: () => import('./bs/translation.json'),
ca: () => import('./ca/translation.json'),
cs: () => import('./cs/translation.json'),
da: () => import('./da/translation.json'),
de: () => import('./de/translation.json'),
es: () => import('./es/translation.json'),
et: () => import('./et/translation.json'),
fa: () => import('./fa/translation.json'),
fi: () => import('./fi/translation.json'),
fr: () => import('./fr/translation.json'),
he: () => import('./he/translation.json'),
hu: () => import('./hu/translation.json'),
hy: () => import('./hy/translation.json'),
id: () => import('./id/translation.json'),
is: () => import('./is/translation.json'),
it: () => import('./it/translation.json'),
ja: () => import('./ja/translation.json'),
ka: () => import('./ka/translation.json'),
ko: () => import('./ko/translation.json'),
lt: () => import('./lt/translation.json'),
lv: () => import('./lv/translation.json'),
nb: () => import('./nb/translation.json'),
nl: () => import('./nl/translation.json'),
nn: () => import('./nn/translation.json'),
pl: () => import('./pl/translation.json'),
'pt-BR': () => import('./pt-BR/translation.json'),
'pt-PT': () => import('./pt-PT/translation.json'),
ru: () => import('./ru/translation.json'),
sk: () => import('./sk/translation.json'),
sl: () => import('./sl/translation.json'),
sv: () => import('./sv/translation.json'),
th: () => import('./th/translation.json'),
tr: () => import('./tr/translation.json'),
ug: () => import('./ug/translation.json'),
uk: () => import('./uk/translation.json'),
vi: () => import('./vi/translation.json'),
'zh-Hans': () => import('./zh-Hans/translation.json'),
'zh-Hant': () => import('./zh-Hant/translation.json'),
};
const localeByLowercase = supportedLocales.reduce<Record<string, SupportedLocale>>(
(acc, locale) => {
acc[locale.toLowerCase()] = locale;
return acc;
},
{},
);
const localeAliases: Record<string, SupportedLocale> = {
'ar-eg': 'ar',
'ca-es': 'ca',
'cs-cz': 'cs',
'da-dk': 'da',
'de-de': 'de',
'en-us': 'en',
'es-es': 'es',
'et-ee': 'et',
'fa-ir': 'fa',
'fi-fi': 'fi',
'fr-fr': 'fr',
'he-he': 'he',
'he-il': 'he',
'hu-hu': 'hu',
'hy-am': 'hy',
'id-id': 'id',
'it-it': 'it',
'ja-jp': 'ja',
'ka-ge': 'ka',
'ko-kr': 'ko',
'lt-lt': 'lt',
'lv-lv': 'lv',
'nl-nl': 'nl',
'pl-pl': 'pl',
pt: 'pt-PT',
'ru-ru': 'ru',
'sv-se': 'sv',
'th-th': 'th',
'tr-tr': 'tr',
'uk-ua': 'uk',
'vi-vn': 'vi',
zh: 'zh-Hans',
'zh-cn': 'zh-Hans',
'zh-sg': 'zh-Hans',
'zh-tw': 'zh-Hant',
'zh-hk': 'zh-Hant',
'zh-mo': 'zh-Hant',
};
const loadedLocales = new Set<SupportedLocale>(['en']);
const loadingLocales: Partial<Record<SupportedLocale, Promise<SupportedLocale>>> = {};
let languageRequestId = 0;
let latestRequestedLocale: SupportedLocale = 'en';
function readCookie(name: string) {
if (typeof document === 'undefined') {
return undefined;
}
const prefix = `${name}=`;
return document.cookie
.split(';')
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(prefix))
?.slice(prefix.length);
}
function readStoredLanguage() {
if (typeof localStorage === 'undefined') {
return undefined;
}
const raw = localStorage.getItem('lang');
if (!raw) {
return undefined;
}
try {
const parsed = JSON.parse(raw);
return typeof parsed === 'string' ? parsed : raw;
} catch {
return raw;
}
}
function getNavigatorLanguage() {
if (typeof navigator === 'undefined') {
return 'en';
}
return navigator.language || navigator.languages?.[0] || 'en';
}
export function normalizeLocale(locale?: string | null): SupportedLocale {
const requested = locale === 'auto' ? getNavigatorLanguage() : locale;
if (!requested) {
return 'en';
}
const normalized = requested.replace(/_/g, '-').toLowerCase();
const exact = localeByLowercase[normalized];
if (exact) {
return exact;
}
const alias = localeAliases[normalized];
if (alias) {
return alias;
}
const base = normalized.split('-')[0];
return localeByLowercase[base] ?? localeAliases[base] ?? 'en';
}
export function detectInitialLanguage() {
const cookieLang = readCookie('lang');
const storedLang = readStoredLanguage();
return normalizeLocale(cookieLang || storedLang || getNavigatorLanguage());
}
export async function ensureLocale(locale?: string | null): Promise<SupportedLocale> {
const normalized = normalizeLocale(locale);
if (normalized === 'en') {
return normalized;
}
if (loadedLocales.has(normalized)) {
return normalized;
}
if (i18n.hasResourceBundle(normalized, defaultNS)) {
loadedLocales.add(normalized);
return normalized;
}
if (!loadingLocales[normalized]) {
const loader = localeLoaders[normalized];
loadingLocales[normalized] = loader()
.then((module) => {
i18n.addResourceBundle(normalized, defaultNS, module.default, true, true);
loadedLocales.add(normalized);
return normalized;
})
.catch((error): SupportedLocale => {
console.error(`[i18n] Failed to load locale "${normalized}"`, error);
return 'en';
})
.finally(() => {
delete loadingLocales[normalized];
});
}
return loadingLocales[normalized] ?? Promise.resolve('en');
}
export function __setLocaleLoaderForTests(
locale: Exclude<SupportedLocale, 'en'>,
loader: () => Promise<{ default: TranslationResource }>,
) {
const previousLoader = localeLoaders[locale];
localeLoaders[locale] = loader;
return () => {
localeLoaders[locale] = previousLoader;
};
}
export function __resetLocaleForTests(locale: Exclude<SupportedLocale, 'en'>) {
delete loadingLocales[locale];
loadedLocales.delete(locale);
if (i18n.hasResourceBundle(locale, defaultNS)) {
i18n.removeResourceBundle(locale, defaultNS);
}
}
export function syncDocumentLanguage(locale: SupportedLocale) {
if (typeof document === 'undefined') {
return;
}
document.documentElement.lang = locale;
document.documentElement.dir = i18n.dir(locale);
}
export const i18nInitPromise = i18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: {
'zh-TW': ['zh-Hant', 'en'],
'zh-HK': ['zh-Hant', 'en'],
zh: ['zh-Hans', 'en'],
default: ['en'],
},
fallbackNS: defaultNS,
ns: [defaultNS],
debug: false,
defaultNS,
resources,
supportedLngs: [...supportedLocales],
partialBundledLanguages: true,
load: 'currentOnly',
react: { useSuspense: false },
interpolation: { escapeValue: false },
});
export async function changeLanguageSafely(locale?: string | null) {
const requestId = ++languageRequestId;
const requestedLocale = normalizeLocale(locale);
latestRequestedLocale = requestedLocale;
await i18nInitPromise;
const loadedLocale = await ensureLocale(requestedLocale);
if (requestId !== languageRequestId) {
return i18n.language;
}
await i18n.changeLanguage(loadedLocale);
if (requestId !== languageRequestId) {
const latestLocale = await ensureLocale(latestRequestedLocale);
await i18n.changeLanguage(latestLocale);
syncDocumentLanguage(latestLocale);
return i18n.language;
}
syncDocumentLanguage(loadedLocale);
return loadedLocale;
}
export async function initializeI18n() {
const initialLanguage = detectInitialLanguage();
await changeLanguageSafely(initialLanguage);
return initialLanguage;
}
export default i18n;

View file

@ -1,6 +1,6 @@
import './polyfills/regeneratorRuntime';
import { createRoot } from 'react-dom/client';
import './locales/i18n';
import { initializeI18n } from './locales/i18n';
import App from './App';
import '@librechat/client/style.css';
import './style.css';
@ -12,8 +12,21 @@ import 'katex/dist/contrib/copy-tex.js';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<ApiErrorBoundaryProvider>
<App />
</ApiErrorBoundaryProvider>,
);
async function bootstrap() {
await initializeI18n();
root.render(
<ApiErrorBoundaryProvider>
<App />
</ApiErrorBoundaryProvider>,
);
}
bootstrap().catch((error) => {
console.error('[i18n] Failed to initialize before render', error);
root.render(
<ApiErrorBoundaryProvider>
<App />
</ApiErrorBoundaryProvider>,
);
});

View file

@ -1,11 +1,36 @@
import { atom } from 'recoil';
import Cookies from 'js-cookie';
import { atomWithLocalStorage } from './utils';
const readStoredLang = () => {
if (typeof localStorage === 'undefined') {
return undefined;
}
const storedLang = localStorage.getItem('lang');
if (!storedLang) {
return undefined;
}
try {
const parsedLang = JSON.parse(storedLang);
return typeof parsedLang === 'string' ? parsedLang : storedLang;
} catch {
return storedLang;
}
};
const defaultLang = () => {
const userLang = navigator.language || navigator.languages[0];
return Cookies.get('lang') || localStorage.getItem('lang') || userLang;
const userLang =
(typeof navigator !== 'undefined' ? navigator.language || navigator.languages?.[0] : null) ??
'en';
return Cookies.get('lang') || readStoredLang() || userLang;
};
const lang = atomWithLocalStorage('lang', defaultLang());
const languageLoading = atom<boolean>({
key: 'languageLoading',
default: false,
});
export default { lang };
export default { lang, languageLoading };

View file

@ -83,10 +83,32 @@ export default defineConfig(({ command }) => ({
'assets/maskable-icon.png',
'manifest.webmanifest',
],
globIgnores: ['images/**/*', '**/*.map', 'index.html', 'assets/rum.*.js'],
globIgnores: [
'images/**/*',
'**/*.map',
'index.html',
'assets/rum.*.js',
'assets/locale-*.js',
],
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
/** LibreChat mutates index.html per request for subpath and language support. */
navigateFallback: null,
runtimeCaching: [
{
urlPattern: ({ url }) => /\/assets\/locale-[^/]+\.js$/.test(url.pathname),
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'locale-chunks',
cacheableResponse: {
statuses: [0, 200],
},
expiration: {
maxEntries: 80,
maxAgeSeconds: 30 * 24 * 60 * 60,
},
},
},
],
},
includeAssets: [],
manifest: {
@ -306,9 +328,12 @@ export default defineConfig(({ command }) => ({
if (normalizedId.includes('/src/polyfills/')) {
return 'polyfills';
}
// Create a separate chunk for all locale files under src/locales.
if (normalizedId.includes('/src/locales/')) {
return 'locales';
// Keep lazy-loaded locale files in one chunk per locale.
const localeMatch = normalizedId.match(
/\/src\/locales\/([^/]+)\/translation\.json$/,
);
if (localeMatch) {
return localeMatch[1] === 'en' ? null : `locale-${localeMatch[1]}`;
}
// Let Rolldown decide automatically for any other files.
return null;

View file

@ -1,9 +1,8 @@
import { useCallback } from 'react';
import { TOptions } from 'i18next';
import { useTranslation } from 'react-i18next';
import { resources } from '~/locales/i18n';
export type TranslationKeys = keyof typeof resources.en.translation;
export type TranslationKeys = string;
/** Language lifecycle is managed by the host app — do not add i18n.changeLanguage() calls here. */
export default function useLocalize(): (phraseKey: TranslationKeys, options?: TOptions) => string {