fix(dev): prevent production service workers from controlling local builds

This commit is contained in:
DEls 2026-10-16 07:13:39 +02:00
parent c3566a1f02
commit 4c4106fe8b
4 changed files with 69 additions and 4 deletions

View file

@ -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,

View file

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

View file

@ -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<typeof registration>[]=[],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();
});

View file

@ -0,0 +1,21 @@
type PwaEnvironment={
production:boolean;
serviceWorker:Pick<ServiceWorkerContainer,'register'|'getRegistrations'|'controller'>;
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();
}