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:
parent
9c03443c48
commit
ca02d2463a
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue