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({
|
} = 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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activated = await activateWaitingWorker(waiting)
|
||||||
|
if (activated) {
|
||||||
markColdStartUpdateAttempt()
|
markColdStartUpdateAttempt()
|
||||||
return activateWaitingWorker(waiting)
|
}
|
||||||
|
return activated
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -233,16 +237,20 @@ 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) {
|
||||||
|
const activated = await activateWaitingWorker(waiting)
|
||||||
|
if (activated) {
|
||||||
markColdStartUpdateAttempt()
|
markColdStartUpdateAttempt()
|
||||||
await activateWaitingWorker(waiting)
|
|
||||||
return 'reload'
|
return 'reload'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!recentlyAttemptedHardRecovery()) {
|
if (!recentlyAttemptedHardRecovery()) {
|
||||||
await forcePwaRecovery()
|
const recovered = await forcePwaRecovery()
|
||||||
|
if (recovered) {
|
||||||
return 'recovered'
|
return 'recovered'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'noop'
|
return 'noop'
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user