diff --git a/client/src/hooks/usePwaUpdate.ts b/client/src/hooks/usePwaUpdate.ts index c74f1c2..e4269ad 100644 --- a/client/src/hooks/usePwaUpdate.ts +++ b/client/src/hooks/usePwaUpdate.ts @@ -57,8 +57,9 @@ function scheduleUpdateChecks( registration.update().catch(() => {}) }, UPDATE_CHECK_INTERVAL_MS) const versionIntervalId = window.setInterval(() => { + if (isUpdateSuppressed()) return void isDeployedVersionNewer().then((outdated) => { - if (outdated) onOutdated() + if (outdated && !isUpdateSuppressed()) onOutdated() }) }, VERSION_CHECK_INTERVAL_MS) @@ -81,8 +82,18 @@ function reloadForServiceWorkerTakeover(): void { export function usePwaUpdate() { const cleanupRef = useRef<(() => void) | null>(null) - const hardRecoveryTimerRef = useRef(null) - const setNeedRefreshRef = useRef<(value: boolean) => void>(() => {}) + const reloadFallbackTimerRef = useRef(null) + const forceRecoveryTimerRef = useRef(null) + const setNeedRefreshRef = useRef<((value: boolean) => void) | null>(null) + const pendingNeedRefreshRef = useRef(null) + + const applyNeedRefresh = (value: boolean) => { + if (setNeedRefreshRef.current) { + setNeedRefreshRef.current(value) + return + } + pendingNeedRefreshRef.current = value + } const { needRefresh: [needRefresh, setNeedRefresh], @@ -94,23 +105,28 @@ export function usePwaUpdate() { }, onNeedRefresh() { if (isUpdateSuppressed()) return - setNeedRefreshRef.current(true) + applyNeedRefresh(true) }, onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) { if (!registration) return if (isUpdateSuppressed() || !registration.waiting) { - setNeedRefreshRef.current(false) + applyNeedRefresh(false) } cleanupRef.current?.() cleanupRef.current = scheduleUpdateChecks(registration, () => { - setNeedRefreshRef.current(true) + if (isUpdateSuppressed()) return + applyNeedRefresh(true) }) } }) setNeedRefreshRef.current = setNeedRefresh + if (pendingNeedRefreshRef.current !== null) { + setNeedRefresh(pendingNeedRefreshRef.current) + pendingNeedRefreshRef.current = null + } useEffect(() => { if (isUpdateSuppressed()) { @@ -126,9 +142,13 @@ export function usePwaUpdate() { return () => { cleanupRef.current?.() cleanupRef.current = null - if (hardRecoveryTimerRef.current !== null) { - window.clearTimeout(hardRecoveryTimerRef.current) - hardRecoveryTimerRef.current = null + if (reloadFallbackTimerRef.current !== null) { + window.clearTimeout(reloadFallbackTimerRef.current) + reloadFallbackTimerRef.current = null + } + if (forceRecoveryTimerRef.current !== null) { + window.clearTimeout(forceRecoveryTimerRef.current) + forceRecoveryTimerRef.current = null } } }, [setNeedRefresh]) @@ -140,11 +160,20 @@ export function usePwaUpdate() { await updateServiceWorker(true) await triggerServiceWorkerUpdate() - hardRecoveryTimerRef.current = window.setTimeout(() => { + if (reloadFallbackTimerRef.current !== null) { + window.clearTimeout(reloadFallbackTimerRef.current) + } + if (forceRecoveryTimerRef.current !== null) { + window.clearTimeout(forceRecoveryTimerRef.current) + } + + reloadFallbackTimerRef.current = window.setTimeout(() => { + reloadFallbackTimerRef.current = null reloadForServiceWorkerTakeover() }, UPDATE_RELOAD_FALLBACK_MS) - window.setTimeout(() => { + forceRecoveryTimerRef.current = window.setTimeout(() => { + forceRecoveryTimerRef.current = null void forcePwaRecovery() }, UPDATE_HARD_RECOVERY_MS) } diff --git a/client/src/services/pwaStartup.test.ts b/client/src/services/pwaStartup.test.ts index de1db55..5ce20bf 100644 --- a/client/src/services/pwaStartup.test.ts +++ b/client/src/services/pwaStartup.test.ts @@ -1,11 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + forcePwaRecovery, markReloadAttempt, recentlyAttemptedReload, reconcileServiceWorkerOnStartup, reconcileVersionOnStartup } from './pwaStartup.js' +const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count' +const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts' + describe('pwaStartup reload guards', () => { beforeEach(() => { sessionStorage.clear() @@ -19,6 +23,39 @@ describe('pwaStartup reload guards', () => { }) }) +describe('forcePwaRecovery stale counter reset', () => { + beforeEach(() => { + sessionStorage.clear() + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + it('clears stale recovery counter before hard recovery reload', async () => { + vi.stubEnv('DEV', false) + sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, '2') + sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(Date.now())) + + const reload = vi.fn() + vi.stubGlobal('location', { reload }) + vi.stubGlobal('caches', { + keys: vi.fn().mockResolvedValue([]), + delete: vi.fn() + }) + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + getRegistrations: vi.fn().mockResolvedValue([]) + } + }) + + await forcePwaRecovery() + + expect(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY)).toBeNull() + expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull() + expect(reload).toHaveBeenCalledOnce() + }) +}) + describe('reconcileServiceWorkerOnStartup', () => { beforeEach(() => { sessionStorage.clear() diff --git a/client/src/services/pwaStartup.ts b/client/src/services/pwaStartup.ts index f8138f6..19beff6 100644 --- a/client/src/services/pwaStartup.ts +++ b/client/src/services/pwaStartup.ts @@ -4,6 +4,8 @@ const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts' const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts' const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts' const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count' +const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts' +const STALE_RECOVERY_WINDOW_MS = 60_000 const RELOAD_DEBOUNCE_MS = 4_000 const COLD_START_UPDATE_DEBOUNCE_MS = 15_000 const HARD_RECOVERY_DEBOUNCE_MS = 30_000 @@ -35,9 +37,22 @@ function markHardRecoveryAttempt(now = Date.now()): void { sessionStorage.setItem(HARD_RECOVERY_KEY, String(now)) } -function incrementStaleRecoveryCount(): number { - const next = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0') + 1 +function resetStaleRecoveryCount(): void { + sessionStorage.removeItem(STALE_RECOVERY_COUNT_KEY) + sessionStorage.removeItem(STALE_RECOVERY_LAST_KEY) +} + +function incrementStaleRecoveryCount(now = Date.now()): number { + const last = Number(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY) || '0') + let current = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0') + + if (now - last > STALE_RECOVERY_WINDOW_MS) { + current = 0 + } + + const next = current + 1 sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, String(next)) + sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(now)) return next } @@ -80,6 +95,7 @@ export async function forcePwaRecovery(): Promise { markHardRecoveryAttempt() markReloadAttempt() + resetStaleRecoveryCount() await clearPwaCachesAndWorkers() window.location.reload() }