fix(05-review): CR-04 pre-fetch VAPID key into state; no await before pushManager.subscribe

This commit is contained in:
Lucas Berger
2026-06-09 22:24:00 -04:00
parent e5f7b1ab7c
commit 82eccc9017
3 changed files with 94 additions and 19 deletions
@@ -19,7 +19,26 @@
import { useState, useEffect, useId } from 'react'
import { Bell, Loader2, X } from 'lucide-react'
import { usePushSubscription, prefetchVapidKey } from '../hooks/usePushSubscription.js'
import { usePushSubscription } from '../hooks/usePushSubscription.js'
// fetchVapidKey is the internal helper; we import it directly via the module
// rather than re-exporting it through usePushSubscription, since we need to
// store the resolved key in state (not just warm the cache).
async function fetchVapidKeyForPrompt(): Promise<string | null> {
try {
const cached = sessionStorage.getItem('vapidPublicKey')
if (cached) return cached
const res = await fetch('/api/push/vapid-public-key', { credentials: 'include' })
if (!res.ok) return null
const data = (await res.json()) as { publicKey: string }
if (data.publicKey) {
sessionStorage.setItem('vapidPublicKey', data.publicKey)
}
return data.publicKey ?? null
} catch {
return null
}
}
// ── installed state check ──────────────────────────────────────────────────
@@ -60,6 +79,10 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
const [installed, setInstalled] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// CR-04: Pre-fetch the VAPID key into state so the tap handler can call
// subscribe(registration, vapidKey) without any await before pushManager.subscribe().
// Button is disabled until the key is ready (null = not yet loaded).
const [vapidKey, setVapidKey] = useState<string | null>(null)
const headingId = useId()
const { subscribe, permission } = usePushSubscription()
@@ -68,12 +91,16 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
setInstalled(isInstalled())
}, [])
// Pre-fetch the VAPID key while the prompt is visible so the tap is instant
// Pre-fetch the VAPID key into state while the prompt is visible.
// CR-04: We store the resolved key in state (not just sessionStorage) so the
// tap handler has synchronous access — no network await inside the tap path.
useEffect(() => {
if (!installed) return
if (permission !== 'default') return
if (dismissed) return
void prefetchVapidKey()
void fetchVapidKeyForPrompt().then((key) => {
if (key) setVapidKey(key)
})
}, [installed, permission, dismissed])
// Don't render when: not installed, already granted/denied, or dismissed
@@ -88,16 +115,20 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
}
// onClick handler — subscribe() called synchronously (iOS user-gesture requirement).
// No await before subscribe() in this scope; the async execution starts inside subscribe().
// CR-04: vapidKey is already resolved from state (pre-fetched in useEffect above).
// No await before subscribe() in this scope; we pass the key directly.
function handleEnableClick() {
if (loading) return
if (loading || !vapidKey) return
setLoading(true)
setError(null)
// Capture the key synchronously before any await — iOS gesture gate requirement.
const resolvedVapidKey = vapidKey
void (async () => {
try {
const registration = await navigator.serviceWorker.ready
await subscribe(registration)
await subscribe(registration, resolvedVapidKey)
// On success: close the prompt (permission is now 'granted')
setLoading(false)
onClose?.()
@@ -242,9 +273,10 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
}}
>
{/* Primary CTA — 48px, accent color */}
{/* CR-04: disabled until vapidKey is loaded (null = pre-fetch pending) */}
<button
onClick={handleEnableClick}
disabled={loading}
disabled={loading || !vapidKey}
aria-busy={loading}
style={{
background: 'var(--color-member-0, #4A90D9)',