Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/decisions/ADR-0054-i18n-vue-i18n-surface-by-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,11 @@ that preference is held by a Pinia store persisting to `localStorage` (`paperThe
`td.paper.mode.v2`). Language follows the identical pattern: `store/localeStore.ts`, key
`td.locale.v1`, with the same validate-on-read / default-on-garbage discipline, and the same
"apply" action that pushes the value into the runtime (here: `i18n.global.locale` plus
`<html lang>`, mirroring how `paperThemeStore` pushes a class onto `<body>`).
`<html lang>`, mirroring how `paperThemeStore` pushes a class onto `<body>`). Since `#2003`
(PR `#2626`'s sibling `#2633`) the push is commit-after-load: the store loads the lazy catalog first
and only then commits the runtime locale and `<html lang>` together, exposing pending and failed
state to the picker; a failed load keeps the previous language visible, and startup waits for the
restore for at most a bounded budget before mounting in English.

Language is a *client display* preference, not account data: it is not sent to or stored by the
backend, and there is no server-side user-preference table for it to live in. If a future
Expand Down
8 changes: 5 additions & 3 deletions frontend/taskdeck-web/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import en from '../locales/en'
* code-split behind `ensureLocaleMessages()` and fetched the first time the
* user selects them, so adding translated surfaces no longer spends the
* total-JS budget for users who never leave English. While a catalog is in
* flight (or if its chunk fails to load), the silent-fallback semantics above
* already describe what the user sees: English.
* flight (or if its chunk fails to load), the locale store keeps the last
* committed language active; the low-level runtime still has English as its
* fallback for keys that are not present in the committed catalog.
*/

export const SUPPORTED_LOCALES = ['en', 'it', 'es'] as const
Expand Down Expand Up @@ -90,7 +91,8 @@ const loaded = new Set<SupportedLocale>([DEFAULT_LOCALE])
* Fetch and register a locale's catalog, once. Resolves `true` when the
* catalog is available (already or newly), `false` when the chunk failed to
* load — in which case the failure is forgotten so a later switch retries,
* and the user simply stays on English fallback in the meantime. Never throws.
* and callers can retain their previous committed language in the meantime.
* Never throws.
*/
export function ensureLocaleMessages(locale: SupportedLocale): Promise<boolean> {
if (loaded.has(locale)) return Promise.resolve(true)
Expand Down
2 changes: 2 additions & 0 deletions frontend/taskdeck-web/src/locales/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,7 @@ export default {
label: 'Language',
hint: 'Taskdeck is being translated one surface at a time. Anything not translated yet stays in English. Options marked "Machine-translated" have not yet been reviewed by a native speaker.',
machineTranslated: 'Machine-translated',
loading: 'Loading {locale}…',
loadFailed: "Couldn’t load {locale}. {activeLocale} remains active. Try again.",
},
}
2 changes: 2 additions & 0 deletions frontend/taskdeck-web/src/locales/es/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,7 @@ export default {
label: 'Idioma',
hint: 'Taskdeck se traduce superficie a superficie. Lo que aún no está traducido se queda en inglés. Las opciones marcadas como "Traducción automática" aún no han sido revisadas por un hablante nativo.',
machineTranslated: 'Traducción automática',
loading: 'Cargando {locale}…',
loadFailed: 'No se pudo cargar {locale}. Se mantiene {activeLocale}. Inténtalo de nuevo.',
},
}
2 changes: 2 additions & 0 deletions frontend/taskdeck-web/src/locales/it/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,7 @@ export default {
label: 'Lingua',
hint: 'Taskdeck viene tradotto una superficie alla volta. Ciò che non è ancora tradotto resta in inglese. Le opzioni contrassegnate come "Traduzione automatica" non sono ancora state riviste da un madrelingua.',
machineTranslated: 'Traduzione automatica',
loading: 'Caricamento di {locale}…',
loadFailed: 'Impossibile caricare {locale}. {activeLocale} resta attivo. Riprova.',
},
}
59 changes: 38 additions & 21 deletions frontend/taskdeck-web/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import './paper-legacy-bridge.css'
import {
installVueErrorHandler,
installWindowErrorListeners,
logError,
} from './utils/errorReporting'

const app = createApp(App)
Expand All @@ -22,33 +23,49 @@ app.use(pinia)
app.use(i18n)
app.use(router)

// Restore the persisted language preference (ADR-0054 §7) and push it into the
// i18n runtime + `<html lang>`. Statically imported and called synchronously on
// purpose: this must happen after `app.use(pinia)` (the store needs an active
// Pinia) and BEFORE `app.mount` below, so the first paint is already in the
// user's language instead of flashing English. A dynamic import would resolve
// after mount and produce exactly that flash.
useLocaleStore(pinia).apply()

// Install global crash-prevention hooks before mount so early errors are
// captured. The Vue handler is the top-level backstop for render/lifecycle
// errors; the window listeners catch async rejections and non-Vue errors.
installVueErrorHandler(app)
installWindowErrorListeners()

