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:
@@ -91,7 +91,8 @@ export function usePwaUpdate() {
|
||||
} = useRegisterSW({
|
||||
immediate: !import.meta.env.DEV,
|
||||
onNeedReload() {
|
||||
reloadForServiceWorkerTakeover()
|
||||
if (isUpdateSuppressed()) return
|
||||
applyNeedRefresh(true)
|
||||
},
|
||||
onNeedRefresh() {
|
||||
if (isUpdateSuppressed()) return
|
||||
|
||||
@@ -50,9 +50,6 @@ async function bootstrap(): Promise<void> {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
if (startupResult === 'recovered') {
|
||||
return
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user