Compare commits

...

1 Commits

Author SHA1 Message Date
Markus Hartung
b83853d55f fix(dashboard): unregister leftover service workers in dev mode
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 66a2515cbc)
2026-08-09 01:38:16 -03:00
2 changed files with 123 additions and 1 deletions

View File

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

View File

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