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
+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'