fix(i18n): preserve explicit saved English on first-visit bootstrap

Closes #7622.

Round-3 follow-up to the maintainer review at
https://github.com/nesquena/hermes-webui/pull/7730#issuecomment-5786493511.
The round-2 'primary === "en"' heuristic overrode users who had
genuinely picked English: when the server-stored 'language' was 'en'
the resolver treated it as the schema default and skipped it, falling
through to localStorage / the browser hint.  This round addresses all
three maintainer findings:

1. BRICK — server stops injecting the schema default.  Drop
   '"language": "en"' from api/config.py:_SETTINGS_DEFAULTS so a
   fresh install returns settings['language'] == None.  Existing
   settings.json files that already carry 'language: "en"' still
   report 'en' (the field is just absent from the defaults dict; the
   per-file load path is unchanged).  'language' is added back to
   _SETTINGS_ALLOWED_KEYS so the Settings modal save path still
   validates and persists explicit user picks (the BCP-47 regex is
   unchanged).

2. CORE — browser-language detection is now in one guarded helper.
   static/i18n.js exposes _detectBrowserLanguageHint(), used by
   loadLocale(), boot.js, and panels.js.  A throwing navigator
   accessor in some embedded webviews can no longer abort boot or
   settings hydration before the model/provider/plugin/extension
   sections are populated.

3. CI RED — restore the test shape.  boot.js and panels.js call
   resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'),
   _detectBrowserLanguageHint()) inline so
   tests/test_language_precedence.py::test_boot_and_settings_panel_use_shared_locale_precedence
   matches the expected argument list.  The TestComposedResolverPrecedence
   tests in tests/test_issue7622_initial_locale_from_browser.py are
   re-pinned to the round-3 contract (server primary is None for a
   fresh install; an explicit 'en' primary from a user-saved file
   beats a non-English browser hint).

Tests (22/22 i18n-related pass; 833/833 settings+i18n+locale pass; ruff
forward-gate --diff origin/master is clean):
- test_language_precedence.py: 8/8 (added guarded-helper, server-default,
  composed-resolver, and cross-file first-visit tests)
- test_issue7622_initial_locale_from_browser.py: 14/14 (re-pinned to
  round-3 contract; added explicit-saved-English regression test)
- test_login_locale.py and friends: unchanged behaviour for the
  save/load round trip
This commit is contained in:
happy5318 committed 2026-09-23 11:21:51 +08:00
1 parent b471e7d1b8
commit 8d0cdaefbe
6 files changed
+343 -139

No files matched your search

