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:
HeavyGee
2026-06-18 10:15:10 +08:00
committed by GitHub
co-authored by Cursor
parent 78155a9d27
commit 5f27abddd4
12 changed files with 649 additions and 41 deletions
+31 -14
View File
@@ -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>
<AppInner />
<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}