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
+1 -1
View File
@@ -4,7 +4,7 @@ server {
client_max_body_size 50M;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
+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 = () => {
+6 -3
View File
@@ -9,7 +9,7 @@ import { applyAppearanceToDocument } from './services/appearance.ts'
import {
installStaleAssetRecovery,
markReloadAttempt,
reconcileServiceWorkerOnStartup
reconcileVersionOnStartup
} from './services/pwaStartup.ts'
/** Stale PWA precache on localhost can shadow Vite dev modules. */
@@ -43,12 +43,15 @@ async function bootstrap(): Promise<void> {
installStaleAssetRecovery()
await clearDevServiceWorkerCaches()
const shouldReloadForWaitingSw = await reconcileServiceWorkerOnStartup()
if (shouldReloadForWaitingSw) {
const startupResult = await reconcileVersionOnStartup()
if (startupResult === 'reload') {
markReloadAttempt()
window.location.reload()
return
}
if (startupResult === 'recovered') {
return
}
const rootEl = document.getElementById('root')
if (!rootEl) {
+32 -2
View File
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
markReloadAttempt,
recentlyAttemptedReload,
reconcileServiceWorkerOnStartup
reconcileServiceWorkerOnStartup,
reconcileVersionOnStartup
} from './pwaStartup.js'
describe('pwaStartup reload guards', () => {
@@ -35,7 +36,12 @@ describe('reconcileServiceWorkerOnStartup', () => {
configurable: true,
value: {
controller: {},
getRegistration: vi.fn().mockResolvedValue({ waiting: null }),
getRegistration: vi.fn().mockResolvedValue({
waiting: null,
installing: null,
update: vi.fn().mockResolvedValue(undefined),
addEventListener: vi.fn()
}),
addEventListener: vi.fn()
}
})
@@ -43,3 +49,27 @@ describe('reconcileServiceWorkerOnStartup', () => {
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
})
})
describe('reconcileVersionOnStartup', () => {
beforeEach(() => {
sessionStorage.clear()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
it('returns noop in dev mode', async () => {
vi.stubEnv('DEV', true)
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
})
it('returns noop when deployed version matches bundled version', async () => {
vi.stubEnv('DEV', false)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: '0.1.0.57' })
}))
vi.stubGlobal('__APP_VERSION__', '0.1.0.57')
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
})
})
+194 -16
View File
@@ -1,7 +1,12 @@
import { isNewerAppVersion, fetchDeployedVersion, getAppVersion } from './pwaVersion.js'
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 RELOAD_DEBOUNCE_MS = 4_000
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
const HARD_RECOVERY_DEBOUNCE_MS = 30_000
export function recentlyAttemptedReload(now = Date.now()): boolean {
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
@@ -21,6 +26,21 @@ function markColdStartUpdateAttempt(now = Date.now()): void {
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
}
function recentlyAttemptedHardRecovery(now = Date.now()): boolean {
const last = Number(sessionStorage.getItem(HARD_RECOVERY_KEY) || '0')
return now - last < HARD_RECOVERY_DEBOUNCE_MS
}
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
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, String(next))
return next
}
function isStaleModuleLoadError(error: unknown): boolean {
const message =
error instanceof Error
@@ -32,30 +52,101 @@ function isStaleModuleLoadError(error: unknown): boolean {
return (
message.includes('Failed to fetch dynamically imported module') ||
message.includes('Importing a module script failed') ||
message.includes('error loading dynamically imported module')
message.includes('error loading dynamically imported module') ||
message.includes('Loading chunk') ||
message.includes('ChunkLoadError') ||
message.includes('Unable to preload CSS')
)
}
export async function clearPwaCachesAndWorkers(): Promise<void> {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map((registration) => registration.unregister()))
}
if ('caches' in window) {
const keys = await caches.keys()
await Promise.all(keys.map((key) => caches.delete(key)))
}
}
/**
* 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.
* Last-resort recovery when soft reloads cannot escape a stale precache.
* Equivalent to manually clearing site data / reinstalling the PWA.
*/
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
export async function forcePwaRecovery(): Promise<void> {
if (recentlyAttemptedHardRecovery()) return
markHardRecoveryAttempt()
markReloadAttempt()
await clearPwaCachesAndWorkers()
window.location.reload()
}
async function waitForWaitingWorker(
registration: ServiceWorkerRegistration,
timeoutMs: number
): Promise<ServiceWorker | null> {
if (registration.waiting) {
return registration.waiting
}
return new Promise((resolve) => {
const timeoutId = window.setTimeout(() => resolve(null), timeoutMs)
const inspectWorker = (worker: ServiceWorker | null) => {
if (!worker) return
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
window.clearTimeout(timeoutId)
resolve(worker)
return
}
worker.addEventListener(
'statechange',
() => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
window.clearTimeout(timeoutId)
resolve(worker)
}
},
{ once: true }
)
}
inspectWorker(registration.installing)
registration.addEventListener(
'updatefound',
() => {
inspectWorker(registration.installing)
},
{ once: true }
)
})
}
export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise<boolean> {
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) {
if (!registration) return false
try {
await registration.update()
} catch {
return false
}
markColdStartUpdateAttempt()
const waiting = await waitForWaitingWorker(registration, timeoutMs)
return waiting !== null
}
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
waiting.postMessage({ type: 'SKIP_WAITING' })
await new Promise<void>((resolve) => {
@@ -73,15 +164,102 @@ export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
return true
}
/**
* 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<boolean> {
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
return false
}
if (recentlyAttemptedColdStartUpdate()) {
return false
}
const registration = await navigator.serviceWorker.getRegistration()
let waiting = registration?.waiting ?? null
if (!waiting && registration) {
await registration.update().catch(() => {})
waiting = await waitForWaitingWorker(registration, 4_000)
}
if (!waiting || !navigator.serviceWorker.controller) {
return false
}
markColdStartUpdateAttempt()
return activateWaitingWorker(waiting)
}
/**
* Compare deployed version.json with the bundled app version.
* When the server is ahead, try a soft SW takeover before hard recovery.
*/
export async function reconcileVersionOnStartup(): Promise<'reload' | 'recovered' | 'noop'> {
if (import.meta.env.DEV || !navigator.onLine) {
return 'noop'
}
const deployedVersion = await fetchDeployedVersion()
if (!deployedVersion || !isNewerAppVersion(deployedVersion, getAppVersion())) {
return 'noop'
}
const reconciled = await reconcileServiceWorkerOnStartup()
if (reconciled) {
return 'reload'
}
const updated = await triggerServiceWorkerUpdate()
if (updated) {
const registration = await navigator.serviceWorker.getRegistration()
const waiting = registration?.waiting
if (waiting) {
markColdStartUpdateAttempt()
await activateWaitingWorker(waiting)
return 'reload'
}
}
if (!recentlyAttemptedHardRecovery()) {
await forcePwaRecovery()
return 'recovered'
}
return 'noop'
}
export function installStaleAssetRecovery(): void {
if (import.meta.env.DEV) return
window.addEventListener('unhandledrejection', (event) => {
if (!isStaleModuleLoadError(event.reason)) return
const recoverFromStaleAssets = () => {
if (recentlyAttemptedReload()) return
const attempts = incrementStaleRecoveryCount()
markReloadAttempt()
event.preventDefault()
window.location.reload()
})
if (attempts >= 2) {
void forcePwaRecovery()
return
}
window.location.reload()
}
window.addEventListener('unhandledrejection', (event) => {
if (!isStaleModuleLoadError(event.reason)) return
event.preventDefault()
recoverFromStaleAssets()
})
window.addEventListener(
'error',
(event) => {
if (!isStaleModuleLoadError(event.message)) return
recoverFromStaleAssets()
},
true
)
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import {
compareAppVersions,
isNewerAppVersion,
parseAppVersion
} from './pwaVersion.js'
describe('pwaVersion', () => {
it('parses semantic build versions', () => {
expect(parseAppVersion('v0.1.0.57')).toEqual([0, 1, 0, 57])
})
it('compares build numbers numerically', () => {
expect(compareAppVersions('0.1.0.65', '0.1.0.57')).toBeGreaterThan(0)
expect(compareAppVersions('0.1.0.57', '0.1.0.65')).toBeLessThan(0)
expect(compareAppVersions('0.1.0.57', '0.1.0.57')).toBe(0)
})
it('detects newer deployed versions', () => {
expect(isNewerAppVersion('0.1.0.66', '0.1.0.57')).toBe(true)
expect(isNewerAppVersion('0.1.0.57', '0.1.0.57')).toBe(false)
})
})
+59
View File
@@ -0,0 +1,59 @@
const APP_VERSION =
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0.0-dev'
export function getAppVersion(): string {
return APP_VERSION
}
export function parseAppVersion(version: string): number[] {
return version
.replace(/^v/i, '')
.split('.')
.map((part) => Number.parseInt(part, 10) || 0)
}
/** Positive when `a` is newer than `b`. */
export function compareAppVersions(a: string, b: string): number {
const partsA = parseAppVersion(a)
const partsB = parseAppVersion(b)
const length = Math.max(partsA.length, partsB.length)
for (let index = 0; index < length; index += 1) {
const diff = (partsA[index] ?? 0) - (partsB[index] ?? 0)
if (diff !== 0) return diff
}
return 0
}
export function isNewerAppVersion(serverVersion: string, clientVersion: string): boolean {
return compareAppVersions(serverVersion, clientVersion) > 0
}
export async function fetchDeployedVersion(timeoutMs = 4_000): Promise<string | null> {
if (!navigator.onLine) return null
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs)
try {
const response = await fetch(`/version.json?_=${Date.now()}`, {
cache: 'no-store',
signal: controller.signal
})
if (!response.ok) return null
const payload = (await response.json()) as { version?: unknown }
return typeof payload.version === 'string' ? payload.version.trim() : null
} catch {
return null
} finally {
window.clearTimeout(timeoutId)
}
}
export async function isDeployedVersionNewer(): Promise<boolean> {
const deployedVersion = await fetchDeployedVersion()
if (!deployedVersion) return false
return isNewerAppVersion(deployedVersion, getAppVersion())
}
+5
View File
@@ -1,6 +1,8 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core'
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing'
import { NetworkOnly } from 'workbox-strategies'
declare let self: ServiceWorkerGlobalScope
@@ -8,6 +10,9 @@ precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
clientsClaim()
// Always fetch the live deploy version, even under an older precache.
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
void self.skipWaiting()
+16 -1
View File
@@ -2,9 +2,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import { readFileSync } from 'node:fs'
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Plugin } from 'vite'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -19,6 +20,19 @@ function readAppVersion(): string {
}
}
function versionJsonPlugin(version: string): Plugin {
return {
name: 'version-json',
writeBundle(options) {
const outDir = options.dir ?? resolve(__dirname, 'dist')
writeFileSync(
resolve(outDir, 'version.json'),
`${JSON.stringify({ version }, null, 2)}\n`
)
}
}
}
// https://vite.dev/config/
export default defineConfig({
test: {
@@ -42,6 +56,7 @@ export default defineConfig({
},
plugins: [
react(),
versionJsonPlugin(readAppVersion()),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',