feat(ui): prompt to reload when a new build is deployed

Long-lived tabs keep running the JS bundle they loaded at page-load, so a
deployed fix doesn't reach them until they happen to reload — which is exactly
what let the memory-leak fix (#9827) look like it "wasn't working" on stale
tabs. The service worker (public/sw.js) is a static file that doesn't change
between builds, so the usual SW updatefound flow never fires for a JS-only
deploy.

Detect a new build by comparing the hashed entry bundle filename (index-<hash>.js,
which Vite changes every build) against a freshly-served /index.html — polled
every 5 min and on tab focus/visibility. When it changes, show a persistent,
dismissible banner ("A new version is available — Reload"), styled like the info
toast. The banner never auto-reloads, so it can't interrupt work or lose unsaved
input; reload is user-driven.

- lib/app-update-check.ts: bundle-id detection + latched watcher (+ tests).
- components/AppUpdateBanner.tsx: the banner, mounted in Layout beside ToastViewport.

tsc clean; app-update-check + Layout suites pass.
This commit is contained in:
Dotta committed 2026-07-21 07:47:54 -05:00
1 parent 37d84700fa
commit f9f6a9c86c
4 files changed
+237

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from "react";
import { RefreshCw, X } from "lucide-react";
import { getLoadedBundleId, startAppUpdateWatcher } from "../lib/app-update-check";
/**
* Shows a persistent, dismissible banner when a newer frontend build has been
* deployed while this tab was open, so long-lived tabs don't keep running a
* stale (possibly buggy) bundle until they happen to reload. See
* `lib/app-update-check.ts` for how a new build is detected.
*/
export function AppUpdateBanner() {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const stop = startAppUpdateWatcher({
currentBundleId: getLoadedBundleId(),
onUpdateAvailable: () => setUpdateAvailable(true),
});
return stop;
}, []);
if (!updateAvailable || dismissed) return null;
return (
<aside
role="status"
aria-live="polite"
className="fixed bottom-3 right-3 z-(--z-120) w-full max-w-sm px-1"
>
<div className="pointer-events-auto rounded-sm border border-sky-300 bg-sky-50 text-sky-900 shadow-lg backdrop-blur-xl dark:border-sky-500/25 dark:bg-sky-950/60 dark:text-sky-100">
<div className="flex items-start gap-3 px-3 py-2.5">
<RefreshCw className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold leading-5">A new version is available</p>
<p className="mt-1 text-xs leading-4 opacity-70">Reload to get the latest update.</p>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-2 inline-flex text-xs font-medium underline underline-offset-4 hover:opacity-90"
>
Reload
</button>
</div>
<button
type="button"
aria-label="Dismiss update notice"
onClick={() => setDismissed(true)}
className="mt-0.5 shrink-0 rounded p-1 opacity-50 hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
</aside>
);
}
+2
View File
@@ -15,6 +15,7 @@ import { NewGoalDialog } from "./NewGoalDialog";
import { NewAgentDialog } from "./NewAgentDialog";
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
import { ToastViewport } from "./ToastViewport";
import { AppUpdateBanner } from "./AppUpdateBanner";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
@@ -658,6 +659,7 @@ export function Layout() {
<NewAgentDialog />
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
<ToastViewport />
<AppUpdateBanner />
</div>
</GeneralSettingsProvider>
);
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getLoadedBundleId,
parseBundleIdFromHtml,
startAppUpdateWatcher,
} from "./app-update-check";
describe("parseBundleIdFromHtml", () => {
it("extracts the hashed entry bundle from index.html", () => {
const html = `<!doctype html><html><head>
<script type="module" crossorigin src="/assets/index-B85r4qUa.js"></script>
<link rel="stylesheet" href="/assets/index-abc123.css"></head><body></body></html>`;
expect(parseBundleIdFromHtml(html)).toBe("index-B85r4qUa.js");
});
it("returns null when no entry bundle is present", () => {
expect(parseBundleIdFromHtml("<html><head></head><body></body></html>")).toBeNull();
});
});
describe("getLoadedBundleId", () => {
it("reads the bundle filename from a script tag", () => {
const doc = {
querySelectorAll: () => [
{ getAttribute: () => "/assets/vendor-x.js" },
{ getAttribute: () => "https://host/assets/index-CB24WRl4.js" },
],
} as unknown as Document;
expect(getLoadedBundleId(doc)).toBe("index-CB24WRl4.js");
});
});
describe("startAppUpdateWatcher", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("fires once when the deployed bundle differs, then latches", async () => {
const onUpdate = vi.fn();
let deployed = "index-AAA.js";
const stop = startAppUpdateWatcher({
currentBundleId: "index-AAA.js",
onUpdateAvailable: onUpdate,
intervalMs: 1000,
getDeployedBundleId: () => Promise.resolve(deployed),
});
await vi.advanceTimersByTimeAsync(1000);
expect(onUpdate).not.toHaveBeenCalled(); // same bundle
deployed = "index-BBB.js";
await vi.advanceTimersByTimeAsync(1000);
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUpdate).toHaveBeenCalledWith("index-BBB.js");
// latched — does not fire again on subsequent polls
await vi.advanceTimersByTimeAsync(5000);
expect(onUpdate).toHaveBeenCalledTimes(1);
stop();
});
it("does nothing when the current bundle id is unknown", () => {
const onUpdate = vi.fn();
const stop = startAppUpdateWatcher({
currentBundleId: null,
onUpdateAvailable: onUpdate,
intervalMs: 1000,
getDeployedBundleId: () => Promise.resolve("index-BBB.js"),
});
vi.advanceTimersByTime(5000);
expect(onUpdate).not.toHaveBeenCalled();
stop();
});
it("stop() halts polling", async () => {
const onUpdate = vi.fn();
const getter = vi.fn(() => Promise.resolve("index-AAA.js"));
const stop = startAppUpdateWatcher({
currentBundleId: "index-AAA.js",
onUpdateAvailable: onUpdate,
intervalMs: 1000,
getDeployedBundleId: getter,
});
stop();
await vi.advanceTimersByTimeAsync(5000);
expect(getter).not.toHaveBeenCalled();
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Detects when a new frontend build has been deployed while a tab is still open.
*
* Long-lived tabs keep running the JS bundle they loaded at page-load, so a
* deployed fix (e.g. the React perf-measure memory fix) doesn't reach them until
* they happen to reload. The service worker (`public/sw.js`) is a static file
* that doesn't change between builds, so the normal SW `updatefound` flow never
* fires for a JS-only deploy. Instead we compare the *hashed bundle filename*
* (which Vite changes every build) against the freshly-served `index.html`.
*/
// Vite emits the entry as `assets/index-<hash>.js`. Match just the filename.
const BUNDLE_FILENAME_RE = /(index-[A-Za-z0-9_-]+\.js)/;
/** The bundle filename this page actually loaded, from its <script> tags. */
export function getLoadedBundleId(doc: Document = document): string | null {
for (const script of Array.from(doc.querySelectorAll("script[src]"))) {
const src = script.getAttribute("src") ?? "";
const match = src.match(BUNDLE_FILENAME_RE);
if (match) return match[1]!;
}
return null;
}
/** The entry bundle filename referenced by a served index.html string. */
export function parseBundleIdFromHtml(html: string): string | null {
const match = html.match(/<script[^>]+src=["'][^"']*?(index-[A-Za-z0-9_-]+\.js)["']/i);
return match ? match[1]! : null;
}
export async function fetchDeployedBundleId(
fetchImpl: typeof fetch = fetch,
): Promise<string | null> {
try {
const res = await fetchImpl("/index.html", { cache: "no-store", credentials: "same-origin" });
if (!res.ok) return null;
return parseBundleIdFromHtml(await res.text());
} catch {
return null;
}
}
export interface AppUpdateWatcherOptions {
currentBundleId: string | null;
onUpdateAvailable: (deployedBundleId: string) => void;
/** Poll interval; also re-checks when the tab regains focus/visibility. */
intervalMs?: number;
/** Injectable for tests. */
getDeployedBundleId?: () => Promise<string | null>;
}
const DEFAULT_INTERVAL_MS = 5 * 60_000;
/**
* Poll for a newer deployed bundle. Fires `onUpdateAvailable` at most once
* (updates are latched), then keeps quiet until the caller reloads. Returns a
* stop function.
*/
export function startAppUpdateWatcher(opts: AppUpdateWatcherOptions): () => void {
const { currentBundleId, onUpdateAvailable, intervalMs = DEFAULT_INTERVAL_MS } = opts;
// If we can't identify our own bundle, we can't compare — do nothing.
if (!currentBundleId) return () => {};
const getDeployed = opts.getDeployedBundleId ?? (() => fetchDeployedBundleId());
let stopped = false;
let notified = false;
const check = async () => {
if (stopped || notified) return;
const deployed = await getDeployed();
if (stopped || notified) return;
if (deployed && deployed !== currentBundleId) {
notified = true;
onUpdateAvailable(deployed);
}
};
const interval = setInterval(() => void check(), intervalMs);
const onVisible = () => {
if (typeof document === "undefined" || document.visibilityState === "visible") void check();
};
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.addEventListener("focus", onVisible);
return () => {
stopped = true;
clearInterval(interval);
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
if (typeof window !== "undefined") window.removeEventListener("focus", onVisible);
};
}