fix: performance improvement (#12759)

| Metric | OLD | NEW | Improvement |

|------------------------------------------|----------|----------|------------------------|
| Blank screen until first content (FCP) | 15.0 s | 9.1 s | −5.9 s (≈40%
faster) |
| Page usable (LCP) | 16.2 s | 10.3 s | −5.9 s |
| Main bundle download done | 13.8 s | 7.5 s | −6.3 s |
| Data downloaded | 2,258 KB | 1,213 KB | −1,045 KB (~half) |



## Problem

All 12 locale files were statically imported into `locales/index.ts` and
spread
into a single `messages` object. Static imports get inlined, so the
entire set of
translations landed in the entry chunk — every user downloaded and
parsed all 12
languages in order to use 1.

## Fix

Only the `en-gb` fallback stays bundled. The other 11 are code-split via
`import.meta.glob` and fetched on demand; `main.ts` loads the active
locale
before mounting, falling back to the bundled `en-gb` if that chunk
fails.

## Impact (measured — prod builds of `main` vs this branch)

| `index.js` | main | this PR | delta |
|---|---|---|---|
| raw | 7,335.9 KB | 4,276.0 KB | **−3,059.9 KB (−41.7%)** |
| brotli | 1,468.2 KB | 874.6 KB | **−593.6 KB (−40.4%)** |

- **English users** (most traffic): −594 KB transfer, ~3 MB less JSON to
parse
  before first render, no extra request.
- **Non-English users**: same entry saving, plus one cacheable ~71 KB
(brotli)
  locale chunk — net ~−523 KB.

The parse-time saving matters more than transfer: decode cost scales
with
decoded size, not compressed size, and it blocks first render.

## Also

- Removed `hi.json` — unreachable since #1857 (absent from the language
picker
and `localeFileMap`, referenced nowhere) and incomplete: 658 keys
missing vs
  `en.json`, 1,905 of 5,280 still holding the English source string.
