Skip to content
← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

FollowFork

starter-tanstack/app/ui/update-banner.tsx

133 lines · 3.9 KB · TypeScript
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>
	)
}