From 09520785f80689623b8cb1ac5014091932ea4bfc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:50 -0300 Subject: [PATCH] fix(dashboard): unregister leftover service workers in dev mode (#9868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A phone that previously loaded a production build on this origin (or an old dev build from before the registration was gated) kept an active service worker across dev restarts. It intercepted every navigation/asset fetch, occasionally serving a JS chunk that didn't match the running dev server, which tripped Next's dev-client chunk-mismatch auto-reload — visible as an unexplained, unstoppable refresh loop on that device only (confirmed via a clean private tab on the same phone/URL not looping). PwaRegister now actively unregisters any existing service worker registrations and clears their caches outside production, instead of just skipping a new registration. (cherry picked from commit 66a2515cbce7a6132639614d88d48349a83bdcde) Co-authored-by: Markus Hartung --- src/shared/components/PwaRegister.tsx | 20 ++++- tests/unit/PwaRegister.test.tsx | 104 ++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/unit/PwaRegister.test.tsx diff --git a/src/shared/components/PwaRegister.tsx b/src/shared/components/PwaRegister.tsx index 0e49a6070f..367bf93f1c 100644 --- a/src/shared/components/PwaRegister.tsx +++ b/src/shared/components/PwaRegister.tsx @@ -8,8 +8,26 @@ export function PwaRegister() { return; } - // Disable service worker in development to avoid chunk loading / HMR conflicts + // Disable service worker in development to avoid chunk loading / HMR conflicts. + // A visitor who previously loaded a production build on this origin (or an + // older dev build from before this gate existed) can still have one left + // over — it keeps intercepting navigations/assets, occasionally serving a + // JS chunk that doesn't match the currently running dev server, which + // triggers Next's dev-client auto-reload-on-chunk-mismatch recovery. Since + // the stale worker never goes away on its own, that repeats forever + // (visible as an unexplained refresh loop). Proactively unregister and + // drop its caches instead of merely skipping a new registration. if (process.env.NODE_ENV !== "production") { + navigator.serviceWorker + .getRegistrations() + .then((registrations) => Promise.all(registrations.map((r) => r.unregister()))) + .catch(() => {}); + if (typeof caches !== "undefined") { + caches + .keys() + .then((keys) => Promise.all(keys.map((key) => caches.delete(key)))) + .catch(() => {}); + } return; } diff --git a/tests/unit/PwaRegister.test.tsx b/tests/unit/PwaRegister.test.tsx new file mode 100644 index 0000000000..bc32e5fea4 --- /dev/null +++ b/tests/unit/PwaRegister.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { PwaRegister } from "../../src/shared/components/PwaRegister"; + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => { + container.remove(); + }); + return container; +} + +function mount() { + const container = makeContainer(); + const root = createRoot(container); + act(() => { + root.render(); + }); + cleanupCallbacks.push(() => root.unmount()); +} + +describe("PwaRegister", () => { + const originalServiceWorker = (navigator as any).serviceWorker; + const originalCaches = (globalThis as any).caches; + + afterEach(() => { + cleanupCallbacks.splice(0).forEach((fn) => fn()); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + Object.defineProperty(navigator, "serviceWorker", { + value: originalServiceWorker, + configurable: true, + }); + (globalThis as any).caches = originalCaches; + }); + + beforeEach(() => { + cleanupCallbacks.length = 0; + }); + + it("unregisters leftover service workers and clears caches outside production", async () => { + vi.stubEnv("NODE_ENV", "development"); + + const unregister1 = vi.fn().mockResolvedValue(true); + const unregister2 = vi.fn().mockResolvedValue(true); + const getRegistrations = vi + .fn() + .mockResolvedValue([{ unregister: unregister1 }, { unregister: unregister2 }]); + const register = vi.fn(); + Object.defineProperty(navigator, "serviceWorker", { + value: { getRegistrations, register }, + configurable: true, + }); + + const cachesDelete = vi.fn().mockResolvedValue(true); + const cachesKeys = vi.fn().mockResolvedValue(["omniroute-pwa-v1", "omniroute-pwa-v2"]); + (globalThis as any).caches = { keys: cachesKeys, delete: cachesDelete }; + + mount(); + // Flush the promise chains kicked off inside the effect. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getRegistrations).toHaveBeenCalledTimes(1); + expect(unregister1).toHaveBeenCalledTimes(1); + expect(unregister2).toHaveBeenCalledTimes(1); + expect(cachesKeys).toHaveBeenCalledTimes(1); + expect(cachesDelete).toHaveBeenCalledWith("omniroute-pwa-v1"); + expect(cachesDelete).toHaveBeenCalledWith("omniroute-pwa-v2"); + expect(register).not.toHaveBeenCalled(); + }); + + it("registers the service worker in production without unregistering anything", async () => { + vi.stubEnv("NODE_ENV", "production"); + + const getRegistrations = vi.fn().mockResolvedValue([]); + const register = vi.fn().mockResolvedValue({}); + Object.defineProperty(navigator, "serviceWorker", { + value: { getRegistrations, register }, + configurable: true, + }); + + const cachesKeys = vi.fn().mockResolvedValue([]); + (globalThis as any).caches = { keys: cachesKeys, delete: vi.fn() }; + + mount(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(register).toHaveBeenCalledWith("/sw.js"); + expect(getRegistrations).not.toHaveBeenCalled(); + }); +});