fix(05-review): NEW-CR-01 pre-resolve SW registration to eliminate await before pushManager.subscribe

Pre-fetch ServiceWorkerRegistration into component state via useEffect in both
PushPermissionPrompt and SettingsSheet. Gate the subscribe tap target as disabled
until both vapidKey AND swRegistration are ready. The tap handler now has zero
awaits between the user gesture and registration.pushManager.subscribe(), fully
satisfying the iOS user-gesture requirement.
This commit is contained in:
Lucas Berger
2026-06-09 22:39:05 -04:00
parent dc8516beb8
commit c7ef5811d1
2 changed files with 49 additions and 15 deletions
@@ -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<string | null>(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<ServiceWorkerRegistration | null>(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 */}
<button
onClick={handleEnableClick}
disabled={loading || !vapidKey}
disabled={loading || !vapidKey || !swRegistration}
aria-busy={loading}
style={{
background: 'var(--color-member-0, #4A90D9)',