From c92faf4efc5c837698134a04312bb68f5ef318db Mon Sep 17 00:00:00 2001 From: root Date: Sat, 27 Jun 2026 06:55:07 +0000 Subject: [PATCH] fix(web): keep app mounted when locale chunk loading fails --- .../scripts/i18n-fallback.test.ts | 32 +++++++++++ web/hwlab-cloud-web/src/i18n/index.ts | 57 +++++++++++++++---- 2 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/i18n-fallback.test.ts diff --git a/web/hwlab-cloud-web/scripts/i18n-fallback.test.ts b/web/hwlab-cloud-web/scripts/i18n-fallback.test.ts new file mode 100644 index 00000000..ee2a39e2 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/i18n-fallback.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isLocaleChunkLoadError, loadLocaleModuleWithRetry } from "../src/i18n/index.ts"; + +test("i18n locale loader retries transient dynamic import failures", async () => { + let calls = 0; + const waits: number[] = []; + + const result = await loadLocaleModuleWithRetry( + async () => { + calls += 1; + if (calls < 3) throw new Error("Failed to fetch dynamically imported module: https://hwlab.pikapython.com/assets/en.js"); + return { default: { nav: { dashboard: "Dashboard" } } }; + }, + [1, 2], + async (ms) => { + waits.push(ms); + } + ); + + assert.equal(result.attempts, 3); + assert.equal(calls, 3); + assert.deepEqual(waits, [1, 2]); + assert.deepEqual(result.module.default, { nav: { dashboard: "Dashboard" } }); +}); + +test("i18n chunk error classifier covers Vite dynamic import failures", () => { + assert.equal(isLocaleChunkLoadError(new Error("Failed to fetch dynamically imported module: https://hwlab.pikapython.com/assets/en.js")), true); + assert.equal(isLocaleChunkLoadError(new Error("Importing a module script failed.")), true); + assert.equal(isLocaleChunkLoadError(new Error("ordinary locale parse error")), false); +}); diff --git a/web/hwlab-cloud-web/src/i18n/index.ts b/web/hwlab-cloud-web/src/i18n/index.ts index 3ff6357e..f7f58487 100644 --- a/web/hwlab-cloud-web/src/i18n/index.ts +++ b/web/hwlab-cloud-web/src/i18n/index.ts @@ -1,41 +1,78 @@ import { createI18n } from "vue-i18n"; +import zhMessages from "./locales/zh"; type LocaleCode = "zh" | "en"; -type LocaleMessages = Record; +type LocaleMessages = typeof zhMessages; +type LocaleModule = { default: T }; +type LocaleLoader = () => Promise>; +type WaitFn = (ms: number) => Promise; const LOCALE_KEY = "hwlab_locale"; const DEFAULT_LOCALE: LocaleCode = "zh"; +const LOCALE_RETRY_DELAYS_MS = Object.freeze([150, 500]); -const localeLoaders: Record Promise<{ default: LocaleMessages }>> = { - zh: () => import("./locales/zh"), +const localeLoaders: Record = { + zh: () => Promise.resolve({ default: zhMessages }), en: () => import("./locales/en") }; -export const i18n = createI18n({ legacy: false, locale: getLocale(), fallbackLocale: DEFAULT_LOCALE, messages: {} }); -const loadedLocales = new Set(); +export const i18n = createI18n({ legacy: false, locale: getLocale(), fallbackLocale: DEFAULT_LOCALE, messages: { [DEFAULT_LOCALE]: zhMessages } }); +const loadedLocales = new Set([DEFAULT_LOCALE]); function isLocaleCode(value: string): value is LocaleCode { return value === "zh" || value === "en"; } +export function isLocaleChunkLoadError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ""); + return /Failed to fetch dynamically imported module|Importing a module script failed|Loading chunk|Loading CSS chunk|dynamically imported module/u.test(message); +} + +export async function loadLocaleModuleWithRetry(loader: () => Promise>, retryDelaysMs: readonly number[] = LOCALE_RETRY_DELAYS_MS, wait: WaitFn = waitMs): Promise<{ module: LocaleModule; attempts: number }> { + let lastError: unknown = null; + for (let index = 0; index <= retryDelaysMs.length; index += 1) { + try { + return { module: await loader(), attempts: index + 1 }; + } catch (error) { + lastError = error; + const delay = retryDelaysMs[index]; + if (delay == null) break; + await wait(delay); + } + } + throw lastError; +} + export function getLocale(): LocaleCode { const saved = typeof localStorage !== "undefined" ? localStorage.getItem(LOCALE_KEY) : null; if (saved && isLocaleCode(saved)) return saved; - const browserLang = typeof navigator !== "undefined" ? navigator.language.toLowerCase() : ""; + const browserLang = typeof navigator !== "undefined" ? String(navigator.language ?? "").toLowerCase() : ""; return browserLang.startsWith("en") ? "en" : DEFAULT_LOCALE; } export async function loadLocaleMessages(locale: LocaleCode): Promise { if (loadedLocales.has(locale)) return; - const module = await localeLoaders[locale](); - i18n.global.setLocaleMessage(locale, module.default); - loadedLocales.add(locale); + try { + const { module } = await loadLocaleModuleWithRetry(localeLoaders[locale]); + i18n.global.setLocaleMessage(locale, module.default); + loadedLocales.add(locale); + } catch (error) { + const message = error instanceof Error ? error.message : String(error ?? ""); + if (!isLocaleChunkLoadError(error)) console.warn(`HWLAB locale load failed for ${locale}; falling back to ${DEFAULT_LOCALE}: ${message}`); + else console.warn(`HWLAB locale chunk failed for ${locale}; falling back to ${DEFAULT_LOCALE}: ${message}`); + } } export async function initI18n(): Promise { const locale = getLocale(); await loadLocaleMessages(locale); - document.documentElement.setAttribute("lang", locale === "zh" ? "zh-CN" : "en"); + if (typeof document !== "undefined") document.documentElement.setAttribute("lang", locale === "zh" ? "zh-CN" : "en"); } export default i18n; + +function waitMs(ms: number): Promise { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, ms); + }); +}