fix(pwa): Recovery-Zähler, Update-Suppression und Timer-Leaks beheben

Stale-Recovery zählt nur aufeinanderfolgende Fehler und wird nach Hard Recovery zurückgesetzt; Update-Checks respektieren „Später“, und PWA-Refresh-State sowie Recovery-Timer werden zuverlässig gesetzt bzw. beim Unmount aufgeräumt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 14:24:25 +02:00
co-authored by Cursor
parent bbd4281dcb
commit 7cf04b3357
3 changed files with 95 additions and 13 deletions
+40 -11
View File
@@ -57,8 +57,9 @@ function scheduleUpdateChecks(
registration.update().catch(() => {}) registration.update().catch(() => {})
}, UPDATE_CHECK_INTERVAL_MS) }, UPDATE_CHECK_INTERVAL_MS)
const versionIntervalId = window.setInterval(() => { const versionIntervalId = window.setInterval(() => {
if (isUpdateSuppressed()) return
void isDeployedVersionNewer().then((outdated) => { void isDeployedVersionNewer().then((outdated) => {
if (outdated) onOutdated() if (outdated && !isUpdateSuppressed()) onOutdated()
}) })
}, VERSION_CHECK_INTERVAL_MS) }, VERSION_CHECK_INTERVAL_MS)
@@ -81,8 +82,18 @@ function reloadForServiceWorkerTakeover(): void {
export function usePwaUpdate() { export function usePwaUpdate() {
const cleanupRef = useRef<(() => void) | null>(null) const cleanupRef = useRef<(() => void) | null>(null)
const hardRecoveryTimerRef = useRef<number | null>(null) const reloadFallbackTimerRef = useRef<number | null>(null)
const setNeedRefreshRef = useRef<(value: boolean) => void>(() => {}) const forceRecoveryTimerRef = useRef<number | null>(null)
const setNeedRefreshRef = useRef<((value: boolean) => void) | null>(null)
const pendingNeedRefreshRef = useRef<boolean | null>(null)
const applyNeedRefresh = (value: boolean) => {
if (setNeedRefreshRef.current) {
setNeedRefreshRef.current(value)
return
}
pendingNeedRefreshRef.current = value
}
const { const {
needRefresh: [needRefresh, setNeedRefresh], needRefresh: [needRefresh, setNeedRefresh],
@@ -94,23 +105,28 @@ export function usePwaUpdate() {
}, },
onNeedRefresh() { onNeedRefresh() {
if (isUpdateSuppressed()) return if (isUpdateSuppressed()) return
setNeedRefreshRef.current(true) applyNeedRefresh(true)
}, },
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) { onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
if (!registration) return if (!registration) return
if (isUpdateSuppressed() || !registration.waiting) { if (isUpdateSuppressed() || !registration.waiting) {
setNeedRefreshRef.current(false) applyNeedRefresh(false)
} }
cleanupRef.current?.() cleanupRef.current?.()
cleanupRef.current = scheduleUpdateChecks(registration, () => { cleanupRef.current = scheduleUpdateChecks(registration, () => {
setNeedRefreshRef.current(true) if (isUpdateSuppressed()) return
applyNeedRefresh(true)
}) })
} }
}) })
setNeedRefreshRef.current = setNeedRefresh setNeedRefreshRef.current = setNeedRefresh
if (pendingNeedRefreshRef.current !== null) {
setNeedRefresh(pendingNeedRefreshRef.current)
pendingNeedRefreshRef.current = null
}
useEffect(() => { useEffect(() => {
if (isUpdateSuppressed()) { if (isUpdateSuppressed()) {
@@ -126,9 +142,13 @@ export function usePwaUpdate() {
return () => { return () => {
cleanupRef.current?.() cleanupRef.current?.()
cleanupRef.current = null cleanupRef.current = null
if (hardRecoveryTimerRef.current !== null) { if (reloadFallbackTimerRef.current !== null) {
window.clearTimeout(hardRecoveryTimerRef.current) window.clearTimeout(reloadFallbackTimerRef.current)
hardRecoveryTimerRef.current = null reloadFallbackTimerRef.current = null
}
if (forceRecoveryTimerRef.current !== null) {
window.clearTimeout(forceRecoveryTimerRef.current)
forceRecoveryTimerRef.current = null
} }
} }
}, [setNeedRefresh]) }, [setNeedRefresh])
@@ -140,11 +160,20 @@ export function usePwaUpdate() {
await updateServiceWorker(true) await updateServiceWorker(true)
await triggerServiceWorkerUpdate() 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() reloadForServiceWorkerTakeover()
}, UPDATE_RELOAD_FALLBACK_MS) }, UPDATE_RELOAD_FALLBACK_MS)
window.setTimeout(() => { forceRecoveryTimerRef.current = window.setTimeout(() => {
forceRecoveryTimerRef.current = null
void forcePwaRecovery() void forcePwaRecovery()
}, UPDATE_HARD_RECOVERY_MS) }, UPDATE_HARD_RECOVERY_MS)
} }
+37
View File
@@ -1,11 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { import {
forcePwaRecovery,
markReloadAttempt, markReloadAttempt,
recentlyAttemptedReload, recentlyAttemptedReload,
reconcileServiceWorkerOnStartup, reconcileServiceWorkerOnStartup,
reconcileVersionOnStartup reconcileVersionOnStartup
} from './pwaStartup.js' } 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', () => { describe('pwaStartup reload guards', () => {
beforeEach(() => { beforeEach(() => {
sessionStorage.clear() 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', () => { describe('reconcileServiceWorkerOnStartup', () => {
beforeEach(() => { beforeEach(() => {
sessionStorage.clear() sessionStorage.clear()
+18 -2
View File
@@ -4,6 +4,8 @@ const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts' const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts' const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts'
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count' 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 RELOAD_DEBOUNCE_MS = 4_000
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000 const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
const HARD_RECOVERY_DEBOUNCE_MS = 30_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)) sessionStorage.setItem(HARD_RECOVERY_KEY, String(now))
} }
function incrementStaleRecoveryCount(): number { function resetStaleRecoveryCount(): void {
const next = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0') + 1 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_COUNT_KEY, String(next))
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(now))
return next return next
} }
@@ -80,6 +95,7 @@ export async function forcePwaRecovery(): Promise<void> {
markHardRecoveryAttempt() markHardRecoveryAttempt()
markReloadAttempt() markReloadAttempt()
resetStaleRecoveryCount()
await clearPwaCachesAndWorkers() await clearPwaCachesAndWorkers()
window.location.reload() window.location.reload()
} }