Browse Source

fix: split locale chunks by removing eager i18n glob

The eager `import.meta.glob` was statically pulling all 13 locale JSON
files into the main bundle, defeating the sibling lazy glob and emitting
INEFFECTIVE_DYNAMIC_IMPORT warnings. Statically import only the en-US
fallback, lazy-load the rest, and await `readyI18n()` in each entry
before mount so the first paint still uses the active locale.
MHSanaei 15 hours ago
parent
commit
79a9be7b22

+ 4 - 2
frontend/src/entries/api-docs.js

@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
 
 
 import { setupAxios } from '@/api/axios-init.js';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
 import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
 
 
@@ -16,4 +16,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/inbounds.js

@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
 
 
 import { setupAxios } from '@/api/axios-init.js';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
 import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
 
 
@@ -16,4 +16,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/index.js

@@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
 // Importing useTheme triggers the boot side-effect that applies the
 // Importing useTheme triggers the boot side-effect that applies the
 // stored theme to <body>/<html> before Vue mounts.
 // stored theme to <body>/<html> before Vue mounts.
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import IndexPage from '@/pages/index/IndexPage.vue';
 import IndexPage from '@/pages/index/IndexPage.vue';
 
 
@@ -18,4 +18,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(IndexPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(IndexPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 4
frontend/src/entries/login.js

@@ -6,18 +6,18 @@ import { setupAxios } from '@/api/axios-init.js';
 // Importing this module triggers the boot side-effect that applies the
 // Importing this module triggers the boot side-effect that applies the
 // stored theme to <body>/<html> before Vue renders anything.
 // stored theme to <body>/<html> before Vue renders anything.
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import LoginPage from '@/pages/login/LoginPage.vue';
 import LoginPage from '@/pages/login/LoginPage.vue';
 
 
 setupAxios();
 setupAxios();
 applyDocumentTitle();
 applyDocumentTitle();
 
 
-// Toasts attach to a #message div the page provides — keeps theme
-// styling in sync with the rest of the panel.
 const messageContainer = document.getElementById('message');
 const messageContainer = document.getElementById('message');
 if (messageContainer) {
 if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(LoginPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(LoginPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/nodes.js

@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
 
 
 import { setupAxios } from '@/api/axios-init.js';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import NodesPage from '@/pages/nodes/NodesPage.vue';
 import NodesPage from '@/pages/nodes/NodesPage.vue';
 
 
@@ -16,4 +16,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(NodesPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(NodesPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/settings.js

@@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
 // Importing useTheme triggers the boot side-effect that applies the
 // Importing useTheme triggers the boot side-effect that applies the
 // stored theme to <body>/<html> before Vue mounts.
 // stored theme to <body>/<html> before Vue mounts.
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import SettingsPage from '@/pages/settings/SettingsPage.vue';
 import SettingsPage from '@/pages/settings/SettingsPage.vue';
 
 
@@ -18,4 +18,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/subpage.js

@@ -7,7 +7,7 @@ import 'ant-design-vue/dist/reset.css';
 // with the parsed traffic/quota/expiry view-model and the rendered
 // with the parsed traffic/quota/expiry view-model and the rendered
 // share links — the SPA reads those at mount.
 // share links — the SPA reads those at mount.
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import SubPage from '@/pages/sub/SubPage.vue';
 import SubPage from '@/pages/sub/SubPage.vue';
 
 
 const messageContainer = document.getElementById('message');
 const messageContainer = document.getElementById('message');
@@ -15,4 +15,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(SubPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(SubPage).use(Antd).use(i18n).mount('#app');
+});

+ 4 - 2
frontend/src/entries/xray.js

@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
 
 
 import { setupAxios } from '@/api/axios-init.js';
 import { setupAxios } from '@/api/axios-init.js';
 import '@/composables/useTheme.js';
 import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
 import { applyDocumentTitle } from '@/utils';
 import { applyDocumentTitle } from '@/utils';
 import XrayPage from '@/pages/xray/XrayPage.vue';
 import XrayPage from '@/pages/xray/XrayPage.vue';
 
 
@@ -16,4 +16,6 @@ if (messageContainer) {
   message.config({ getContainer: () => messageContainer });
   message.config({ getContainer: () => messageContainer });
 }
 }
 
 
-createApp(XrayPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+  createApp(XrayPage).use(Antd).use(i18n).mount('#app');
+});

+ 19 - 58
frontend/src/i18n/index.js

@@ -1,93 +1,54 @@
-// vue-i18n setup. Locale files live in web/translation/*.json — the same
-// directory the Go binary embeds, so SPA + Telegram bot + subscription
-// page all read from a single source.
-//
-// Usage in a component:
-//   import { useI18n } from 'vue-i18n';
-//   const { t } = useI18n();
-//   ...
-//   <span>{{ t('pages.inbounds.email') }}</span>
-//
-// Or via the global helper exposed on the app:
-//   <span>{{ $t('pages.inbounds.email') }}</span>
-//
-// The locale follows the `lang` cookie that LanguageManager already
-// reads/writes — switching language anywhere in the app continues to
-// trigger a full page reload (matches legacy ergonomics), so we don't
-// need a runtime locale switcher here.
-
 import { createI18n } from 'vue-i18n';
 import { createI18n } from 'vue-i18n';
 
 
 import { LanguageManager } from '@/utils';
 import { LanguageManager } from '@/utils';
+import enUS from '../../../web/translation/en-US.json';
 
 
-// Lazy-loaded locales — Vite splits each one into its own chunk. We
-// eager-load only the active language plus the en-US fallback so the
-// initial page payload stays small (the inbounds bundle was sitting
-// at ~700kB gzipped with all 13 locales eager; now ~480kB).
-//
-// LanguageManager.setLanguage() does a full reload on change, so
-// "lazy" here effectively means "load only what this page needs for
-// its lifetime."
 const FALLBACK = 'en-US';
 const FALLBACK = 'en-US';
-const lazyModules = import.meta.glob('../../../web/translation/*.json');
-const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
+const lazyModules = import.meta.glob([
+  '../../../web/translation/*.json',
+  '!../../../web/translation/en-US.json',
+]);
 
 
 function moduleKeyFor(code) {
 function moduleKeyFor(code) {
   return `../../../web/translation/${code}.json`;
   return `../../../web/translation/${code}.json`;
 }
 }
 
 
-// Resolve the active locale via LanguageManager so the cookie set on
-// the legacy panel keeps working after a user upgrades. Falls back
-// to en-US when the cookie names a language we don't have.
 let active = LanguageManager.getLanguage();
 let active = LanguageManager.getLanguage();
-if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
+if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
   active = FALLBACK;
   active = FALLBACK;
 }
 }
 
 
-const messages = {};
-// Eagerly include the active locale + the fallback (when distinct)
-// so the very first render has strings ready. Vite still emits these
-// as their own chunks so the user pays for at most two locales.
-for (const code of new Set([active, FALLBACK])) {
-  const mod = eagerModules[moduleKeyFor(code)];
-  if (mod) messages[code] = mod.default || mod;
-}
-
 export const i18n = createI18n({
 export const i18n = createI18n({
   legacy: false,
   legacy: false,
-  // `composition` mode (legacy: false) so `useI18n()` works in
-  // <script setup> blocks.
   globalInjection: true,
   globalInjection: true,
   locale: active,
   locale: active,
   fallbackLocale: FALLBACK,
   fallbackLocale: FALLBACK,
-  // Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}})
-  // so vue-i18n's default `.`-delimited lookups walk straight into it.
-  messages,
-  // The Go side sometimes interpolates `#variable#` into translated
-  // strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
-  // expects `{var}` — disable warnings about strings that look like
-  // they don't use the new syntax.
+  messages: { [FALLBACK]: enUS },
   warnHtmlMessage: false,
   warnHtmlMessage: false,
   missingWarn: false,
   missingWarn: false,
   fallbackWarn: false,
   fallbackWarn: false,
 });
 });
 
 
-// Convenience export for non-component contexts (HTTP error toasts,
-// stores, etc.) that need to look up a translation outside a setup
-// scope.
 export function t(key, params) {
 export function t(key, params) {
   return i18n.global.t(key, params || {});
   return i18n.global.t(key, params || {});
 }
 }
 
 
-// loadLocale fetches a locale module on demand and registers it with
-// vue-i18n. Pages that switch language at runtime (rather than via
-// LanguageManager's reload) can call this to swap strings live.
 export async function loadLocale(code) {
 export async function loadLocale(code) {
-  const key = moduleKeyFor(code);
-  const loader = lazyModules[key];
+  if (code === FALLBACK) {
+    i18n.global.locale.value = FALLBACK;
+    return true;
+  }
+  const loader = lazyModules[moduleKeyFor(code)];
   if (!loader) return false;
   if (!loader) return false;
   const mod = await loader();
   const mod = await loader();
   i18n.global.setLocaleMessage(code, mod.default || mod);
   i18n.global.setLocaleMessage(code, mod.default || mod);
   i18n.global.locale.value = code;
   i18n.global.locale.value = code;
   return true;
   return true;
 }
 }
+
+export async function readyI18n() {
+  if (active !== FALLBACK) {
+    await loadLocale(active);
+  }
+  return i18n;
+}