fix(pwa): Startup-Hänger nach Inaktivität stabilisieren

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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 15:00:51 +02:00
co-authored by Cursor
parent abb708c3d0
commit 951b5b3f1c
5 changed files with 74 additions and 20 deletions
+2 -1
View File
@@ -91,7 +91,8 @@ export function usePwaUpdate() {
} = useRegisterSW({ } = useRegisterSW({
immediate: !import.meta.env.DEV, immediate: !import.meta.env.DEV,
onNeedReload() { onNeedReload() {
reloadForServiceWorkerTakeover() if (isUpdateSuppressed()) return
applyNeedRefresh(true)
}, },
onNeedRefresh() { onNeedRefresh() {
if (isUpdateSuppressed()) return if (isUpdateSuppressed()) return
-3
View File
@@ -50,9 +50,6 @@ async function bootstrap(): Promise<void> {
window.location.reload() window.location.reload()
return return
} }
if (startupResult === 'recovered') {
return
}
const rootEl = document.getElementById('root') const rootEl = document.getElementById('root')
if (!rootEl) { if (!rootEl) {
+34
View File
@@ -54,6 +54,12 @@ describe('forcePwaRecovery stale counter reset', () => {
expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull() expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull()
expect(reload).toHaveBeenCalledOnce() 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', () => { describe('reconcileServiceWorkerOnStartup', () => {
@@ -85,6 +91,34 @@ describe('reconcileServiceWorkerOnStartup', () => {
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false) 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', () => { describe('reconcileVersionOnStartup', () => {
+22 -14
View File
@@ -90,14 +90,15 @@ export async function clearPwaCachesAndWorkers(): Promise<void> {
* Last-resort recovery when soft reloads cannot escape a stale precache. * Last-resort recovery when soft reloads cannot escape a stale precache.
* Equivalent to manually clearing site data / reinstalling the PWA. * Equivalent to manually clearing site data / reinstalling the PWA.
*/ */
export async function forcePwaRecovery(): Promise<void> { export async function forcePwaRecovery(): Promise<boolean> {
if (recentlyAttemptedHardRecovery()) return if (recentlyAttemptedHardRecovery()) return false
markHardRecoveryAttempt() markHardRecoveryAttempt()
markReloadAttempt() markReloadAttempt()
resetStaleRecoveryCount() resetStaleRecoveryCount()
await clearPwaCachesAndWorkers() await clearPwaCachesAndWorkers()
window.location.reload() window.location.reload()
return true
} }
async function waitForWaitingWorker( async function waitForWaitingWorker(
@@ -163,21 +164,21 @@ export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise<boo
} }
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> { async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
const currentController = navigator.serviceWorker.controller?.scriptURL ?? null
waiting.postMessage({ type: 'SKIP_WAITING' }) waiting.postMessage({ type: 'SKIP_WAITING' })
await new Promise<void>((resolve) => { return new Promise<boolean>((resolve) => {
const timeoutId = window.setTimeout(resolve, 4_000) const timeoutId = window.setTimeout(() => resolve(false), 4_000)
navigator.serviceWorker.addEventListener( navigator.serviceWorker.addEventListener(
'controllerchange', 'controllerchange',
() => { () => {
window.clearTimeout(timeoutId) window.clearTimeout(timeoutId)
resolve() const nextController = navigator.serviceWorker.controller?.scriptURL ?? null
resolve(nextController !== null && nextController !== currentController)
}, },
{ once: true } { once: true }
) )
}) })
return true
} }
/** /**
@@ -205,8 +206,11 @@ export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
return false return false
} }
markColdStartUpdateAttempt() const activated = await activateWaitingWorker(waiting)
return 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 registration = await navigator.serviceWorker.getRegistration()
const waiting = registration?.waiting const waiting = registration?.waiting
if (waiting) { if (waiting) {
markColdStartUpdateAttempt() const activated = await activateWaitingWorker(waiting)
await activateWaitingWorker(waiting) if (activated) {
return 'reload' markColdStartUpdateAttempt()
return 'reload'
}
} }
} }
if (!recentlyAttemptedHardRecovery()) { if (!recentlyAttemptedHardRecovery()) {
await forcePwaRecovery() const recovered = await forcePwaRecovery()
return 'recovered' if (recovered) {
return 'recovered'
}
} }
return 'noop' return 'noop'
+16 -2
View File
@@ -1,11 +1,25 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core' import { clientsClaim } from 'workbox-core'
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
import { registerRoute } from 'workbox-routing' import { registerRoute } from 'workbox-routing'
import { NetworkOnly } from 'workbox-strategies' import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
declare let self: ServiceWorkerGlobalScope 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) precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches() cleanupOutdatedCaches()
clientsClaim() clientsClaim()