fix(web): keep app mounted when locale chunk loading fails
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user