i18n: keep the page's locale in a vue-i18n instance

The application views install one vue-i18n instance that holds the active
locale and the catalogs. src/i18n/index.js stays free of Vue: bindLocale()
keeps its locale in the instance's ref, so t(), tn() and the Intl formatters
read it from there and a view that translates while rendering renders again
when the locale changes. A tag written straight to the ref is normalized.
Message formatting, catalogs and call sites are unchanged, and switching
language still reloads the page.

The instance outlives the views: unmounting one no longer disposes it. The
browser build defines vue-i18n's feature flags and drops its message
compiler, which Core does not use.
This commit is contained in:
Sameh Khamis committed 2026-11-01 15:25:55 -08:00
1 parent 22d4cf8ee7
commit 2c72a038d5
12 files changed
+330 -44

No files matched your search

+36 -15
View File
@@ -40,16 +40,37 @@ const CATALOGS = Object.freeze({
'zh-Hant': zhHant,
});
let currentLocale = DEFAULT_LOCALE;
// The page's locale. The UI binds it to its vue-i18n instance's
// (src/ui/i18n.js), so a Vue render that translates re-renders when it
// changes; until then, and in portable code, it is plain state.
let pageLocale = { value: DEFAULT_LOCALE };
/**
* Keep the active locale in `locale`, a ref such as a vue-i18n composer's
* `locale`. It takes the current locale; switch with setLocale afterwards.
* @param {{value: string}} locale
*/
export function bindLocale(locale) {
locale.value = pageLocale.value;
pageLocale = locale;
}
/** The catalog for a locale; null for English and unknown codes. */
export function getCatalog(locale) {
return CATALOGS[normalizeLocale(locale)] ?? null;
}
/** Active locale. */
let boundValue = DEFAULT_LOCALE;
let activeLocale = DEFAULT_LOCALE;
/** Active locale, normalized: a bound ref may be set to any language tag. */
export function getLocale() {
return currentLocale;
const value = pageLocale.value;
if (value !== boundValue) {
boundValue = value;
activeLocale = normalizeLocale(value) || DEFAULT_LOCALE;
}
return activeLocale;
}
/**
@@ -59,18 +80,18 @@ export function getLocale() {
*/
export function setLocale(locale) {
const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
if (normalized !== currentLocale) {
currentLocale = normalized;
if (normalized !== pageLocale.value) {
pageLocale.value = normalized;
formatterCache.clear();
}
return currentLocale;
return normalized;
}
/** Development-only warning; silent under node:test and in production. */
function warnMissing(text) {
if (import.meta.env?.DEV !== true) return;
console.warn(
`[i18n] no ${currentLocale} translation for ${JSON.stringify(text)}`,
`[i18n] no ${getLocale()} translation for ${JSON.stringify(text)}`,
);
}
@@ -89,7 +110,7 @@ function interpolate(pattern, values) {
const filled = (value) => typeof value === 'string' && value.trim() !== '';
function lookup(text) {
const catalog = CATALOGS[currentLocale];
const catalog = CATALOGS[getLocale()];
if (!catalog) return undefined;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
if (filled(entry) || (entry && typeof entry === 'object')) return entry;
@@ -124,7 +145,7 @@ export function t(text, values) {
*/
export function tn(count, one, other, values) {
const entry = lookup(other);
const category = pluralRules(currentLocale).select(count);
const category = pluralRules(getLocale()).select(count);
let pattern;
const forms = entry && typeof entry === 'object' ? entry : null;
if (forms && (filled(forms[category]) || filled(forms.other))) {
@@ -141,7 +162,7 @@ export function tn(count, one, other, values) {
* missing-translation warning.
*/
export function translateKnown(text) {
const catalog = CATALOGS[currentLocale];
const catalog = CATALOGS[getLocale()];
if (!catalog || typeof text !== 'string') return text;
const entry = Object.hasOwn(catalog, text) ? catalog[text] : undefined;
return filled(entry) ? entry : text;
@@ -171,10 +192,10 @@ function pluralRules(locale) {
const formatterCache = new Map();
function cachedFormatter(kind, Constructor, options) {
const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`;
const cacheKey = `${kind}|${getLocale()}|${options ? JSON.stringify(options) : ''}`;
let formatter = formatterCache.get(cacheKey);
if (!formatter) {
formatter = new Constructor(currentLocale, options);
formatter = new Constructor(getLocale(), options);
formatterCache.set(cacheKey, formatter);
}
return formatter;
@@ -210,7 +231,7 @@ export function formatUtcDayMonth(value) {
? Date.UTC(Number(written[1]), Number(written[2]) - 1, Number(written[3]))
: new Date(value).getTime();
if (!Number.isFinite(time)) return '';
if (currentLocale === 'en') return new Date(time).toISOString().slice(5, 10);
if (getLocale() === 'en') return new Date(time).toISOString().slice(5, 10);
return cachedFormatter('date', Intl.DateTimeFormat, UTC_DAY_MONTH).format(
time,
);
@@ -228,7 +249,7 @@ export function formatRelativeTime(value, unit = 'second', options) {
}).format(value, unit);
// CLDR spaces Japanese (5 分前); the ja catalog writes the number and its
// unit together (5分前), so the two must not differ on one screen.
return currentLocale === 'ja'
return getLocale() === 'ja'
? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '')
: text;
}
@@ -263,7 +284,7 @@ const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
*/
export function applyDocumentTranslations(doc) {
if (typeof doc?.querySelectorAll !== 'function') return 0;
if (!CATALOGS[currentLocale]) return 0;
if (!CATALOGS[getLocale()]) return 0;
let applied = 0;
for (const [marker, attribute] of DOCUMENT_TRANSLATION_TARGETS) {
for (const element of doc.querySelectorAll(`[${marker}]`)) {
+4
View File
@@ -65,6 +65,10 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
assert.ok(!csp.includes("script-src 'self' 'unsafe-inline'"));
assert.deepEqual(config.preview.headers, config.server.headers);
assert.deepEqual(config.define, {
__VUE_I18N_FULL_INSTALL__: 'false',
__VUE_I18N_LEGACY_API__: 'false',
__INTLIFY_PROD_DEVTOOLS__: 'false',
__INTLIFY_DROP_MESSAGE_COMPILER__: 'true',
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
'import.meta.env.MAPILLARY_CLIENT_TOKEN': '""',
+46
View File
@@ -0,0 +1,46 @@
import { createI18n } from 'vue-i18n';
import { bindLocale, getCatalog, getLocale } from '../i18n/index.js';
import { DEFAULT_LOCALE, LOCALES } from '../i18n/locale.js';
let pageI18n = null;
// Catalogs are flat and keyed by English text, which holds dots.
const resolveFlat = (messages, key) =>
Object.hasOwn(messages, key) ? messages[key] : null;
/**
* The page's vue-i18n instance, installed in every application view. It
* holds the active locale and the catalogs; Core's t(), tn() and the Intl
* formatters read the locale from it, so a view that calls them while
* rendering re-renders when the locale changes. Views translate through
* t()/tn() rather than vue-i18n's own t: English is the key, `context` picks
* an entry, and plural forms follow Intl.PluralRules (docs/I18N.md).
* @returns {import('vue-i18n').I18n}
*/
export function applicationI18n() {
if (pageI18n) return pageI18n;
const messages = {};
for (const locale of LOCALES)
if (locale !== DEFAULT_LOCALE) messages[locale] = getCatalog(locale);
pageI18n = createI18n({
legacy: false,
globalInjection: false,
locale: getLocale(),
fallbackLocale: DEFAULT_LOCALE,
messages,
messageResolver: resolveFlat,
missingWarn: false,
fallbackWarn: false,
});
// vue-i18n disposes its instance when an app it is installed in unmounts;
// the page's outlives every view, so an unmounted panel keeps it.
const install = pageI18n.install;
pageI18n.install = (app, ...options) => {
const unmount = app.unmount;
const installed = install.call(pageI18n, app, ...options);
app.unmount = unmount;
return installed;
};
bindLocale(pageI18n.global.locale);
return pageI18n;
}
+96
View File
@@ -0,0 +1,96 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../testSupport/vueDom.mjs';
let vue;
let i18n;
let applicationI18n;
let uninstall;
before(async () => {
uninstall = installVueDom();
vue = await import('vue');
i18n = await import('../i18n/index.js');
({ applicationI18n } = await import('./i18n.js'));
});
after(() => {
i18n.setLocale('en');
uninstall();
});
test('vue-i18n takes the locale set before it and holds the catalogs', () => {
i18n.setLocale('ru');
const { global } = applicationI18n();
assert.equal(applicationI18n(), applicationI18n());
assert.equal(global.locale.value, 'ru');
assert.equal(global.te('Settings'), true);
assert.equal(global.tm('Settings'), 'Настройки');
global.locale.value = 'de';
assert.equal(i18n.getLocale(), 'de');
global.locale.value = 'ru_RU';
assert.equal(i18n.getLocale(), 'ru');
assert.equal(i18n.t('Settings'), 'Настройки');
assert.equal(i18n.formatNumber(1234.5), '1\u00a0234,5');
global.locale.value = 'xx';
assert.equal(i18n.getLocale(), 'en');
i18n.setLocale('en');
assert.equal(global.locale.value, 'en');
});
test('a view that translates re-renders when the locale changes', async () => {
const { createApp, h, nextTick } = vue;
const host = document.createElement('div');
document.body.append(host);
const app = createApp({
render: () =>
h('p', [
i18n.t('Settings'),
' / ',
i18n.tn(
3,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
),
]),
}).use(applicationI18n());
app.mount(host);
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
i18n.setLocale('ru');
await nextTick();
assert.equal(host.textContent, 'Настройки / ЗАПУСК · 3 КЛЮЧА В ОЖИДАНИИ');
i18n.setLocale('en');
await nextTick();
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
app.unmount();
host.remove();
});
test('unmounting one view keeps the page instance for the others', async () => {
const { createApp, h, nextTick } = vue;
const page = applicationI18n();
let disposed = 0;
const dispose = page.dispose;
page.dispose = () => {
disposed += 1;
dispose.call(page);
};
const mountView = () => {
const host = document.createElement('div');
document.body.append(host);
const app = createApp({ render: () => h('p', i18n.t('Settings')) });
app.use(page).mount(host);
return { app, host };
};
const first = mountView();
const second = mountView();
first.app.unmount();
assert.equal(disposed, 0);
i18n.setLocale('ru');
await nextTick();
assert.equal(second.host.textContent, 'Настройки');
const third = mountView();
assert.equal(third.host.textContent, 'Настройки');
i18n.setLocale('en');
second.app.unmount();
third.app.unmount();
page.dispose = dispose;
});
+4 -1
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue';
import { applicationPinia } from '../vueRuntime.js';
import { applicationI18n } from '../i18n.js';
import WelcomeView from './WelcomeView.vue';
import ContextView from './ContextView.vue';
import LayerPanelsView from './LayerPanelsView.vue';
@@ -57,7 +58,9 @@ export function mountApplicationViews(
if (mounted.has(host)) continue;
if (!Object.hasOwn(APPLICATION_VIEWS, name))
throw new Error(`Unknown application view: ${name}`);
const app = createApp(APPLICATION_VIEWS[name]).use(applicationPinia());
const app = createApp(APPLICATION_VIEWS[name])
.use(applicationPinia())
.use(applicationI18n());
app.mount(host);
mounted.set(host, app);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { applicationI18n } from './i18n.js';
let pagePinia = null;
@@ -15,7 +16,7 @@ export function applicationPinia() {
/**
* Create a Vue runtime: one Pinia instance shared by every view it mounts,
* and the mounts themselves for teardown.
* the page's vue-i18n instance, and the mounts themselves for teardown.
* @param {{pinia?: import('pinia').Pinia}} [options]
* @returns {{pinia: import('pinia').Pinia, mount: Function, destroy: Function}}
*/
@@ -32,7 +33,7 @@ export function createUiRuntime({ pinia = createPinia() } = {}) {
*/
mount(component, host, props = {}) {
const app = createApp(component, props);
app.use(pinia);
app.use(pinia).use(applicationI18n());
app.mount(host);
apps.add(app);
return () => {