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:
- The server-stored preference was never read.
UserAppSettingReturnDto.localeexisted on the wire and round-tripped safely through every PUT, but the only consumer in the monorepo was TTS voice selection inuse-rep-voice.ts, which worked around the gap withsettings?.locale || locale. - The language picker never wrote the server (a standing
TODOinsettings-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. resolveLocalewas dead code. It already implemented a precedence chain including auserSettingtier and validation againstGET /app_languages, anddocs/i18n.mddocumented 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
syncLocaleinuser-app-settings/store.ts, sitting directly beside the existingsyncUnitSystem— 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:
setUserAppSettings→syncLocale→applyLocale. - Every read and write of a locale string passes
sanitizeLocale, canonicalizing tolanguage[-Script][-REGION]. Comparisons usenormalizeLocale, 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()insidepreloadAfterAuth— one extra round trip on login only. - Re-entrancy had to be guarded. The settings drawer reloads the settings on
every open, so
syncLocaleruns constantly; without a guard each open would re-fetch all nine stores, several bypassing their SWR window.applyLocaleis 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.tsxcallslogout()straight from theenode:unauthorizedlistener withoutbeginLogout(), so a re-resolve there would fire nine unauthenticated GETs — nine moreenode:unauthorizedevents and an error toast on the welcome screen, mid-teardown. translations/index.tsno longer re-exports the locale switch.set-locale.tsimports all nine locale-dependent stores, so keeping it in the barrel would drag that subgraph into everyuseTranslationconsumer 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
pendingrecord, which would otherwise PUT a stalelocaleon 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.