From 82eccc90175acc68fa96ac17df01c6bbb5dccf14 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Tue, 9 Jun 2026 22:24:00 -0400 Subject: [PATCH] fix(05-review): CR-04 pre-fetch VAPID key into state; no await before pushManager.subscribe --- .../src/components/PushPermissionPrompt.tsx | 46 ++++++++++++++++--- apps/pwa/src/components/SettingsSheet.tsx | 37 ++++++++++++++- apps/pwa/src/hooks/usePushSubscription.ts | 30 +++++++----- 3 files changed, 94 insertions(+), 19 deletions(-) diff --git a/apps/pwa/src/components/PushPermissionPrompt.tsx b/apps/pwa/src/components/PushPermissionPrompt.tsx index d71ea2a..6f8b846 100644 --- a/apps/pwa/src/components/PushPermissionPrompt.tsx +++ b/apps/pwa/src/components/PushPermissionPrompt.tsx @@ -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 { + 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(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(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) */}