Files
PaperClipAI/ui/src/lib/service-worker-updates.ts
T
Devin Foley ca02d2463a 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
2026-08-25 16:12:02 -07:00

102 lines
3.7 KiB
TypeScript

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