From d603b5799532c60f99f9954f8821ae9d9aeca014 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:46:58 +0200 Subject: [PATCH] fix: FireFox regional setting (#30377) * fix: keep en-US as the last i18n fallback when a stored locale has no bundle Settings labels rendered as raw keys such as settings.admin.connections.title instead of "Connections" from the second page load on, while every other label looked fine. On the first visit the language detector saves whatever the browser reports into localStorage, including bare codes like "en" that have no locale bundle. The same value is saved by ?lang=en or DEFAULT_LOCALE=en. On the next load that stored value became the only fallback language, so the missing bundle left nothing to fall back to. Plain keys still looked right because the key is the English text; only the settings.* keys have a distinct value. en-US now stays at the end of the fallback list whenever a stored locale is passed in. The stored locale keeps precedence, real locales are unaffected, and the en-US bundle was already loaded for every language for the settings merge, so there is no extra request. Fixes #30348 * fix: match browser language codes to a locale bundle Firefox reports German, Dutch and Polish as bare codes (de, nl, pl), both Chrome and Firefox report Japanese as ja, and Chrome in Latin America reports es-419. No bundle is keyed by any of these. The page layout used to match them on the first visit, but since 67ac1a4e9 (0.11.4) awaits the i18n init, the detector has already cached the raw code by the time that check runs, so it never does. Those users get an English interface, a language dropdown with nothing selected and English date formats, and the cached code keeps them there. The detector now maps a reported code onto a bundle before anything else sees it. A code we ship stays as it is, matched case-insensitively. Otherwise the language's xx-XX bundle is used when there is one, so de and de-AT become de-DE, es-419 becomes es-ES, and fr becomes fr-FR even though fr-CA comes first in the list. A bare code with no xx-XX bundle takes the first one for its language, so ja becomes ja-JP. Any other regional code passes through untouched, so zh-HK is not sent to Simplified zh-CN, and no locale that works today changes. Restoring the layout check would leave every browser that cached a raw code under 0.11.4 stuck. Matching in the detector migrates them on their next load, because the matched code is what gets cached. {{USER_LANGUAGE}} reports that matched code too, so es-419 users now send es-ES. Verified against the real bundles on first and repeat loads, including the untouched cases fr-CA, pt-BR, zh-TW, zh-HK, ar-BH, en-GB and uz-Latn-UZ. --- src/lib/i18n/index.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 548e6709e8..48872052ba 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -6,6 +6,7 @@ import { writable } from 'svelte/store'; import type { I18nOverrides } from '$lib/utils/translationDictionary'; import { assembleSettingsTranslations } from './settings-translations'; +import languages from './locales/languages.json'; let overrides: I18nOverrides = {}; @@ -71,6 +72,19 @@ const createIsLoadingStore = (i18n: i18nType) => { return isLoading; }; +const languageCodes = languages.map(({ code }) => code); + +const toBundleCode = (code: string) => { + const ownBundle = languageCodes.find((bundle) => bundle.toLowerCase() === code.toLowerCase()); + if (ownBundle) return ownBundle; + const baseLanguage = code.split('-')[0].toLowerCase(); + const primaryBundle = `${baseLanguage}-${baseLanguage.toUpperCase()}`; + if (languageCodes.includes(primaryBundle)) return primaryBundle; + // the first sibling of a regional code can be another script (zh-HK would get zh-CN) + if (code.includes('-')) return code; + return languageCodes.find((bundle) => bundle.startsWith(`${baseLanguage}-`)) ?? code; +}; + export const initI18n = (defaultLocale?: string, value: I18nOverrides = {}) => { overrides = value; const detectionOrder = defaultLocale @@ -87,7 +101,8 @@ export const initI18n = (defaultLocale?: string, value: I18nOverrides = {}) => { order: detectionOrder, caches: ['localStorage'], lookupQuerystring: 'lang', - lookupLocalStorage: 'locale' + lookupLocalStorage: 'locale', + convertDetectedLanguage: toBundleCode }, fallbackLng: { fr: ['fr-FR'], @@ -106,10 +121,7 @@ export const initI18n = (defaultLocale?: string, value: I18nOverrides = {}) => { const i18n = createI18nStore(i18next); const isLoadingStore = createIsLoadingStore(i18next); -export const getLanguages = async () => { - const languages = (await import(`./locales/languages.json`)).default; - return languages; -}; +export const getLanguages = async () => languages; export const changeLanguage = (lang: string) => { document.documentElement.setAttribute('lang', lang); return i18next.changeLanguage(lang);