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
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({
immediate: !import.meta.env.DEV,
onNeedReload() {
reloadForServiceWorkerTakeover()
if (isUpdateSuppressed()) return
applyNeedRefresh(true)
},
onNeedRefresh() {
if (isUpdateSuppressed()) return
-3
View File
@@ -50,9 +50,6 @@ async function bootstrap(): Promise<void> {
window.location.reload()
return
}
if (startupResult === 'recovered') {
return
}
const rootEl = document.getElementById('root')
if (!rootEl) {
+34
View File
@@ -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', () => {
+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.
* Equivalent to manually clearing site data / reinstalling the PWA.
*/
export async function forcePwaRecovery(): Promise<void> {
if (recentlyAttemptedHardRecovery()) return
export async function forcePwaRecovery(): Promise<boolean> {
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<boo
}
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
const currentController = navigator.serviceWorker.controller?.scriptURL ?? null
waiting.postMessage({ type: 'SKIP_WAITING' })
await new Promise<void>((resolve) => {
const timeoutId = window.setTimeout(resolve, 4_000)
return new Promise<boolean>((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<boolean> {
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'
+16 -2
View File
@@ -1,11 +1,25 @@
/// <reference lib="webworker" />
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()