feat: prompt-mode PWA update toast instead of autoUpdate reload (#102)
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run

This commit is contained in:
seahaven-openswe[bot] 2026-07-01 21:53:28 -04:00 • committed by GitHub
parent 47a9a1ba89
commit a7ddd79e98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 78 additions and 10 deletions

View file

@ -1,21 +1,42 @@
import { StartClient } from "@tanstack/react-start/client"
import { StrictMode } from "react"
import { StrictMode, useEffect, useState } from "react"
import { hydrateRoot } from "react-dom/client"
import { registerSW } from "virtual:pwa-register"
// autoUpdate: vite-plugin-pwa generates a SW that calls skipWaiting and
// clientsClaim, so a new deploy activates for existing tabs. Open dashboard
// tabs may reload when the service worker takes over; this is acceptable for
// a dev/internal dashboard where silent updates are preferred over stale
// bundles. Skip in dev — the SW precaches production-only build artifacts that
// 404 against the dev server.
if (import.meta.env.PROD) {
registerSW()
import { PwaUpdateToast } from "@/components/PwaUpdateToast"
// Prompt-mode PWA: vite-plugin-pwa still generates a SW that precaches the
// production bundle, but it does not force skipWaiting/clientsClaim. Instead,
// the new SW waits until the user accepts the update, so open dashboard tabs
// are not reloaded mid-session. This protects in-flight agent runs and any
// unsaved text in the compose box. Skip in dev — the SW precaches production-only
// build artifacts that 404 against the dev server.
function PwaUpdateProvider() {
const [needRefresh, setNeedRefresh] = useState(false)
const [updateSW, setUpdateSW] = useState<(() => Promise<void>) | undefined>(undefined)
useEffect(() => {
if (!import.meta.env.PROD) return
const updateSWRef = registerSW({
onNeedRefresh() {
setNeedRefresh(true)
},
})
setUpdateSW(() => updateSWRef)
}, [])
return (
<PwaUpdateToast
needRefresh={needRefresh}
updateServiceWorker={updateSW}
/>
)
}
hydrateRoot(
document,
<StrictMode>
<StartClient />
<PwaUpdateProvider />
</StrictMode>
)

View file

@ -0,0 +1,47 @@
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
interface PwaUpdateToastProps {
needRefresh: boolean
updateServiceWorker: (() => Promise<void>) | undefined
}
export function PwaUpdateToast({
needRefresh,
updateServiceWorker,
}: PwaUpdateToastProps) {
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
if (needRefresh) {
setDismissed(false)
}
}, [needRefresh])
if (!needRefresh || dismissed) {
return null
}
return (
<div className="fixed right-4 bottom-4 z-50 flex max-w-sm items-center gap-3 rounded-lg border border-border bg-card p-3 shadow-lg">
<div className="flex-1 text-xs">
<p className="font-medium">A new version is available.</p>
<p className="text-muted-foreground">Reload to update the dashboard.</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
onClick={() => setDismissed(true)}
aria-label="Dismiss update notice"
>
Later
</Button>
<Button size="xs" onClick={() => void updateServiceWorker?.()}>
Reload
</Button>
</div>
</div>
)
}

View file

@ -173,7 +173,7 @@ const config = defineConfig({
tanstackStart({ spa: { enabled: true } }),
VitePWA({
injectRegister: false,
registerType: "autoUpdate",
registerType: "prompt",
outDir: ".output/public",
devOptions: {
enabled: true,