mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
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:
1 parent
37d84700fa
commit
f9f6a9c86c
4 files changed
+237
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user