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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user