fix(web): keep app mounted when locale chunk loading fails

This commit is contained in:
root
2026-06-27 06:55:07 +00:00
parent 1415abb7e6
commit c92faf4efc
2 changed files with 79 additions and 10 deletions
@@ -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);
});
+47 -10
View File
@@ -1,41 +1,78 @@
import { createI18n } from "vue-i18n";
import zhMessages from "./locales/zh";
type LocaleCode = "zh" | "en";
type LocaleMessages = Record<string, unknown>;
type LocaleMessages = typeof zhMessages;
type LocaleModule<T> = { default: T };
type LocaleLoader = () => Promise<LocaleModule<LocaleMessages>>;
type WaitFn = (ms: number) => Promise<void>;
const LOCALE_KEY = "hwlab_locale";
const DEFAULT_LOCALE: LocaleCode = "zh";
const LOCALE_RETRY_DELAYS_MS = Object.freeze([150, 500]);
const localeLoaders: Record<LocaleCode, () => Promise<{ default: LocaleMessages }>> = {
zh: () => import("./locales/zh"),
const localeLoaders: Record<LocaleCode, LocaleLoader> = {
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<LocaleCode>();
export const i18n = createI18n({ legacy: false, locale: getLocale(), fallbackLocale: DEFAULT_LOCALE, messages: { [DEFAULT_LOCALE]: zhMessages } });
const loadedLocales = new Set<LocaleCode>([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<T>(loader: () => Promise<LocaleModule<T>>, retryDelaysMs: readonly number[] = LOCALE_RETRY_DELAYS_MS, wait: WaitFn = waitMs): Promise<{ module: LocaleModule<T>; 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<void> {
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<void> {
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<void> {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, ms);
});
}