Skip to main content

0008 — userAppSettings.locale is the source of truth for the display language

Status: Implemented · Date: 2026-08-05

Context

The active display locale was seeded synchronously at module import in packages/core/src/locale/active-locale.ts:

let activeLocale = getPersistedLocaleOverride() ?? getDeviceLocale() ?? DEFAULT_LOCALE;

That is localStorage["enode.locale"]navigator.language"en". Three things followed from it:

  1. The server-stored preference was never read. UserAppSettingReturnDto.locale existed on the wire and round-tripped safely through every PUT, but the only consumer in the monorepo was TTS voice selection in use-rep-voice.ts, which worked around the gap with settings?.locale || locale.
  2. The language picker never wrote the server (a standing TODO in settings-drawer.tsx). A language chosen in the web app was invisible to iOS and to the same user's other browsers — and invisible to the portal even on the same machine, since each origin has its own localStorage.
  3. resolveLocale was dead code. It already implemented a precedence chain including a userSetting tier and validation against GET /app_languages, and docs/i18n.md documented it as the boot behaviour, but nothing called it.

Nothing was sanitized either: navigator.language yields full tags ("en-US", "nb-NO") and that raw string became both the active locale and the Accept-Language header. Normalization existed as four private copies across active-locale.ts, ui-text-content/store.ts, language-picker.tsx and settings-drawer.tsx.

Decision

userAppSettings.locale is the authoritative display language. Precedence becomes:

userAppSettings.locale > localStorage mirror > navigator.language > "en"
  • localStorage["enode.locale"] is demoted from user override to a cold-start mirror of the last known server value. It is consulted only before the settings exist and is always overwritten by them.
  • The bridge is syncLocale in user-app-settings/store.ts, sitting directly beside the existing syncUnitSystem — the established seam for deriving a global from the settings record.
  • The settings drawer's language row stages the pick into its pending record like every other row, and Save PUTs it. Applying it is then automatic: setUserAppSettingssyncLocaleapplyLocale.
  • Every read and write of a locale string passes sanitizeLocale, canonicalizing to language[-Script][-REGION]. Comparisons use normalizeLocale, never ===.

Why keep a localStorage tier at all

Accept-Language is sent on the very request that fetches the settings, so a bootstrap value is unavoidable. Making it a mirror rather than an override satisfies "local settings are never consulted once the server value exists" while still letting a cold start issue its first requests in the right language. Without it every cold start would fetch all reference data in English and re-fetch.

Why validation is fail-open

Candidates are checked against GET /app_languages only when that list is non-empty. It is empty for the entire window before the endpoint answers — which includes the first settings load — so rejecting there would stamp every user down to English and destroy the very preference this change exists to honour. reresolveActiveLocale() runs once the list lands.

Why the region is preserved

/app_languages serves bare language codes (en, de, ja, zh — live-verified). Validation therefore matches on the primary subtag but returns the user's own tag: canonicalizing de-AT to de would discard the region that Intl and the TTS voice select on. The backend ignores the region for content selection anyway.

Consequences

  • A language picked in tracking now applies in the portal, and on iOS. This is the point of the change.
  • A one-time migration is visible. A user whose localStorage["enode.locale"] disagrees with their server locale switches to the server value on next load. Their old web-app choice was never persisted server-side, so it cannot be preserved.
  • First login re-fetches once. With no warm mirror, the preload burst runs in the device language and reconciles. It self-heals from the second load on. The escape hatch, if this ever matters, is awaiting loadUserAppSettings() inside preloadAfterAuth — one extra round trip on login only.
  • Re-entrancy had to be guarded. The settings drawer reloads the settings on every open, so syncLocale runs constantly; without a guard each open would re-fetch all nine stores, several bypassing their SWR window. applyLocale is therefore idempotent on a normalized comparison — a raw === would miss a server echo that differs only in casing or separator.
  • Logout deliberately does not reset the locale, unlike syncUnitSystem(null). auth-guard.tsx calls logout() straight from the enode:unauthorized listener without beginLogout(), so a re-resolve there would fire nine unauthenticated GETs — nine more enode:unauthorized events and an error toast on the welcome screen, mid-teardown.
  • translations/index.ts no longer re-exports the locale switch. set-locale.ts imports all nine locale-dependent stores, so keeping it in the barrel would drag that subgraph into every useTranslation consumer and make the barrel the shortest accidental path from a store back into the settings layer. Switching is a deep import.

Alternatives considered

  • Drop localStorage entirely. Rejected: every cold start would fetch the whole reference-data set in the wrong language and immediately re-fetch it.
  • Keep localStorage as an explicit override above the server value. Rejected: it is what produces the cross-device and cross-app divergence in the first place, and it makes "change the language" mean different things in different browsers.
  • Apply and PUT the language immediately on pick, so the UI switches live. Rejected: every other row in the drawer stages until Save, and an immediate write has no clean discard — dismissing the drawer would leave the interface in a language the user didn't commit to, until the next settings load reverted it. It also had to fight the drawer's buffered pending record, which would otherwise PUT a stale locale on the next save and revert the choice. Staging removes that whole class of problem: the picker shows the staged value, and saving applies it through the same path a cross-device change takes.

Out of scope

Date and number formatting. Around forty call sites use toLocaleDateString(undefined, …) (the browser locale) and a few pin "en-US" / "en-GB" deliberately; formatMeasurement pins "en-US" to avoid thousand separators. That inconsistency predates this change and is orthogonal to which source defines the language. See the note at the end of docs/i18n.md.