mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
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.
58 lines
2.2 KiB
TypeScript
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>
|
|
);
|
|
}
|