fix(05-review): CR-04 pre-fetch VAPID key into state; no await before pushManager.subscribe
This commit is contained in:
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user