fix(05-review): CR-04 pre-fetch VAPID key into state; no await before pushManager.subscribe

This commit is contained in:
Lucas Berger
2026-06-09 22:24:00 -04:00
parent e5f7b1ab7c
commit 82eccc9017
3 changed files with 94 additions and 19 deletions
+19 -11
View File
@@ -114,8 +114,14 @@ function persistNotificationsEnabled(value: boolean): void {
// ---------------------------------------------------------------------------
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>
/** Current Notification.permission value ('default' | 'granted' | 'denied') */
permission: NotificationPermission
@@ -197,17 +203,19 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
* synchronously within a user gesture (tap) callback. This function starts
* the subscription synchronously and awaits only the POST afterwards.
*
* The VAPID key should be pre-fetched and cached (e.g., in a useEffect before
* the component renders) so this path avoids an extra round-trip.
* CR-04: The VAPID key MUST be pre-fetched before the tap (e.g., via
* 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> => {
// Fetch VAPID key (from cache if already prefetched)
const vapidKey = await fetchVapidKey()
const subscribe = async (
registration: ServiceWorkerRegistration,
vapidKey: string,
): Promise<void> => {
// pushManager.subscribe — synchronous initiation; iOS gesture gate is here.
// In practice the async resolution of PushSubscription is after the gesture
// frame, which iOS allows as long as subscribe() is the first async operation
// called from the tap handler.
// No await before this call: vapidKey is already resolved by the caller.
// iOS allows the async resolution of PushSubscription after the gesture frame,
// as long as subscribe() is the first async operation in the tap handler.
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),