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:
@@ -1,5 +1,6 @@
|
|||||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
import { Component, type ReactNode, type ErrorInfo } from "react";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { handleChunkLoadError } from "../versionCheck";
|
||||||
import "./ErrorBoundary.css";
|
import "./ErrorBoundary.css";
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
@@ -26,6 +27,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||||
console.error("[ErrorBoundary]", error, errorInfo);
|
console.error("[ErrorBoundary]", error, errorInfo);
|
||||||
|
if (handleChunkLoadError(error)) return;
|
||||||
this.props.onError?.(error, errorInfo);
|
this.props.onError?.(error, errorInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { RootErrorBoundary } from "./components/ErrorBoundary";
|
import { RootErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { installAuthFetch } from "./auth";
|
import { installAuthFetch } from "./auth";
|
||||||
|
import { installVersionCheck } from "./versionCheck";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
// Install the bearer-token fetch wrapper before React mounts so every API
|
// 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
|
// the token that was either captured from `?token=` in the launch URL or
|
||||||
// stored from a previous session.
|
// stored from a previous session.
|
||||||
installAuthFetch();
|
installAuthFetch();
|
||||||
|
installVersionCheck();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
|||||||
76
packages/dashboard/app/versionCheck.ts
Normal file
76
packages/dashboard/app/versionCheck.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -534,9 +534,10 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
}
|
}
|
||||||
: remoteAccess;
|
: remoteAccess;
|
||||||
|
|
||||||
const issued = issueRemoteAuthToken("short-lived", modeSettings);
|
const issuedAtMs = Date.now();
|
||||||
|
const issued = issueRemoteAuthToken("short-lived", modeSettings, issuedAtMs);
|
||||||
const effectiveTtlMs = issued.expiresAt
|
const effectiveTtlMs = issued.expiresAt
|
||||||
? Math.max(0, Date.parse(issued.expiresAt) - Date.now())
|
? Math.max(0, Date.parse(issued.expiresAt) - issuedAtMs)
|
||||||
: modeSettings.tokenStrategy.shortLived.ttlMs;
|
: modeSettings.tokenStrategy.shortLived.ttlMs;
|
||||||
res.json({ token: issued.token, expiresAt: issued.expiresAt ?? null, ttlMs: effectiveTtlMs });
|
res.json({ token: issued.token, expiresAt: issued.expiresAt ?? null, ttlMs: effectiveTtlMs });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -503,6 +503,14 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
: join(__dirname, "..", "client");
|
: join(__dirname, "..", "client");
|
||||||
|
|
||||||
if (!isHeadless) {
|
if (!isHeadless) {
|
||||||
|
app.get("/version.json", (_req, res) => {
|
||||||
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
||||||
|
res.sendFile(join(clientDir, "version.json"), (err) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(404).json({ version: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
app.use(express.static(clientDir));
|
app.use(express.static(clientDir));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const buildVersion = `${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
||||||
|
|
||||||
|
function emitVersionJson(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "fusion-emit-version-json",
|
||||||
|
apply: "build",
|
||||||
|
closeBundle() {
|
||||||
|
const outFile = resolve(__dirname, "dist/client/version.json");
|
||||||
|
writeFileSync(outFile, `${JSON.stringify({ version: buildVersion })}\n`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: "app",
|
root: "app",
|
||||||
plugins: [react()],
|
plugins: [react(), emitVersionJson()],
|
||||||
|
define: {
|
||||||
|
__BUILD_VERSION__: JSON.stringify(buildVersion),
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@fusion/core": resolve(__dirname, "../core/src/types.ts"),
|
"@fusion/core": resolve(__dirname, "../core/src/types.ts"),
|
||||||
|
|||||||
Reference in New Issue
Block a user