← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
import { useEffect, useState } from 'react'
import { writeLastUpdateCheckAt } from './local-time.ts'
import { createBusyGate } from './busy.ts'
/**
* Update check as a fixed dismissible toast — never inserts into document flow
* (avoids content layout shift).
*/
export function UpdateBanner() {
const [available, setAvailable] = useState<string | null>(null)
const [dismissed, setDismissed] = useState(false)
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [showBusy, setShowBusy] = useState(false)
const appId = '__PACKAGE_ID__'
function dismissKey(token: string) {
return `${appId}-update-dismissed-${token}`
}
function isDismissed(token: string) {
try {
return localStorage.getItem(dismissKey(token)) !== null
} catch {
return false
}
}
function markDismissed(token: string) {
try {
localStorage.setItem(dismissKey(token), String(Date.now()))
} catch {
/* ignore */
}
}
async function check() {
if (typeof window === 'undefined') return
try {
const root = document.documentElement
const appBase = root.getAttribute('data-app-base') || ''
const runningSha = root.getAttribute('data-running-sha') || ''
const versionPath = `${appBase}/api/version`
const res = await fetch(versionPath, { cache: 'no-store', credentials: 'same-origin' })
const version = res.ok ? ((await res.json()) as { sha?: string; shortSha?: string }) : {}
let waiting: ServiceWorker | null = null
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
for (const reg of regs) {
if (reg.waiting) waiting = reg.waiting
if (reg.update) await reg.update().catch(() => null)
}
}
setWaitingWorker(waiting)
let next: string | null = null
if (waiting) next = 'worker'
else if (runningSha && version.sha && runningSha !== version.sha) {
next = version.shortSha || String(version.sha).slice(0, 7)
}
setAvailable(next)
setDismissed(next ? isDismissed(next) : false)
writeLastUpdateCheckAt(new Date().toISOString(), appId)
} catch {
/* ignore */
}
}
useEffect(() => {
void check()
const onVis = () => {
if (document.visibilityState === 'visible') void check()
}
document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
const show = Boolean(available) && !dismissed
if (!show) {
return <span hidden data-update-idle aria-hidden="true" />
}
const label = available === 'worker' ? 'Update ready' : `Update available (${available})`
const refreshLabel = showBusy || refreshing ? 'Refreshing…' : 'Refresh'
return (
<div
className="pak-toast pak-update-toast"
data-update-toast
data-tone="default"
role="status"
aria-live="polite"
>
<div>
<strong>{label}</strong>
<p className="pak-muted">Refresh when you are ready</p>
</div>
<div className="pak-toast-actions">
<button
className="pak-btn pak-btn-accent"
type="button"
data-pending={refreshing ? 'true' : undefined}
aria-busy={refreshing ? 'true' : undefined}
disabled={refreshing || undefined}
onClick={() => {
if (refreshing) return
const gate = createBusyGate({ delayMs: 120, minDurationMs: 400 })
setRefreshing(true)
gate.start((phase) => setShowBusy(phase === 'busy'))
if (waitingWorker) waitingWorker.postMessage('SKIP_WAITING')
requestAnimationFrame(() => {
location.reload()
})
}}
>
{refreshLabel}
</button>
<button
className="pak-btn"
type="button"
data-tip="Dismiss until the next version"
aria-label="Dismiss update"
disabled={refreshing || undefined}
onClick={() => {
if (available) markDismissed(available)
setDismissed(true)
}}
>
Dismiss
</button>
</div>
</div>
)
}