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
+21 -7
View File
@@ -54,6 +54,9 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
// 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)
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the toggle
// tap handler has ZERO awaits between the user gesture and pushManager.subscribe().
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
// Compute initial toggle on/off state per UI-SPEC toggle initial state rule:
@@ -87,6 +90,18 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
})
}, [isOpen, permission])
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the toggle
// tap handler never needs to await navigator.serviceWorker.ready. Any await
// between the tap gesture and pushManager.subscribe() breaks iOS.
useEffect(() => {
if (!isOpen) return
if (permission === 'denied') return
if (!navigator.serviceWorker) return
void navigator.serviceWorker.ready.then((reg) => {
setSwRegistration(reg)
})
}, [isOpen, permission])
if (!isOpen) return null
const handleToggle = async () => {
@@ -106,16 +121,15 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
} else {
// off → on, permission 'default': needs tap-gated subscribe with OS dialog
// 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
// NEW-CR-01: Both vapidKey AND swRegistration are pre-resolved into state
// (useEffects above). There is ZERO await between the tap and
// registration.pushManager.subscribe() — iOS gesture gate satisfied.
if (!vapidKey || !swRegistration) return // not ready — useEffects still resolving
const resolvedVapidKey = vapidKey
const resolvedRegistration = swRegistration
setIsTogglingOn(true)
try {
const registration = await navigator.serviceWorker?.ready
if (registration) {
await subscribe(registration, resolvedVapidKey)
}
await subscribe(resolvedRegistration, resolvedVapidKey)
} catch {
// Permission denied by OS or error — permission state will update reactively
} finally {