fix(dashboard): reload on stale chunk after redeploy

Builds now emit version.json + a __BUILD_VERSION__ define. The client
re-checks the remote version on visibilitychange/focus and reloads on
mismatch, so a backgrounded tab doesn't hit a 404'd hashed chunk and
surface "'text/html' is not a valid JavaScript MIME type" when opening
Settings. ErrorBoundary catches stale-chunk errors as a safety net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fusion
2026-04-26 09:48:04 -07:00
committed by gsxdsm
parent a692edf079
commit ca26ae0781
6 changed files with 111 additions and 4 deletions

View File

@@ -1,5 +1,6 @@
import { Component, type ReactNode, type ErrorInfo } from "react";
import { AlertTriangle } from "lucide-react";
import { handleChunkLoadError } from "../versionCheck";
import "./ErrorBoundary.css";
interface ErrorBoundaryProps {
@@ -26,6 +27,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("[ErrorBoundary]", error, errorInfo);
if (handleChunkLoadError(error)) return;
this.props.onError?.(error, errorInfo);
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { RootErrorBoundary } from "./components/ErrorBoundary";
import { App } from "./App";
import { installAuthFetch } from "./auth";
import { installVersionCheck } from "./versionCheck";
import "./styles.css";
// Install the bearer-token fetch wrapper before React mounts so every API
@@ -10,6 +11,7 @@ import "./styles.css";
// the token that was either captured from `?token=` in the launch URL or
// stored from a previous session.
installAuthFetch();
installVersionCheck();
createRoot(document.getElementById("root")!).render(
<StrictMode>

View File

@@ -0,0 +1,76 @@
declare const __BUILD_VERSION__: string;
const RELOAD_FLAG = "fusion:version-reload";
function reloadOnce(reason: string): void {
if (sessionStorage.getItem(RELOAD_FLAG)) {
console.warn("[versionCheck] reload already attempted, suppressing", reason);
return;
}
sessionStorage.setItem(RELOAD_FLAG, "1");
console.info("[versionCheck] reloading:", reason);
window.location.reload();
}
export function isStaleChunkError(error: unknown): boolean {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "";
return /Failed to fetch dynamically imported module|error loading dynamically imported module|Importing a module script failed|is not a valid JavaScript MIME type|ChunkLoadError/i.test(
message,
);
}
export function handleChunkLoadError(error: unknown): boolean {
if (!isStaleChunkError(error)) return false;
reloadOnce(`chunk load error: ${(error as Error)?.message ?? error}`);
return true;
}
async function fetchRemoteVersion(): Promise<string | null> {
try {
const res = await fetch("/version.json", {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!res.ok) return null;
const ct = res.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return null;
const data = (await res.json()) as { version?: unknown };
return typeof data.version === "string" ? data.version : null;
} catch {
return null;
}
}
let checkInFlight = false;
async function checkVersion(): Promise<void> {
if (checkInFlight || document.visibilityState !== "visible") return;
checkInFlight = true;
try {
const remote = await fetchRemoteVersion();
if (remote && remote !== __BUILD_VERSION__) {
reloadOnce(`build version changed: ${__BUILD_VERSION__} -> ${remote}`);
}
} finally {
checkInFlight = false;
}
}
export function installVersionCheck(): void {
if (!import.meta.env.PROD) return;
// Clear stale flag once a fresh page has rendered successfully.
window.setTimeout(() => sessionStorage.removeItem(RELOAD_FLAG), 5_000);
document.addEventListener("visibilitychange", () => {
void checkVersion();
});
window.addEventListener("focus", () => {
void checkVersion();
});
// Initial check after load to catch tabs restored from bfcache.
window.setTimeout(() => void checkVersion(), 2_000);
}