From c836d0f702c9d0588ee9f795b8427eae6d42fb34 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 2 Jul 2026 14:49:34 +0800 Subject: [PATCH] Handle asset preload failures with auto reload --- src/app/ErrorBoundary.jsx | 113 +++++++++++++++++++++++++++++++-- src/app/ErrorBoundary.test.jsx | 21 ++++++ 2 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 src/app/ErrorBoundary.test.jsx diff --git a/src/app/ErrorBoundary.jsx b/src/app/ErrorBoundary.jsx index 733e02b..f9cd8c9 100644 --- a/src/app/ErrorBoundary.jsx +++ b/src/app/ErrorBoundary.jsx @@ -1,15 +1,107 @@ import { Component } from "react"; import styles from "./ErrorBoundary.module.css"; -export class ErrorBoundary extends Component { - state = { error: null }; +const ASSET_RELOAD_STORAGE_PREFIX = "hyapp-admin:asset-reload:"; +const ASSET_RELOAD_RETRY_MS = 5 * 60 * 1000; +const ASSET_ERROR_PATTERNS = [ + /Unable to preload CSS/i, + /Failed to fetch dynamically imported module/i, + /error loading dynamically imported module/i, + /Importing a module script failed/i, + /Load failed for the module/i, + /ChunkLoadError/i, + /CSS_CHUNK_LOAD_FAILED/i, +]; - static getDerivedStateFromError(error) { - return { error }; +export function isRecoverableAssetLoadError(error) { + const message = errorMessage(error); + return ASSET_ERROR_PATTERNS.some((pattern) => pattern.test(message)); +} + +export function assetReloadStorageKey(error, location = window.location) { + const message = errorMessage(error); + const assetMatch = message.match(/\/assets\/[^\s"')]+/); + const asset = assetMatch?.[0] || message.slice(0, 160) || "unknown"; + return `${ASSET_RELOAD_STORAGE_PREFIX}${location.pathname}:${asset}`; +} + +function errorMessage(error) { + if (!error) { + return ""; } + if (typeof error === "string") { + return error; + } + + return [error.name, error.message, error.stack].filter(Boolean).join("\n"); +} + +function canRetryAssetReload(error) { + const key = assetReloadStorageKey(error); + const now = Date.now(); + + try { + const previous = Number(window.sessionStorage.getItem(key) || "0"); + if (previous && now - previous < ASSET_RELOAD_RETRY_MS) { + return false; + } + + window.sessionStorage.setItem(key, String(now)); + return true; + } catch { + if (window.__hyappAssetReloadAttempted) { + return false; + } + window.__hyappAssetReloadAttempted = true; + return true; + } +} + +function scheduleAssetReload(error) { + if (!isRecoverableAssetLoadError(error) || !canRetryAssetReload(error)) { + return false; + } + + window.setTimeout(() => { + window.location.reload(); + }, 50); + return true; +} + +export class ErrorBoundary extends Component { + state = { error: null, recovering: false }; + + static getDerivedStateFromError(error) { + return { error, recovering: isRecoverableAssetLoadError(error) }; + } + + componentDidMount() { + window.addEventListener("vite:preloadError", this.handlePreloadError); + } + + componentWillUnmount() { + window.removeEventListener("vite:preloadError", this.handlePreloadError); + } + + componentDidCatch(error) { + if (!scheduleAssetReload(error) && isRecoverableAssetLoadError(error)) { + this.setState({ recovering: false }); + } + } + + handlePreloadError = (event) => { + const error = event.payload || event.reason || event.error; + if (!scheduleAssetReload(error)) { + return; + } + + event.preventDefault(); + this.setState({ error, recovering: true }); + }; + reset = () => { - this.setState({ error: null }); + this.setState({ error: null, recovering: false }); }; reload = () => { @@ -21,6 +113,17 @@ export class ErrorBoundary extends Component { return this.props.children; } + if (this.state.recovering) { + return ( +
+
+

正在刷新页面

+

正在加载最新版本

+
+
+ ); + } + return (
diff --git a/src/app/ErrorBoundary.test.jsx b/src/app/ErrorBoundary.test.jsx new file mode 100644 index 0000000..5a0ebec --- /dev/null +++ b/src/app/ErrorBoundary.test.jsx @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { assetReloadStorageKey, isRecoverableAssetLoadError } from "./ErrorBoundary.jsx"; + +test("detects Vite CSS preload failures as recoverable asset errors", () => { + expect(isRecoverableAssetLoadError(new Error("Unable to preload CSS for /assets/HostOrgTable-DWK6sgCN.css"))).toBe( + true, + ); + expect(isRecoverableAssetLoadError(new Error("Failed to fetch dynamically imported module"))).toBe(true); +}); + +test("keeps ordinary runtime errors visible", () => { + expect(isRecoverableAssetLoadError(new Error("Cannot read properties of undefined"))).toBe(false); +}); + +test("scopes asset reload attempts by route and asset", () => { + const key = assetReloadStorageKey(new Error("Unable to preload CSS for /assets/HostOrgTable-DWK6sgCN.css"), { + pathname: "/host/managers", + }); + + expect(key).toContain("/host/managers:/assets/HostOrgTable-DWK6sgCN.css"); +});