fix(pwa): Kaltstart nach verpassten Updates stabilisieren
Service Worker übernimmt Updates zuverlässig (SKIP_WAITING, clientsClaim), wartende Versionen werden beim Start angewendet und veraltete Chunks führen nicht mehr zum Hängenbleiben. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,13 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
|
import { markReloadAttempt, recentlyAttemptedReload } from '../services/pwaStartup.js'
|
||||||
|
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
||||||
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||||
const UPDATE_SUPPRESS_MS = 30_000
|
const UPDATE_SUPPRESS_MS = 30_000
|
||||||
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
||||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
||||||
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
|
|
||||||
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
|
|
||||||
|
|
||||||
function isUpdateSuppressed(): boolean {
|
function isUpdateSuppressed(): boolean {
|
||||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||||
@@ -43,6 +42,13 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadForServiceWorkerTakeover(): void {
|
||||||
|
if (recentlyAttemptedReload()) return
|
||||||
|
markReloadAttempt()
|
||||||
|
clearUpdateSuppression()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
export function usePwaUpdate() {
|
export function usePwaUpdate() {
|
||||||
const cleanupRef = useRef<(() => void) | null>(null)
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
@@ -52,14 +58,7 @@ export function usePwaUpdate() {
|
|||||||
} = useRegisterSW({
|
} = useRegisterSW({
|
||||||
immediate: !import.meta.env.DEV,
|
immediate: !import.meta.env.DEV,
|
||||||
onNeedReload() {
|
onNeedReload() {
|
||||||
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
|
reloadForServiceWorkerTakeover()
|
||||||
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
|
|
||||||
clearUpdateSuppression()
|
|
||||||
setNeedRefresh(false)
|
|
||||||
window.location.reload()
|
|
||||||
},
|
},
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
if (isUpdateSuppressed()) return
|
if (isUpdateSuppressed()) return
|
||||||
@@ -96,7 +95,7 @@ export function usePwaUpdate() {
|
|||||||
|
|
||||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
window.location.reload()
|
reloadForServiceWorkerTakeover()
|
||||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import './index.css'
|
|||||||
import './i18n'
|
import './i18n'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||||
|
import {
|
||||||
|
installStaleAssetRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
reconcileServiceWorkerOnStartup
|
||||||
|
} from './services/pwaStartup.ts'
|
||||||
|
|
||||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||||
@@ -35,8 +40,16 @@ function renderBootstrapError(message: string): void {
|
|||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
applyAppearanceToDocument()
|
applyAppearanceToDocument()
|
||||||
|
installStaleAssetRecovery()
|
||||||
await clearDevServiceWorkerCaches()
|
await clearDevServiceWorkerCaches()
|
||||||
|
|
||||||
|
const shouldReloadForWaitingSw = await reconcileServiceWorkerOnStartup()
|
||||||
|
if (shouldReloadForWaitingSw) {
|
||||||
|
markReloadAttempt()
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rootEl = document.getElementById('root')
|
const rootEl = document.getElementById('root')
|
||||||
if (!rootEl) {
|
if (!rootEl) {
|
||||||
throw new Error('Missing #root element')
|
throw new Error('Missing #root element')
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
markReloadAttempt,
|
||||||
|
recentlyAttemptedReload,
|
||||||
|
reconcileServiceWorkerOnStartup
|
||||||
|
} from './pwaStartup.js'
|
||||||
|
|
||||||
|
describe('pwaStartup reload guards', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks repeated reload attempts within the debounce window', () => {
|
||||||
|
expect(recentlyAttemptedReload(10_000)).toBe(false)
|
||||||
|
markReloadAttempt(10_000)
|
||||||
|
expect(recentlyAttemptedReload(12_000)).toBe(true)
|
||||||
|
expect(recentlyAttemptedReload(15_000)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reconcileServiceWorkerOnStartup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false in dev mode', async () => {
|
||||||
|
vi.stubEnv('DEV', true)
|
||||||
|
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when no waiting worker exists', async () => {
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
controller: {},
|
||||||
|
getRegistration: vi.fn().mockResolvedValue({ waiting: null }),
|
||||||
|
addEventListener: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
|
||||||
|
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
|
||||||
|
const RELOAD_DEBOUNCE_MS = 4_000
|
||||||
|
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
|
||||||
|
|
||||||
|
export function recentlyAttemptedReload(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
|
||||||
|
return now - last < RELOAD_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markReloadAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentlyAttemptedColdStartUpdate(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(COLD_START_UPDATE_KEY) || '0')
|
||||||
|
return now - last < COLD_START_UPDATE_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function markColdStartUpdateAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleModuleLoadError(error: unknown): boolean {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
message.includes('Failed to fetch dynamically imported module') ||
|
||||||
|
message.includes('Importing a module script failed') ||
|
||||||
|
message.includes('error loading dynamically imported module')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
|
||||||
|
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
|
||||||
|
*/
|
||||||
|
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
|
||||||
|
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentlyAttemptedColdStartUpdate()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
const waiting = registration?.waiting
|
||||||
|
if (!waiting || !navigator.serviceWorker.controller) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
markColdStartUpdateAttempt()
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeoutId = window.setTimeout(resolve, 4_000)
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
() => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
resolve()
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installStaleAssetRecovery(): void {
|
||||||
|
if (import.meta.env.DEV) return
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
if (!isStaleModuleLoadError(event.reason)) return
|
||||||
|
if (recentlyAttemptedReload()) return
|
||||||
|
|
||||||
|
markReloadAttempt()
|
||||||
|
event.preventDefault()
|
||||||
|
window.location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
import { clientsClaim } from 'workbox-core'
|
||||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope
|
declare let self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
precacheAndRoute(self.__WB_MANIFEST)
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
cleanupOutdatedCaches()
|
cleanupOutdatedCaches()
|
||||||
|
clientsClaim()
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'SKIP_WAITING') {
|
||||||
|
void self.skipWaiting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
interface PushPayload {
|
interface PushPayload {
|
||||||
title?: string
|
title?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user