fix(i18n): translate route titles once locale messages are loaded - #4231
Conversation
Opening a route in a new tab showed the raw i18n key (e.g. "settings.scan-settings") as the tab title. Locale namespaces load as separate chunks and nothing awaited them, so the router guard resolved the title before any messages existed. Route definitions compounded it by calling `t()` at module-eval time, baking the key into `meta.title` and leaving the guard to translate it a second time. Bootstrap now awaits a `localesReady` promise before installing the router, `meta.title` holds the plain key, and a locale watcher re-translates the current title — the stored language is applied when the app mounts, after the first navigation guard has already run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greptile SummaryThe PR defers initial router navigation until locale namespaces have loaded and stores untranslated i18n keys in route metadata, translating them when navigation resolves. It also reapplies route-owned titles when the active locale changes and adds coverage ensuring route titles resolve from loaded messages. Confidence Score: 5/5The PR appears safe to merge with no actionable defects identified in the changed paths. Locale messages are available before the first navigation guard translates route metadata, and later locale changes reapply titles only for routes whose titles are router-owned. Important Files Changed
Reviews (1): Last reviewed commit: "fix(i18n): translate route titles once l..." | Re-trigger Greptile |
There was a problem hiding this comment.
Pull request overview
Fixes initial browser tab titles showing raw vue-i18n keys on fresh loads by ensuring locale messages are available before the first router guard runs, and by storing route title metadata as i18n keys (translated at navigation time) instead of eagerly translating at module evaluation time.
Changes:
- Added a
localesReadybootstrap promise that registers locale namespaces viasetLocaleMessage(). - Awaited
localesReadyduring app initialization before installing the router, and centralized title translation in the router viaapplyRouteTitle()plus a locale-change watcher. - Added a regression test asserting route
meta.titlevalues are i18n keys that resolve against loaded messages.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| frontend/src/plugins/router.ts | Stores meta.title as i18n keys and applies translated document.title per navigation, with a locale watcher to re-apply on language change. |
| frontend/src/plugins/router.test.ts | Adds a unit test ensuring route titles are stored as i18n keys and translate after locales load. |
| frontend/src/main.ts | Awaits locale message readiness before installing the router to prevent the first guard from translating without messages. |
| frontend/src/locales/index.ts | Reworks locale loading to async-register all namespaced messages and exports localesReady for boot gating. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review follow-ups: - Only the fallback and stored languages are awaited at boot; the rest load when switched to, so startup no longer blocks on every locale bundle. The locale watcher awaits the incoming language before re-applying the route title. - `settings.controller-debug` already exists, so the last literal route title becomes a key, and the root route drops its redundant "RomM" title (the guard already falls back to it). Every `meta.title` is now an i18n key, which the test no longer has to exempt. - Drop the em-dash from the new comment per the repo writing rule, and the trailing space in the locale load error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`useI18n()` already resolves to the global scope while no SFC declares an `<i18n>` block, but the components that switch the app's language now say so, per the vue-i18n scope guide. An `<i18n>` block added to either SFC would otherwise flip the write to component-local and silently stop the language switch from reaching the rest of the app. The v1 language selector is left alone: it writes the locale the same way, but v1 is frozen and this is a defensive change, not a bug fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Description
Opening a route in a new tab (or reloading one directly) showed the raw i18n key as the browser tab title — e.g.
settings.scan-settingsinstead of "Scan settings". Most visible on the settings pages, since their views don't set a title of their own.Two causes, both on the tab-title path:
loadLocales()usedimport.meta.globwithouteagerand filled themessagesobject insideasynccallbacks that nobody awaited, so namespaces landed a few ticks aftercreateI18n. On a fresh page load thebeforeEachguard ran first, andt("settings.scan-settings")returned the key. In-app navigation looked fine because the chunks had landed by then.meta.titlewas built withi18n.global.t(...)while the route table was constructed — before any messages existed — and the guard then translated the result a second time.Changes:
locales/index.ts— adds a memoizedloadLocale()that fetches a language's namespaces and registers them as one bundle, plus alocalesReadypromise covering the two bundles the first paint can need (the fallback and the stored language). Other languages load on demand when switched to, so boot doesn't block on all 19. Namespace loads are fail-soft, so one stale chunk after a redeploy can't block boot.main.ts— awaitslocalesReadyalongsideinitializeData()beforeapp.use(router), so the first guard run always has messages.plugins/router.ts—meta.titlenow holds the plain i18n key, a singleapplyRouteTitle()helper does the translating, and a watcher on the locale loads the incoming language before re-translating the current route's title. The two literal titles are gone: the controller-debug tool uses thesettings.controller-debugkey that already existed, and the root route drops its redundant"RomM"(the guard falls back to that anyway).That last watcher fixes a second, latent bug: the user's stored language is applied when
RomM.vuemounts, after the first navigation, so even with messages loaded a non-English user's new tab would have shown the English title. A fresh load of/scan-settingswithja_JPnow correctly reads スキャン設定.No translation strings were added or changed: every key involved already exists in all 19 locales (including
settings.controller-debug, which now gives that page a translated tab title instead of hardcoded English).Verification
Reproduced against the unpatched code in a browser: a fresh load of
/scan-settingssetdocument.titletosettings.scan-settings. After the fix, fresh loads of/scan-settings,/user-interface,/administrationand/metadata-sourcesall show real titles, router pushes across the remaining settings routes are correct, and switching language live updates the title.On-demand loading verified too: a fresh load with
ja_JPstored fetched only theen_USandja_JPbundles, and switching tofr_FR(never loaded that session) pulled it in and updated the title to Paramètres de scan.Note: the browser checks ran against a small stub backend (no RomM backend available locally), so the settings pages rendered with stub data — the guard logic exercised is identical.
npm run typecheck,npm run test(737 passed),check_i18n_locales.py,check_i18n_sorted.py, prettier and eslint all pass. Trunk isn't installed in this environment, so its underlying JS linters were run directly.Checklist
AI assistance disclosure
This change was written with AI assistance (Claude Code). The AI diagnosed the bug, wrote the fix and the regression test, and ran the verification described above; the result was reviewed by me before submitting.