fix(ui): keep the installed service worker fresh on parked tabs (#12198)

## 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
This commit is contained in:
Devin Foley 2026-08-25 16:12:02 -07:00 committed by GitHub
parent 9c03443c48
commit ca02d2463a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 326 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@ -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<string, Set<Listener>>();
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<string, Set<Listener>>();
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();
});
});

View File

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

View File

@ -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: {