From 951b5b3f1c86cd796606a3faa9d533d4c2aa8ec4 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sun, 31 May 2026 15:00:51 +0200 Subject: [PATCH] =?UTF-8?q?fix(pwa):=20Startup-H=C3=A4nger=20nach=20Inakti?= =?UTF-8?q?vit=C3=A4t=20stabilisieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verhindert Blank-Screens und Reload-Schleifen beim Wiederöffnen der PWA, indem Recovery nur bei bestätigter SW-Übernahme greift und Navigationen bevorzugt frisch aus dem Netzwerk geladen werden. Co-authored-by: Cursor --- client/src/hooks/usePwaUpdate.ts | 3 ++- client/src/main.tsx | 3 --- client/src/services/pwaStartup.test.ts | 34 ++++++++++++++++++++++++ client/src/services/pwaStartup.ts | 36 ++++++++++++++++---------- client/src/sw.ts | 18 +++++++++++-- 5 files changed, 74 insertions(+), 20 deletions(-) diff --git a/client/src/hooks/usePwaUpdate.ts b/client/src/hooks/usePwaUpdate.ts index c657093..2f4eab7 100644 --- a/client/src/hooks/usePwaUpdate.ts +++ b/client/src/hooks/usePwaUpdate.ts @@ -91,7 +91,8 @@ export function usePwaUpdate() { } = useRegisterSW({ immediate: !import.meta.env.DEV, onNeedReload() { - reloadForServiceWorkerTakeover() + if (isUpdateSuppressed()) return + applyNeedRefresh(true) }, onNeedRefresh() { if (isUpdateSuppressed()) return diff --git a/client/src/main.tsx b/client/src/main.tsx index dfefcb3..258ac15 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -50,9 +50,6 @@ async function bootstrap(): Promise { 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 5ce20bf..56fc243 100644 --- a/client/src/services/pwaStartup.test.ts +++ b/client/src/services/pwaStartup.test.ts @@ -54,6 +54,12 @@ describe('forcePwaRecovery stale counter reset', () => { expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull() expect(reload).toHaveBeenCalledOnce() }) + + it('returns false when hard recovery was just attempted', async () => { + sessionStorage.setItem('pwa_hard_recovery_ts', String(Date.now())) + const result = await forcePwaRecovery() + expect(result).toBe(false) + }) }) describe('reconcileServiceWorkerOnStartup', () => { @@ -85,6 +91,34 @@ describe('reconcileServiceWorkerOnStartup', () => { await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false) }) + + it('returns false when waiting worker activation never takes over', async () => { + vi.useFakeTimers() + const postMessage = vi.fn() + const addEventListener = vi.fn() + vi.stubEnv('DEV', false) + + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + controller: { scriptURL: '/sw.js?v=1' }, + getRegistration: vi.fn().mockResolvedValue({ + waiting: { postMessage }, + installing: null, + update: vi.fn().mockResolvedValue(undefined), + addEventListener: vi.fn() + }), + addEventListener + } + }) + + const reconcilePromise = reconcileServiceWorkerOnStartup() + await vi.advanceTimersByTimeAsync(4_000) + + await expect(reconcilePromise).resolves.toBe(false) + expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' }) + vi.useRealTimers() + }) }) describe('reconcileVersionOnStartup', () => { diff --git a/client/src/services/pwaStartup.ts b/client/src/services/pwaStartup.ts index 19beff6..33998fe 100644 --- a/client/src/services/pwaStartup.ts +++ b/client/src/services/pwaStartup.ts @@ -90,14 +90,15 @@ export async function clearPwaCachesAndWorkers(): Promise { * Last-resort recovery when soft reloads cannot escape a stale precache. * Equivalent to manually clearing site data / reinstalling the PWA. */ -export async function forcePwaRecovery(): Promise { - if (recentlyAttemptedHardRecovery()) return +export async function forcePwaRecovery(): Promise { + if (recentlyAttemptedHardRecovery()) return false markHardRecoveryAttempt() markReloadAttempt() resetStaleRecoveryCount() await clearPwaCachesAndWorkers() window.location.reload() + return true } async function waitForWaitingWorker( @@ -163,21 +164,21 @@ export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise { + const currentController = navigator.serviceWorker.controller?.scriptURL ?? null waiting.postMessage({ type: 'SKIP_WAITING' }) - await new Promise((resolve) => { - const timeoutId = window.setTimeout(resolve, 4_000) + return new Promise((resolve) => { + const timeoutId = window.setTimeout(() => resolve(false), 4_000) navigator.serviceWorker.addEventListener( 'controllerchange', () => { window.clearTimeout(timeoutId) - resolve() + const nextController = navigator.serviceWorker.controller?.scriptURL ?? null + resolve(nextController !== null && nextController !== currentController) }, { once: true } ) }) - - return true } /** @@ -205,8 +206,11 @@ export async function reconcileServiceWorkerOnStartup(): Promise { return false } - markColdStartUpdateAttempt() - return activateWaitingWorker(waiting) + const activated = await activateWaitingWorker(waiting) + if (activated) { + markColdStartUpdateAttempt() + } + return activated } /** @@ -233,15 +237,19 @@ export async function reconcileVersionOnStartup(): Promise<'reload' | 'recovered const registration = await navigator.serviceWorker.getRegistration() const waiting = registration?.waiting if (waiting) { - markColdStartUpdateAttempt() - await activateWaitingWorker(waiting) - return 'reload' + const activated = await activateWaitingWorker(waiting) + if (activated) { + markColdStartUpdateAttempt() + return 'reload' + } } } if (!recentlyAttemptedHardRecovery()) { - await forcePwaRecovery() - return 'recovered' + const recovered = await forcePwaRecovery() + if (recovered) { + return 'recovered' + } } return 'noop' diff --git a/client/src/sw.ts b/client/src/sw.ts index 4002508..ca48f18 100644 --- a/client/src/sw.ts +++ b/client/src/sw.ts @@ -1,11 +1,25 @@ /// import { clientsClaim } from 'workbox-core' -import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' +import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' -import { NetworkOnly } from 'workbox-strategies' +import { NetworkFirst, NetworkOnly } from 'workbox-strategies' declare let self: ServiceWorkerGlobalScope +const appShellFallback = createHandlerBoundToURL('/index.html') +const navigationStrategy = new NetworkFirst({ + cacheName: 'app-shell', + networkTimeoutSeconds: 3 +}) + +registerRoute(({ request }) => request.mode === 'navigate', async (context) => { + try { + return await navigationStrategy.handle(context) + } catch { + return appShellFallback(context) + } +}) + precacheAndRoute(self.__WB_MANIFEST) cleanupOutdatedCaches() clientsClaim()