Skip to content

fix(web): remember last UI language on this device (login screen + F5 no longer revert to English) (#182) - #253

Merged
mforce merged 2 commits into
mainfrom
f182-i18n-language-hint
Jul 28, 2026
Merged

mforce merged 2 commits into
mainfrom
f182-i18n-language-hint

Conversation

@mforce

@mforce mforce commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Remember the last UI language on this device. Fixes two related reports: (1) the login screen always rendered English even after choosing Spanish/Tagalog, and (2) a signed-in Spanish user who pressed F5/Ctrl+R momentarily reverted to English (only a cache-busting Ctrl+Shift+R "stuck" to Spanish).

Root cause

The SPA had no client-side language memory. Every load booted i18next in English and only switched after /me resolved:

  • Login screen is outside ProtectedRoute — no /me to resolve from — so it was unavoidably English (the i18n infrastructure (SPA foundation): react-i18next + resolution + string sweep (tracker) #182 plan explicitly left room for "a persisted non-sensitive local language hint").
  • Signed-in refresh started English, then the bootstrap switched to the /me language. On a warm F5 that English starting point paints first and lingers; a cold hard-reload hides it behind the loading state — exactly the F5-vs-hard-reload split. (/me is not service-worker cached — runtimeCaching: [], and the navigate-fallback denylist excludes /api — so this was never stale data, only the English starting point.)

Fix

  • web/src/lib/languageHint.ts — a device-local hint over localStorage["cluckwork.lang"] (readLanguageHint/writeLanguageHint, both storage-guarded) + a pure pickInitialLanguage(hint, supported, fallback) that validates the hint against the installed packs (case-insensitive, mirroring resolve.ts; garbage/removed code → English).
  • web/src/i18n/index.ts — seed i18next's initial lng from the hint, and persist the hint on every languageChanged. i18next.init is synchronous with in-memory resources, so first paint already renders in the remembered language — no flash, no pre-paint script. The languageChanged listener is registered before init() so it catches init's own synchronous first event and sets <html lang> correctly on a hinted boot.

/me stays the source of truth: after sign-in, SessionProvider still resolves from /me + /account and overrides the hint (behind the existing render gate — no flash), and the listener writes that resolved value straight back, so the hint self-heals to the authoritative preference. Device-persistent like cluckwork.theme (not cleared on logout — a returning user meets their own language). Non-sensitive: a language code only; no interaction with the memory-only token store (#145).

Tests

  • web/src/lib/languageHint.test.ts — round-trip, storage-throw guards, and pickInitialLanguage (supported / null / garbage / case-insensitive).
  • web/src/i18n/languageHint.integration.test.ts — the real singleton's languageChanged persists the hint and keeps <html lang> in sync.
  • web/src/test/setup.ts — reset now runs before localStorage.clear() so the listener's hint write is wiped → every test starts hint-free.

npm run test 1129 green · npm run typecheck clean · npm run build succeeds.

Review

Reviewed (Claude); first pass caught a real defect — the languageChanged listener was registered after init(), so it missed init's synchronous first event and left <html lang> at en on a hinted boot; the seeding test's resetModules didn't actually isolate i18next and masked it. Both fixed (listener reordered; seeding extracted to a pure unit-tested function; integration test rewritten to be honest about what it exercises). Re-review: RESOLVED. One residual known-gap: no single end-to-end cold-boot test through index.ts (i18next is externalized, so Vitest can't cleanly reload it) — covered by the pure-logic unit test + the listener test + an out-of-band probe.

Not included

Separate from the #182 string sweep (its final PR is #252). Unrelated local working-tree edits (deploy/docker-compose.yml, web/vite.config.ts) are deliberately not in this branch.

mforce added 2 commits July 27, 2026 23:02
…een (#182)

The login screen is outside ProtectedRoute, so with no /me it always rendered
English even after a user had chosen Spanish/Tagalog. Persist the chosen language
to a device-local hint (localStorage cluckwork.lang) on every i18next
languageChanged, and seed i18next's initial language from that hint (validated
against installed packs) so the pre-auth login screen opens in the last-used
language. i18next.init is synchronous, so first paint is already correct — no
flash, no pre-paint script. Post-auth, SessionProvider still resolves from
/me + /account and overrides the hint, which self-heals to that value.
Cold-boot test proving i18n/index.ts feeds the device hint into i18next's
initial lng — the wiring pickInitialLanguage's unit tests don't cover. A
severed wiring line (verified via mutation probe) would otherwise leave the
pre-auth login screen silently falling back to English with every other test
still green. Mocks i18next so vi.resetModules can re-run the module-load
seeding against a chosen localStorage hint and read what init() was seeded
with.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant