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:
1 parent
b471e7d1b8
commit
8d0cdaefbe
6 files changed
+343
-139
No files matched your search
+11
-17
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user