diff --git a/src/hooks/useRegisterSW/pwaUpdate.test.ts b/src/hooks/useRegisterSW/pwaUpdate.test.ts new file mode 100644 index 0000000..0173b71 --- /dev/null +++ b/src/hooks/useRegisterSW/pwaUpdate.test.ts @@ -0,0 +1,81 @@ +import { + hasRecentPWAUpdateReload, + markPWAUpdateReload, + requestPWAUpdate, + watchForPWAUpdate, +} from './pwaUpdate'; + +class MockServiceWorker extends EventTarget { + state: ServiceWorkerState = 'installing'; + postMessage = jest.fn(); +} + +class MockRegistration extends EventTarget { + installing: MockServiceWorker | null = null; + waiting: MockServiceWorker | null = null; +} + +describe('watchForPWAUpdate', () => { + it('reports an existing waiting update when a worker already controls the page', () => { + const registration = new MockRegistration(); + registration.waiting = new MockServiceWorker(); + const onUpdateReady = jest.fn(); + + watchForPWAUpdate(registration as unknown as ServiceWorkerRegistration, { + getController: () => new MockServiceWorker() as unknown as ServiceWorker, + onUpdateReady, + }); + + expect(onUpdateReady).toHaveBeenCalledTimes(1); + }); + + it('reports a newly installed update', () => { + const registration = new MockRegistration(); + const installingWorker = new MockServiceWorker(); + registration.installing = installingWorker; + const onUpdateReady = jest.fn(); + + watchForPWAUpdate(registration as unknown as ServiceWorkerRegistration, { + getController: () => new MockServiceWorker() as unknown as ServiceWorker, + onUpdateReady, + }); + + registration.dispatchEvent(new Event('updatefound')); + registration.waiting = installingWorker; + installingWorker.state = 'installed'; + installingWorker.dispatchEvent(new Event('statechange')); + + expect(onUpdateReady).toHaveBeenCalledTimes(1); + }); +}); + +describe('requestPWAUpdate', () => { + it('asks a waiting worker to activate', () => { + const worker = new MockServiceWorker(); + const updated = requestPWAUpdate({ waiting: worker }); + + expect(updated).toBe(true); + expect(worker.postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' }); + }); + + it('does nothing when no update is waiting', () => { + const updated = requestPWAUpdate({ waiting: null }); + + expect(updated).toBe(false); + }); +}); + +describe('the PWA reload guard', () => { + it('blocks only a second reload during the guard window', () => { + const storage = new Map(); + const sessionStorage = { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }; + + markPWAUpdateReload(sessionStorage, 1_000); + + expect(hasRecentPWAUpdateReload(sessionStorage, 20_000)).toBe(true); + expect(hasRecentPWAUpdateReload(sessionStorage, 40_000)).toBe(false); + }); +}); diff --git a/src/hooks/useRegisterSW/pwaUpdate.ts b/src/hooks/useRegisterSW/pwaUpdate.ts new file mode 100644 index 0000000..19e557f --- /dev/null +++ b/src/hooks/useRegisterSW/pwaUpdate.ts @@ -0,0 +1,82 @@ +export const PWA_UPDATE_RELOAD_GUARD_MS = 30_000; + +const PWA_UPDATE_LAST_RELOAD_AT_KEY = 'pwa-update-last-reload-at'; + +type UpdateRegistration = Pick< + ServiceWorkerRegistration, + 'installing' | 'removeEventListener' | 'waiting' +> & + Pick; + +interface WatchForPWAUpdateOptions { + getController: () => ServiceWorker | null; + onUpdateReady: () => void; +} + +export function watchForPWAUpdate( + registration: UpdateRegistration, + { getController, onUpdateReady }: WatchForPWAUpdateOptions, +) { + let installingWorker: ServiceWorker | null = null; + let onWorkerStateChange: (() => void) | undefined; + + const reportWaitingUpdate = () => { + if (getController() && registration.waiting) { + onUpdateReady(); + } + }; + + const onUpdateFound = () => { + const worker = registration.installing; + if (!worker) { + return; + } + + if (installingWorker && onWorkerStateChange) { + installingWorker.removeEventListener('statechange', onWorkerStateChange); + } + installingWorker = worker; + const onStateChange = () => { + if (worker.state === 'installed') { + reportWaitingUpdate(); + } + }; + onWorkerStateChange = onStateChange; + worker.addEventListener('statechange', onStateChange); + }; + + registration.addEventListener('updatefound', onUpdateFound); + reportWaitingUpdate(); + + return () => { + registration.removeEventListener('updatefound', onUpdateFound); + if (installingWorker && onWorkerStateChange) { + installingWorker.removeEventListener('statechange', onWorkerStateChange); + } + }; +} + +interface WaitingServiceWorker { + postMessage: ServiceWorker['postMessage']; +} + +type PWAUpdateStorage = Pick; + +export function requestPWAUpdate(registration: { waiting: WaitingServiceWorker | null }) { + const worker = registration.waiting; + if (!worker) { + return false; + } + + worker.postMessage({ type: 'SKIP_WAITING' }); + return true; +} + +export function hasRecentPWAUpdateReload(storage: PWAUpdateStorage, now: number) { + const lastReloadAt = Number(storage.getItem(PWA_UPDATE_LAST_RELOAD_AT_KEY)); + return Number.isFinite(lastReloadAt) && now - lastReloadAt < PWA_UPDATE_RELOAD_GUARD_MS; +} + +export function markPWAUpdateReload(storage: PWAUpdateStorage, now: number) { + storage.setItem(PWA_UPDATE_LAST_RELOAD_AT_KEY, String(now)); +} diff --git a/src/hooks/useRegisterSW/useRegisterSW.ts b/src/hooks/useRegisterSW/useRegisterSW.ts index d09156b..f3be8f2 100644 --- a/src/hooks/useRegisterSW/useRegisterSW.ts +++ b/src/hooks/useRegisterSW/useRegisterSW.ts @@ -1,10 +1,15 @@ -import { registerSW } from 'virtual:pwa-register'; import { useEffect, useRef, useState } from 'react'; +import { + hasRecentPWAUpdateReload, + markPWAUpdateReload, + requestPWAUpdate, + watchForPWAUpdate, +} from './pwaUpdate'; import { startPWAUpdatePolling } from './pwaUpdatePolling'; export function usePWAUpdate() { const [updateAvailable, setUpdateAvailable] = useState(false); - const updateSWRef = useRef<(reloadPage?: boolean) => Promise>(); + const registrationRef = useRef(); const stopUpdatePollingRef = useRef<() => void>(); useEffect(() => { @@ -12,32 +17,57 @@ export function usePWAUpdate() { return; } - updateSWRef.current = registerSW({ - immediate: true, - onNeedRefresh() { - setUpdateAvailable(true); - }, - onRegisteredSW(_swUrl, registration) { - if (!registration) { + let active = true; + let stopWatchingForUpdates: (() => void) | undefined; + + void navigator.serviceWorker + .register('/sw.js', { + updateViaCache: 'none', + }) + .then((registration) => { + if (!active) { return; } + registrationRef.current = registration; + stopWatchingForUpdates = watchForPWAUpdate(registration, { + getController: () => navigator.serviceWorker.controller, + onUpdateReady: () => setUpdateAvailable(true), + }); stopUpdatePollingRef.current?.(); stopUpdatePollingRef.current = startPWAUpdatePolling(registration); - }, - onOfflineReady() { - // optionally notify - }, - }); + }) + .catch(() => undefined); return () => { + active = false; + stopWatchingForUpdates?.(); stopUpdatePollingRef.current?.(); }; }, []); - const updateSW = async (reloadPage = true) => { + const updateSW = async () => { setUpdateAvailable(false); - await updateSWRef.current?.(reloadPage); + const registration = registrationRef.current; + if (!registration) { + return; + } + + try { + await registration.update(); + } catch { + setUpdateAvailable(true); + return; + } + if (!registration.waiting || hasRecentPWAUpdateReload(sessionStorage, Date.now())) { + return; + } + + markPWAUpdateReload(sessionStorage, Date.now()); + navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { + once: true, + }); + requestPWAUpdate(registration); }; return { updateAvailable, updateSW }; diff --git a/src/layouts/RootLayout/index.tsx b/src/layouts/RootLayout/index.tsx index dce0c36..27b16fd 100644 --- a/src/layouts/RootLayout/index.tsx +++ b/src/layouts/RootLayout/index.tsx @@ -10,7 +10,7 @@ export function RootLayout() { return (
- {updateAvailable && updateSW(true)} />} + {updateAvailable && }
diff --git a/vite.config.ts b/vite.config.ts index 98d0acc..79aef59 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -43,8 +43,10 @@ export default defineConfig(({ mode }) => ({ viteTsconfigPaths(), ViteYaml(), VitePWA({ + injectRegister: false, registerType: 'prompt', workbox: { + clientsClaim: true, importScripts: ['notification-sw.js'], }, }),