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");
+});