fix(pwa): Updates zuverlässiger erkennen und veraltete Instanzen automatisch reparieren

Unabhängige version.json-Prüfung, häufigere Update-Checks und Hard Recovery
beheben hängende Android-PWAs ohne manuelles Cache-Löschen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 14:20:54 +02:00
parent d2833f7664
commit bbd4281dcb
9 changed files with 396 additions and 33 deletions
+62 -12
View File
@@ -1,12 +1,20 @@
import { useEffect, useRef } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { markReloadAttempt, recentlyAttemptedReload } from '../services/pwaStartup.js'
import {
forcePwaRecovery,
markReloadAttempt,
recentlyAttemptedReload,
triggerServiceWorkerUpdate
} from '../services/pwaStartup.js'
import { isDeployedVersionNewer } from '../services/pwaVersion.js'
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
const UPDATE_CHECK_INTERVAL_MS = 15 * 60 * 1000
const VERSION_CHECK_INTERVAL_MS = 10 * 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
const UPDATE_DISMISS_SUPPRESS_MS = 15 * 60 * 1000
const UPDATE_RELOAD_FALLBACK_MS = 2_000
const UPDATE_HARD_RECOVERY_MS = 5_000
function isUpdateSuppressed(): boolean {
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
@@ -21,10 +29,16 @@ function clearUpdateSuppression(): void {
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
}
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
function scheduleUpdateChecks(
registration: ServiceWorkerRegistration,
onOutdated: () => void
): () => void {
const checkForUpdate = () => {
if (isUpdateSuppressed()) return
registration.update().catch(() => {})
void isDeployedVersionNewer().then((outdated) => {
if (outdated) onOutdated()
})
}
const onVisibilityChange = () => {
@@ -33,12 +47,28 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
}
}
const onOnline = () => {
checkForUpdate()
}
document.addEventListener('visibilitychange', onVisibilityChange)
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
window.addEventListener('online', onOnline)
const swIntervalId = window.setInterval(() => {
registration.update().catch(() => {})
}, UPDATE_CHECK_INTERVAL_MS)
const versionIntervalId = window.setInterval(() => {
void isDeployedVersionNewer().then((outdated) => {
if (outdated) onOutdated()
})
}, VERSION_CHECK_INTERVAL_MS)
checkForUpdate()
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.clearInterval(intervalId)
window.removeEventListener('online', onOnline)
window.clearInterval(swIntervalId)
window.clearInterval(versionIntervalId)
}
}
@@ -51,6 +81,8 @@ function reloadForServiceWorkerTakeover(): void {
export function usePwaUpdate() {
const cleanupRef = useRef<(() => void) | null>(null)
const hardRecoveryTimerRef = useRef<number | null>(null)
const setNeedRefreshRef = useRef<(value: boolean) => void>(() => {})
const {
needRefresh: [needRefresh, setNeedRefresh],
@@ -62,28 +94,42 @@ export function usePwaUpdate() {
},
onNeedRefresh() {
if (isUpdateSuppressed()) return
setNeedRefresh(true)
setNeedRefreshRef.current(true)
},
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
if (!registration) return
if (isUpdateSuppressed() || !registration.waiting) {
setNeedRefresh(false)
setNeedRefreshRef.current(false)
}
cleanupRef.current?.()
cleanupRef.current = scheduleUpdateChecks(registration)
cleanupRef.current = scheduleUpdateChecks(registration, () => {
setNeedRefreshRef.current(true)
})
}
})
setNeedRefreshRef.current = setNeedRefresh
useEffect(() => {
if (isUpdateSuppressed()) {
setNeedRefresh(false)
}
void isDeployedVersionNewer().then((outdated) => {
if (outdated) {
setNeedRefresh(true)
}
})
return () => {
cleanupRef.current?.()
cleanupRef.current = null
if (hardRecoveryTimerRef.current !== null) {
window.clearTimeout(hardRecoveryTimerRef.current)
hardRecoveryTimerRef.current = null
}
}
}, [setNeedRefresh])
@@ -92,11 +138,15 @@ export function usePwaUpdate() {
suppressUpdatePrompt()
await updateServiceWorker(true)
await triggerServiceWorkerUpdate()
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
window.setTimeout(() => {
hardRecoveryTimerRef.current = window.setTimeout(() => {
reloadForServiceWorkerTakeover()
}, UPDATE_RELOAD_FALLBACK_MS)
window.setTimeout(() => {
void forcePwaRecovery()
}, UPDATE_HARD_RECOVERY_MS)
}
const dismissUpdate = () => {