From 4c4106fe8b10e7ef285e1ce99f2bc14f370462e1 Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 16 Oct 2026 07:13:39 +0200 Subject: [PATCH] fix(dev): prevent production service workers from controlling local builds --- next.config.js | 3 +++ src/components/pwa-register.tsx | 14 +++++++++---- src/lib/pwa-registration.test.ts | 35 ++++++++++++++++++++++++++++++++ src/lib/pwa-registration.ts | 21 +++++++++++++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 src/lib/pwa-registration.test.ts create mode 100644 src/lib/pwa-registration.ts diff --git a/next.config.js b/next.config.js index 5c41aa0..d5fbb12 100644 --- a/next.config.js +++ b/next.config.js @@ -34,6 +34,9 @@ const nextConfig = { }; const withPWA = require("@ducanh2912/next-pwa").default({ + // Development and production share public/sw.js. Never let a build-generated + // worker control HMR assets or rewrite the worker during local recompilation. + disable: process.env.NODE_ENV === "development", dest: "public", cacheOnFrontEndNav: false, aggressiveFrontEndNavCaching: false, diff --git a/src/components/pwa-register.tsx b/src/components/pwa-register.tsx index 5200b12..e3e9120 100644 --- a/src/components/pwa-register.tsx +++ b/src/components/pwa-register.tsx @@ -1,14 +1,20 @@ "use client"; import * as React from "react"; +import {configurePwa} from "@/lib/pwa-registration"; export function PwaRegister() { + const started = React.useRef(false); React.useEffect(() => { - if (typeof window === "undefined") return; + if (typeof window === "undefined" || started.current) return; + started.current = true; if ("serviceWorker" in navigator) { - navigator.serviceWorker - .register("/sw.js", { scope: "/" }) - .catch((err) => console.warn("SW registration failed:", err)); + void configurePwa({ + production: process.env.NODE_ENV === "production", + serviceWorker: navigator.serviceWorker, + origin: window.location.origin, + reload: () => window.location.reload(), + }).catch((err) => console.warn("SW setup failed:", err)); } }, []); return null; diff --git a/src/lib/pwa-registration.test.ts b/src/lib/pwa-registration.test.ts new file mode 100644 index 0000000..03b04d1 --- /dev/null +++ b/src/lib/pwa-registration.test.ts @@ -0,0 +1,35 @@ +import {it,expect,vi} from 'vitest'; +import {configurePwa} from './pwa-registration'; +const origin='http://localhost:6004'; +function registration(script='/sw.js',scope=origin+'/'){ + return {scope,active:{scriptURL:new URL(script,origin).href},unregister:vi.fn(async()=>true)}; +} +function setup(registrations:ReturnType[]=[],controlled=true){ + const serviceWorker={register:vi.fn(async()=>({})),getRegistrations:vi.fn(async()=>registrations),controller:controlled?{scriptURL:origin+'/sw.js'}:null}; + return {serviceWorker,reload:vi.fn(),origin}; +} +it('registers the offline worker in production without removing registrations or reloading',async()=>{ + const input=setup([registration()]); + await configurePwa({...input,production:true} as never); + expect(input.serviceWorker.register).toHaveBeenCalledWith('/sw.js',{scope:'/'}); + expect(input.serviceWorker.getRegistrations).not.toHaveBeenCalled();expect(input.reload).not.toHaveBeenCalled(); +}); +it('removes a controlling app worker in development and reloads to release the old cache',async()=>{ + const own=registration();const input=setup([own]); + await configurePwa({...input,production:false} as never); + expect(own.unregister).toHaveBeenCalledTimes(1);expect(input.reload).toHaveBeenCalledTimes(1); + expect(input.serviceWorker.register).not.toHaveBeenCalled(); +}); +it('does not reload a clean development browser or an uncontrolled page',async()=>{ + for(const input of [setup([]),setup([registration()],false)]){ + await configurePwa({...input,production:false} as never); + expect(input.reload).not.toHaveBeenCalled();expect(input.serviceWorker.register).not.toHaveBeenCalled(); + } +}); +it('preserves workers from another scope, script or origin',async()=>{ + const registrations=[registration('/other-worker.js'),registration('/sw.js',origin+'/another-app/'),registration('http://localhost:4001/sw.js','http://localhost:4001/')]; + const input=setup(registrations); + await configurePwa({...input,production:false} as never); + for(const reg of registrations)expect(reg.unregister).not.toHaveBeenCalled(); + expect(input.reload).not.toHaveBeenCalled(); +}); diff --git a/src/lib/pwa-registration.ts b/src/lib/pwa-registration.ts new file mode 100644 index 0000000..dbfa3e8 --- /dev/null +++ b/src/lib/pwa-registration.ts @@ -0,0 +1,21 @@ +type PwaEnvironment={ + production:boolean; + serviceWorker:Pick; + origin:string; + reload:()=>void; +}; +/** Development assets must not be controlled by a worker generated by a production build. */ +export async function configurePwa({production,serviceWorker,origin,reload}:PwaEnvironment){ + if(production){await serviceWorker.register('/sw.js',{scope:'/'});return;} + const workerUrl=new URL('/sw.js',origin).href; + const registrations=await serviceWorker.getRegistrations(); + let removed=false; + for(const registration of registrations){ + const worker=registration.active??registration.waiting??registration.installing; + if(registration.scope!==new URL('/',origin).href||worker?.scriptURL!==workerUrl)continue; + removed=await registration.unregister()||removed; + } + // Unregistering takes effect on the next navigation. Reload only when our + // worker actually controlled this page, so clean dev browsers never loop. + if(removed&&serviceWorker.controller?.scriptURL===workerUrl)reload(); +}