From ca02d2463af3626ca05e05e4e7572b23444fb256 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 16:12:02 -0700 Subject: [PATCH] fix(ui): keep the installed service worker fresh on parked tabs (#12198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The web UI registers a service worker (`/sw.js`) that caches the app shell for an offline fallback. > - Browsers only re-fetch a worker script on navigation or a ~24h timer, and Paperclip is a parked-tab SPA: a tab can sit open for weeks without one navigation. > - An installed worker — and the shell it cached — can therefore keep serving an old bundle long after a deploy, and the server let `sw.js` inherit the generic 1h static TTL on top of that. > - This pull request adds explicit update checks (tab-visible + hourly), applies a discovered update with one reload while the tab is hidden, and serves `sw.js` with `Cache-Control: no-cache`. > - The benefit is that a deploy reaches every open tab within about an hour, instead of some tabs silently running stale UI indefinitely. ## Linked Issues or Issue Description Refs #11292 (the network-first `sw.js` fallback fix; this PR closes the delivery gap that can keep clients pinned on a pre-#11292 worker). **What happened?** A browser that had an older cache-first worker installed kept rendering a stale app shell — old feature set, old naming — while the server was verified to be running the current release. Nothing on the client checks for a new worker outside navigations, so a parked tab never picked up the fixed worker, and `sw.js` was served with a 1h cache TTL that further delayed update checks. **Expected behavior** Every open tab converges to the deployed bundle shortly after a release, without users unregistering workers in DevTools or hard-reloading. **Steps to reproduce** Install a build's service worker, deploy a newer build, and leave the tab parked (no navigation): the tab keeps running the old bundle indefinitely; the worker update check only happens if the user navigates, and even then a cached `sw.js` can answer it. ## What Changed - New `ui/src/lib/service-worker-updates.ts`: registers `/sw.js`, runs `registration.update()` when the tab becomes visible and on an hourly timer, and on `controllerchange` of a previously-controlled page applies the update with a single reload — only while the tab is hidden, so an update never yanks the page mid-session; a takeover while visible defers the reload to the next hidden transition. First-ever installs never reload. - `ui/src/main.tsx`: replaces the fire-and-forget `register()` with the new module. - New `server/src/static-ui-cache.ts` (`staticUiCacheControl`): `index.html` and `sw.js` are served `Cache-Control: no-cache`; other non-hashed statics keep the 1h default. `server/src/app.ts` uses it in the static middleware. ## Verification - `npx vitest run ui/src/lib/service-worker-updates.test.ts` — 8 tests: registration, hidden-takeover reload (once), deferred reload on visible takeover, no reload on first install, visibility-triggered and timer-triggered update checks, cleanup, no-container no-op. - `npx vitest run server/src/__tests__/static-ui-cache.test.ts` — 3 tests incl. the `sw.js.map` lookalike keeping the default TTL. - `tsc -b` (ui) and `tsc --noEmit` (server) clean; `pnpm check:tokens` clean. ## Risks - Behavioral shift: tabs now reload once, while hidden, after a deploy lands. Unsaved in-page state in a hidden tab is lost at that moment — the same exposure as a browser discarding a background tab, which SPAs must already tolerate. - Self-hosted behavior is otherwise unchanged: same worker script, same registration URL, one added conditional header. - Low risk on the server side: the header change only widens revalidation. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic coding session with tool use and extended thinking. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- server/src/__tests__/static-ui-cache.test.ts | 22 +++ server/src/app.ts | 11 +- server/src/static-ui-cache.ts | 19 +++ ui/src/lib/service-worker-updates.test.ts | 170 +++++++++++++++++++ ui/src/lib/service-worker-updates.ts | 101 +++++++++++ ui/src/main.tsx | 13 +- 6 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 server/src/__tests__/static-ui-cache.test.ts create mode 100644 server/src/static-ui-cache.ts create mode 100644 ui/src/lib/service-worker-updates.test.ts create mode 100644 ui/src/lib/service-worker-updates.ts diff --git a/server/src/__tests__/static-ui-cache.test.ts b/server/src/__tests__/static-ui-cache.test.ts new file mode 100644 index 0000000000..1285d2bd0e --- /dev/null +++ b/server/src/__tests__/static-ui-cache.test.ts @@ -0,0 +1,22 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { staticUiCacheControl } from "../static-ui-cache.js"; + +describe("staticUiCacheControl", () => { + it("forces revalidation for index.html", () => { + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "index.html"))).toBe("no-cache"); + }); + + it("forces revalidation for the service-worker script", () => { + // Browsers refresh an installed worker only by re-fetching /sw.js; a + // cached copy pins every client on old worker code for the TTL. + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "sw.js"))).toBe("no-cache"); + }); + + it("leaves other static files on the middleware default", () => { + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "favicon.ico"))).toBeUndefined(); + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "robots.txt"))).toBeUndefined(); + // Lookalikes keep the default: only the exact worker filename is special. + expect(staticUiCacheControl(path.join("/srv", "ui-dist", "sw.js.map"))).toBeUndefined(); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 5c16989a23..e7d4230ca8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -85,6 +85,7 @@ import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gatew import { adapterRoutes } from "./routes/adapters.js"; import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js"; import { readBrandedStaticIndexHtml } from "./static-index-html.js"; +import { staticUiCacheControl } from "./static-ui-cache.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader, type PluginLoader } from "./services/plugin-loader.js"; @@ -686,15 +687,15 @@ export async function createApp( ); // Non-hashed static files (favicon.ico, manifest, robots.txt, etc.): // short cache so operators who swap them out see the new version - // reasonably fast. Override for `index.html` specifically — it is - // served by this middleware for `/` and `/index.html`, and it must - // never outlive the asset hashes it points at. + // reasonably fast, with must-revalidate overrides for index.html and + // sw.js (see staticUiCacheControl for why those two). app.use( express.static(uiDist, { maxAge: "1h", setHeaders(res, filePath) { - if (path.basename(filePath) === "index.html") { - res.set("Cache-Control", "no-cache"); + const override = staticUiCacheControl(filePath); + if (override) { + res.set("Cache-Control", override); } }, }), diff --git a/server/src/static-ui-cache.ts b/server/src/static-ui-cache.ts new file mode 100644 index 0000000000..7555036bd5 --- /dev/null +++ b/server/src/static-ui-cache.ts @@ -0,0 +1,19 @@ +import path from "node:path"; + +/** + * Cache-Control override for non-hashed UI static files (everything outside + * /assets, which is content-hashed and immutable). Two files must always be + * revalidated: + * + * - `index.html` must never outlive the asset hashes it points at. + * - `sw.js` is the browser's only channel for updating an installed service + * worker: clients re-fetch this exact URL to discover new worker code, so + * any cache TTL here delays every client's update by that long on top of + * the browser's own update timer. + * + * Returns undefined for files where the middleware's default TTL applies. + */ +export function staticUiCacheControl(filePath: string): "no-cache" | undefined { + const basename = path.basename(filePath); + return basename === "index.html" || basename === "sw.js" ? "no-cache" : undefined; +} diff --git a/ui/src/lib/service-worker-updates.test.ts b/ui/src/lib/service-worker-updates.test.ts new file mode 100644 index 0000000000..74709abc67 --- /dev/null +++ b/ui/src/lib/service-worker-updates.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startServiceWorkerUpdates } from "./service-worker-updates"; + +type Listener = () => void; + +function fakeContainer(opts: { controlled: boolean }) { + const listeners = new Map>(); + const registration = { update: vi.fn(() => Promise.resolve()) }; + const container = { + controller: opts.controlled ? ({} as ServiceWorker) : null, + register: vi.fn(() => Promise.resolve(registration)), + addEventListener: vi.fn((type: string, listener: Listener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }), + removeEventListener: vi.fn((type: string, listener: Listener) => { + listeners.get(type)?.delete(listener); + }), + }; + const emit = (type: string) => { + for (const listener of listeners.get(type) ?? []) listener(); + }; + return { container: container as unknown as ServiceWorkerContainer, registration, emit, listeners }; +} + +function fakeDocument(initialVisibility: DocumentVisibilityState = "visible") { + const listeners = new Map>(); + const doc = { + visibilityState: initialVisibility, + addEventListener: (type: string, listener: Listener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }, + removeEventListener: (type: string, listener: Listener) => { + listeners.get(type)?.delete(listener); + }, + }; + const emit = (type: string) => { + for (const listener of listeners.get(type) ?? []) listener(); + }; + return { doc: doc as unknown as Document & { visibilityState: DocumentVisibilityState }, emit, listeners }; +} + +describe("startServiceWorkerUpdates", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("registers /sw.js", async () => { + const { container } = fakeContainer({ controlled: false }); + const { doc } = fakeDocument(); + startServiceWorkerUpdates({ container, documentRef: doc, reload: vi.fn() }); + expect(container.register).toHaveBeenCalledWith("/sw.js"); + }); + + it("reloads once when a new worker takes over a hidden, already-controlled page", async () => { + const { container, emit } = fakeContainer({ controlled: true }); + const { doc } = fakeDocument("hidden"); + const reload = vi.fn(); + startServiceWorkerUpdates({ container, documentRef: doc, reload }); + + emit("controllerchange"); + emit("controllerchange"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("defers the reload to the next hidden transition when the takeover lands mid-session", async () => { + const { container, emit } = fakeContainer({ controlled: true }); + const docState = fakeDocument("visible"); + const reload = vi.fn(); + startServiceWorkerUpdates({ container, documentRef: docState.doc, reload }); + + emit("controllerchange"); + // The user is looking at the page: never yank it out from under them. + expect(reload).not.toHaveBeenCalled(); + + docState.doc.visibilityState = "hidden"; + docState.emit("visibilitychange"); + expect(reload).toHaveBeenCalledTimes(1); + + docState.emit("visibilitychange"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does not reload on a first-ever install", async () => { + const { container, emit } = fakeContainer({ controlled: false }); + const { doc } = fakeDocument("hidden"); + const reload = vi.fn(); + startServiceWorkerUpdates({ container, documentRef: doc, reload }); + + emit("controllerchange"); + expect(reload).not.toHaveBeenCalled(); + }); + + it("reloads when a later deploy replaces the worker a first-visit tab installed", async () => { + const { container, emit } = fakeContainer({ controlled: false }); + const { doc } = fakeDocument("hidden"); + const reload = vi.fn(); + startServiceWorkerUpdates({ container, documentRef: doc, reload }); + + // First takeover: the fresh install controls the page, no reload. + emit("controllerchange"); + expect(reload).not.toHaveBeenCalled(); + + // A deploy lands while the same tab is still open: now the controller + // change means newer code, and the hidden tab reloads onto it. + emit("controllerchange"); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("checks for updates when the tab becomes visible", async () => { + const { container, registration, emit } = fakeContainer({ controlled: true }); + const docState = fakeDocument("hidden"); + startServiceWorkerUpdates({ container, documentRef: docState.doc, reload: vi.fn() }); + await vi.waitFor(() => expect(container.register).toHaveBeenCalled()); + // Let the register() promise settle so the registration is captured. + await Promise.resolve(); + + docState.emit("visibilitychange"); + expect(registration.update).not.toHaveBeenCalled(); + + docState.doc.visibilityState = "visible"; + docState.emit("visibilitychange"); + expect(registration.update).toHaveBeenCalledTimes(1); + }); + + it("checks for updates on the timer", async () => { + const { container, registration } = fakeContainer({ controlled: true }); + const { doc } = fakeDocument(); + startServiceWorkerUpdates({ + container, + documentRef: doc, + reload: vi.fn(), + updateIntervalMs: 1000, + }); + await Promise.resolve(); + + vi.advanceTimersByTime(3000); + expect(registration.update).toHaveBeenCalledTimes(3); + }); + + it("stops listening and ticking after cleanup", async () => { + const { container, registration, emit, listeners } = fakeContainer({ controlled: true }); + const docState = fakeDocument(); + const reload = vi.fn(); + const stop = startServiceWorkerUpdates({ + container, + documentRef: docState.doc, + reload, + updateIntervalMs: 1000, + }); + await Promise.resolve(); + + stop(); + emit("controllerchange"); + docState.emit("visibilitychange"); + vi.advanceTimersByTime(5000); + expect(reload).not.toHaveBeenCalled(); + expect(registration.update).not.toHaveBeenCalled(); + expect(listeners.get("controllerchange")?.size ?? 0).toBe(0); + }); + + it("is a no-op without a service worker container", () => { + expect(() => startServiceWorkerUpdates({ reload: vi.fn() })()).not.toThrow(); + }); +}); diff --git a/ui/src/lib/service-worker-updates.ts b/ui/src/lib/service-worker-updates.ts new file mode 100644 index 0000000000..7ce1d33739 --- /dev/null +++ b/ui/src/lib/service-worker-updates.ts @@ -0,0 +1,101 @@ +/** + * Registers `/sw.js` and keeps the installed worker fresh on a long-lived tab. + * + * Browsers only re-fetch a service-worker script on navigation or on a ~24h + * timer. Paperclip is a parked-tab SPA — a tab can stay open for weeks without + * a single navigation — so without explicit update checks an old worker (and + * the app shell it cached) can outlive a deploy indefinitely. The symptom is + * invisible: the tab just keeps running the old bundle. + * + * Two behaviors close the gap: + * - `registration.update()` runs when the tab becomes visible and on an + * hourly timer, so parked tabs learn about new workers without navigating. + * - When a new worker takes control (`controllerchange`), the page reloads + * once so the fresh shell actually replaces the running bundle — but only + * while the tab is hidden, so an update landing mid-session never yanks + * the page out from under the user; a takeover while visible defers the + * reload to the next time the tab is hidden. First-ever installs skip the + * reload entirely: an uncontrolled page is already running the code the + * server just handed it. + */ +const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000; + +export function startServiceWorkerUpdates( + options: { + container?: ServiceWorkerContainer; + documentRef?: Document; + reload?: () => void; + updateIntervalMs?: number; + } = {}, +): () => void { + const container = + options.container ?? + (typeof navigator !== "undefined" && "serviceWorker" in navigator + ? navigator.serviceWorker + : undefined); + if (!container) { + return () => {}; + } + const documentRef = options.documentRef ?? document; + const reload = options.reload ?? (() => window.location.reload()); + const updateIntervalMs = options.updateIntervalMs ?? DEFAULT_UPDATE_INTERVAL_MS; + + // Only a page that is already worker-controlled is running + // potentially-stale code when the controller changes; a first-ever + // install taking control is caching the very bundle the page is running, + // so reloading would be a no-op. The flag is promoted on that first + // takeover: any later controller change on this (possibly weeks-old) tab + // does mean newer code exists. + let wasControlled = Boolean(container.controller); + let reloaded = false; + let reloadPending = false; + const applyUpdate = () => { + if (reloaded) return; + reloaded = true; + reload(); + }; + const onControllerChange = () => { + if (!wasControlled) { + wasControlled = true; + return; + } + if (documentRef.visibilityState === "hidden") { + applyUpdate(); + } else { + reloadPending = true; + } + }; + container.addEventListener("controllerchange", onControllerChange); + + let registration: ServiceWorkerRegistration | undefined; + const checkForUpdates = () => { + // update() rejects while offline or mid-deploy; the next visibility + // change or timer tick retries, so failures are deliberately swallowed. + void registration?.update().catch(() => {}); + }; + const onVisibilityChange = () => { + if (documentRef.visibilityState === "visible") { + checkForUpdates(); + } else if (reloadPending) { + applyUpdate(); + } + }; + documentRef.addEventListener("visibilitychange", onVisibilityChange); + const intervalId = setInterval(checkForUpdates, updateIntervalMs); + + void container + .register("/sw.js") + .then((reg) => { + registration = reg; + }) + .catch(() => { + // Registration can fail in private windows or hardened browsers; the + // app works without a worker, it just loses the offline fallback. + }); + + return () => { + container.removeEventListener("controllerchange", onControllerChange); + documentRef.removeEventListener("visibilitychange", onVisibilityChange); + clearInterval(intervalId); + }; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 2766c51e1e..c6aa4eb559 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -19,6 +19,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { initPluginBridge } from "./plugins/bridge-init"; import { PluginLauncherProvider } from "./plugins/launchers"; import { startPerfMeasureReaper } from "./lib/perf-measure-reaper"; +import { startServiceWorkerUpdates } from "./lib/service-worker-updates"; import "@mdxeditor/editor/style.css"; import "./index.css"; @@ -29,11 +30,13 @@ initPluginBridge(React, ReactDOM); // accumulate into millions of native objects (GBs). Reap them periodically. startPerfMeasureReaper(); -if ("serviceWorker" in navigator) { - window.addEventListener("load", () => { - navigator.serviceWorker.register("/sw.js"); - }); -} +// Parked SPA tabs never navigate, so beyond registering the worker this also +// re-checks /sw.js on tab focus and hourly, and applies a discovered update +// with one reload while the tab is hidden — otherwise an old worker and its +// cached shell can outlive a deploy indefinitely. +window.addEventListener("load", () => { + startServiceWorkerUpdates(); +}); const queryClient = new QueryClient({ defaultOptions: {