Merge pull request #2227 from pikasTech/fix/2226-mdtodo-locale-import-fallback
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 { createI18n } from "vue-i18n";
|
||||||
|
import zhMessages from "./locales/zh";
|
||||||
|
|
||||||
type LocaleCode = "zh" | "en";
|
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 LOCALE_KEY = "hwlab_locale";
|
||||||
const DEFAULT_LOCALE: LocaleCode = "zh";
|
const DEFAULT_LOCALE: LocaleCode = "zh";
|
||||||
|
const LOCALE_RETRY_DELAYS_MS = Object.freeze([150, 500]);
|
||||||
|
|
||||||
const localeLoaders: Record<LocaleCode, () => Promise<{ default: LocaleMessages }>> = {
|
const localeLoaders: Record<LocaleCode, LocaleLoader> = {
|
||||||
zh: () => import("./locales/zh"),
|
zh: () => Promise.resolve({ default: zhMessages }),
|
||||||
en: () => import("./locales/en")
|
en: () => import("./locales/en")
|
||||||
};
|
};
|
||||||
|
|
||||||
export const i18n = createI18n({ legacy: false, locale: getLocale(), fallbackLocale: DEFAULT_LOCALE, messages: {} });
|
export const i18n = createI18n({ legacy: false, locale: getLocale(), fallbackLocale: DEFAULT_LOCALE, messages: { [DEFAULT_LOCALE]: zhMessages } });
|
||||||
const loadedLocales = new Set<LocaleCode>();
|
const loadedLocales = new Set<LocaleCode>([DEFAULT_LOCALE]);
|
||||||
|
|
||||||
function isLocaleCode(value: string): value is LocaleCode {
|
function isLocaleCode(value: string): value is LocaleCode {
|
||||||
return value === "zh" || value === "en";
|
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 {
|
export function getLocale(): LocaleCode {
|
||||||
const saved = typeof localStorage !== "undefined" ? localStorage.getItem(LOCALE_KEY) : null;
|
const saved = typeof localStorage !== "undefined" ? localStorage.getItem(LOCALE_KEY) : null;
|
||||||
if (saved && isLocaleCode(saved)) return saved;
|
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;
|
return browserLang.startsWith("en") ? "en" : DEFAULT_LOCALE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLocaleMessages(locale: LocaleCode): Promise<void> {
|
export async function loadLocaleMessages(locale: LocaleCode): Promise<void> {
|
||||||
if (loadedLocales.has(locale)) return;
|
if (loadedLocales.has(locale)) return;
|
||||||
const module = await localeLoaders[locale]();
|
try {
|
||||||
i18n.global.setLocaleMessage(locale, module.default);
|
const { module } = await loadLocaleModuleWithRetry(localeLoaders[locale]);
|
||||||
loadedLocales.add(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> {
|
export async function initI18n(): Promise<void> {
|
||||||
const locale = getLocale();
|
const locale = getLocale();
|
||||||
await loadLocaleMessages(locale);
|
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;
|
export default i18n;
|
||||||
|
|
||||||
|
function waitMs(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
globalThis.setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user