+11 -17
View File
@@ -3431,24 +3431,18 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
localStorage.setItem('hermes-font-size',fontSize);
_applyFontSize(fontSize);
if(typeof setLocale==='function'){
// #7622 (round 2): the settings payload's `s.language` is always
// the schema default `"en"` for a fresh install, so we cannot
// trust it as an explicit preference. Resolve through the new
// 3-arg `resolvePreferredLocale(primary, fallback, fallback2)`
// which treats `primary === 'en' && !fallback` as "no explicit
// server preference" and lets the browser navigator hint take
// the next slot. The fallback ternary below preserves the
// pre-#7622 boot behaviour when `resolvePreferredLocale` is
// somehow not in scope (defence in depth).
const _stored=localStorage.getItem('hermes-lang');
const _browserHint=(typeof navigator!=='undefined')
? (Array.isArray(navigator.languages)&&navigator.languages.length
? navigator.languages[0]
: (typeof navigator.language==='string'?navigator.language:null))
: null;
// #7622 (round 3): the settings payload's `s.language` is
// absent (None) for a fresh install, so an explicit non-empty
// value is the user's genuine saved choice. The browser
// navigator hint is now read via the guarded
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
// a throwing `navigator` accessor in some embedded webviews
// can no longer abort this branch and reset loaded preferences.
// The fallback ternary preserves the pre-#7622 boot behaviour
// when neither helper is in scope (defence in depth).
const _lang=typeof resolvePreferredLocale==='function'
? resolvePreferredLocale(s.language, _stored, _browserHint)
: (s.language || _stored || _browserHint || 'en');
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
: (s.language || localStorage.getItem('hermes-lang') || 'en');
setLocale(_lang);
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
}
+56 -48
View File
@@ -26988,36 +26988,63 @@ function resolveLocale(lang) {
* 3) fallback2 (typically browser navigator hint — first-visit only)
* 4) 'en' (final fallback)
*
* #7622 (round 2): the server's schema default `"language": "en"` is NOT
* an explicit user / server preference — it's the field's default value
* from a fresh settings file. Treating it as `primary` and resolving it
* to `'en'` would pin the page to English on the very first visit and
* overwrite a freshly-stored browser hint the next time boot.js or
* panels.js hydrates the settings payload. The fix: if `primary` is the
* schema default and `fallback` is empty, skip `primary` and let
* `fallback2` (the browser hint) take its slot in the chain.
* Resolve a language preference to a known LOCALES key, applying a strict
* `primary -> fallback -> fallback2 -> 'en'` precedence chain.
*
* @param {string} primary
* @param {string} fallback
* @param {string} [fallback2]
* #7622 (round 3): the server no longer injects `"language": "en"` as a
* schema default — `load_settings()` reports `None` (the key is absent
* from the response) until the user explicitly picks a locale in the
* Settings modal. This lets the resolver use `primary` directly
* without a `primary === 'en'` special case:
* - an explicit `primary` (the server's stored value) always wins
* - a `None` / empty `primary` falls through to `fallback` (localStorage)
* - `fallback2` (the browser navigator hint) only fires when neither
* server nor localStorage has a preference
* - `'en'` is the final safety net
*
* Side benefit: a user who picked English on purpose (their settings
* file contains `"language": "en"`) is no longer overridden by a
* non-English browser locale on hydration, because the round-2
* `primary === 'en'` skip has been removed.
*
* @param {string|null|undefined} primary server-stored language
* @param {string|null|undefined} fallback localStorage `hermes-lang`
* @param {string|null|undefined} fallback2 browser navigator hint
* @returns {string}
*/
function resolvePreferredLocale(primary, fallback, fallback2) {
// #7622 (round 2): the server's schema default `"language": "en"`
// is NOT an explicit user / server preference — it's the field's
// default value from a fresh settings file (api/config.py:11804).
// Without this skip, primary='en' resolves to 'en' on the first
// call and pins the page to English, overwriting both a freshly-
// stored browser hint and any explicit stored choice. Skip
// 'en' as primary and let `fallback` (localStorage) / `fallback2`
// (browser hint) take its slot. Any other primary value is
// treated as explicit and wins as before — so a future maintainer
// who later makes the server emit an explicit 'en' for a user
// who has picked English will still see English on top.
if (primary === 'en') {
return resolveLocale(fallback) || resolveLocale(fallback2) || 'en';
return resolveLocale(primary)
|| resolveLocale(fallback)
|| resolveLocale(fallback2)
|| 'en';
}
/**
* Detect the browser's preferred language for first-visit locale
* selection. Guarded against `navigator` access exceptions: some
* embedded webviews (Tauri desktop shell, kiosk modes) report a
* `navigator` object but throw on `.languages` / `.language` reads.
* Callers (loadLocale, boot.js, panels.js) must use this helper rather
* than reading `navigator` directly so a single throw does not abort
* the boot or settings-panel hydration.
*
* @returns {string|null} the first entry of `navigator.languages`, or
* `navigator.language`, or `null` when neither is reachable.
*/
function _detectBrowserLanguageHint() {
try {
const nav = (typeof navigator !== 'undefined') ? navigator : null;
if (!nav) return null;
if (Array.isArray(nav.languages) && nav.languages.length) {
return nav.languages[0] || null;
}
if (typeof nav.language === 'string' && nav.language) {
return nav.language;
}
return null;
} catch (_) {
return null;
}
return resolveLocale(primary) || resolveLocale(fallback) || resolveLocale(fallback2) || 'en';
}
/**
@@ -27071,29 +27098,10 @@ function loadLocale() {
// Only consult the browser hint when no stored preference exists.
// The settings panel and `loadSettingsPanel()` will overwrite this
// hint on their first call, so explicit user / server preferences
// always win.
let browserHint = null;
if (!stored) {
try {
const nav = (typeof navigator !== 'undefined') ? navigator : null;
if (nav) {
// `navigator.languages` is the ordered preference list (Chrome
// / Firefox); `navigator.language` is the single primary. We
// only read the FIRST entry — the others are reserved for any
// future per-locale UI hints.
if (Array.isArray(nav.languages) && nav.languages.length) {
browserHint = nav.languages[0] || null;
} else if (typeof nav.language === 'string' && nav.language) {
browserHint = nav.language;
}
}
} catch (_) {
// Some embedded webviews (Tauri desktop shell, certain kiosk
// modes) report `navigator` but throw on access; fall through.
browserHint = null;
}
}
setLocale(resolvePreferredLocale(browserHint, stored));
// always win. #7622 (round 3): the browser-hint read is delegated
// to `_detectBrowserLanguageHint()` so a `navigator` access throw
// (some embedded webviews) can no longer abort loadLocale().
setLocale(resolvePreferredLocale(stored, _detectBrowserLanguageHint()));
}
/**
+11 -16
View File
@@ -9300,23 +9300,18 @@ async function loadSettingsPanel(){
_setHiddenTabs(hiddenTabs);
_applyTabVisibility(hiddenTabs);
_renderTabVisibilityChips();
// #7622 (round 2): the settings payload's `settings.language` is
// always the schema default `"en"` for a fresh install, so we
// cannot trust it as an explicit preference. Resolve through the
// new 3-arg `resolvePreferredLocale(primary, fallback, fallback2)`
// which treats `primary === 'en' && !fallback` as "no explicit
// server preference" and lets the browser navigator hint take
// the next slot. The fallback ternary preserves the pre-#7622
// settings-modal behaviour when the new resolver is not in scope.
const _stored=localStorage.getItem('hermes-lang');
const _browserHint=(typeof navigator!=='undefined')
? (Array.isArray(navigator.languages)&&navigator.languages.length
? navigator.languages[0]
: (typeof navigator.language==='string'?navigator.language:null))
: null;
// #7622 (round 3): the settings payload's `settings.language` is
// absent (None) for a fresh install, so an explicit non-empty
// value is the user's genuine saved choice. The browser
// navigator hint is now read via the guarded
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
// a throwing `navigator` accessor can no longer abort settings
// hydration before model, provider, plugin and extension sections
// are populated. The fallback ternary preserves the pre-#7622
// settings-modal behaviour when neither helper is in scope.
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, _stored, _browserHint)
: (settings.language || _stored || _browserHint || 'en');
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
// Keep settings modal and current page strings in sync with the resolved locale.
if(typeof setLocale==='function'){
setLocale(resolvedLanguage);