mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-08 07:17:39 +00:00
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>
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
PWA_UPDATE_CHECK_INTERVAL_MS,
|
||||
PWA_UPDATE_RELOAD_FALLBACK_MS,
|
||||
requestPwaUpdateReload,
|
||||
setupRegistrationUpdateChecks,
|
||||
usePwaUpdate,
|
||||
} from '@/hooks/usePwaUpdate'
|
||||
|
||||
const registerSWMock = vi.fn()
|
||||
const serviceWorkerListeners = new Map<string, Set<EventListener>>()
|
||||
|
||||
vi.mock('virtual:pwa-register', () => ({
|
||||
registerSW: (options: Parameters<typeof registerSWMock>[0]) => registerSWMock(options),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
serviceWorkerListeners.clear()
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: (type: string, listener: EventListener) => {
|
||||
const bucket = serviceWorkerListeners.get(type) ?? new Set<EventListener>()
|
||||
bucket.add(listener)
|
||||
serviceWorkerListeners.set(type, bucket)
|
||||
},
|
||||
removeEventListener: (type: string, listener: EventListener) => {
|
||||
serviceWorkerListeners.get(type)?.delete(listener)
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupRegistrationUpdateChecks', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('checks for updates on an hourly interval', () => {
|
||||
const registration = {
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ServiceWorkerRegistration
|
||||
|
||||
const cleanup = setupRegistrationUpdateChecks(registration)
|
||||
|
||||
vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS)
|
||||
expect(registration.update).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS)
|
||||
expect(registration.update).toHaveBeenCalledTimes(2)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('checks for updates when the tab becomes visible', () => {
|
||||
const registration = {
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ServiceWorkerRegistration
|
||||
|
||||
const cleanup = setupRegistrationUpdateChecks(registration)
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'hidden',
|
||||
})
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(registration.update).not.toHaveBeenCalled()
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'visible',
|
||||
})
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(registration.update).toHaveBeenCalledTimes(1)
|
||||
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('removes listeners and clears the interval on cleanup', () => {
|
||||
const registration = {
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ServiceWorkerRegistration
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
|
||||
const clearIntervalSpy = vi.spyOn(window, 'clearInterval')
|
||||
|
||||
const cleanup = setupRegistrationUpdateChecks(registration)
|
||||
cleanup()
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
|
||||
expect(clearIntervalSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestPwaUpdateReload', () => {
|
||||
it('reloads immediately when updateSW is unavailable', async () => {
|
||||
const reloadPage = vi.fn()
|
||||
|
||||
await requestPwaUpdateReload(null, { reloadPage })
|
||||
|
||||
expect(reloadPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls updateSW and reloads on controllerchange', async () => {
|
||||
const updateSW = vi.fn().mockImplementation(async () => {
|
||||
for (const listener of serviceWorkerListeners.get('controllerchange') ?? []) {
|
||||
listener(new Event('controllerchange'))
|
||||
}
|
||||
})
|
||||
const reloadPage = vi.fn()
|
||||
|
||||
await requestPwaUpdateReload(updateSW, { reloadPage })
|
||||
|
||||
expect(updateSW).toHaveBeenCalledWith(true)
|
||||
expect(reloadPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to reload when controllerchange never fires', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const updateSW = vi.fn().mockResolvedValue(undefined)
|
||||
const reloadPage = vi.fn()
|
||||
|
||||
const pending = requestPwaUpdateReload(updateSW, {
|
||||
reloadPage,
|
||||
setTimeoutFn: vi.fn((callback, delay) => {
|
||||
expect(delay).toBe(PWA_UPDATE_RELOAD_FALLBACK_MS)
|
||||
return setTimeout(callback, delay)
|
||||
}) as typeof setTimeout,
|
||||
})
|
||||
|
||||
await pending
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(updateSW).toHaveBeenCalledWith(true)
|
||||
expect(reloadPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePwaUpdate', () => {
|
||||
let capturedOptions: {
|
||||
onNeedRefresh?: () => void
|
||||
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void
|
||||
} = {}
|
||||
const updateSW = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
beforeEach(() => {
|
||||
capturedOptions = {}
|
||||
updateSW.mockClear()
|
||||
registerSWMock.mockImplementation((options) => {
|
||||
capturedOptions = options
|
||||
return updateSW
|
||||
})
|
||||
})
|
||||
|
||||
it('registers the service worker and exposes refresh state', () => {
|
||||
const { result } = renderHook(() => usePwaUpdate())
|
||||
|
||||
expect(registerSWMock).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.needRefresh).toBe(false)
|
||||
|
||||
act(() => {
|
||||
capturedOptions.onNeedRefresh?.()
|
||||
})
|
||||
|
||||
expect(result.current.needRefresh).toBe(true)
|
||||
})
|
||||
|
||||
it('reloads through updateSW when reload is called', async () => {
|
||||
const updateSW = vi.fn().mockImplementation(async () => {
|
||||
for (const listener of serviceWorkerListeners.get('controllerchange') ?? []) {
|
||||
listener(new Event('controllerchange'))
|
||||
}
|
||||
})
|
||||
registerSWMock.mockImplementation((options) => {
|
||||
capturedOptions = options
|
||||
return updateSW
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePwaUpdate())
|
||||
|
||||
await act(async () => {
|
||||
result.current.reload()
|
||||
})
|
||||
|
||||
expect(updateSW).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('keeps needRefresh true until a successful reload clears the page', () => {
|
||||
const { result } = renderHook(() => usePwaUpdate())
|
||||
|
||||
act(() => {
|
||||
capturedOptions.onNeedRefresh?.()
|
||||
})
|
||||
|
||||
expect(result.current.needRefresh).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.reload()
|
||||
})
|
||||
|
||||
expect(updateSW).toHaveBeenCalledWith(true)
|
||||
expect(result.current.needRefresh).toBe(true)
|
||||
})
|
||||
|
||||
it('wires registration update checks from onRegistered', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const registration = {
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ServiceWorkerRegistration
|
||||
|
||||
renderHook(() => usePwaUpdate())
|
||||
|
||||
act(() => {
|
||||
capturedOptions.onRegistered?.(registration)
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS)
|
||||
expect(registration.update).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user