From 6d6b6027c58799cf3670bd5cbe771902f3b8986a Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Mon, 7 Sep 2026 07:57:15 -0400 Subject: [PATCH] fix(pwa): do not intercept navigations so Chrome can retry HTTP/2 (#12767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes. A separação entre o que é do service worker e o que é do Caddy está certa e é o que torna a PR mergeável: o `respondWith` em navegação é defeito nosso, o `Alt-Svc` mentindo h3 é config de proxy reverso e não tem o que fazer aqui. O `/dashboardfoo` casando com `startsWith("/dashboard")` é um achado à parte, e o bump de cache v2→v3 é o que faz o worker antigo sair do ar nos clientes que já estão presos. --- .../fixes/dashboard-stale-tab-refresh.md | 1 + docs/guides/PWA_GUIDE.md | 6 +- public/sw.js | 46 +++------ scripts/build/assembleStandalone.mjs | 4 +- ...service-worker-navigation-fallback.test.ts | 94 ++++++++++++++----- 5 files changed, 91 insertions(+), 60 deletions(-) create mode 100644 changelog.d/fixes/dashboard-stale-tab-refresh.md diff --git a/changelog.d/fixes/dashboard-stale-tab-refresh.md b/changelog.d/fixes/dashboard-stale-tab-refresh.md new file mode 100644 index 0000000000..d2d8fbb3f5 --- /dev/null +++ b/changelog.d/fixes/dashboard-stale-tab-refresh.md @@ -0,0 +1 @@ +- **fix(pwa):** do not intercept dashboard navigations so Chrome can fall back from a stale HTTP/3 Alt-Svc advertisement (UDP :20128 is unpublished; F5 on a long-lived tab hung until a new tab opened a fresh TCP connection). diff --git a/docs/guides/PWA_GUIDE.md b/docs/guides/PWA_GUIDE.md index 8927661fdc..afb828ca82 100644 --- a/docs/guides/PWA_GUIDE.md +++ b/docs/guides/PWA_GUIDE.md @@ -58,8 +58,8 @@ OmniRoute includes a service worker (`sw.js`) that provides intelligent caching: | **App Shell** | Cache-first | `/`, `/offline`, manifest, and icons are pre-cached on install | | **Static assets** (CSS, JS, images, fonts) | Network-first with cache fallback | Fetches fresh from the network; falls back to cache if offline | | **Next.js bundles** (`/_next/`) | Network-first with cache update | Fetches from network and updates cache; serves cached version if offline | -| **Navigation requests** | Network-only with offline fallback | Always fetches from network; shows `/offline` page if network is unavailable | -| **API routes** (`/api/`, `/a2a`, `/dashboard/endpoint`) | Bypass (never cached) | Always goes directly to the server — never intercepted by the service worker | +| **Navigation requests** | Bypass (never intercepted) | Browser owns HTTP/3→HTTP/2 fallback; a dead QUIC socket must not become `Response.error()` | +| **API / dashboard routes** (`/api/`, `/a2a`, `/dashboard`) | Bypass (never cached) | Always goes directly to the server — never intercepted by the service worker | ### Offline Page @@ -118,7 +118,7 @@ A vanilla service worker (no framework dependencies) with: - **Install phase**: Pre-caches the app shell (root, offline page, manifest, icons) - **Activate phase**: Cleans up old cache versions and claims all clients - **Fetch phase**: Intelligent routing based on request type (navigation, static asset, API) -- **Cache versioning**: `omniroute-pwa-v2` — bump this to force a fresh cache on update +- **Cache versioning**: `omniroute-pwa-v3` — bump this to force a fresh cache on update ### Layout Metadata (`src/app/layout.tsx`) diff --git a/public/sw.js b/public/sw.js index ac720377fd..d1a32374f4 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,11 +1,18 @@ -const CACHE_NAME = "omniroute-pwa-v2"; +const CACHE_NAME = "omniroute-pwa-v3"; const APP_SHELL = [ "/", "/manifest.webmanifest", "/icon-512.png", "/apple-touch-icon.png", ]; -const EXCLUDED_PATH_PREFIXES = ["/api/", "/a2a", "/dashboard/endpoint"]; +const EXCLUDED_PATH_PREFIXES = ["/api/", "/a2a", "/dashboard"]; + +function pathIsExcluded(pathname) { + return EXCLUDED_PATH_PREFIXES.some((prefix) => { + const base = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + return pathname === base || pathname.startsWith(`${base}/`); + }); +} self.addEventListener("install", (event) => { event.waitUntil( @@ -37,34 +44,22 @@ self.addEventListener("fetch", (event) => { const requestUrl = new URL(event.request.url); const isSameOrigin = requestUrl.origin === self.location.origin; - const isExcludedPath = EXCLUDED_PATH_PREFIXES.some((prefix) => - requestUrl.pathname.startsWith(prefix) - ); + const excluded = pathIsExcluded(requestUrl.pathname); const isNextAsset = requestUrl.pathname.startsWith("/_next/"); const destination = event.request.destination; const isStaticAsset = ["style", "script", "image", "font"].includes(destination); const isNavigateRequest = event.request.mode === "navigate"; + // Never intercept navigations. Chrome owns HTTP/3→HTTP/2 fallback after a + // stale Alt-Svc advertisement; respondWith(Response.error()) on a dead QUIC + // socket made F5 hang until a new tab opened a fresh connection. // Never cache API/dashboard traffic with potentially auth-sensitive content. - if (!isSameOrigin || isExcludedPath) { + if (!isSameOrigin || excluded || isNavigateRequest) { return; } event.respondWith( (async () => { - if (isNavigateRequest) { - try { - const networkResponse = await fetch(event.request); - if (networkResponse && networkResponse.status === 200) { - const responseClone = networkResponse.clone(); - void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone)); - } - return networkResponse; - } catch { - return (await navigationFallback(event.request)) || Response.error(); - } - } - if (!isStaticAsset) { return fetch(event.request); } @@ -97,19 +92,6 @@ self.addEventListener("fetch", (event) => { ); }); -// Navigations are network-first on purpose: the dashboard is an online -// tool. When the network fails, serving a cached navigation response is -// worse than surfacing the failure -- the cached shell references -// /_next/static// chunks that no longer exist after a -// deploy, so the page loads and then breaks on chunk 404s, and the -// broken state sticks until the cache happens to clear. An honest -// network error lets the browser show its own offline state and -// recover on the next reload. (The /offline page is still precached -// for the APP_SHELL list; it just is no longer used as a decoy.) -async function navigationFallback() { - return Response.error(); -} - // ── Push Notifications ─────────────────────────────────────────────────────── self.addEventListener("push", (event) => { diff --git a/scripts/build/assembleStandalone.mjs b/scripts/build/assembleStandalone.mjs index 8cd78d40b3..d6e2010ef2 100644 --- a/scripts/build/assembleStandalone.mjs +++ b/scripts/build/assembleStandalone.mjs @@ -586,8 +586,8 @@ function stampServiceWorkerBuildId(resolvedOutDir) { process.env.OMNIROUTE_SW_BUILD_ID || process.env.SOURCE_VERSION || String(Date.now()); let sw = fsSync.readFileSync(swDest, "utf8"); sw = sw.replace( - /^const CACHE_NAME = "omniroute-pwa-v2";$/m, - `const CACHE_NAME = "omniroute-pwa-v2-${buildId}"; // build ${buildId}` + /^const CACHE_NAME = "omniroute-pwa-v3";$/m, + `const CACHE_NAME = "omniroute-pwa-v3-${buildId}"; // build ${buildId}` ); fsSync.writeFileSync(swDest, sw); } diff --git a/tests/unit/service-worker-navigation-fallback.test.ts b/tests/unit/service-worker-navigation-fallback.test.ts index abdce2d21c..974b5369ee 100644 --- a/tests/unit/service-worker-navigation-fallback.test.ts +++ b/tests/unit/service-worker-navigation-fallback.test.ts @@ -39,7 +39,7 @@ function createServiceWorkerHarness() { const caches = { delete: async () => true, - keys: async () => ["omniroute-pwa-v2"], + keys: async () => ["omniroute-pwa-v3"], match: async (request: Request | string) => cacheEntries.get(typeof request === "string" ? request : request.url), open: async () => cache, @@ -77,8 +77,7 @@ function createServiceWorkerHarness() { }, }; listener(event); - assert.ok(responsePromise, "navigate request must call respondWith"); - return responsePromise; + return { intercepted: responsePromise !== undefined, response: responsePromise }; }, setFetch: (nextFetch: (request: Request) => Promise) => { fetchImpl = nextFetch; @@ -86,44 +85,93 @@ function createServiceWorkerHarness() { }; } -test("#11779: network failure on navigation surfaces an error, not a stale shell", async () => { +test("#11779: dashboard navigations are not intercepted so the browser can retry HTTP/2", async () => { const harness = createServiceWorkerHarness(); const request = { - url: "https://app.example/dashboard", + url: "https://app.example/dashboard/quota", method: "GET", mode: "navigate", destination: "document", }; - // A cached navigation response from a PREVIOUS deploy exists; serving it - // would replay a shell whose /_next/static// chunks are gone. + // A cached navigation response from a PREVIOUS deploy exists. The worker + // must not intercept at all — Chrome then owns HTTP/3→HTTP/2 fallback + // after a stale Alt-Svc advertisement. Intercepting and returning + // Response.error() is what made F5 hang until a new tab opened. harness.cacheEntries.set(request.url, new Response("cached dashboard", { status: 200 })); - const response = await harness.dispatchFetch(request); - - assert.ok(response.type === "error", "navigation fallback must surface the network failure"); + const result = await harness.dispatchFetch(request); + assert.equal(result.intercepted, false, "dashboard navigation must fall through to the browser"); }); -test("#5165: successful navigations are cached for later transient failures", async () => { +test("#11779: API and Next assets still go through the worker", async () => { const harness = createServiceWorkerHarness(); - const request = { + harness.setFetch(async () => new Response("ok", { status: 200 })); + + const api = await harness.dispatchFetch({ + url: "https://app.example/api/health/ping", + method: "GET", + mode: "cors", + destination: "", + }); + assert.equal(api.intercepted, false, "/api/ is already excluded"); + + const asset = await harness.dispatchFetch({ + url: "https://app.example/_next/static/chunk.js", + method: "GET", + mode: "cors", + destination: "script", + }); + assert.equal(asset.intercepted, true); + assert.equal(await (await asset.response!).text(), "ok"); +}); + +test("#11779: /dashboardfoo is not treated as a dashboard path", async () => { + const harness = createServiceWorkerHarness(); + harness.setFetch(async () => new Response("ok", { status: 200 })); + const result = await harness.dispatchFetch({ + url: "https://app.example/dashboardfoo", + method: "GET", + mode: "cors", + destination: "script", + }); + assert.equal(result.intercepted, true, "prefix match must not swallow /dashboardfoo"); + + const dash = await harness.dispatchFetch({ url: "https://app.example/dashboard", method: "GET", - mode: "navigate", - destination: "document", + mode: "cors", + destination: "script", + }); + assert.equal(dash.intercepted, false, "/dashboard exact must stay excluded"); + + const nested = await harness.dispatchFetch({ + url: "https://app.example/dashboard/quota", + method: "GET", + mode: "cors", + destination: "script", + }); + assert.equal(nested.intercepted, false, "/dashboard/quota must stay excluded"); +}); + +test("#5165: static assets stay cache-first; navigations do not", async () => { + const harness = createServiceWorkerHarness(); + const icon = { + url: "https://app.example/icon-512.png", + method: "GET", + mode: "cors", + destination: "image", }; - harness.setFetch(async () => new Response("fresh dashboard", { status: 200 })); - assert.equal(await (await harness.dispatchFetch(request)).text(), "fresh dashboard"); + harness.setFetch(async () => new Response("fresh icon", { status: 200 })); + const first = await harness.dispatchFetch(icon); + assert.equal(first.intercepted, true); + assert.equal(await (await first.response!).text(), "fresh icon"); harness.setFetch(async () => { throw new Error("transient network failure"); }); - - // #11779: the transient failure surfaces as an error response. The freshly - // cached entry stays in the cache (a future navigations-are-cached design - // may use it), but it must NOT be replayed as a navigation response -- the - // shell's chunk references belong to a specific build. - const failed = await harness.dispatchFetch(request); - assert.ok(failed.type === "error", "transient failure must surface, not replay cache"); + const second = await harness.dispatchFetch(icon); + assert.equal(second.intercepted, true); + assert.equal(await (await second.response!).text(), "fresh icon"); });