← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
app/ui/update-banner.tsx
147 lines · 4.3 KB · TypeScript/** @jsxRuntime automatic */
/** @jsxImportSource remix/ui */
import { clientEntry, on, type Handle } from 'remix/ui'
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). Prefer overlays/toasts over in-flow banners.
* Refresh gives immediate press feedback; busy label uses spin-delay timing.
*/
export const UpdateBanner = clientEntry(
'kody:app#UpdateBanner',
function UpdateBanner(_handle: Handle<Record<string, never>>) {
let available: string | null = null
let checking = false
let dismissed = false
let waitingWorker: ServiceWorker | null = null
const appId = 'package-app-kit-demo'
const refreshBusy = createBusyGate({ delayMs: 120, minDurationMs: 400 })
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 (checking || typeof window === 'undefined') return
checking = true
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 }) : {}
waitingWorker = null
if ('serviceWorker' in navigator) {
const regs = await navigator.serviceWorker.getRegistrations()
for (const reg of regs) {
if (reg.waiting) waitingWorker = reg.waiting
if (reg.update) await reg.update().catch(() => null)
}
}
if (waitingWorker) available = 'worker'
else if (runningSha && version.sha && runningSha !== version.sha) {
available = version.shortSha || String(version.sha).slice(0, 7)
} else {
available = null
}
dismissed = available ? isDismissed(available) : false
writeLastUpdateCheckAt(new Date().toISOString(), appId)
} catch {
/* ignore */
} finally {
checking = false
_handle.update()
}
}
if (typeof window !== 'undefined') {
void check()
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') void check()
})
}
return () => {
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 refreshing = refreshBusy.isActive
const refreshLabel = refreshBusy.showBusy || refreshing ? 'Refreshing…' : 'Refresh'
return (
<div
class="pak-toast pak-update-toast"
data-update-toast
data-tone="default"
role="status"
aria-live="polite"
>
<div>
<strong>{label}</strong>
<p class="pak-muted">Refresh when you are ready</p>
</div>
<div class="pak-toast-actions">
<button
class="pak-btn pak-btn-accent"
type="button"
data-pending={refreshing ? 'true' : undefined}
aria-busy={refreshing ? 'true' : undefined}
disabled={refreshing ? true : undefined}
mix={on('click', () => {
if (refreshBusy.isActive) return
refreshBusy.start(() => {
_handle.update()
})
_handle.update()
if (waitingWorker) waitingWorker.postMessage('SKIP_WAITING')
// Let paint flush pending state before reload
requestAnimationFrame(() => {
location.reload()
})
})}
>
{refreshLabel}
</button>
<button
class="pak-btn"
type="button"
data-tip="Dismiss until the next version"
aria-label="Dismiss update"
disabled={refreshing ? true : undefined}
mix={on('click', () => {
if (available) markDismissed(available)
dismissed = true
_handle.update()
})}
>
Dismiss
</button>
</div>
</div>
)
}
},
)