fix(panel): 加错误边界与新版本 chunk 自动刷新,无 WebGL 时仪表盘降级为平面视图
This commit is contained in:
13 files changed
+432
-14
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { claimChunkReload, isChunkLoadError } from "./chunk";
|
||||
|
||||
// After a deploy an old tab's lazy import 404s. The panel reloads once to pick up
|
||||
// the new chunks; these cases pin which errors count and that a second failure
|
||||
// inside the window surfaces instead of looping.
|
||||
|
||||
function memory(): Pick<Storage, "getItem" | "setItem"> & { data: Map<string, string> } {
|
||||
const data = new Map<string, string>();
|
||||
return { data, getItem: (k) => data.get(k) ?? null, setItem: (k, v) => void data.set(k, v) };
|
||||
}
|
||||
|
||||
describe("isChunkLoadError", () => {
|
||||
it("recognizes each browser's wording", () => {
|
||||
for (const msg of [
|
||||
"Failed to fetch dynamically imported module: https://x/assets/VoxelFleet-abc.js",
|
||||
"error loading dynamically imported module",
|
||||
"Importing a module script failed.",
|
||||
"Unable to preload CSS for /assets/index-abc.css",
|
||||
]) {
|
||||
expect(isChunkLoadError(new TypeError(msg))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves ordinary render errors alone", () => {
|
||||
expect(isChunkLoadError(new TypeError("Cannot read properties of undefined (reading 'name')"))).toBe(false);
|
||||
expect(isChunkLoadError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claimChunkReload", () => {
|
||||
it("allows the first reload and stamps it", () => {
|
||||
const s = memory();
|
||||
expect(claimChunkReload(s, 1_000_000)).toBe(true);
|
||||
expect(s.data.size).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a second reload inside the window", () => {
|
||||
const s = memory();
|
||||
claimChunkReload(s, 1_000_000);
|
||||
expect(claimChunkReload(s, 1_010_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows another reload once the window has passed", () => {
|
||||
const s = memory();
|
||||
claimChunkReload(s, 1_000_000);
|
||||
expect(claimChunkReload(s, 1_031_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("declines without storage, since nothing could stop a loop", () => {
|
||||
expect(claimChunkReload(null, 1_000_000)).toBe(false);
|
||||
const broken = { getItem: () => { throw new Error("denied"); }, setItem: () => {} };
|
||||
expect(claimChunkReload(broken, 1_000_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { lazy, type ComponentType } from "react";
|
||||
|
||||
// A deploy replaces the hashed chunks, so a tab opened before it asks for files
|
||||
// that no longer exist (panel.go answers index.html and the dynamic import
|
||||
// fails). One full reload picks up the new index and its chunk names; the
|
||||
// sessionStorage stamp keeps a real outage from turning into a reload loop.
|
||||
|
||||
const RELOAD_KEY = "felis:chunk-reload-at";
|
||||
const RELOAD_WINDOW_MS = 30_000;
|
||||
|
||||
const CHUNK_ERROR =
|
||||
/Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|Unable to preload CSS|ChunkLoadError/i;
|
||||
|
||||
/** isChunkLoadError recognizes a failed lazy import in Chrome, Firefox and Safari. */
|
||||
export function isChunkLoadError(err: unknown): boolean {
|
||||
const text = err instanceof Error ? `${err.name} ${err.message}` : String(err ?? "");
|
||||
return CHUNK_ERROR.test(text);
|
||||
}
|
||||
|
||||
type Stamp = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
/** claimChunkReload reports whether this tab may reload now and, if so, stamps
|
||||
* the attempt. Without usable storage there is no loop guard, so it declines. */
|
||||
export function claimChunkReload(storage: Stamp | null, now: number): boolean {
|
||||
if (!storage) return false;
|
||||
try {
|
||||
const last = Number(storage.getItem(RELOAD_KEY) ?? 0);
|
||||
if (Number.isFinite(last) && now - last < RELOAD_WINDOW_MS) return false;
|
||||
storage.setItem(RELOAD_KEY, String(now));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** reloadForNewDeploy reloads the page once per window; false means the caller
|
||||
* must surface the error instead. */
|
||||
export function reloadForNewDeploy(): boolean {
|
||||
let storage: Stamp | null = null;
|
||||
try {
|
||||
storage = window.sessionStorage;
|
||||
} catch {
|
||||
// Storage blocked (privacy mode): no guard, no automatic reload.
|
||||
}
|
||||
if (!claimChunkReload(storage, Date.now())) return false;
|
||||
window.location.reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** lazyWithReload is React.lazy that reloads once when the chunk has gone. */
|
||||
export function lazyWithReload<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>) {
|
||||
return lazy(() =>
|
||||
factory().catch((err: unknown) => {
|
||||
if (isChunkLoadError(err) && reloadForNewDeploy()) return new Promise<{ default: T }>(() => {});
|
||||
throw err;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** installPreloadReload covers Vite's own modulepreload/CSS preloads, which
|
||||
* fail through the vite:preloadError event instead of the import promise. */
|
||||
export function installPreloadReload(): void {
|
||||
window.addEventListener("vite:preloadError", (event) => {
|
||||
if (reloadForNewDeploy()) event.preventDefault();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// webglAvailable probes once whether this browser can create a WebGL context
|
||||
// (hardware acceleration off, some VMs and remote desktops cannot). The probe
|
||||
// context is released right away so the fleet view still gets its own.
|
||||
|
||||
let cached: boolean | undefined;
|
||||
|
||||
export function webglAvailable(): boolean {
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||
gl?.getExtension("WEBGL_lose_context")?.loseContext();
|
||||
cached = gl != null;
|
||||
} catch {
|
||||
cached = false;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
Reference in new issue
Block a user