diff --git a/apps/pwa/src/components/PushPermissionPrompt.tsx b/apps/pwa/src/components/PushPermissionPrompt.tsx index 6f8b846..d721859 100644 --- a/apps/pwa/src/components/PushPermissionPrompt.tsx +++ b/apps/pwa/src/components/PushPermissionPrompt.tsx @@ -83,6 +83,11 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) { // 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) + // NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the tap + // handler has ZERO awaits between the user gesture and pushManager.subscribe(). + // Any await (including navigator.serviceWorker.ready) between the tap and + // pushManager.subscribe() breaks the iOS user-gesture requirement. + const [swRegistration, setSwRegistration] = useState(null) const headingId = useId() const { subscribe, permission } = usePushSubscription() @@ -103,6 +108,20 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) { }) }, [installed, permission, dismissed]) + // NEW-CR-01: Pre-resolve the ServiceWorkerRegistration in a useEffect so the + // tap handler never has to await navigator.serviceWorker.ready. + // navigator.serviceWorker.ready resolves once the SW is active; doing this + // eagerly means the result is in state before the user can tap the button. + useEffect(() => { + if (!installed) return + if (permission !== 'default') return + if (dismissed) return + if (!navigator.serviceWorker) return + void navigator.serviceWorker.ready.then((reg) => { + setSwRegistration(reg) + }) + }, [installed, permission, dismissed]) + // Don't render when: not installed, already granted/denied, or dismissed if (!installed) return null if (permission !== 'default') return null @@ -115,20 +134,21 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) { } // onClick handler — subscribe() called synchronously (iOS user-gesture requirement). - // 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. + // NEW-CR-01: Both vapidKey AND swRegistration are pre-resolved in state (useEffects above). + // There is ZERO await between the tap gesture and registration.pushManager.subscribe() + // inside subscribe() — the iOS gesture gate is fully satisfied. function handleEnableClick() { - if (loading || !vapidKey) return + if (loading || !vapidKey || !swRegistration) return setLoading(true) setError(null) - // Capture the key synchronously before any await — iOS gesture gate requirement. + // Capture both synchronously — no await in this scope before subscribe(). const resolvedVapidKey = vapidKey + const resolvedRegistration = swRegistration void (async () => { try { - const registration = await navigator.serviceWorker.ready - await subscribe(registration, resolvedVapidKey) + await subscribe(resolvedRegistration, resolvedVapidKey) // On success: close the prompt (permission is now 'granted') setLoading(false) onClose?.() @@ -273,10 +293,10 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) { }} > {/* Primary CTA — 48px, accent color */} - {/* CR-04: disabled until vapidKey is loaded (null = pre-fetch pending) */} + {/* NEW-CR-01: disabled until BOTH vapidKey and swRegistration are ready */}