From bbd4281dcbbc2f7a1082cdd4d9cb93337b72f306 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sun, 31 May 2026 14:20:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(pwa):=20Updates=20zuverl=C3=A4ssiger=20erke?= =?UTF-8?q?nnen=20und=20veraltete=20Instanzen=20automatisch=20reparieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/nginx.conf | 2 +- client/src/hooks/usePwaUpdate.ts | 74 +++++++-- client/src/main.tsx | 9 +- client/src/services/pwaStartup.test.ts | 34 +++- client/src/services/pwaStartup.ts | 206 +++++++++++++++++++++++-- client/src/services/pwaVersion.test.ts | 23 +++ client/src/services/pwaVersion.ts | 59 +++++++ client/src/sw.ts | 5 + client/vite.config.ts | 17 +- 9 files changed, 396 insertions(+), 33 deletions(-) create mode 100644 client/src/services/pwaVersion.test.ts create mode 100644 client/src/services/pwaVersion.ts diff --git a/client/nginx.conf b/client/nginx.conf index 8fa2891..214bdce 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -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"; } diff --git a/client/src/hooks/usePwaUpdate.ts b/client/src/hooks/usePwaUpdate.ts index ef06ee1..c74f1c2 100644 --- a/client/src/hooks/usePwaUpdate.ts +++ b/client/src/hooks/usePwaUpdate.ts @@ -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(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 = () => { diff --git a/client/src/main.tsx b/client/src/main.tsx index d02b815..415fdf3 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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 { 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) { diff --git a/client/src/services/pwaStartup.test.ts b/client/src/services/pwaStartup.test.ts index 7d1b648..de1db55 100644 --- a/client/src/services/pwaStartup.test.ts +++ b/client/src/services/pwaStartup.test.ts @@ -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') + }) +}) diff --git a/client/src/services/pwaStartup.ts b/client/src/services/pwaStartup.ts index 7069eb0..f8138f6 100644 --- a/client/src/services/pwaStartup.ts +++ b/client/src/services/pwaStartup.ts @@ -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 { + 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 { +export async function forcePwaRecovery(): Promise { + if (recentlyAttemptedHardRecovery()) return + + markHardRecoveryAttempt() + markReloadAttempt() + await clearPwaCachesAndWorkers() + window.location.reload() +} + +async function waitForWaitingWorker( + registration: ServiceWorkerRegistration, + timeoutMs: number +): Promise { + 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 { 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 { waiting.postMessage({ type: 'SKIP_WAITING' }) await new Promise((resolve) => { @@ -73,15 +164,102 @@ export async function reconcileServiceWorkerOnStartup(): Promise { 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 { + 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() + + 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 + ) } diff --git a/client/src/services/pwaVersion.test.ts b/client/src/services/pwaVersion.test.ts new file mode 100644 index 0000000..ad3d8cc --- /dev/null +++ b/client/src/services/pwaVersion.test.ts @@ -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) + }) +}) diff --git a/client/src/services/pwaVersion.ts b/client/src/services/pwaVersion.ts new file mode 100644 index 0000000..a026d15 --- /dev/null +++ b/client/src/services/pwaVersion.ts @@ -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 { + 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 { + const deployedVersion = await fetchDeployedVersion() + if (!deployedVersion) return false + return isNewerAppVersion(deployedVersion, getAppVersion()) +} diff --git a/client/src/sw.ts b/client/src/sw.ts index bf69575..4002508 100644 --- a/client/src/sw.ts +++ b/client/src/sw.ts @@ -1,6 +1,8 @@ /// 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() diff --git a/client/vite.config.ts b/client/vite.config.ts index bfd71f2..298ed85 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -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',