From 236588c753c1408e6e7a686b5ab6f150d607feb0 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 3 Sep 2026 13:08:19 -0700 Subject: [PATCH] fix(ui): stamp the service worker with a per-build id so deploys reach parked tabs (#12725) 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 ships a service worker (`ui/public/sw.js`) plus update logic (`ui/src/lib/service-worker-updates.ts`) whose job is to keep long-lived, parked SPA tabs on the freshly deployed bundle. > - That reload-on-update path fires only on `controllerchange` — i.e., only when the browser installs a new `sw.js`. > - But `sw.js` was a static public asset (`CACHE_NAME = "paperclip-v2"`), copied verbatim and never varying per deploy, so a normal deploy (new app bundle, unchanged `sw.js`) installed no new worker and triggered no reload. > - So a parked tab kept running the old bundle after a deploy until a manual reload — the exact failure the update logic was written to prevent. > - This pull request makes `sw.js` change whenever the app bundle changes, by stamping it with a per-build id at build time. > - The benefit is that shipped UI fixes actually reach open tabs, instead of waiting for each user to reload by hand. ## Linked Issues or Issue Description No separate issue. Describing the bug in-PR using the bug-report fields: **What happened?** After a deploy that changes the app bundle but not `sw.js`, tabs left open across the upgrade keep running the old bundle indefinitely. The network-first service worker means a manual reload always recovers, but nothing triggers that reload automatically. Concretely, the `2026.831.1` onboarding fix did not reach tabs that were open on `2026.831.0`. **Expected behavior** When a new bundle is deployed, the existing update machinery (`registration.update()` on visibility/interval, reload on `controllerchange`) should bring parked tabs onto the new bundle without a manual reload. **Steps to reproduce** 1. Open the app and leave the tab open. 2. Deploy a build that changes the app bundle but not `sw.js` (the common case — `sw.js` was static). 3. Observe the open tab keeps running the previous bundle; no new worker installs, so no `controllerchange` and no reload. **Paperclip version or commit** Reproduced against `2026.831.1` and `master` before this change. **Deployment mode** Any web deployment that serves the built UI (local trusted quickstart, managed, or self-hosted). Related PRs (searched open + closed before opening this one): - Refs #12198 (merged) — added the parked-tab `update()`/`controllerchange` reload logic this PR completes by making `sw.js` actually change per deploy. - Refs #9951 (open) — an alternative "prompt to reload on new build" approach to the same problem; this PR instead reuses the existing silent auto-reload path. Reviewers may want to pick one. - Refs #8112 (open) — serves `sw.js` with `no-cache`; complementary (that keeps the worker script itself fresh; this makes the script vary per build). ## What Changed - `ui/public/sw.js`: derive `CACHE_NAME` from a `__PAPERCLIP_BUILD_ID__` placeholder so the worker source varies per build. - `ui/src/lib/vite-sw-build-id.ts`: new Vite build plugin that rewrites the placeholder in the emitted `sw.js` with the entry chunk's content hash (stable when the app is unchanged, new when it changes). Throws if the placeholder is missing, so the worker can never silently stop rotating. - `ui/vite.config.ts`: register the plugin. - `ui/src/lib/vite-sw-build-id.test.ts`: unit tests for the stamping helper, the build-id derivation, and a contract test that `public/sw.js` still carries the placeholder. ## Verification - `vitest run ui/src/lib/vite-sw-build-id.test.ts` — 7 tests pass. - `vite build` — the emitted `dist/sw.js` contains `BUILD_ID = "index-"` matching the entry chunk `dist/assets/index-.js`, and the `__PAPERCLIP_BUILD_ID__` placeholder is gone. A subsequent build with unchanged app code produces the same id (no needless worker churn); a build with changed code produces a new id. - Dev (`vite serve`) leaves the literal placeholder in `sw.js`, where HMR (not the worker) drives refreshes. ## Risks - Low risk, build-time only. No runtime service-worker logic changes beyond the cache name being build-specific; the activate handler already deletes all caches, so a rotating name is inert there. - If a future edit removes the placeholder, the build fails loudly rather than silently shipping a non-rotating worker. ## Model Used - Claude (Anthropic), model id `claude-fable-5` (Claude Fable 5), used with tool use, shell commands, file editing, and test execution. ## 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 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 considered and documented any risks above - [x] 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 --- ui/public/sw.js | 9 +++- ui/src/lib/vite-sw-build-id.test.ts | 63 ++++++++++++++++++++++ ui/src/lib/vite-sw-build-id.ts | 81 +++++++++++++++++++++++++++++ ui/vite.config.ts | 3 +- 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 ui/src/lib/vite-sw-build-id.test.ts create mode 100644 ui/src/lib/vite-sw-build-id.ts diff --git a/ui/public/sw.js b/ui/public/sw.js index e5997304dc..9a9d1a7ee6 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -1,4 +1,11 @@ -const CACHE_NAME = "paperclip-v2"; +// The build id is stamped into this file at production build time (see +// stampServiceWorkerBuildId in vite.config.ts), so a deploy that changes only +// the app bundle still changes sw.js byte-for-byte. That is what makes the +// browser install a new worker, which — via skipWaiting + controllerchange — +// reloads parked tabs onto the fresh bundle. Left as the literal placeholder in +// dev, where HMR (not the worker) drives refreshes. +const BUILD_ID = "__PAPERCLIP_BUILD_ID__"; +const CACHE_NAME = `paperclip-${BUILD_ID}`; self.addEventListener("install", () => { self.skipWaiting(); diff --git a/ui/src/lib/vite-sw-build-id.test.ts b/ui/src/lib/vite-sw-build-id.test.ts new file mode 100644 index 0000000000..5b978a9f84 --- /dev/null +++ b/ui/src/lib/vite-sw-build-id.test.ts @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + SERVICE_WORKER_BUILD_ID_PLACEHOLDER, + deriveBuildIdFromEntryFileName, + stampServiceWorkerBuildId, +} from "./vite-sw-build-id"; + +const swSource = () => + `const BUILD_ID = "${SERVICE_WORKER_BUILD_ID_PLACEHOLDER}";\n` + + "const CACHE_NAME = `paperclip-${BUILD_ID}`;\n"; + +describe("stampServiceWorkerBuildId", () => { + it("replaces the placeholder with the build id and leaves no placeholder", () => { + const out = stampServiceWorkerBuildId(swSource(), "index-abc123"); + expect(out).toContain("index-abc123"); + expect(out).not.toContain(SERVICE_WORKER_BUILD_ID_PLACEHOLDER); + }); + + it("produces different worker bytes for different build ids", () => { + // This is the whole point: a new bundle -> a new sw.js -> a new worker -> + // parked tabs reload. Identical build ids must stay byte-identical so the + // worker does not churn when the app did not change. + const a = stampServiceWorkerBuildId(swSource(), "index-aaaaaa"); + const b = stampServiceWorkerBuildId(swSource(), "index-bbbbbb"); + const again = stampServiceWorkerBuildId(swSource(), "index-aaaaaa"); + expect(a).not.toEqual(b); + expect(a).toEqual(again); + }); + + it("throws when the placeholder is missing so a drifted worker fails the build", () => { + expect(() => stampServiceWorkerBuildId("const CACHE_NAME = 'paperclip';", "x")).toThrow( + /placeholder/, + ); + }); + + it("throws on an empty build id rather than shipping a nameless cache", () => { + expect(() => stampServiceWorkerBuildId(swSource(), "")).toThrow(); + }); +}); + +describe("deriveBuildIdFromEntryFileName", () => { + it("uses the content-hashed entry file name", () => { + expect(deriveBuildIdFromEntryFileName("assets/index-BHbrFFmp.js")).toBe("index-BHbrFFmp"); + }); + + it("sanitizes characters that are unsafe in a cache name", () => { + expect(deriveBuildIdFromEntryFileName("assets/index @weird!.js")).toBe("index--weird-"); + }); +}); + +describe("public/sw.js contract", () => { + it("still contains the placeholder the plugin rewrites", () => { + const swPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../public/sw.js", + ); + const source = fs.readFileSync(swPath, "utf8"); + expect(source).toContain(SERVICE_WORKER_BUILD_ID_PLACEHOLDER); + }); +}); diff --git a/ui/src/lib/vite-sw-build-id.ts b/ui/src/lib/vite-sw-build-id.ts new file mode 100644 index 0000000000..d9ea0a13d8 --- /dev/null +++ b/ui/src/lib/vite-sw-build-id.ts @@ -0,0 +1,81 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { Plugin } from "vite"; + +/** + * Stamp the service worker with a per-build id so bundle-only deploys still + * refresh parked tabs. + * + * `sw.js` is a static public asset copied verbatim into the build, and its + * update machinery (`service-worker-updates.ts`) only reloads a parked tab when + * a *new* worker takes control — which happens only when `sw.js` changes + * byte-for-byte. Without this, a deploy that ships a new app bundle but the same + * `sw.js` installs no new worker, so an open tab keeps running the old bundle + * until someone reloads by hand. Rewriting the placeholder with a value derived + * from the bundle makes `sw.js` change exactly when the app does. + */ + +export const SERVICE_WORKER_BUILD_ID_PLACEHOLDER = "__PAPERCLIP_BUILD_ID__"; + +/** + * Replace the build-id placeholder in a service-worker source string. + * + * Throws when the placeholder is absent: that means the worker drifted away + * from the contract (renamed or removed placeholder) and would ship a service + * worker that never rotates — the exact bug this plugin exists to prevent — so + * a loud build failure beats a silent no-op. + */ +export function stampServiceWorkerBuildId(source: string, buildId: string): string { + if (!source.includes(SERVICE_WORKER_BUILD_ID_PLACEHOLDER)) { + throw new Error( + `service worker is missing the ${SERVICE_WORKER_BUILD_ID_PLACEHOLDER} placeholder; ` + + "the build cannot stamp a build id and parked tabs would not refresh after a deploy", + ); + } + if (!buildId) { + throw new Error("refusing to stamp the service worker with an empty build id"); + } + return source.split(SERVICE_WORKER_BUILD_ID_PLACEHOLDER).join(buildId); +} + +/** + * Derive a build id from the emitted bundle. The entry chunk's file name + * carries a content hash that changes whenever the app code changes and stays + * stable when it does not, so the worker rotates precisely with the app. + */ +export function deriveBuildIdFromEntryFileName(entryFileName: string): string { + const base = path.basename(entryFileName).replace(/\.js$/, ""); + // Keep only characters that are safe inside a Cache Storage name. + const sanitized = base.replace(/[^A-Za-z0-9_-]/g, "-"); + return sanitized || "build"; +} + +export function serviceWorkerBuildIdPlugin( + options: { serviceWorkerFileName?: string } = {}, +): Plugin { + const serviceWorkerFileName = options.serviceWorkerFileName ?? "sw.js"; + let buildId: string | null = null; + let outDir = "dist"; + + return { + name: "paperclip-sw-build-id", + apply: "build", + configResolved(config) { + outDir = config.build.outDir; + }, + generateBundle(_options, bundle) { + const entry = Object.values(bundle).find( + (chunk) => chunk.type === "chunk" && chunk.isEntry, + ); + if (entry) { + buildId = deriveBuildIdFromEntryFileName(entry.fileName); + } + }, + closeBundle() { + const swPath = path.resolve(outDir, serviceWorkerFileName); + const source = fs.readFileSync(swPath, "utf8"); + const stamped = stampServiceWorkerBuildId(source, buildId ?? "build"); + fs.writeFileSync(swPath, stamped); + }, + }; +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index d235797fec..3ac9f91485 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -4,11 +4,12 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { createUiDevWatchOptions } from "./src/lib/vite-watch"; import { createApiProxy } from "./src/lib/vite-api-proxy"; +import { serviceWorkerBuildIdPlugin } from "./src/lib/vite-sw-build-id"; const apiProxy = createApiProxy(); export default defineConfig(({ mode }) => ({ - plugins: [react(), tailwindcss()], + plugins: [react(), tailwindcss(), serviceWorkerBuildIdPlugin()], build: { minify: "esbuild", },