mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +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:
+7
-4
@@ -56,11 +56,14 @@ An offline indicator appears when you lose connection.
|
||||
|
||||
### Auto-Update
|
||||
|
||||
HAPI automatically checks for updates:
|
||||
HAPI checks for updates in the background and lets you choose when to reload:
|
||||
|
||||
- Updates are checked hourly in the background
|
||||
- When a new version is available, you'll see a prompt
|
||||
- Click "Reload" to get the latest version
|
||||
- Updates are checked hourly and when you return to the tab
|
||||
- When a new version is available, a persistent in-app banner appears at the top
|
||||
- Tap **Reload** when you're ready to apply the update — the banner stays until you do
|
||||
- Expand **"Why can't I dismiss this?"** on the banner for the rationale
|
||||
|
||||
HAPI uses a user-controlled reload instead of forcing an automatic refresh, so you choose when to reload. The banner cannot be dismissed without upgrading, so you won't forget you're on an old build.
|
||||
|
||||
### Background Sync
|
||||
|
||||
|
||||
+30
-13
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram'
|
||||
@@ -23,11 +23,13 @@ import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from '@/lib
|
||||
import { LoginPrompt } from '@/components/LoginPrompt'
|
||||
import { InstallPrompt } from '@/components/InstallPrompt'
|
||||
import { OfflineBanner } from '@/components/OfflineBanner'
|
||||
import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/PwaUpdateBanner'
|
||||
import { SyncingBanner } from '@/components/SyncingBanner'
|
||||
import { ReconnectingBanner } from '@/components/ReconnectingBanner'
|
||||
import { VoiceErrorBanner } from '@/components/VoiceErrorBanner'
|
||||
import { LoadingState } from '@/components/LoadingState'
|
||||
import { ToastContainer } from '@/components/ToastContainer'
|
||||
import { PwaUpdateProvider } from '@/lib/pwa-update-context'
|
||||
import { ToastProvider, useToast } from '@/lib/toast-context'
|
||||
import type { SyncEvent } from '@/types/api'
|
||||
|
||||
@@ -35,10 +37,21 @@ type ToastEvent = Extract<SyncEvent, { type: 'toast' }>
|
||||
|
||||
const REQUIRE_SERVER_URL = requireHubUrlForLogin()
|
||||
|
||||
function withPwaBanner(content: ReactNode) {
|
||||
return (
|
||||
<>
|
||||
<PwaUpdateBanner />
|
||||
{content}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<PwaUpdateProvider>
|
||||
<AppInner />
|
||||
</PwaUpdateProvider>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -341,16 +354,16 @@ function AppInner() {
|
||||
|
||||
// Loading auth source
|
||||
if (isAuthSourceLoading) {
|
||||
return (
|
||||
return withPwaBanner(
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<LoadingState label={t('loading')} className="text-sm" />
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
// No auth source (browser environment, not logged in)
|
||||
if (!authSource) {
|
||||
return (
|
||||
return withPwaBanner(
|
||||
<LoginPrompt
|
||||
onLogin={setAccessToken}
|
||||
baseUrl={baseUrl}
|
||||
@@ -358,12 +371,12 @@ function AppInner() {
|
||||
setServerUrl={setServerUrl}
|
||||
clearServerUrl={clearServerUrl}
|
||||
requireServerUrl={REQUIRE_SERVER_URL}
|
||||
/>
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
if (needsBinding) {
|
||||
return (
|
||||
return withPwaBanner(
|
||||
<LoginPrompt
|
||||
mode="bind"
|
||||
onBind={bind}
|
||||
@@ -373,16 +386,16 @@ function AppInner() {
|
||||
clearServerUrl={clearServerUrl}
|
||||
requireServerUrl={REQUIRE_SERVER_URL}
|
||||
error={authError ?? undefined}
|
||||
/>
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// Authenticating (also covers the gap before useAuth effect starts)
|
||||
if (isAuthLoading || (authSource && !token && !authError)) {
|
||||
return (
|
||||
return withPwaBanner(
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<LoadingState label={t('authorizing')} className="text-sm" />
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -390,7 +403,7 @@ function AppInner() {
|
||||
if (authError || !token || !api) {
|
||||
// If using access token and auth failed, show login again
|
||||
if (authSource.type === 'accessToken') {
|
||||
return (
|
||||
return withPwaBanner(
|
||||
<LoginPrompt
|
||||
onLogin={setAccessToken}
|
||||
baseUrl={baseUrl}
|
||||
@@ -399,12 +412,12 @@ function AppInner() {
|
||||
clearServerUrl={clearServerUrl}
|
||||
requireServerUrl={REQUIRE_SERVER_URL}
|
||||
error={authError ?? t('login.error.authFailed')}
|
||||
/>
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// Telegram auth failed
|
||||
return (
|
||||
return withPwaBanner(
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="text-base font-semibold">{t('login.title')}</div>
|
||||
<div className="text-sm text-red-600">
|
||||
@@ -413,13 +426,17 @@ function AppInner() {
|
||||
<div className="text-xs text-[var(--app-hint)]">
|
||||
Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContextProvider value={{ api, token, baseUrl }}>
|
||||
<VoiceProvider>
|
||||
<PwaUpdateBannerWithStatusOffset
|
||||
isSyncing={isSyncing}
|
||||
isReconnecting={sseDisconnected && !isSyncing}
|
||||
/>
|
||||
<SyncingBanner isSyncing={isSyncing} />
|
||||
<ReconnectingBanner
|
||||
isReconnecting={sseDisconnected && !isSyncing}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/PwaUpdateBanner'
|
||||
|
||||
const usePwaUpdateMock = vi.fn()
|
||||
const useVoiceOptionalMock = vi.fn()
|
||||
|
||||
vi.mock('@/lib/pwa-update-context', () => ({
|
||||
usePwaUpdateContext: () => usePwaUpdateMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/voice-context', () => ({
|
||||
useVoiceOptional: () => useVoiceOptionalMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useOnlineStatus', () => ({
|
||||
useOnlineStatus: () => true,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/usePlatform', () => ({
|
||||
usePlatform: () => ({
|
||||
haptic: {
|
||||
impact: vi.fn(),
|
||||
notification: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
function renderBanner() {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<PwaUpdateBanner />
|
||||
</I18nProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('PwaUpdateBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useVoiceOptionalMock.mockReturnValue(null)
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: vi.fn(() => 'en'),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
key: vi.fn(() => null),
|
||||
length: 0,
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('does not render when no update is available', () => {
|
||||
usePwaUpdateMock.mockReturnValue({
|
||||
needRefresh: false,
|
||||
reload: vi.fn(),
|
||||
})
|
||||
|
||||
renderBanner()
|
||||
|
||||
expect(screen.queryByTestId('pwa-update-banner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a reload-only banner with no dismiss action', () => {
|
||||
const reload = vi.fn()
|
||||
|
||||
usePwaUpdateMock.mockReturnValue({
|
||||
needRefresh: true,
|
||||
reload,
|
||||
})
|
||||
|
||||
renderBanner()
|
||||
|
||||
expect(screen.getByTestId('pwa-update-banner')).toBeInTheDocument()
|
||||
expect(screen.getByText('New version available')).toBeInTheDocument()
|
||||
expect(screen.getByText('Reload to get the latest HAPI')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload' }))
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('honors a custom top offset when provided', () => {
|
||||
usePwaUpdateMock.mockReturnValue({
|
||||
needRefresh: true,
|
||||
reload: vi.fn(),
|
||||
})
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<PwaUpdateBanner topClassName="top-12" />
|
||||
</I18nProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('pwa-update-banner')).toHaveClass('top-12')
|
||||
})
|
||||
|
||||
it('offsets below voice error banners when shown inside the voice provider', () => {
|
||||
usePwaUpdateMock.mockReturnValue({
|
||||
needRefresh: true,
|
||||
reload: vi.fn(),
|
||||
})
|
||||
useVoiceOptionalMock.mockReturnValue({
|
||||
status: 'error',
|
||||
errorMessage: 'Mic failed',
|
||||
})
|
||||
|
||||
render(
|
||||
<I18nProvider>
|
||||
<PwaUpdateBannerWithStatusOffset isSyncing={false} isReconnecting={false} />
|
||||
</I18nProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('pwa-update-banner')).toHaveClass('top-12')
|
||||
})
|
||||
|
||||
it('expands the rationale section when the disclosure is opened', () => {
|
||||
usePwaUpdateMock.mockReturnValue({
|
||||
needRefresh: true,
|
||||
reload: vi.fn(),
|
||||
})
|
||||
|
||||
renderBanner()
|
||||
|
||||
const disclosure = screen.getByText("Why can't I dismiss this?")
|
||||
expect(screen.queryByText(/agent running/i)).not.toBeVisible()
|
||||
|
||||
fireEvent.click(disclosure)
|
||||
|
||||
expect(screen.getByText(/agent running/i)).toBeVisible()
|
||||
expect(screen.getByText(/finish what you are doing first/i)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useOnlineStatus } from '@/hooks/useOnlineStatus'
|
||||
import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { usePwaUpdateContext } from '@/lib/pwa-update-context'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import { useVoiceOptional } from '@/lib/voice-context'
|
||||
|
||||
export function PwaUpdateBanner({ topClassName }: { topClassName?: string } = {}) {
|
||||
const { t } = useTranslation()
|
||||
const { needRefresh, reload } = usePwaUpdateContext()
|
||||
const isOnline = useOnlineStatus()
|
||||
const { haptic } = usePlatform()
|
||||
|
||||
if (!needRefresh) {
|
||||
return null
|
||||
}
|
||||
|
||||
const topClass = topClassName ?? (isOnline ? 'top-2' : 'top-10')
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="pwa-update-banner"
|
||||
className={`fixed left-4 right-4 bg-[var(--app-secondary-bg)] border border-[var(--app-border)] rounded-lg p-4 shadow-lg z-50 ${topClass}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--app-fg)]">
|
||||
{t('pwa.update.title')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--app-hint)] mt-0.5">
|
||||
{t('pwa.update.body')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
haptic.impact('light')
|
||||
reload()
|
||||
}}
|
||||
className="shrink-0 px-4 py-2 bg-[var(--app-fg)] text-[var(--app-bg)] rounded-lg text-sm font-medium active:opacity-80"
|
||||
>
|
||||
{t('pwa.update.reload')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details className="mt-3 border-t border-[var(--app-border)] pt-2">
|
||||
<summary className="cursor-pointer text-xs text-[var(--app-link)] active:opacity-60 list-none [&::-webkit-details-marker]:hidden">
|
||||
{t('pwa.update.whyToggle')}
|
||||
</summary>
|
||||
<p className="mt-2 text-xs text-[var(--app-hint)] leading-relaxed">
|
||||
{t('pwa.update.whyBody')}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PwaUpdateBannerWithStatusOffset({
|
||||
isSyncing,
|
||||
isReconnecting,
|
||||
}: {
|
||||
isSyncing: boolean
|
||||
isReconnecting: boolean
|
||||
}) {
|
||||
const voice = useVoiceOptional()
|
||||
const hasTopStatusBanner =
|
||||
isSyncing ||
|
||||
isReconnecting ||
|
||||
Boolean(voice && voice.status === 'error' && voice.errorMessage)
|
||||
|
||||
return (
|
||||
<PwaUpdateBanner topClassName={hasTopStatusBanner ? 'top-12' : undefined} />
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -466,6 +466,11 @@ export default {
|
||||
'reconnecting.reason.closed': 'stream closed',
|
||||
'reconnecting.reason.heartbeatTimeout': 'heartbeat timeout',
|
||||
'reconnecting.reason.visibilityRecovery': 'resuming after background',
|
||||
'pwa.update.title': 'New version available',
|
||||
'pwa.update.body': 'Reload to get the latest HAPI',
|
||||
'pwa.update.reload': 'Reload',
|
||||
'pwa.update.whyToggle': "Why can't I dismiss this?",
|
||||
'pwa.update.whyBody': 'HAPI will not reload your tab automatically while you may have an agent running, a permission waiting, or a message in progress. Running an old web build against the current server can cause sync bugs and failed actions. This banner stays visible until you reload so you are not stuck on a stale version by accident — but you choose when to tap Reload and finish what you are doing first.',
|
||||
|
||||
// Send blocked
|
||||
'send.blocked.title': 'Cannot send message',
|
||||
|
||||
@@ -470,6 +470,11 @@ export default {
|
||||
'reconnecting.reason.closed': '流连接已关闭',
|
||||
'reconnecting.reason.heartbeatTimeout': '心跳超时',
|
||||
'reconnecting.reason.visibilityRecovery': '后台恢复中',
|
||||
'pwa.update.title': '新版本可用',
|
||||
'pwa.update.body': '重新加载以获取最新版 HAPI',
|
||||
'pwa.update.reload': '重新加载',
|
||||
'pwa.update.whyToggle': '为什么不能关闭此提示?',
|
||||
'pwa.update.whyBody': '当你可能有正在运行的智能体、待处理的权限请求或未发送的消息时,HAPI 不会自动重新加载标签页。旧版网页与当前服务器一起运行可能导致同步错误和操作失败。此横幅会一直保持显示,直到你重新加载,以免你在不知情的情况下停留在旧版本 — 但何时点击「重新加载」由你决定,可以先完成手头的工作。',
|
||||
|
||||
// Send blocked
|
||||
'send.blocked.title': '无法发送消息',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import { usePwaUpdate } from '@/hooks/usePwaUpdate'
|
||||
|
||||
type PwaUpdateContextValue = ReturnType<typeof usePwaUpdate>
|
||||
|
||||
const PwaUpdateContext = createContext<PwaUpdateContextValue | null>(null)
|
||||
|
||||
export function PwaUpdateProvider({ children }: { children: ReactNode }) {
|
||||
const value = usePwaUpdate()
|
||||
|
||||
return (
|
||||
<PwaUpdateContext.Provider value={value}>
|
||||
{children}
|
||||
</PwaUpdateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePwaUpdateContext() {
|
||||
const value = useContext(PwaUpdateContext)
|
||||
if (!value) {
|
||||
throw new Error('usePwaUpdateContext must be used within PwaUpdateProvider')
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { RouterProvider, createMemoryHistory } from '@tanstack/react-router'
|
||||
import './index.css'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { initializeFontScale } from '@/hooks/useFontScale'
|
||||
import { getTelegramWebApp, isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram'
|
||||
import { queryClient } from './lib/query-client'
|
||||
@@ -52,27 +51,6 @@ async function bootstrap() {
|
||||
restoreSpaRedirect()
|
||||
}
|
||||
|
||||
const updateSW = registerSW({
|
||||
onNeedRefresh() {
|
||||
if (confirm('New version available! Reload to update?')) {
|
||||
updateSW(true)
|
||||
}
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('App ready for offline use')
|
||||
},
|
||||
onRegistered(registration) {
|
||||
if (registration) {
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000)
|
||||
}
|
||||
},
|
||||
onRegisterError(error) {
|
||||
console.error('SW registration error:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const history = isTelegram
|
||||
? createMemoryHistory({ initialEntries: [getInitialPath()] })
|
||||
: undefined
|
||||
|
||||
@@ -91,6 +91,16 @@ registerRoute(
|
||||
})
|
||||
)
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting()
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const payload = event.data?.json() as PushPayload | undefined
|
||||
if (!payload) {
|
||||
|
||||
+2
-1
@@ -65,7 +65,8 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
// User-controlled reload avoids mid-session surprise reloads (autoUpdate reloads all tabs).
|
||||
registerType: 'prompt',
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'],
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
|
||||
Reference in New Issue
Block a user