fix(ui): stamp the service worker with a per-build id so deploys reach parked tabs (#12725)

## 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-<hash>"` matching the entry chunk `dist/assets/index-<hash>.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
This commit is contained in:
Devin Foley 2026-09-03 13:08:19 -07:00 committed by GitHub
parent 0cf06c8fa1
commit 236588c753
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 154 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@ -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",
},