This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 5f1c969ccc3 UI: Fix wrong language auto-detected from browser
preferences (#68258)
5f1c969ccc3 is described below
commit 5f1c969ccc32e8660db9fe0e44745381487f345c
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Jun 10 13:20:18 2026 +0100
UI: Fix wrong language auto-detected from browser preferences (#68258)
The UI auto-detects its language from navigator.languages. When the
browser lists a region-qualified primary locale (e.g. en-GB) ahead of
other languages, i18next's getBestMatchFromCodes selects the first exact
supportedLngs member before reducing en-GB to en. Because supportedLngs
mixes base codes (en) with region codes (zh-CN, zh-TW), a later exact
match such as hi wins, and the UI loads in the wrong language (cached in
localStorage, so it persists).
Add a convertDetectedLanguage hook that normalizes each detected tag in
priority order using the native Intl.Locale API: region variants fold to
their supported base (en-GB -> en), exact region locales stay (zh-CN,
zh-TW), and a language with only region/script-specific locales is matched
by language + maximized script (zh-HK/zh-Hant -> zh-TW; zh/zh-SG -> zh-CN).
---
.../src/airflow/ui/src/i18n/config.test.ts | 91 ++++++++++++++++++++++
airflow-core/src/airflow/ui/src/i18n/config.ts | 81 +++++++++++++++----
2 files changed, 158 insertions(+), 14 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/i18n/config.test.ts
b/airflow-core/src/airflow/ui/src/i18n/config.test.ts
new file mode 100644
index 00000000000..ec0548b1ca7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/i18n/config.test.ts
@@ -0,0 +1,91 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { createInstance } from "i18next";
+import { describe, expect, it } from "vitest";
+
+import { convertDetectedLanguage, i18nBaseOptions } from "./config";
+
+// getBestMatchFromCodes is the resolver i18next runs on the array the
+// LanguageDetector returns. It is not part of i18next's public types
+// (services.languageUtils is `any`), so we pin the one method we use.
+type LanguageUtils = { getBestMatchFromCodes: (codes: ReadonlyArray<string>)
=> string };
+
+// Resolve a language exactly as the running app does: the detector maps
+// convertDetectedLanguage over navigator.languages (preserving order), then
+// i18next picks the best match. Uses the production init options so the test
+// guards the real config rather than a copy.
+const resolveLanguage = async (navigatorLanguages: ReadonlyArray<string>):
Promise<string> => {
+ const instance = createInstance();
+
+ await instance.init({ ...i18nBaseOptions, initImmediate: false, resources:
{} });
+
+ const languageUtils = instance.services.languageUtils as LanguageUtils;
+
+ return
languageUtils.getBestMatchFromCodes(navigatorLanguages.map(convertDetectedLanguage));
+};
+
+describe("i18n language resolution", () => {
+ it.each([
+ // The reported bug: real UK Chrome with Hindi added. Before the fix this
+ // resolved to "hi" because "en-GB"/"en-US" are not exact members of
+ // supportedLngs but "hi" is, so the first bare supported code won.
+ { expected: "en", languages: ["en-GB", "hi-IN", "hi", "en-US", "en", "gu"]
},
+ { expected: "en", languages: ["en-GB", "hi"] },
+ { expected: "en", languages: ["en-GB"] },
+ // Browsers may emit non-canonical casing.
+ { expected: "en", languages: ["EN-GB", "hi"] },
+ // Other region-qualified base languages were affected the same way.
+ { expected: "pt", languages: ["pt-BR", "en"] },
+ { expected: "de", languages: ["de-AT", "de", "en"] },
+ { expected: "fr", languages: ["fr-FR", "fr", "en"] },
+ ])(
+ "resolves $languages to $expected (region variant of a base language)",
+ async ({ expected, languages }) => {
+ expect(await resolveLanguage(languages)).toBe(expected);
+ },
+ );
+
+ it.each([
+ // Region-specific supported locales must stay intact even when English
+ // follows them -- this is what
nonExplicitSupportedLngs/load:"languageOnly"
+ // would break by collapsing "zh-CN"/"zh-TW" to the unsupported "zh".
+ { expected: "zh-CN", languages: ["zh-CN"] },
+ { expected: "zh-CN", languages: ["zh-CN", "zh", "en"] },
+ { expected: "zh-CN", languages: ["zh-CN", "en-US"] },
+ { expected: "zh-TW", languages: ["zh-TW", "zh", "en"] },
+ { expected: "zh-TW", languages: ["zh-TW", "en"] },
+ // Traditional Chinese via script/region subtags (e.g. macOS Safari) must
+ // resolve to zh-TW, not be collapsed to Simplified by a generic "zh-*"
match.
+ { expected: "zh-TW", languages: ["zh-Hant-TW", "en"] },
+ { expected: "zh-TW", languages: ["zh-Hant", "en"] },
+ { expected: "zh-TW", languages: ["zh-HK", "en"] },
+ { expected: "zh-TW", languages: ["zh-MO", "en"] },
+ // Simplified markers and a bare "zh" map to zh-CN.
+ { expected: "zh-CN", languages: ["zh-Hans-CN", "en"] },
+ { expected: "zh-CN", languages: ["zh-SG", "en"] },
+ { expected: "zh-CN", languages: ["zh", "en"] },
+ ])("keeps Chinese locale $expected for $languages", async ({ expected,
languages }) => {
+ expect(await resolveLanguage(languages)).toBe(expected);
+ });
+
+ it("still honors a genuinely preferred non-English language", async () => {
+ expect(await resolveLanguage(["hi"])).toBe("hi");
+ expect(await resolveLanguage(["hi-IN", "en"])).toBe("hi");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/i18n/config.ts
b/airflow-core/src/airflow/ui/src/i18n/config.ts
index 0cca099fcb5..20b2970d9af 100644
--- a/airflow-core/src/airflow/ui/src/i18n/config.ts
+++ b/airflow-core/src/airflow/ui/src/i18n/config.ts
@@ -63,6 +63,72 @@ const baseHref = document.querySelector("head >
base")?.getAttribute("href") ??
const baseUrl = new URL(baseHref, globalThis.location.origin);
const basePath = new URL(baseUrl).pathname.replace(/\/$/u, "");
+const supportedCodes: Array<string> = supportedLanguages.map((lang) =>
lang.code);
+
+// i18next resolves navigator.languages with two global passes: it only reduces
+// a region code ("en-GB") to its base ("en") when NO exact match exists
anywhere
+// in the list. So a browser list like ["en-GB", "hi-IN", "hi", "en-US", "en"]
+// matched the exact "hi" before "en-GB" was ever reduced to "en", and the UI
+// loaded in Hindi. The detector maps this function over each
navigator.languages
+// entry (preserving order), so normalising per code makes the base-language
+// match happen in priority order. Preferred over nonExplicitSupportedLngs /
+// load:"languageOnly", which strip the region from every code and so collapse
+// "zh-CN"/"zh-TW" to the unsupported "zh".
+export const convertDetectedLanguage = (lng: string): string => {
+ let locale: Intl.Locale;
+
+ try {
+ locale = new Intl.Locale(lng);
+ } catch {
+ // Not a well-formed BCP-47 tag (e.g. a stale "zh_TW" cache value). Leave
it
+ // for i18next to fall back rather than guess.
+ return lng;
+ }
+
+ // Exact supported code, tolerating browser casing ("ZH-CN" -> "zh-CN").
+ if (supportedCodes.includes(locale.baseName)) {
+ return locale.baseName;
+ }
+
+ // Region/script variant of a supported base language: "en-GB" -> "en",
"pt-BR" -> "pt".
+ if (supportedCodes.includes(locale.language)) {
+ return locale.language;
+ }
+
+ // For a language whose base code isn't supported but which has
region-specific
+ // supported locales, match by language + resolved script. maximize() derives
+ // the script from the region via CLDR data, so "zh-HK"/"zh-Hant" (Hant) map
to
+ // "zh-TW" and "zh"/"zh-SG" (Hans) map to "zh-CN". A Chinese tag thus prefers
+ // Chinese over a lower-priority language.
+ const wanted = locale.maximize();
+
+ return (
+ supportedCodes.find((code) => {
+ const candidate = new Intl.Locale(code).maximize();
+
+ return candidate.language === wanted.language && candidate.script ===
wanted.script;
+ }) ?? lng
+ );
+};
+
+export const i18nBaseOptions = {
+ defaultNS: "common",
+ detection: {
+ caches: ["localStorage"],
+ convertDetectedLanguage,
+ order: ["localStorage", "navigator", "htmlTag"],
+ },
+ fallbackLng: defaultLanguage,
+ interpolation: {
+ escapeValue: false,
+ },
+ ns: namespaces,
+ react: {
+ useSuspense: false,
+ },
+ supportedLngs: supportedCodes,
+};
+
const initI18n = (version: string) => {
const queryString = version ? `?v=${version}` : "";
@@ -71,23 +137,10 @@ const initI18n = (version: string) => {
.use(LanguageDetector)
.use(initReactI18next)
.init({
+ ...i18nBaseOptions,
backend: {
loadPath:
`${basePath}/static/i18n/locales/{{lng}}/{{ns}}.json${queryString}`,
},
- defaultNS: "common",
- detection: {
- caches: ["localStorage"],
- order: ["localStorage", "navigator", "htmlTag"],
- },
- fallbackLng: defaultLanguage,
- interpolation: {
- escapeValue: false,
- },
- ns: namespaces,
- react: {
- useSuspense: false,
- },
- supportedLngs: supportedLanguages.map((lang) => lang.code),
});
};