diff --git a/client/src/@types/i18next.d.ts b/client/src/@types/i18next.d.ts index 82f1ce1a3d..2070c55271 100644 --- a/client/src/@types/i18next.d.ts +++ b/client/src/@types/i18next.d.ts @@ -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; } } diff --git a/client/src/App.jsx b/client/src/App.jsx index fe280f7129..975291526c 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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 ( + { 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 = ({
{localize('com_nav_language')}
- +
+ {isLanguageLoading && ( + + + + )} + +
); }; @@ -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 }); }, diff --git a/client/src/components/Nav/SettingsTabs/General/LangSelector.spec.tsx b/client/src/components/Nav/SettingsTabs/General/LangSelector.spec.tsx index 2ad856d688..b2920ef744 100644 --- a/client/src/components/Nav/SettingsTabs/General/LangSelector.spec.tsx +++ b/client/src/components/Nav/SettingsTabs/General/LangSelector.spec.tsx @@ -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( + set(store.languageLoading, true)}> + + , + ); + + expect(getByRole('status', { name: 'Loading...' })).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Share/ShareView.tsx b/client/src/components/Share/ShareView.tsx index 6424546a23..a9341f5d5e 100644 --- a/client/src/components/Share/ShareView.tsx +++ b/client/src/components/Share/ShareView.tsx @@ -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 }); }, diff --git a/client/src/components/System/LanguageSync.tsx b/client/src/components/System/LanguageSync.tsx new file mode 100644 index 0000000000..095192a6b2 --- /dev/null +++ b/client/src/components/System/LanguageSync.tsx @@ -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; +} diff --git a/client/src/hooks/useLocalize.ts b/client/src/hooks/useLocalize.ts index f87ee5932b..4e2384f6c0 100644 --- a/client/src/hooks/useLocalize.ts +++ b/client/src/hooks/useLocalize.ts @@ -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), diff --git a/client/src/locales/Translation.spec.ts b/client/src/locales/Translation.spec.ts index 6c6e1b7ac8..994edf4166 100644 --- a/client/src/locales/Translation.spec.ts +++ b/client/src/locales/Translation.spec.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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'); + }); }); diff --git a/client/src/locales/i18n.ts b/client/src/locales/i18n.ts index cbebd4d5e3..02f1d0e691 100644 --- a/client/src/locales/i18n.ts +++ b/client/src/locales/i18n.ts @@ -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; + 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, + () => 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>( + (acc, locale) => { + acc[locale.toLowerCase()] = locale; + return acc; + }, + {}, +); + +const localeAliases: Record = { + '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(['en']); +const loadingLocales: Partial>> = {}; +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 { + 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, + loader: () => Promise<{ default: TranslationResource }>, +) { + const previousLoader = localeLoaders[locale]; + localeLoaders[locale] = loader; + return () => { + localeLoaders[locale] = previousLoader; + }; +} + +export function __resetLocaleForTests(locale: Exclude) { + 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; diff --git a/client/src/main.jsx b/client/src/main.jsx index 491f13f634..983c3b8cf2 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -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( - - - , -); +async function bootstrap() { + await initializeI18n(); + + root.render( + + + , + ); +} + +bootstrap().catch((error) => { + console.error('[i18n] Failed to initialize before render', error); + root.render( + + + , + ); +}); diff --git a/client/src/store/language.ts b/client/src/store/language.ts index 50e98fb127..f49ee99764 100644 --- a/client/src/store/language.ts +++ b/client/src/store/language.ts @@ -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({ + key: 'languageLoading', + default: false, +}); -export default { lang }; +export default { lang, languageLoading }; diff --git a/client/vite.config.ts b/client/vite.config.ts index 11c2cc1b02..47b588e989 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -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; diff --git a/packages/client/src/hooks/useLocalize.ts b/packages/client/src/hooks/useLocalize.ts index 1652c11f4a..0035f53e83 100644 --- a/packages/client/src/hooks/useLocalize.ts +++ b/packages/client/src/hooks/useLocalize.ts @@ -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 {