diff --git a/client/src/hooks/usePwaUpdate.ts b/client/src/hooks/usePwaUpdate.ts index 60cbb88..ef06ee1 100644 --- a/client/src/hooks/usePwaUpdate.ts +++ b/client/src/hooks/usePwaUpdate.ts @@ -1,13 +1,12 @@ import { useEffect, useRef } from 'react' import { useRegisterSW } from 'virtual:pwa-register/react' +import { markReloadAttempt, recentlyAttemptedReload } from '../services/pwaStartup.js' const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000 const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until' const UPDATE_SUPPRESS_MS = 30_000 const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000 const UPDATE_RELOAD_FALLBACK_MS = 2000 -/** Prevent Android PWA cold-start reload loops from onNeedReload. */ -const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done' function isUpdateSuppressed(): boolean { const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0') @@ -43,6 +42,13 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo } } +function reloadForServiceWorkerTakeover(): void { + if (recentlyAttemptedReload()) return + markReloadAttempt() + clearUpdateSuppression() + window.location.reload() +} + export function usePwaUpdate() { const cleanupRef = useRef<(() => void) | null>(null) @@ -52,14 +58,7 @@ export function usePwaUpdate() { } = useRegisterSW({ immediate: !import.meta.env.DEV, onNeedReload() { - // First SW takeover requires one reload; guard against repeated reloads on Android PWA resume. - if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) { - return - } - sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1') - clearUpdateSuppression() - setNeedRefresh(false) - window.location.reload() + reloadForServiceWorkerTakeover() }, onNeedRefresh() { if (isUpdateSuppressed()) return @@ -96,7 +95,7 @@ export function usePwaUpdate() { // vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire. window.setTimeout(() => { - window.location.reload() + reloadForServiceWorkerTakeover() }, UPDATE_RELOAD_FALLBACK_MS) } diff --git a/client/src/main.tsx b/client/src/main.tsx index a3b4d70..d02b815 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -6,6 +6,11 @@ import './index.css' import './i18n' import App from './App.tsx' import { applyAppearanceToDocument } from './services/appearance.ts' +import { + installStaleAssetRecovery, + markReloadAttempt, + reconcileServiceWorkerOnStartup +} from './services/pwaStartup.ts' /** Stale PWA precache on localhost can shadow Vite dev modules. */ async function clearDevServiceWorkerCaches(): Promise { @@ -35,8 +40,16 @@ function renderBootstrapError(message: string): void { async function bootstrap(): Promise { applyAppearanceToDocument() + installStaleAssetRecovery() await clearDevServiceWorkerCaches() + const shouldReloadForWaitingSw = await reconcileServiceWorkerOnStartup() + if (shouldReloadForWaitingSw) { + markReloadAttempt() + window.location.reload() + return + } + const rootEl = document.getElementById('root') if (!rootEl) { throw new Error('Missing #root element') diff --git a/client/src/services/pwaStartup.test.ts b/client/src/services/pwaStartup.test.ts new file mode 100644 index 0000000..7d1b648 --- /dev/null +++ b/client/src/services/pwaStartup.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + markReloadAttempt, + recentlyAttemptedReload, + reconcileServiceWorkerOnStartup +} from './pwaStartup.js' + +describe('pwaStartup reload guards', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it('blocks repeated reload attempts within the debounce window', () => { + expect(recentlyAttemptedReload(10_000)).toBe(false) + markReloadAttempt(10_000) + expect(recentlyAttemptedReload(12_000)).toBe(true) + expect(recentlyAttemptedReload(15_000)).toBe(false) + }) +}) + +describe('reconcileServiceWorkerOnStartup', () => { + beforeEach(() => { + sessionStorage.clear() + vi.unstubAllEnvs() + }) + + it('returns false in dev mode', async () => { + vi.stubEnv('DEV', true) + await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false) + }) + + it('returns false when no waiting worker exists', async () => { + vi.stubEnv('DEV', false) + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + controller: {}, + getRegistration: vi.fn().mockResolvedValue({ waiting: null }), + addEventListener: vi.fn() + } + }) + + await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false) + }) +}) diff --git a/client/src/services/pwaStartup.ts b/client/src/services/pwaStartup.ts new file mode 100644 index 0000000..7069eb0 --- /dev/null +++ b/client/src/services/pwaStartup.ts @@ -0,0 +1,87 @@ +const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts' +const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts' +const RELOAD_DEBOUNCE_MS = 4_000 +const COLD_START_UPDATE_DEBOUNCE_MS = 15_000 + +export function recentlyAttemptedReload(now = Date.now()): boolean { + const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0') + return now - last < RELOAD_DEBOUNCE_MS +} + +export function markReloadAttempt(now = Date.now()): void { + sessionStorage.setItem(RELOAD_ATTEMPT_KEY, String(now)) +} + +function recentlyAttemptedColdStartUpdate(now = Date.now()): boolean { + const last = Number(sessionStorage.getItem(COLD_START_UPDATE_KEY) || '0') + return now - last < COLD_START_UPDATE_DEBOUNCE_MS +} + +function markColdStartUpdateAttempt(now = Date.now()): void { + sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now)) +} + +function isStaleModuleLoadError(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : '' + + return ( + message.includes('Failed to fetch dynamically imported module') || + message.includes('Importing a module script failed') || + message.includes('error loading dynamically imported module') + ) +} + +/** + * After missed deploys, a waiting SW may exist while the page still runs an old bundle. + * Apply the waiting worker once on cold start (one controlled reload) instead of hanging. + */ +export async function reconcileServiceWorkerOnStartup(): Promise { + if (import.meta.env.DEV || !('serviceWorker' in navigator)) { + return false + } + + if (recentlyAttemptedColdStartUpdate()) { + return false + } + + const registration = await navigator.serviceWorker.getRegistration() + const waiting = registration?.waiting + if (!waiting || !navigator.serviceWorker.controller) { + return false + } + + markColdStartUpdateAttempt() + waiting.postMessage({ type: 'SKIP_WAITING' }) + + await new Promise((resolve) => { + const timeoutId = window.setTimeout(resolve, 4_000) + navigator.serviceWorker.addEventListener( + 'controllerchange', + () => { + window.clearTimeout(timeoutId) + resolve() + }, + { once: true } + ) + }) + + return true +} + +export function installStaleAssetRecovery(): void { + if (import.meta.env.DEV) return + + window.addEventListener('unhandledrejection', (event) => { + if (!isStaleModuleLoadError(event.reason)) return + if (recentlyAttemptedReload()) return + + markReloadAttempt() + event.preventDefault() + window.location.reload() + }) +} diff --git a/client/src/sw.ts b/client/src/sw.ts index 571cd5b..bf69575 100644 --- a/client/src/sw.ts +++ b/client/src/sw.ts @@ -1,10 +1,18 @@ /// +import { clientsClaim } from 'workbox-core' import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' declare let self: ServiceWorkerGlobalScope precacheAndRoute(self.__WB_MANIFEST) cleanupOutdatedCaches() +clientsClaim() + +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + void self.skipWaiting() + } +}) interface PushPayload { title?: string