Files
hapi/web/src/hooks/usePwaUpdate.ts
T
5f27abddd4 feat(web): in-app PWA update prompt when new service worker is available (#946)
* feat(web): in-app PWA update prompt when new service worker is available (closes #938)

User-controlled reload with a persistent banner, visibility-triggered SW
checks, and an expandable rationale. Switches registerType to prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align vite.config with soup layers for clean driver merge

Keeps registerType prompt while matching garden IWER stubs and PWA
share_target shape expected by feat/pwa-share-target in the manifest.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "fix(web): align vite.config with soup layers for clean driver merge"

This reverts commit 6f0915b0884d029a2413d8819a4dfe81d7c4e595.

* fix(web): make PWA reload apply waiting service worker updates

Handle SKIP_WAITING in injectManifest sw.ts and reload via controllerchange
with a timed fallback when vite-plugin-pwa prompt mode does not navigate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): satisfy setTimeout mock typing in PWA reload tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): register PWA service worker before auth gates

Mount PwaUpdateProvider at app root and show the update banner on login
and error screens so registerSW runs for logged-out users too.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): offset PWA update banner below top status banners

Reserve top-12 when syncing or reconnecting so the reload prompt stays
visible above SyncingBanner and ReconnectingBanner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): offset PWA update banner below voice error banner

Use PwaUpdateBannerWithStatusOffset inside VoiceProvider so voice errors
share the same top-12 reservation as sync and reconnect banners.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:15:10 +08:00

124 lines
3.6 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { registerSW } from 'virtual:pwa-register'
export const PWA_UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
export const PWA_UPDATE_RELOAD_FALLBACK_MS = 2000
export async function requestPwaUpdateReload(
updateSW: ((reloadPage?: boolean) => Promise<void>) | null | undefined,
options: {
reloadPage?: () => void
setTimeoutFn?: typeof setTimeout
clearTimeoutFn?: typeof clearTimeout
} = {},
): Promise<void> {
const reloadPage = options.reloadPage ?? (() => window.location.reload())
const setTimeoutFn = options.setTimeoutFn ?? setTimeout
const clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout
if (!updateSW) {
reloadPage()
return
}
let reloaded = false
const doReload = () => {
if (reloaded) {
return
}
reloaded = true
reloadPage()
}
const onControllerChange = () => {
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange)
doReload()
}
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange)
let fallbackTimer: ReturnType<typeof setTimeout> | undefined
try {
await updateSW(true)
} catch (error) {
console.error('PWA update failed', error)
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange)
if (fallbackTimer !== undefined) {
clearTimeoutFn(fallbackTimer)
}
doReload()
return
}
fallbackTimer = setTimeoutFn(() => {
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange)
doReload()
}, PWA_UPDATE_RELOAD_FALLBACK_MS)
}
export function setupRegistrationUpdateChecks(
registration: ServiceWorkerRegistration,
): () => void {
const intervalId = window.setInterval(() => {
void registration.update()
}, PWA_UPDATE_CHECK_INTERVAL_MS)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void registration.update()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}
export function usePwaUpdate() {
const [needRefresh, setNeedRefresh] = useState(false)
const updateSWRef = useRef<((reloadPage?: boolean) => Promise<void>) | null>(null)
const cleanupRef = useRef<(() => void) | null>(null)
useEffect(() => {
const updateSW = registerSW({
onNeedRefresh() {
setNeedRefresh(true)
},
onOfflineReady() {
console.log('App ready for offline use')
},
onRegistered(registration) {
cleanupRef.current?.()
cleanupRef.current = null
if (!registration) {
return
}
cleanupRef.current = setupRegistrationUpdateChecks(registration)
},
onRegisterError(error) {
console.error('SW registration error:', error)
},
})
updateSWRef.current = updateSW
return () => {
cleanupRef.current?.()
cleanupRef.current = null
updateSWRef.current = null
}
}, [])
const reload = useCallback(() => {
void requestPwaUpdateReload(updateSWRef.current)
}, [])
return { needRefresh, reload }
}