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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user