Repository navigation
fix(web): remember last UI language on this device (login screen + F5 no longer revert to English) (#182) - #253
Merged
Merged
Conversation
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.
This was referenced Jul 28, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/meresolved:ProtectedRoute— no/meto 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")./melanguage. 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. (/meis 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 overlocalStorage["cluckwork.lang"](readLanguageHint/writeLanguageHint, both storage-guarded) + a purepickInitialLanguage(hint, supported, fallback)that validates the hint against the installed packs (case-insensitive, mirroringresolve.ts; garbage/removed code → English).web/src/i18n/index.ts— seed i18next's initiallngfrom the hint, and persist the hint on everylanguageChanged.i18next.initis synchronous with in-memory resources, so first paint already renders in the remembered language — no flash, no pre-paint script. ThelanguageChangedlistener is registered beforeinit()so it catches init's own synchronous first event and sets<html lang>correctly on a hinted boot./mestays the source of truth: after sign-in,SessionProviderstill resolves from/me+/accountand 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 likecluckwork.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, andpickInitialLanguage(supported / null / garbage / case-insensitive).web/src/i18n/languageHint.integration.test.ts— the real singleton'slanguageChangedpersists the hint and keeps<html lang>in sync.web/src/test/setup.ts— reset now runs beforelocalStorage.clear()so the listener's hint write is wiped → every test starts hint-free.npm run test1129 green ·npm run typecheckclean ·npm run buildsucceeds.Review
Reviewed (Claude); first pass caught a real defect — the
languageChangedlistener was registered afterinit(), so it missed init's synchronous first event and left<html lang>atenon a hinted boot; the seeding test'sresetModulesdidn'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 throughindex.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.