fix(pwa): Updates zuverlässiger erkennen und veraltete Instanzen automatisch reparieren
Unabhängige version.json-Prüfung, häufigere Update-Checks und Hard Recovery beheben hängende Android-PWAs ohne manuelles Cache-Löschen. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ server {
|
|||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
# Service worker and app shell must revalidate so PWA updates are detected
|
# Service worker and app shell must revalidate so PWA updates are detected
|
||||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
|
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
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'
|
import {
|
||||||
|
forcePwaRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
recentlyAttemptedReload,
|
||||||
|
triggerServiceWorkerUpdate
|
||||||
|
} from '../services/pwaStartup.js'
|
||||||
|
import { isDeployedVersionNewer } from '../services/pwaVersion.js'
|
||||||
|
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
const UPDATE_CHECK_INTERVAL_MS = 15 * 60 * 1000
|
||||||
|
const VERSION_CHECK_INTERVAL_MS = 10 * 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 = 15 * 60 * 1000
|
||||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
const UPDATE_RELOAD_FALLBACK_MS = 2_000
|
||||||
|
const UPDATE_HARD_RECOVERY_MS = 5_000
|
||||||
|
|
||||||
function isUpdateSuppressed(): boolean {
|
function isUpdateSuppressed(): boolean {
|
||||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||||
@@ -21,10 +29,16 @@ function clearUpdateSuppression(): void {
|
|||||||
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
function scheduleUpdateChecks(
|
||||||
|
registration: ServiceWorkerRegistration,
|
||||||
|
onOutdated: () => void
|
||||||
|
): () => void {
|
||||||
const checkForUpdate = () => {
|
const checkForUpdate = () => {
|
||||||
if (isUpdateSuppressed()) return
|
if (isUpdateSuppressed()) return
|
||||||
registration.update().catch(() => {})
|
registration.update().catch(() => {})
|
||||||
|
void isDeployedVersionNewer().then((outdated) => {
|
||||||
|
if (outdated) onOutdated()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVisibilityChange = () => {
|
const onVisibilityChange = () => {
|
||||||
@@ -33,12 +47,28 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onOnline = () => {
|
||||||
|
checkForUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
window.addEventListener('online', onOnline)
|
||||||
|
const swIntervalId = window.setInterval(() => {
|
||||||
|
registration.update().catch(() => {})
|
||||||
|
}, UPDATE_CHECK_INTERVAL_MS)
|
||||||
|
const versionIntervalId = window.setInterval(() => {
|
||||||
|
void isDeployedVersionNewer().then((outdated) => {
|
||||||
|
if (outdated) onOutdated()
|
||||||
|
})
|
||||||
|
}, VERSION_CHECK_INTERVAL_MS)
|
||||||
|
|
||||||
|
checkForUpdate()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
window.clearInterval(intervalId)
|
window.removeEventListener('online', onOnline)
|
||||||
|
window.clearInterval(swIntervalId)
|
||||||
|
window.clearInterval(versionIntervalId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +81,8 @@ function reloadForServiceWorkerTakeover(): void {
|
|||||||
|
|
||||||
export function usePwaUpdate() {
|
export function usePwaUpdate() {
|
||||||
const cleanupRef = useRef<(() => void) | null>(null)
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
const hardRecoveryTimerRef = useRef<number | null>(null)
|
||||||
|
const setNeedRefreshRef = useRef<(value: boolean) => void>(() => {})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
needRefresh: [needRefresh, setNeedRefresh],
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
@@ -62,28 +94,42 @@ export function usePwaUpdate() {
|
|||||||
},
|
},
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
if (isUpdateSuppressed()) return
|
if (isUpdateSuppressed()) return
|
||||||
setNeedRefresh(true)
|
setNeedRefreshRef.current(true)
|
||||||
},
|
},
|
||||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||||
if (!registration) return
|
if (!registration) return
|
||||||
|
|
||||||
if (isUpdateSuppressed() || !registration.waiting) {
|
if (isUpdateSuppressed() || !registration.waiting) {
|
||||||
setNeedRefresh(false)
|
setNeedRefreshRef.current(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
cleanupRef.current = scheduleUpdateChecks(registration, () => {
|
||||||
|
setNeedRefreshRef.current(true)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setNeedRefreshRef.current = setNeedRefresh
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUpdateSuppressed()) {
|
if (isUpdateSuppressed()) {
|
||||||
setNeedRefresh(false)
|
setNeedRefresh(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void isDeployedVersionNewer().then((outdated) => {
|
||||||
|
if (outdated) {
|
||||||
|
setNeedRefresh(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = null
|
cleanupRef.current = null
|
||||||
|
if (hardRecoveryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(hardRecoveryTimerRef.current)
|
||||||
|
hardRecoveryTimerRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [setNeedRefresh])
|
}, [setNeedRefresh])
|
||||||
|
|
||||||
@@ -92,11 +138,15 @@ export function usePwaUpdate() {
|
|||||||
suppressUpdatePrompt()
|
suppressUpdatePrompt()
|
||||||
|
|
||||||
await updateServiceWorker(true)
|
await updateServiceWorker(true)
|
||||||
|
await triggerServiceWorkerUpdate()
|
||||||
|
|
||||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
hardRecoveryTimerRef.current = window.setTimeout(() => {
|
||||||
window.setTimeout(() => {
|
|
||||||
reloadForServiceWorkerTakeover()
|
reloadForServiceWorkerTakeover()
|
||||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
void forcePwaRecovery()
|
||||||
|
}, UPDATE_HARD_RECOVERY_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dismissUpdate = () => {
|
const dismissUpdate = () => {
|
||||||
|
|||||||
+6
-3
@@ -9,7 +9,7 @@ import { applyAppearanceToDocument } from './services/appearance.ts'
|
|||||||
import {
|
import {
|
||||||
installStaleAssetRecovery,
|
installStaleAssetRecovery,
|
||||||
markReloadAttempt,
|
markReloadAttempt,
|
||||||
reconcileServiceWorkerOnStartup
|
reconcileVersionOnStartup
|
||||||
} from './services/pwaStartup.ts'
|
} from './services/pwaStartup.ts'
|
||||||
|
|
||||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||||
@@ -43,12 +43,15 @@ async function bootstrap(): Promise<void> {
|
|||||||
installStaleAssetRecovery()
|
installStaleAssetRecovery()
|
||||||
await clearDevServiceWorkerCaches()
|
await clearDevServiceWorkerCaches()
|
||||||
|
|
||||||
const shouldReloadForWaitingSw = await reconcileServiceWorkerOnStartup()
|
const startupResult = await reconcileVersionOnStartup()
|
||||||
if (shouldReloadForWaitingSw) {
|
if (startupResult === 'reload') {
|
||||||
markReloadAttempt()
|
markReloadAttempt()
|
||||||
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) {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
markReloadAttempt,
|
markReloadAttempt,
|
||||||
recentlyAttemptedReload,
|
recentlyAttemptedReload,
|
||||||
reconcileServiceWorkerOnStartup
|
reconcileServiceWorkerOnStartup,
|
||||||
|
reconcileVersionOnStartup
|
||||||
} from './pwaStartup.js'
|
} from './pwaStartup.js'
|
||||||
|
|
||||||
describe('pwaStartup reload guards', () => {
|
describe('pwaStartup reload guards', () => {
|
||||||
@@ -35,7 +36,12 @@ describe('reconcileServiceWorkerOnStartup', () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
controller: {},
|
controller: {},
|
||||||
getRegistration: vi.fn().mockResolvedValue({ waiting: null }),
|
getRegistration: vi.fn().mockResolvedValue({
|
||||||
|
waiting: null,
|
||||||
|
installing: null,
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addEventListener: vi.fn()
|
||||||
|
}),
|
||||||
addEventListener: vi.fn()
|
addEventListener: vi.fn()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -43,3 +49,27 @@ describe('reconcileServiceWorkerOnStartup', () => {
|
|||||||
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('reconcileVersionOnStartup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns noop in dev mode', async () => {
|
||||||
|
vi.stubEnv('DEV', true)
|
||||||
|
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns noop when deployed version matches bundled version', async () => {
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ version: '0.1.0.57' })
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('__APP_VERSION__', '0.1.0.57')
|
||||||
|
|
||||||
|
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import { isNewerAppVersion, fetchDeployedVersion, getAppVersion } from './pwaVersion.js'
|
||||||
|
|
||||||
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
|
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
|
||||||
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
|
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
|
||||||
|
const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts'
|
||||||
|
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||||
const RELOAD_DEBOUNCE_MS = 4_000
|
const RELOAD_DEBOUNCE_MS = 4_000
|
||||||
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
|
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
|
||||||
|
const HARD_RECOVERY_DEBOUNCE_MS = 30_000
|
||||||
|
|
||||||
export function recentlyAttemptedReload(now = Date.now()): boolean {
|
export function recentlyAttemptedReload(now = Date.now()): boolean {
|
||||||
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
|
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
|
||||||
@@ -21,6 +26,21 @@ function markColdStartUpdateAttempt(now = Date.now()): void {
|
|||||||
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
|
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recentlyAttemptedHardRecovery(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(HARD_RECOVERY_KEY) || '0')
|
||||||
|
return now - last < HARD_RECOVERY_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function markHardRecoveryAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(HARD_RECOVERY_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementStaleRecoveryCount(): number {
|
||||||
|
const next = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0') + 1
|
||||||
|
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, String(next))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
function isStaleModuleLoadError(error: unknown): boolean {
|
function isStaleModuleLoadError(error: unknown): boolean {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
@@ -32,30 +52,101 @@ function isStaleModuleLoadError(error: unknown): boolean {
|
|||||||
return (
|
return (
|
||||||
message.includes('Failed to fetch dynamically imported module') ||
|
message.includes('Failed to fetch dynamically imported module') ||
|
||||||
message.includes('Importing a module script failed') ||
|
message.includes('Importing a module script failed') ||
|
||||||
message.includes('error loading dynamically imported module')
|
message.includes('error loading dynamically imported module') ||
|
||||||
|
message.includes('Loading chunk') ||
|
||||||
|
message.includes('ChunkLoadError') ||
|
||||||
|
message.includes('Unable to preload CSS')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearPwaCachesAndWorkers(): Promise<void> {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(registrations.map((registration) => registration.unregister()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('caches' in window) {
|
||||||
|
const keys = await caches.keys()
|
||||||
|
await Promise.all(keys.map((key) => caches.delete(key)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
|
* Last-resort recovery when soft reloads cannot escape a stale precache.
|
||||||
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
|
* Equivalent to manually clearing site data / reinstalling the PWA.
|
||||||
*/
|
*/
|
||||||
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
|
export async function forcePwaRecovery(): Promise<void> {
|
||||||
|
if (recentlyAttemptedHardRecovery()) return
|
||||||
|
|
||||||
|
markHardRecoveryAttempt()
|
||||||
|
markReloadAttempt()
|
||||||
|
await clearPwaCachesAndWorkers()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWaitingWorker(
|
||||||
|
registration: ServiceWorkerRegistration,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<ServiceWorker | null> {
|
||||||
|
if (registration.waiting) {
|
||||||
|
return registration.waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeoutId = window.setTimeout(() => resolve(null), timeoutMs)
|
||||||
|
|
||||||
|
const inspectWorker = (worker: ServiceWorker | null) => {
|
||||||
|
if (!worker) return
|
||||||
|
|
||||||
|
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
resolve(worker)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.addEventListener(
|
||||||
|
'statechange',
|
||||||
|
() => {
|
||||||
|
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
resolve(worker)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectWorker(registration.installing)
|
||||||
|
|
||||||
|
registration.addEventListener(
|
||||||
|
'updatefound',
|
||||||
|
() => {
|
||||||
|
inspectWorker(registration.installing)
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise<boolean> {
|
||||||
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recentlyAttemptedColdStartUpdate()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const registration = await navigator.serviceWorker.getRegistration()
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
const waiting = registration?.waiting
|
if (!registration) return false
|
||||||
if (!waiting || !navigator.serviceWorker.controller) {
|
|
||||||
|
try {
|
||||||
|
await registration.update()
|
||||||
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
markColdStartUpdateAttempt()
|
const waiting = await waitForWaitingWorker(registration, timeoutMs)
|
||||||
|
return waiting !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
|
||||||
waiting.postMessage({ type: 'SKIP_WAITING' })
|
waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -73,15 +164,102 @@ export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
let waiting = registration?.waiting ?? null
|
||||||
|
|
||||||
|
if (!waiting && registration) {
|
||||||
|
await registration.update().catch(() => {})
|
||||||
|
waiting = await waitForWaitingWorker(registration, 4_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!waiting || !navigator.serviceWorker.controller) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
markColdStartUpdateAttempt()
|
||||||
|
return activateWaitingWorker(waiting)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare deployed version.json with the bundled app version.
|
||||||
|
* When the server is ahead, try a soft SW takeover before hard recovery.
|
||||||
|
*/
|
||||||
|
export async function reconcileVersionOnStartup(): Promise<'reload' | 'recovered' | 'noop'> {
|
||||||
|
if (import.meta.env.DEV || !navigator.onLine) {
|
||||||
|
return 'noop'
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployedVersion = await fetchDeployedVersion()
|
||||||
|
if (!deployedVersion || !isNewerAppVersion(deployedVersion, getAppVersion())) {
|
||||||
|
return 'noop'
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconciled = await reconcileServiceWorkerOnStartup()
|
||||||
|
if (reconciled) {
|
||||||
|
return 'reload'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await triggerServiceWorkerUpdate()
|
||||||
|
if (updated) {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
const waiting = registration?.waiting
|
||||||
|
if (waiting) {
|
||||||
|
markColdStartUpdateAttempt()
|
||||||
|
await activateWaitingWorker(waiting)
|
||||||
|
return 'reload'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recentlyAttemptedHardRecovery()) {
|
||||||
|
await forcePwaRecovery()
|
||||||
|
return 'recovered'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'noop'
|
||||||
|
}
|
||||||
|
|
||||||
export function installStaleAssetRecovery(): void {
|
export function installStaleAssetRecovery(): void {
|
||||||
if (import.meta.env.DEV) return
|
if (import.meta.env.DEV) return
|
||||||
|
|
||||||
window.addEventListener('unhandledrejection', (event) => {
|
const recoverFromStaleAssets = () => {
|
||||||
if (!isStaleModuleLoadError(event.reason)) return
|
|
||||||
if (recentlyAttemptedReload()) return
|
if (recentlyAttemptedReload()) return
|
||||||
|
|
||||||
|
const attempts = incrementStaleRecoveryCount()
|
||||||
markReloadAttempt()
|
markReloadAttempt()
|
||||||
event.preventDefault()
|
|
||||||
|
if (attempts >= 2) {
|
||||||
|
void forcePwaRecovery()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
if (!isStaleModuleLoadError(event.reason)) return
|
||||||
|
event.preventDefault()
|
||||||
|
recoverFromStaleAssets()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
'error',
|
||||||
|
(event) => {
|
||||||
|
if (!isStaleModuleLoadError(event.message)) return
|
||||||
|
recoverFromStaleAssets()
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
compareAppVersions,
|
||||||
|
isNewerAppVersion,
|
||||||
|
parseAppVersion
|
||||||
|
} from './pwaVersion.js'
|
||||||
|
|
||||||
|
describe('pwaVersion', () => {
|
||||||
|
it('parses semantic build versions', () => {
|
||||||
|
expect(parseAppVersion('v0.1.0.57')).toEqual([0, 1, 0, 57])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compares build numbers numerically', () => {
|
||||||
|
expect(compareAppVersions('0.1.0.65', '0.1.0.57')).toBeGreaterThan(0)
|
||||||
|
expect(compareAppVersions('0.1.0.57', '0.1.0.65')).toBeLessThan(0)
|
||||||
|
expect(compareAppVersions('0.1.0.57', '0.1.0.57')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects newer deployed versions', () => {
|
||||||
|
expect(isNewerAppVersion('0.1.0.66', '0.1.0.57')).toBe(true)
|
||||||
|
expect(isNewerAppVersion('0.1.0.57', '0.1.0.57')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const APP_VERSION =
|
||||||
|
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0.0-dev'
|
||||||
|
|
||||||
|
export function getAppVersion(): string {
|
||||||
|
return APP_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppVersion(version: string): number[] {
|
||||||
|
return version
|
||||||
|
.replace(/^v/i, '')
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number.parseInt(part, 10) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Positive when `a` is newer than `b`. */
|
||||||
|
export function compareAppVersions(a: string, b: string): number {
|
||||||
|
const partsA = parseAppVersion(a)
|
||||||
|
const partsB = parseAppVersion(b)
|
||||||
|
const length = Math.max(partsA.length, partsB.length)
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const diff = (partsA[index] ?? 0) - (partsB[index] ?? 0)
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNewerAppVersion(serverVersion: string, clientVersion: string): boolean {
|
||||||
|
return compareAppVersions(serverVersion, clientVersion) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDeployedVersion(timeoutMs = 4_000): Promise<string | null> {
|
||||||
|
if (!navigator.onLine) return null
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/version.json?_=${Date.now()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
if (!response.ok) return null
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { version?: unknown }
|
||||||
|
return typeof payload.version === 'string' ? payload.version.trim() : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isDeployedVersionNewer(): Promise<boolean> {
|
||||||
|
const deployedVersion = await fetchDeployedVersion()
|
||||||
|
if (!deployedVersion) return false
|
||||||
|
return isNewerAppVersion(deployedVersion, getAppVersion())
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import { clientsClaim } from 'workbox-core'
|
import { clientsClaim } from 'workbox-core'
|
||||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||||
|
import { registerRoute } from 'workbox-routing'
|
||||||
|
import { NetworkOnly } from 'workbox-strategies'
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope
|
declare let self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
@@ -8,6 +10,9 @@ precacheAndRoute(self.__WB_MANIFEST)
|
|||||||
cleanupOutdatedCaches()
|
cleanupOutdatedCaches()
|
||||||
clientsClaim()
|
clientsClaim()
|
||||||
|
|
||||||
|
// Always fetch the live deploy version, even under an older precache.
|
||||||
|
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
|
||||||
|
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'SKIP_WAITING') {
|
if (event.data?.type === 'SKIP_WAITING') {
|
||||||
void self.skipWaiting()
|
void self.skipWaiting()
|
||||||
|
|||||||
+16
-1
@@ -2,9 +2,10 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { resolve, dirname } from 'node:path'
|
import { resolve, dirname } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
@@ -19,6 +20,19 @@ function readAppVersion(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function versionJsonPlugin(version: string): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'version-json',
|
||||||
|
writeBundle(options) {
|
||||||
|
const outDir = options.dir ?? resolve(__dirname, 'dist')
|
||||||
|
writeFileSync(
|
||||||
|
resolve(outDir, 'version.json'),
|
||||||
|
`${JSON.stringify({ version }, null, 2)}\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -42,6 +56,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
versionJsonPlugin(readAppVersion()),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
|
|||||||
Reference in New Issue
Block a user