app.mount('#app')
// Restore the persisted language preference (ADR-0054 §7) before mount. The
// store keeps the committed locale on English until a lazy catalog is ready,
// so waiting here preserves the no-flash guarantee without briefly claiming a
// language whose messages are unavailable. Catalog failure is reported by the
// mounted picker; it must not prevent the app from starting in English. The
// wait is bounded: a lazy catalog request that stalls instead of failing must
// not hold first paint hostage, so after LOCALE_RESTORE_MOUNT_BUDGET_MS the app
// mounts in English and the store commits the locale atomically if the catalog
// arrives later.
const LOCALE_RESTORE_MOUNT_BUDGET_MS = 1500

// Initialize telemetry after mount (non-blocking, opt-in).
// This restores user consent from localStorage and fetches server config.
// No events are emitted unless the user has explicitly opted in.
import('./store/telemetryStore').then(({ useTelemetryStore }) => {
const telemetry = useTelemetryStore()
void telemetry.initialize()
})
const localeRestore = useLocaleStore(pinia)
.apply()
.catch((error: unknown) => {
logError('[main] locale restore rejected before mount', error)
})

// Initialize analytics script watcher after mount (non-blocking).
// This watches the telemetry store's analyticsConfig and injects/removes
// the analytics script based on user consent and server configuration.
import('./composables/useAnalyticsScript').then(({ initAnalyticsScriptWatcher }) => {
initAnalyticsScriptWatcher()
const mountBudget = new Promise<void>((resolve) => {
window.setTimeout(resolve, LOCALE_RESTORE_MOUNT_BUDGET_MS)
})

void Promise.race([localeRestore, mountBudget])
.finally(() => {
app.mount('#app')

// Initialize telemetry after mount (non-blocking, opt-in).
// This restores user consent from localStorage and fetches server config.
// No events are emitted unless the user has explicitly opted in.
import('./store/telemetryStore').then(({ useTelemetryStore }) => {
const telemetry = useTelemetryStore()
void telemetry.initialize()
})

// Initialize analytics script watcher after mount (non-blocking).
// This watches the telemetry store's analyticsConfig and injects/removes
// the analytics script based on user consent and server configuration.
import('./composables/useAnalyticsScript').then(({ initAnalyticsScriptWatcher }) => {
initAnalyticsScriptWatcher()
})
})
132 changes: 97 additions & 35 deletions frontend/taskdeck-web/src/store/localeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import {
/**
* Language preference (ADR-0054 §7).
*
* Deliberately mirrors `paperThemeStore`: a Pinia store whose value is read
* from and written to `localStorage`, validated on read, defaulted on garbage,
* with an `apply()` action that pushes the value into the runtime — there, a
* class on `<body>`; here, the vue-i18n locale plus `<html lang>`.
* Deliberately mirrors `paperThemeStore`: a Pinia store whose preferred value
* is read from and written to `localStorage`, validated on read, defaulted on
* garbage, with an `apply()` action that pushes a loaded preference into the
* runtime — there, a class on `<body>`; here, the vue-i18n locale plus
* `<html lang>`.
*
* This is a CLIENT DISPLAY preference. It is not sent to the backend and there
* is no server-side user-preference row for it. If it ever needs to follow the
Expand Down Expand Up @@ -46,57 +47,118 @@ function applyLocale(locale: SupportedLocale) {
}
}

function persistLocale(locale: SupportedLocale) {
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, locale)
}
} catch {
// ignore quota / private-mode failures — the in-memory preference still applies
}
}

// A catalog response is allowed to commit only if it belongs to the most
// recent request. This is deliberately separate from the catalog loader's
// per-locale in-flight deduplication: two different locale requests can be in
// flight at once, and the older one must not overwrite the newer choice.
let latestRequestGeneration = 0

export const useLocaleStore = defineStore('locale', {
state: () => ({
locale: readStoredLocale() as SupportedLocale,
// `locale` is the committed/displayed locale. The stored preference is a
// desired locale until its catalog has loaded successfully.
locale: DEFAULT_LOCALE as SupportedLocale,
preferredLocale: readStoredLocale() as SupportedLocale,
pendingLocale: null as SupportedLocale | null,
failedLocale: null as SupportedLocale | null,
}),
getters: {
available(): ReadonlyArray<SupportedLocale> {
return SUPPORTED_LOCALES
},
isPending: (state): boolean => state.pendingLocale !== null,
},
actions: {
/**
* Push the current locale into the i18n runtime and `<html lang>`.
* Idempotent. Flip FIRST: `it`/`es` catalogs are code-split (#1858), and
* until the chunk arrives the silent en-fallback shows English — the same
* thing the user already sees for any not-yet-extracted surface;
* `setLocaleMessage` is reactive, so translations appear when it lands.
* Restore the stored preference before the first app mount. A non-English
* catalog is loaded before the preference is committed, so the first
* mounted render cannot claim a language whose messages are unavailable.
*
* If the chunk FAILS, the runtime, `<html lang>`, and the in-memory store
* value are all reverted to English so the UI never claims a language it
* is not rendering (the flip-first window is bounded by the request; a
* failure is not). The persisted preference is deliberately KEPT: a
* transient failure (offline, stale deployment mid-swap) self-heals on the
* next boot or the next manual switch instead of silently discarding the
* user's choice. The returned promise settles after any revert; callers
* that only care about the switch itself may ignore it.
* If the chunk fails, the committed runtime locale remains usable and the
* persisted preference is deliberately kept so a transient failure
* (offline, stale deployment mid-swap) retries on the next boot or manual
* switch. `failedLocale` lets the mounted picker report that target
* honestly instead of silently falling back.
*/
apply(): Promise<void> {
const target = this.locale
applyLocale(target)
return ensureLocaleMessages(target).then((ok) => {
const stillWanted = this.locale === target && i18n.global.locale.value === target
if (!ok && stillWanted && target !== DEFAULT_LOCALE) {
this.locale = DEFAULT_LOCALE
applyLocale(DEFAULT_LOCALE)
}
})
const target = this.preferredLocale
const generation = ++latestRequestGeneration

this.failedLocale = null
applyLocale(this.locale)

if (target === this.locale) {
this.pendingLocale = null
return Promise.resolve()
}

this.pendingLocale = target
return Promise.resolve()
.then(() => ensureLocaleMessages(target))
.catch(() => false)
.then((ok) => {
if (generation !== latestRequestGeneration) return

this.pendingLocale = null
if (ok) {
this.locale = target
applyLocale(target)
return
}

applyLocale(this.locale)
this.failedLocale = target
})
},
setLocale(locale: SupportedLocale): Promise<void> {
// Guard the public entry point too: a bad value here would otherwise be
// persisted and only rejected on the NEXT read, leaving the running app
// on a locale with no catalog.
if (!isSupportedLocale(locale)) return Promise.resolve()
this.locale = locale
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, locale)
}
} catch {
// ignore quota / private-mode failures — the in-memory switch still applies

this.preferredLocale = locale
persistLocale(locale)

const generation = ++latestRequestGeneration
this.failedLocale = null
applyLocale(this.locale)

// Selecting the committed language again cancels an older pending
// request and restores a truthful, idle picker immediately.
if (locale === this.locale) {
this.pendingLocale = null
return Promise.resolve()
}
return this.apply()

this.pendingLocale = locale
return Promise.resolve()
.then(() => ensureLocaleMessages(locale))
.catch(() => false)
.then((ok) => {
if (generation !== latestRequestGeneration) return

this.pendingLocale = null
if (ok) {
this.locale = locale
applyLocale(locale)
return
}

// Do not infer that a failed chunk left a usable catalog. Keep the
// last committed language and report the requested target to the UI.
applyLocale(this.locale)
this.failedLocale = locale
})
},
},
})
25 changes: 16 additions & 9 deletions frontend/taskdeck-web/src/tests/i18n/lazyLocales.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import { useLocaleStore } from '../../store/localeStore'
*
* Only `en` is registered at module load; `it`/`es` arrive through
* `ensureLocaleMessages()`. The global test setup preloads every catalog (so
* the wider suite can keep flipping locales synchronously), which means this
* spec asserts the CONTRACT of the loader — idempotence, registration, and the
* flip-first switch semantics — not the pre-load empty state. The "en-only in
* the initial chunk" claim itself is a build-graph property, proven by the
* bundle-budget CI gate and the emitted per-locale chunks, not assertable here.
* the wider suite can keep checking translated rendering), which means this
* spec asserts the loader contract plus the atomic switch boundary. The
* "en-only in the initial chunk" claim itself is a build-graph property,
* proven by the bundle-budget CI gate and the emitted per-locale chunks, not
* assertable here.
*/

describe('lazy locale catalogs (#1858)', () => {
Expand All @@ -30,17 +30,24 @@ describe('lazy locale catalogs (#1858)', () => {
expect(second).toBe(true)
})

it('setLocale flips the runtime locale synchronously and returns the catalog promise', async () => {
it('keeps the committed locale until the catalog promise settles', async () => {
const store = useLocaleStore()

const pending = store.setLocale('es')
// Flip-first: the locale is live before the catalog promise settles, so a
// slow chunk shows English fallback rather than blocking the switch.
expect(i18n.global.locale.value).toBe('es')
// A slow chunk must not make the UI claim Spanish while the old language
// is still rendering.
expect(store.locale).toBe('en')
expect(store.pendingLocale).toBe('es')
expect(i18n.global.locale.value).toBe('en')
expect(pending).toBeInstanceOf(Promise)
await pending

expect(store.locale).toBe('es')
expect(store.pendingLocale).toBeNull()
expect(i18n.global.locale.value).toBe('es')

await store.setLocale('en')
expect(store.locale).toBe('en')
expect(i18n.global.locale.value).toBe('en')
})
})
Loading
Loading