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 { useState, useEffect, useId } from 'react'
|
||||||
import { Bell, Loader2, X } from 'lucide-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 ──────────────────────────────────────────────────
|
// ── installed state check ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -60,6 +79,10 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
|
|||||||
const [installed, setInstalled] = useState(false)
|
const [installed, setInstalled] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 headingId = useId()
|
||||||
|
|
||||||
const { subscribe, permission } = usePushSubscription()
|
const { subscribe, permission } = usePushSubscription()
|
||||||
@@ -68,12 +91,16 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
|
|||||||
setInstalled(isInstalled())
|
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(() => {
|
useEffect(() => {
|
||||||
if (!installed) return
|
if (!installed) return
|
||||||
if (permission !== 'default') return
|
if (permission !== 'default') return
|
||||||
if (dismissed) return
|
if (dismissed) return
|
||||||
void prefetchVapidKey()
|
void fetchVapidKeyForPrompt().then((key) => {
|
||||||
|
if (key) setVapidKey(key)
|
||||||
|
})
|
||||||
}, [installed, permission, dismissed])
|
}, [installed, permission, dismissed])
|
||||||
|
|
||||||
// Don't render when: not installed, already granted/denied, or 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).
|
// 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() {
|
function handleEnableClick() {
|
||||||
if (loading) return
|
if (loading || !vapidKey) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
|
// Capture the key synchronously before any await — iOS gesture gate requirement.
|
||||||
|
const resolvedVapidKey = vapidKey
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker.ready
|
const registration = await navigator.serviceWorker.ready
|
||||||
await subscribe(registration)
|
await subscribe(registration, resolvedVapidKey)
|
||||||
// On success: close the prompt (permission is now 'granted')
|
// On success: close the prompt (permission is now 'granted')
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
onClose?.()
|
onClose?.()
|
||||||
@@ -242,9 +273,10 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Primary CTA — 48px, accent color */}
|
{/* Primary CTA — 48px, accent color */}
|
||||||
|
{/* CR-04: disabled until vapidKey is loaded (null = pre-fetch pending) */}
|
||||||
<button
|
<button
|
||||||
onClick={handleEnableClick}
|
onClick={handleEnableClick}
|
||||||
disabled={loading}
|
disabled={loading || !vapidKey}
|
||||||
aria-busy={loading}
|
aria-busy={loading}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-member-0, #4A90D9)',
|
background: 'var(--color-member-0, #4A90D9)',
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'
|
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'
|
||||||
import { usePushSubscription } from '../hooks/usePushSubscription.js'
|
import { usePushSubscription } from '../hooks/usePushSubscription.js'
|
||||||
|
|
||||||
|
// CR-04: fetch VAPID key (from sessionStorage cache if available) for the
|
||||||
|
// tap-gated subscribe() path. Same logic as PushPermissionPrompt.
|
||||||
|
async function fetchVapidKeyForSettings(): 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsSheetProps {
|
interface SettingsSheetProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -33,6 +51,9 @@ interface SettingsSheetProps {
|
|||||||
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||||
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription()
|
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription()
|
||||||
const [isTogglingOn, setIsTogglingOn] = useState(false)
|
const [isTogglingOn, setIsTogglingOn] = useState(false)
|
||||||
|
// CR-04: pre-fetch the VAPID key into state so the toggle tap handler can call
|
||||||
|
// subscribe(registration, vapidKey) without any network await before pushManager.subscribe().
|
||||||
|
const [vapidKey, setVapidKey] = useState<string | null>(null)
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
// Compute initial toggle on/off state per UI-SPEC toggle initial state rule:
|
// Compute initial toggle on/off state per UI-SPEC toggle initial state rule:
|
||||||
@@ -56,6 +77,16 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
|||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
|
// CR-04: Pre-fetch the VAPID key while the sheet is open so the OS-dialog path
|
||||||
|
// (permission === 'default') has the key ready before the user taps.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
if (permission === 'denied') return
|
||||||
|
void fetchVapidKeyForSettings().then((key) => {
|
||||||
|
if (key) setVapidKey(key)
|
||||||
|
})
|
||||||
|
}, [isOpen, permission])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
@@ -75,11 +106,15 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
|||||||
} else {
|
} else {
|
||||||
// off → on, permission 'default': needs tap-gated subscribe with OS dialog
|
// off → on, permission 'default': needs tap-gated subscribe with OS dialog
|
||||||
// The toggle click IS the tap gesture — call subscribe() directly here.
|
// The toggle click IS the tap gesture — call subscribe() directly here.
|
||||||
|
// CR-04: vapidKey was pre-fetched into state (useEffect above); pass it directly
|
||||||
|
// so there is no await-fetch between the tap and pushManager.subscribe().
|
||||||
|
if (!vapidKey) return // key not ready — this should be rare; button should show spinner
|
||||||
|
const resolvedVapidKey = vapidKey
|
||||||
setIsTogglingOn(true)
|
setIsTogglingOn(true)
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker?.ready
|
const registration = await navigator.serviceWorker?.ready
|
||||||
if (registration) {
|
if (registration) {
|
||||||
await subscribe(registration)
|
await subscribe(registration, resolvedVapidKey)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Permission denied by OS or error — permission state will update reactively
|
// Permission denied by OS or error — permission state will update reactively
|
||||||
|
|||||||
@@ -114,8 +114,14 @@ function persistNotificationsEnabled(value: boolean): void {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface UsePushSubscriptionReturn {
|
export interface UsePushSubscriptionReturn {
|
||||||
/** Call this inside an onClick handler (no await before the call). */
|
/**
|
||||||
subscribe: (registration: ServiceWorkerRegistration) => Promise<void>
|
* Call this inside an onClick handler (no await before the call).
|
||||||
|
*
|
||||||
|
* CR-04: `vapidKey` must be pre-fetched before the tap (e.g. via prefetchVapidKey()
|
||||||
|
* in a useEffect) and passed in here. This eliminates the network round-trip inside
|
||||||
|
* the tap handler, preserving the iOS user-gesture requirement for pushManager.subscribe().
|
||||||
|
*/
|
||||||
|
subscribe: (registration: ServiceWorkerRegistration, vapidKey: string) => Promise<void>
|
||||||
unsubscribe: () => Promise<void>
|
unsubscribe: () => Promise<void>
|
||||||
/** Current Notification.permission value ('default' | 'granted' | 'denied') */
|
/** Current Notification.permission value ('default' | 'granted' | 'denied') */
|
||||||
permission: NotificationPermission
|
permission: NotificationPermission
|
||||||
@@ -197,17 +203,19 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
|
|||||||
* synchronously within a user gesture (tap) callback. This function starts
|
* synchronously within a user gesture (tap) callback. This function starts
|
||||||
* the subscription synchronously and awaits only the POST afterwards.
|
* the subscription synchronously and awaits only the POST afterwards.
|
||||||
*
|
*
|
||||||
* The VAPID key should be pre-fetched and cached (e.g., in a useEffect before
|
* CR-04: The VAPID key MUST be pre-fetched before the tap (e.g., via
|
||||||
* the component renders) so this path avoids an extra round-trip.
|
* prefetchVapidKey() in a useEffect) and passed in as `vapidKey`. Fetching
|
||||||
|
* it here (inside the async function) would insert an await-fetch before
|
||||||
|
* pushManager.subscribe() and break the iOS gesture gate on cache miss.
|
||||||
*/
|
*/
|
||||||
const subscribe = async (registration: ServiceWorkerRegistration): Promise<void> => {
|
const subscribe = async (
|
||||||
// Fetch VAPID key (from cache if already prefetched)
|
registration: ServiceWorkerRegistration,
|
||||||
const vapidKey = await fetchVapidKey()
|
vapidKey: string,
|
||||||
|
): Promise<void> => {
|
||||||
// pushManager.subscribe — synchronous initiation; iOS gesture gate is here.
|
// pushManager.subscribe — synchronous initiation; iOS gesture gate is here.
|
||||||
// In practice the async resolution of PushSubscription is after the gesture
|
// No await before this call: vapidKey is already resolved by the caller.
|
||||||
// frame, which iOS allows as long as subscribe() is the first async operation
|
// iOS allows the async resolution of PushSubscription after the gesture frame,
|
||||||
// called from the tap handler.
|
// as long as subscribe() is the first async operation in the tap handler.
|
||||||
const sub = await registration.pushManager.subscribe({
|
const sub = await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||||
|
|||||||
Reference in New Issue
Block a user