Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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>
			)
		}
	},
)