Files
PaperClipAI/ui/src/components/AppUpdateBanner.tsx
T
Dotta f9f6a9c86c 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.
2026-07-21 07:47:54 -05:00

58 lines
2.2 KiB
TypeScript

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