- Added guard tests asserting the locale registries stay in sync — a
file with no
mapping, a mapping with no file, and a locale missing from the
number-format
map all now fail CI. Each was verified to fail against an injected
regression.
- `GeneralDangerZone.spec.ts` set the i18n locale directly and relied on
all
locales being preloaded. It now awaits `loadLocaleMessages()` first,
mirroring
`main.ts`. Assertions unchanged — it still catches keys missing from a
translation (#13092).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sai Nikhil
2026-07-17 06:25:47 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 3c8f106425
commit 74fa10a138
6 changed files with 103 additions and 6049 deletions
@@ -19,7 +19,7 @@
import { mount, flushPromises } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import General from "./General.vue";
import i18n from "@/locales";
import i18n, { loadLocaleMessages } from "@/locales";
import { createRouter, createWebHistory } from "vue-router";
vi.mock("@/lib/feedback/Toast/useToast", () => ({
@@ -153,6 +153,10 @@ describe("General settings Danger Zone", () => {
["ja", "危険ゾーン", "管理者のみ"],
["de", "Gefahrenzone", "Nur Administratoren"],
])("renders the panel in %s", async (locale, header, ownerFact) => {
// Only en-gb is bundled; every other locale is a lazy chunk. main.ts awaits
// this before mounting, so mirror that here rather than assuming the
// messages are already registered.
await loadLocaleMessages(locale);
i18n.global.locale.value = locale;
const wrapper = await mountGeneral();
const text = wrapper
@@ -170,6 +174,7 @@ describe("General settings Danger Zone", () => {
});
it("resolves the member plural in a locale with no plural distinction", async () => {
await loadLocaleMessages("zh-cn");
i18n.global.locale.value = "zh-cn";
const wrapper = await mountGeneral();
expect(
+37 -1
View File
@@ -14,6 +14,9 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { readdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { getLanguage } from "@/utils/cookies";
vi.mock("@/utils/cookies", () => ({
@@ -21,7 +24,40 @@ vi.mock("@/utils/cookies", () => ({
setLanguage: vi.fn(),
}));
import { getNumberLocale } from "@/locales/numberFormat";
import { getNumberLocale, APP_LOCALE_TO_BCP47 } from "@/locales/numberFormat";
import { localeFileMap } from "@/locales";
describe("locale registry stays in sync", () => {
// locales/index.ts code-splits via import.meta.glob("./languages/*.json"), so
// the files on disk decide what gets built. Nothing else enforces that the
// registries agree, and every way they can drift fails silently:
// - file with no localeFileMap entry -> chunk built that nothing can load
// (hi.json sat unreachable this way for ~2 years)
// - localeFileMap entry with no file -> loadLocaleMessages() no-ops and the
// UI quietly renders English
// - localeFileMap entry missing from APP_LOCALE_TO_BCP47 -> numbers format
// as en-GB while the rest of the UI is translated
const languagesDir = resolve(
dirname(fileURLToPath(import.meta.url)),
"languages",
);
it("has exactly one localeFileMap entry per language file on disk", () => {
const onDisk = readdirSync(languagesDir)
.filter((f) => f.endsWith(".json"))
.sort();
const mapped = Object.values(localeFileMap)
.map((f) => `${f}.json`)
.sort();
expect(onDisk).toEqual(mapped);
});
it("formats numbers for every locale it can load", () => {
expect(Object.keys(APP_LOCALE_TO_BCP47).sort()).toEqual(
Object.keys(localeFileMap).sort(),
);
});
});
describe("getNumberLocale (locale format unit)", () => {
beforeEach(() => {
+49 -51
View File
@@ -17,66 +17,42 @@ import { createI18n } from "vue-i18n"; // import from runtime only
import { getLanguage } from "../utils/cookies";
import { setBadgeTranslator } from "@/lib/core/Badge/badgeI18n";
// User defined lang
import zhLocale from "./languages/zh.json";
// Only the fallback locale (en-gb) is bundled into the main chunk. Every other
// language is code-split and loaded on demand via loadLocaleMessages() so we
// don't ship all 12 locale files (~3.9MB raw) to every user on first load.
import enLocale from "./languages/en.json";
import trLocale from "./languages/tr.json";
import deLocale from "./languages/de.json";
import esLocale from "./languages/es.json";
import frLocale from "./languages/fr.json";
import itLocale from "./languages/it.json";
import jaLocale from "./languages/ja.json";
import koLocale from "./languages/ko.json";
import nlLocale from "./languages/nl.json";
import ptLocale from "./languages/pt.json";
import zhTWLocale from "./languages/zh_TW.json";
const messages = {
"en-gb": {
...enLocale,
},
"zh-cn": {
...zhLocale,
},
"tr-turk": {
...trLocale,
},
de: {
...deLocale,
},
es: {
...esLocale,
},
fr: {
...frLocale,
},
it: {
...itLocale,
},
ja: {
...jaLocale,
},
ko: {
...koLocale,
},
nl: {
...nlLocale,
},
pt: {
...ptLocale,
},
"zh-tw": {
...zhTWLocale,
},
// Maps an i18n locale code to its source file name (without extension).
// Exported so tests can assert it stays in sync with the files on disk — an
// unmapped file ships as a chunk nothing can load, and a mapping with no file
// silently falls back to en-gb.
export const localeFileMap: Record<string, string> = {
"en-gb": "en",
"zh-cn": "zh",
"tr-turk": "tr",
de: "de",
es: "es",
fr: "fr",
it: "it",
ja: "ja",
ko: "ko",
nl: "nl",
pt: "pt",
"zh-tw": "zh_TW",
};
// Lazy importers — each locale json becomes its own cacheable chunk.
const localeLoaders = import.meta.glob<{ default: Record<string, unknown> }>(
"./languages/*.json",
);
export const getLocale = () => {
const cookieLanguage = getLanguage();
if (cookieLanguage) {
return cookieLanguage;
}
const language = navigator.language.toLowerCase();
const locales = Object.keys(messages);
const locales = Object.keys(localeFileMap);
for (const locale of locales) {
if (language.indexOf(locale) > -1) {
return locale;
@@ -90,11 +66,33 @@ export const getLocale = () => {
const i18n = createI18n({
locale: getLocale(),
fallbackLocale: "en-gb",
messages: messages,
messages: {
"en-gb": {
...enLocale,
},
},
});
// Wire the badge registry's i18n resolver (OTag labelKey → translated text)
// without coupling the low-level OTag component to this module graph.
setBadgeTranslator((key: string) => i18n.global.t(key as never));
/**
* Loads and registers the messages for a locale on demand. No-op when the
* locale is already loaded (e.g. the statically bundled en-gb fallback) or
* unknown (the fallback locale then handles missing keys).
*/
export const loadLocaleMessages = async (locale: string): Promise<void> => {
if (i18n.global.availableLocales.includes(locale)) {
return;
}
const file = localeFileMap[locale];
const loader = file && localeLoaders[`./languages/${file}.json`];
if (!loader) {
return;
}
const messages = await loader();
i18n.global.setLocaleMessage(locale, messages.default);
};
export default i18n;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,7 +16,7 @@
import { getLanguage } from "@/utils/cookies";
// App language codes are not all valid BCP-47, so map them for Intl.NumberFormat.
const APP_LOCALE_TO_BCP47: Record<string, string> = {
export const APP_LOCALE_TO_BCP47: Record<string, string> = {
"en-gb": "en-GB",
"tr-turk": "tr-TR",
"zh-cn": "zh-CN",
+10 -2
View File
@@ -17,7 +17,7 @@ import { createApp } from "vue";
import store from "./stores";
import App from "./App.vue";
import createRouter from "./router";
import i18n from "./locales";
import i18n, { getLocale, loadLocaleMessages } from "./locales";
import "./styles/tailwind.css";
import config from "./aws-exports";
import configService from "./services/config";
@@ -274,4 +274,12 @@ router.onError(async (error) => {
}
});
app.mount("#app");
// Ensure the active locale's messages are loaded (en-gb is bundled; any other
// language is fetched as a code-split chunk) before the first render.
loadLocaleMessages(getLocale())
// On a locale-chunk load failure, fall back to the bundled en-gb messages
// (no console noise). The app must mount regardless.
.catch(() => {})
.finally(() => {
app.mount("#app");
});