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:
parent
0cf06c8fa1
commit
236588c753
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue