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),
     });
 };
 

Reply via email to