From 2f4712e7fe52f712fd9a1384331d80dc643aaa83 Mon Sep 17 00:00:00 2001 From: Megha Goyal Date: Thu, 18 Jun 2026 20:06:03 +0000 Subject: [PATCH 1/5] fix(status-bar): make status title tap reliably open rename on iOS On iOS Safari, tapping the status-title in the top header was interpreted as native text-selection (showing the blue selection handles + magnifier) instead of firing the click handler that swaps the span for a rename . As a result the rename input never appeared and the session could not be renamed from the header on iPhone. Mirror what we already do on .sessionItemMarkerBtn: mark the title as non-user-selectable, suppress the iOS callout, and set touch-action: manipulation so taps go straight to the click handler without the 300ms delay. The element is already role="button" and keyboard-activatable, so disabling text selection only removes a behaviour that was incompatible with the button contract. Tests: - e2e: assert the rendered .statusTitle has user-select: none and touch-action: manipulation (regression for the CSS contract). - e2e: on the mobile project, simulate a real touchscreen tap and assert the rename input becomes visible and focused, then rename the session through it. Signed-off-by: Megha Goyal --- src/styles/statusBar.css | 10 +++++++++ tests/e2e/pi-web.spec.ts | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/styles/statusBar.css b/src/styles/statusBar.css index b1488c8..c237330 100644 --- a/src/styles/statusBar.css +++ b/src/styles/statusBar.css @@ -49,6 +49,16 @@ white-space: nowrap; max-width: 240px; cursor: pointer; + /* The status title acts as a button (role="button") that swaps in a + rename input on click. On iOS Safari a tap on a plain with + selectable text is interpreted as native text-selection (showing the + blue selection handles + magnifier), which suppresses the click event + and the rename input never appears. Disable text selection and the + callout so iOS reliably treats the tap as a button activation. */ + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + touch-action: manipulation; } .statusTitle:hover { color: var(--accent); diff --git a/tests/e2e/pi-web.spec.ts b/tests/e2e/pi-web.spec.ts index 6228b4b..3a85828 100644 --- a/tests/e2e/pi-web.spec.ts +++ b/tests/e2e/pi-web.spec.ts @@ -263,6 +263,52 @@ test.describe("composer layout", () => { expect(sessions.sessions.find((item: any) => item.id === "mock-current").name).toBe("Renamed from title"); }); + test("status title is not text-selectable on iOS so taps reach the rename handler", async ({ page }) => { + // Regression: on iOS Safari, tapping a with selectable text (the + // status title) shows the native blue selection handles + magnifier and + // suppresses the click event, so the rename input never appears. The fix + // is to mark the status title as non-user-selectable. Verify the rendered + // styles still enforce that contract. + await page.goto("/"); + await expect(page.locator("#statusTitle")).toHaveText("Current mock session"); + + const styles = await page.locator("#statusTitle").evaluate((el) => { + const cs = getComputedStyle(el); + return { + userSelect: cs.userSelect || (cs as any).webkitUserSelect, + webkitUserSelect: (cs as any).webkitUserSelect, + webkitTouchCallout: (cs as any).webkitTouchCallout, + touchAction: cs.touchAction, + cursor: cs.cursor, + role: el.getAttribute("role"), + }; + }); + expect(styles.role).toBe("button"); + expect(styles.cursor).toBe("pointer"); + expect([styles.userSelect, styles.webkitUserSelect]).toContain("none"); + // touch-action: manipulation removes the iOS 300ms tap delay too. + expect(styles.touchAction).toBe("manipulation"); + }); + + test("tapping the status title on a touch viewport opens the rename input", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "mobile", "Touch-tap behaviour is only meaningful on the mobile project."); + // End-to-end version of the iOS bug: simulate a real tap (not a synthetic + // mouse click) and assert the rename input is rendered and focused. + await page.goto("/"); + await expect(page.locator("#statusTitle")).toHaveText("Current mock session"); + + const box = await page.locator("#statusTitle").boundingBox(); + expect(box).not.toBeNull(); + await page.touchscreen.tap(box!.x + box!.width / 2, box!.y + box!.height / 2); + + await expect(page.locator("#statusTitle input")).toBeVisible(); + await expect(page.locator("#statusTitle input")).toBeFocused(); + + await page.locator("#statusTitle input").fill("Renamed via tap"); + await page.locator("#statusTitle input").press("Enter"); + await expect(page.locator("#statusTitle")).toHaveText("Renamed via tap"); + }); + test("new session resets status title", async ({ page }) => { await page.goto("/"); await expect(page.locator("#statusTitle")).toHaveText("Current mock session"); From 1ad59b4fb645c86fc28298dd61d54fc712444ec0 Mon Sep 17 00:00:00 2001 From: Megha Goyal Date: Fri, 19 Jun 2026 19:34:49 +0000 Subject: [PATCH 2/5] fix(status-bar): keep iOS rename input alive through keyboard blur + pointer tap The CSS-only fix (user-select:none) stopped the title itself from being text-selected, but two iOS-specific issues still made the header rename unusable on iPhone: 1. iOS Safari fires a spurious `blur` on the freshly-focused rename input while the on-screen keyboard animates in / the visual viewport resizes. The old `blur -> finish(true)` handler tore the input back down to the static title before the user could type, so the tap looked like it did nothing. Now the blur-to-save handler is only armed once focus has settled (350ms); an earlier blur re-focuses the input and keeps the keyboard up. 2. The tap could still be partly consumed by iOS's selection gesture. Open the editor on `pointerup` with preventDefault (suppressing the native selection), falling back to `click` where Pointer Events are absent, with a short window to stop the synthesized click from double-triggering. Tests (tests/e2e/pi-web.spec.ts, mobile project): - extend the touch-tap test to dispatch the spurious post-focus blur within the grace window and assert the rename input survives it and still saves. Verified this fails without the blur guard (input torn down) and passes with it. Signed-off-by: Megha Goyal --- src/status/statusBar.ts | 40 ++++++++++++++++++++++++++++++++++++++-- tests/e2e/pi-web.spec.ts | 12 +++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/status/statusBar.ts b/src/status/statusBar.ts index 996f2bb..695f265 100644 --- a/src/status/statusBar.ts +++ b/src/status/statusBar.ts @@ -96,6 +96,7 @@ export function createStatusBar(options: { const originalValue = input.value.trim(); let finished = false; + let blurArmed = false; const finish = (save: boolean) => { if (finished) return; finished = true; @@ -114,12 +115,27 @@ export function createStatusBar(options: { finish(false); } }); - input.addEventListener("blur", () => finish(true)); + // iOS Safari fires a spurious `blur` immediately after we focus the input + // (while the on-screen keyboard animates in and the visual viewport + // resizes). If we acted on that blur we would tear the rename field back + // down to the static title before the user could type a single character + // — which looks exactly like "tapping the header does nothing". Only arm + // the blur-to-save handler once focus has settled; an early blur instead + // re-focuses the input so the keyboard stays up. + input.addEventListener("blur", () => { + if (finished) return; + if (!blurArmed && document.body.contains(input)) { + requestAnimationFrame(() => { if (!finished) input.focus(); }); + return; + } + finish(true); + }); elements.statusTitleEl.textContent = ""; elements.statusTitleEl.append(input); input.focus(); input.select(); + window.setTimeout(() => { blurArmed = true; }, 350); } function clearConnectionTimers() { @@ -303,7 +319,27 @@ export function createStatusBar(options: { elements.statusTitleEl.setAttribute("role", "button"); elements.statusTitleEl.tabIndex = 0; setStatusTitle(state.currentSessionTitle); - elements.statusTitleEl.addEventListener("click", beginRenameSessionTitle); + + // On iOS Safari a plain tap on the title is otherwise interpreted + // as the start of a native text-selection gesture (blue handles + + // magnifier) which competes with — and can swallow — the click that opens + // the rename field. Handle the tap on `pointerup` and preventDefault so + // the selection gesture never starts; fall back to `click` for + // environments without Pointer Events. A short suppression window stops + // the synthesized click from re-triggering after a pointer-driven open. + let suppressClickUntil = 0; + if (typeof window !== "undefined" && "PointerEvent" in window) { + elements.statusTitleEl.addEventListener("pointerup", (event) => { + if (event.button !== undefined && event.button !== 0) return; + if (event.cancelable) event.preventDefault(); + suppressClickUntil = Date.now() + 600; + beginRenameSessionTitle(); + }); + } + elements.statusTitleEl.addEventListener("click", () => { + if (Date.now() < suppressClickUntil) return; + beginRenameSessionTitle(); + }); elements.statusTitleEl.addEventListener("keydown", (event) => { if (event.target !== elements.statusTitleEl || (event.key !== "Enter" && event.key !== " ")) return; event.preventDefault(); diff --git a/tests/e2e/pi-web.spec.ts b/tests/e2e/pi-web.spec.ts index 3a85828..9c097a2 100644 --- a/tests/e2e/pi-web.spec.ts +++ b/tests/e2e/pi-web.spec.ts @@ -290,8 +290,8 @@ test.describe("composer layout", () => { expect(styles.touchAction).toBe("manipulation"); }); - test("tapping the status title on a touch viewport opens the rename input", async ({ page }, testInfo) => { - test.skip(testInfo.project.name !== "mobile", "Touch-tap behaviour is only meaningful on the mobile project."); + test("tapping the status title on a touch viewport opens the rename input and survives an iOS keyboard blur", async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "mobile", "Touch-tap + iOS blur race is only meaningful on the mobile project."); // End-to-end version of the iOS bug: simulate a real tap (not a synthetic // mouse click) and assert the rename input is rendered and focused. await page.goto("/"); @@ -299,11 +299,17 @@ test.describe("composer layout", () => { const box = await page.locator("#statusTitle").boundingBox(); expect(box).not.toBeNull(); - await page.touchscreen.tap(box!.x + box!.width / 2, box!.y + box!.height / 2); + await page.touchscreen.tap(box!.x + Math.min(40, box!.width / 2), box!.y + box!.height / 2); await expect(page.locator("#statusTitle input")).toBeVisible(); await expect(page.locator("#statusTitle input")).toBeFocused(); + // iOS Safari fires a spurious blur right after focus while the on-screen + // keyboard animates in. The editor must NOT be torn down by it (otherwise + // the tap appears to "do nothing"). Fire that blur within the grace window. + await page.locator("#statusTitle input").evaluate((el) => el.dispatchEvent(new FocusEvent("blur"))); + await expect(page.locator("#statusTitle input")).toBeVisible(); + await page.locator("#statusTitle input").fill("Renamed via tap"); await page.locator("#statusTitle input").press("Enter"); await expect(page.locator("#statusTitle")).toHaveText("Renamed via tap"); From 6c9748a42d51e03da9dbb576831e0ac5622555f9 Mon Sep 17 00:00:00 2001 From: Megha Goyal Date: Fri, 19 Jun 2026 21:52:53 +0000 Subject: [PATCH 3/5] fix(pwa): make iOS reliably receive new builds (cache headers + NetworkFirst nav) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even with the rename fix shipped, iPhone clients kept running the old bundle because the service worker layer never let the new build through. Two root causes, both fixed here: 1. The static file server sent no Cache-Control headers, so iOS heuristically cached /sw.js, never re-fetched the service-worker script, never saw the new SW, and the old SW kept serving the old precached bundle forever. Now: - sw.js / registerSW.js / workbox-*.js -> no-cache, no-store, must-revalidate - index.html / *.html / manifest -> no-cache - content-hashed assets/index-*.{js,css} -> public, max-age=31536000, immutable 2. vite-plugin-pwa registers an SPA NavigationRoute bound to the precached index.html before runtimeCaching, shadowing our NetworkFirst navigate route. A stale SW therefore served old precached HTML (old asset hashes) with no network fallback. Deny-list every navigation from that precache fallback so navigations fall through to NetworkFirst (3s timeout, cached fallback offline) — any reload now self-heals to the latest build. Tests: - tests/api.test.ts boots server.ts in production mode and asserts the three Cache-Control classes (all three fail without the change). - tests/sw-config.test.ts asserts the generated dist/sw.js denylists the precache navigation fallback, routes navigations NetworkFirst, and keeps skipWaiting/clientsClaim/cleanupOutdatedCaches. Signed-off-by: Megha Goyal --- server.ts | 28 +++++++++++++++++++- tests/api.test.ts | 57 +++++++++++++++++++++++++++++++++++++++++ tests/sw-config.test.ts | 43 +++++++++++++++++++++++++++++++ vite.config.ts | 20 ++++++++++++--- 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 tests/sw-config.test.ts diff --git a/server.ts b/server.ts index e963f06..ff36e87 100644 --- a/server.ts +++ b/server.ts @@ -144,6 +144,29 @@ function serveArtifact(req: IncomingMessage, res: ServerResponse) { createReadStream(resolvedFile).pipe(res); } +function cacheControlFor(relative: string): string { + const name = relative.replace(/^\/+/, ""); + const base = name.split("/").pop() || name; + // The service-worker script, its registration shim, and the workbox runtime + // MUST never be cached: if iOS Safari serves a stale /sw.js it never detects + // a new service worker, so the old SW keeps serving the old precached bundle + // forever (refresh appears to do nothing). Force revalidation every load. + if (base === "sw.js" || base === "registerSW.js" || /^workbox-[A-Za-z0-9_-]+\.js$/.test(base)) { + return "no-cache, no-store, must-revalidate"; + } + // HTML / the app shell is served network-first and must stay fresh so it + // always references the current hashed asset names. + if (base === "index.html" || base.endsWith(".html") || base === "manifest.webmanifest") { + return "no-cache"; + } + // Content-hashed build assets are immutable — safe to cache aggressively. + if (name.startsWith("assets/") && /-[A-Za-z0-9_-]{6,}\.[a-z0-9]+$/.test(base)) { + return "public, max-age=31536000, immutable"; + } + // Everything else (icons, svg, etc.): revalidate to stay safe. + return "no-cache"; +} + function serveStatic(req: IncomingMessage, res: ServerResponse) { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); const pathname = decodeURIComponent(url.pathname); @@ -155,7 +178,10 @@ function serveStatic(req: IncomingMessage, res: ServerResponse) { return; } - res.writeHead(200, { "content-type": contentTypes[extname(file)] || "application/octet-stream" }); + res.writeHead(200, { + "content-type": contentTypes[extname(file)] || "application/octet-stream", + "cache-control": cacheControlFor(relative), + }); createReadStream(file).pipe(res); } diff --git a/tests/api.test.ts b/tests/api.test.ts index 3371ff9..099719f 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -820,3 +820,60 @@ describe("WebSocket authentication", () => { } }, 25_000); }); + +describe("static asset cache headers (iOS service-worker freshness)", () => { + let child: ChildProcess; + let baseUrl: string; + + beforeAll(async () => { + const port = await freePort(); + baseUrl = `http://127.0.0.1:${port}`; + // Production mode (PI_WEB_DEV=0) so the built-in static file server runs. + // In dev mode Vite's middleware serves these files with its own headers, + // which is not the code path real deployments use. + child = spawn(process.execPath, ["--import", "tsx", "server.ts"], { + env: { ...process.env, PI_WEB_MOCK: "1", PI_WEB_DEV: "0", NODE_ENV: "production", HOST: "127.0.0.1", PORT: String(port), PI_WEB_TOKEN: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stderr?.on("data", (data) => process.stderr.write(data)); + await waitForServer(baseUrl); + }, 20_000); + + afterAll(() => { + child?.kill(); + }); + + it("never caches the service worker or its registration shim", async () => { + // Regression: with no Cache-Control, iOS Safari heuristically caches + // /sw.js, never detects a new service worker, and keeps serving the old + // precached bundle forever (a deploy looks like it "did nothing" on + // iPhone). The SW script + register shim must always revalidate. + const sw = await fetch(`${baseUrl}/sw.js`); + if (sw.status === 404) { + // dist/ not built in this environment — nothing to assert. + return; + } + expect(sw.headers.get("cache-control")).toContain("no-store"); + + const reg = await fetch(`${baseUrl}/registerSW.js`); + expect(reg.status).toBe(200); + expect(reg.headers.get("cache-control")).toContain("no-store"); + }); + + it("serves the app shell HTML as no-cache so it references current asset hashes", async () => { + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + expect((res.headers.get("cache-control") || "")).toContain("no-cache"); + }); + + it("caches content-hashed build assets immutably", async () => { + const html = await (await fetch(`${baseUrl}/`)).text(); + const match = html.match(/assets\/index-[A-Za-z0-9_-]+\.(?:js|css)/); + if (!match) return; // dist/ not built + const asset = await fetch(`${baseUrl}/${match[0]}`); + expect(asset.status).toBe(200); + const cc = asset.headers.get("cache-control") || ""; + expect(cc).toContain("immutable"); + expect(cc).toContain("max-age=31536000"); + }); +}); diff --git a/tests/sw-config.test.ts b/tests/sw-config.test.ts new file mode 100644 index 0000000..07b2c39 --- /dev/null +++ b/tests/sw-config.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Guards the generated service worker's navigation strategy. + * + * Regression: vite-plugin-pwa registers an SPA NavigationRoute bound to the + * *precached* index.html, and it is registered before runtimeCaching. Left + * alone it shadows our NetworkFirst navigate route, so a stale service worker + * keeps serving the old precached HTML/JS forever — a deploy looks like it + * "did nothing" on the client (especially iOS). We deny-list every navigation + * from that precache fallback so navigations fall through to NetworkFirst and + * a reload always fetches fresh HTML. + */ +describe("service worker navigation strategy", () => { + const swPath = join(process.cwd(), "dist", "sw.js"); + + it("denylists the precache navigation fallback and serves navigations NetworkFirst", async () => { + if (!existsSync(swPath)) { + // dist/ not built in this environment (e.g. unit-only CI without build). + return; + } + const sw = await readFile(swPath, "utf8"); + + // The SPA precache NavigationRoute must be present but neutralised by a + // catch-all denylist so it never serves stale precached HTML. + expect(sw).toMatch(/NavigationRoute/); + expect(sw).toMatch(/denylist:\[\/\.\/\]/); + + // Navigations must be handled by a NetworkFirst route (fresh HTML online, + // cached fallback offline). + expect(sw).toMatch(/"navigate"===\w+\.mode/); + expect(sw).toMatch(/NetworkFirst\(\{cacheName:"pages"/); + + // The auto-update primitives must stay in place so a new SW activates and + // claims open clients immediately. + expect(sw).toMatch(/skipWaiting\(\)/); + expect(sw).toMatch(/clientsClaim\(\)/); + expect(sw).toMatch(/cleanupOutdatedCaches\(\)/); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 3ed7eb3..153e26c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,15 +22,27 @@ export default defineConfig({ ], }, workbox: { - // Network-first: always try server, fall back to cache - // Only precache the app shell assets - navigateFallback: "/index.html", + // Navigations must be NETWORK-FIRST so a reload always fetches fresh + // HTML (and therefore the current hashed asset names). vite-plugin-pwa + // always registers an SPA NavigationRoute bound to the *precached* + // index.html, and it is registered before runtimeCaching — so left + // alone it shadows the NetworkFirst route and a stale service worker + // keeps serving old HTML/JS forever (a deploy looks like it "did + // nothing", especially on iOS). Deny-list every navigation from that + // precache fallback so navigations fall through to the NetworkFirst + // route below, which serves fresh HTML online and the cached copy + // offline. + navigateFallback: "index.html", + navigateFallbackDenylist: [/./], globPatterns: ["index.html", "assets/index-*.{js,css}", "*.{svg,png,webmanifest}"], runtimeCaching: [ { urlPattern: ({ request }) => request.mode === "navigate", handler: "NetworkFirst", - options: { cacheName: "pages" }, + options: { + cacheName: "pages", + networkTimeoutSeconds: 3, + }, }, ], }, From 5f8c796bbdfd91916a2d0c88ec8aa5b67adb01c4 Mon Sep 17 00:00:00 2001 From: Megha Goyal Date: Fri, 19 Jun 2026 23:28:35 +0000 Subject: [PATCH 4/5] feat(server): add self-destroying SW kill switch for cache busting Opt-in self-destroying service worker served at /sw.js when PI_WEB_SW_RESET=1 or a .pi-sw-reset sentinel file exists. Lets a stale, captured client (e.g. an iPhone that won't update) be forced to drop its old SW + caches server-side: the client re-fetches /sw.js on navigation, installs the kill SW, which clears caches, unregisters, and reloads once (loop-safe via a marker cache). Flip off to restore the normal PWA SW. Test asserts /sw.js is the self-destroying script under PI_WEB_SW_RESET=1. Signed-off-by: Megha Goyal --- server.ts | 31 +++++++++++++++++++++++++++++++ tests/api.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/server.ts b/server.ts index ff36e87..b1a90fd 100644 --- a/server.ts +++ b/server.ts @@ -2327,6 +2327,37 @@ const server = createServer(async (req, res) => { return sendJson(res, 404, { ok: false, error: "Unknown API route" }); } + // Server-side cache-bust: when PI_WEB_SW_RESET=1, serve a self-destroying + // service worker at /sw.js. A client captured by a stale SW re-fetches the + // SW script on its next navigation (the spec bypasses the HTTP cache for + // the script, and our no-store header guarantees it), installs this one, + // which deletes every cache and unregisters itself — freeing the client to + // load fresh content straight from the network. Loop-safe: it force-reloads + // clients only the first time (guarded by a marker cache), so re-registering + // it does not bounce the page repeatedly. Flip the flag off once clients + // have recovered to restore the normal PWA service worker. + if (req.method === "GET" && url.pathname === "/sw.js" && (process.env.PI_WEB_SW_RESET === "1" || existsSync(join(appDir, ".pi-sw-reset")))) { + const body = `// pi-web self-destroying service worker (PI_WEB_SW_RESET=1) +const KILL_FLAG = 'pi-sw-killed'; +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => { + event.waitUntil((async () => { + const firstTime = !(await caches.has(KILL_FLAG)); + for (const key of await caches.keys()) { if (key !== KILL_FLAG) await caches.delete(key); } + try { await self.registration.unregister(); } catch (e) {} + if (firstTime) { + await caches.open(KILL_FLAG); + const clients = await self.clients.matchAll({ type: 'window' }); + for (const client of clients) { try { client.navigate(client.url); } catch (e) {} } + } + })()); +}); +`; + res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-cache, no-store, must-revalidate" }); + res.end(body); + return; + } + if (viteDevServer) { viteDevServer.middlewares(req, res, () => { if (!res.writableEnded) sendJson(res, 404, { ok: false, error: "Not found" }); diff --git a/tests/api.test.ts b/tests/api.test.ts index 099719f..f482c11 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -877,3 +877,39 @@ describe("static asset cache headers (iOS service-worker freshness)", () => { expect(cc).toContain("max-age=31536000"); }); }); + +describe("service-worker kill switch (PI_WEB_SW_RESET)", () => { + let child: ChildProcess; + let baseUrl: string; + + beforeAll(async () => { + const port = await freePort(); + baseUrl = `http://127.0.0.1:${port}`; + child = spawn(process.execPath, ["--import", "tsx", "server.ts"], { + env: { ...process.env, PI_WEB_MOCK: "1", PI_WEB_DEV: "0", NODE_ENV: "production", PI_WEB_SW_RESET: "1", HOST: "127.0.0.1", PORT: String(port), PI_WEB_TOKEN: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stderr?.on("data", (data) => process.stderr.write(data)); + await waitForServer(baseUrl); + }, 20_000); + + afterAll(() => { + child?.kill(); + }); + + it("serves a self-destroying service worker that clears caches and unregisters", async () => { + // Server-side cache bust: a client captured by a stale SW re-fetches + // /sw.js on navigation, installs this one, which wipes caches and + // unregisters itself so fresh content loads from the network. + const res = await fetch(`${baseUrl}/sw.js`); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toContain("no-store"); + const body = await res.text(); + expect(body).toContain("self.registration.unregister()"); + expect(body).toContain("caches.delete"); + expect(body).toContain("skipWaiting"); + // It must NOT be the precaching workbox SW (that would re-cache the bundle). + expect(body).not.toContain("precacheAndRoute"); + expect(body).not.toContain("__WB_MANIFEST"); + }); +}); From c3f0d6291988e7d36a38b8a0821e6596011374d6 Mon Sep 17 00:00:00 2001 From: Megha Goyal Date: Sat, 20 Jun 2026 01:25:21 +0000 Subject: [PATCH 5/5] fix(status-bar): open rename on click, not pointerup, for iOS keyboard iOS Safari only raises the keyboard when input.focus() runs inside a click/touchend gesture; focusing from pointerup (or after preventDefault) opens the field but leaves the keyboard down, so a finger tap appears to do nothing while a desktop click works. Trigger purely on click; selection is already prevented by user-select:none + touch-action:manipulation. Verified on real WebKit (iPhone 14 profile) with a genuine touch tap. Signed-off-by: Megha Goyal --- src/status/statusBar.ts | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/status/statusBar.ts b/src/status/statusBar.ts index 695f265..2e252b0 100644 --- a/src/status/statusBar.ts +++ b/src/status/statusBar.ts @@ -320,26 +320,17 @@ export function createStatusBar(options: { elements.statusTitleEl.tabIndex = 0; setStatusTitle(state.currentSessionTitle); - // On iOS Safari a plain tap on the title is otherwise interpreted - // as the start of a native text-selection gesture (blue handles + - // magnifier) which competes with — and can swallow — the click that opens - // the rename field. Handle the tap on `pointerup` and preventDefault so - // the selection gesture never starts; fall back to `click` for - // environments without Pointer Events. A short suppression window stops - // the synthesized click from re-triggering after a pointer-driven open. - let suppressClickUntil = 0; - if (typeof window !== "undefined" && "PointerEvent" in window) { - elements.statusTitleEl.addEventListener("pointerup", (event) => { - if (event.button !== undefined && event.button !== 0) return; - if (event.cancelable) event.preventDefault(); - suppressClickUntil = Date.now() + 600; - beginRenameSessionTitle(); - }); - } - elements.statusTitleEl.addEventListener("click", () => { - if (Date.now() < suppressClickUntil) return; - beginRenameSessionTitle(); - }); + // The title is role="button" and opens an inline rename input on + // activation. On iOS Safari two things matter: + // - text selection must be disabled (user-select:none + touch-action: + // manipulation in CSS) so a finger tap is treated as a tap, not the + // start of a text-selection gesture that swallows the click; and + // - the input must be focused inside the *click* handler. iOS only + // raises the on-screen keyboard when focus() runs in a click/touchend + // user gesture; doing it from pointerup (or after preventDefault) + // opens the field but leaves the keyboard down on real devices. + // So we trigger purely on click — the canonical, device-tested path. + elements.statusTitleEl.addEventListener("click", () => beginRenameSessionTitle()); elements.statusTitleEl.addEventListener("keydown", (event) => { if (event.target !== elements.statusTitleEl || (event.key !== "Enter" && event.key !== " ")) return; event.preventDefault();