mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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:
co-authored by
Claude Opus 4.8
parent
3c8f106425
commit
74fa10a138
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user