fix(push): timeout every await + iOS-not-PWA early exit so subscribe never wedges

The subscribe/disable flow awaited several browser APIs that on certain
devices — most reliably iOS Safari in a non-installed PWA — return a
Promise that never resolves or rejects (requestPermission, serviceWorker
.ready, pushManager.subscribe). When any of them hung, the try/finally
never ran, so `busy` stayed true forever: the dashboard nudge button
stuck at "..." and the settings toggle refused to flip.

Every awaited step is now wrapped in withTimeout(15s). Failures surface
as toasts instead of silent hangs (subscribe success, permission denied,
generic error). iOS Safari without the site installed to home screen —
where Web Push physically cannot work — short-circuits with an
add-to-home-screen instruction before we ever try, so we don't burn a
timeout waiting for a doomed subscribe().

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
fazli 2026-07-19 15:33:04 +00:00
parent 225161249e
commit c4e0f2f89f

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useAuthToken } from "./use-root-data"; import { useAuthToken } from "./use-root-data";
import { getApiBaseUrl } from "~/utils/api-config"; import { getApiBaseUrl } from "~/utils/api-config";
import { toast } from "./use-toast";
/** /**
* Web Push subscription management. * Web Push subscription management.
@ -8,10 +9,42 @@ import { getApiBaseUrl } from "~/utils/api-config";
* Flow: ask permission -> fetch VAPID public key -> subscribe via the service * Flow: ask permission -> fetch VAPID public key -> subscribe via the service
* worker's PushManager -> POST the subscription to the backend. Disabling * worker's PushManager -> POST the subscription to the backend. Disabling
* unsubscribes locally and tells the backend to drop the row. * unsubscribes locally and tells the backend to drop the row.
*
* Every awaited step is guarded by a timeout several of them (notably
* `pushManager.subscribe` and `serviceWorker.ready` on iOS Safari when the
* PWA isn't home-screen-installed) can return a Promise that never resolves.
* Without timeouts the toggle wedges as "..." forever.
*/ */
type PermissionState = NotificationPermission | "unsupported"; type PermissionState = NotificationPermission | "unsupported";
const STEP_TIMEOUT_MS = 15000;
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`timeout: ${label}`)), ms);
p.then(
(v) => { clearTimeout(t); resolve(v); },
(e) => { clearTimeout(t); reject(e); },
);
});
}
function isIosSafari(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
const isIos = /iPad|iPhone|iPod/.test(ua) && !("MSStream" in window);
return isIos;
}
function isStandalonePwa(): boolean {
if (typeof window === "undefined") return false;
const mm = window.matchMedia?.("(display-mode: standalone)").matches;
// iOS-specific standalone flag on the Navigator object.
const nav = navigator as Navigator & { standalone?: boolean };
return !!(mm || nav.standalone);
}
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> { function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
@ -62,8 +95,8 @@ export function usePushNotifications() {
return; return;
} }
setPermission(Notification.permission); setPermission(Notification.permission);
navigator.serviceWorker.ready withTimeout(navigator.serviceWorker.ready, STEP_TIMEOUT_MS, "serviceWorker.ready")
.then((reg) => reg.pushManager.getSubscription()) .then((reg) => withTimeout(reg.pushManager.getSubscription(), STEP_TIMEOUT_MS, "getSubscription"))
.then((sub) => { .then((sub) => {
setIsSubscribed(!!sub); setIsSubscribed(!!sub);
if (sub && token) { if (sub && token) {
@ -80,38 +113,97 @@ export function usePushNotifications() {
const subscribe = useCallback(async (): Promise<boolean> => { const subscribe = useCallback(async (): Promise<boolean> => {
if (!isPushSupported() || !token) return false; if (!isPushSupported() || !token) return false;
// iOS Safari only allows Web Push from a home-screen-installed PWA. In a
// regular Safari tab, `pushManager.subscribe` returns a Promise that
// never resolves — indistinguishable from a hang. Bail out early with a
// clear message rather than wedging the UI.
if (isIosSafari() && !isStandalonePwa()) {
toast({
title: "برای فعال‌سازی، ابتدا سایت را به صفحه اصلی اضافه کنید",
description:
"در Safari دکمه اشتراک‌گذاری را بزنید و «Add to Home Screen» را انتخاب کنید، سپس از آیکن روی صفحه اصلی وارد شوید و دوباره تلاش کنید.",
});
return false;
}
setBusy(true); setBusy(true);
try { try {
const perm = await Notification.requestPermission(); const perm = await withTimeout(
Notification.requestPermission(),
STEP_TIMEOUT_MS,
"requestPermission",
);
setPermission(perm); setPermission(perm);
if (perm !== "granted") return false; if (perm !== "granted") {
if (perm === "denied") {
toast({
title: "اعلان‌ها در مرورگر مسدود شده‌اند",
description:
"از تنظیمات مرورگر یا PWA اعلان‌ها را برای این سایت فعال کنید و صفحه را رفرش کنید.",
variant: "destructive",
});
}
return false;
}
// Get the VAPID public key from the backend. The backend's view returns // Get the VAPID public key from the backend. The backend's view returns
// {"public_key": ...} but its global CamelCaseJSONRenderer rewrites that // {"public_key": ...} but its global CamelCaseJSONRenderer rewrites that
// to {"publicKey": ...} on the wire — so we read camelCase here. // to {"publicKey": ...} on the wire — so we read camelCase here.
const keyRes = await fetch(`${apiBase}/api/notif/v1/push/vapid-public-key/`); const keyRes = await withTimeout(
fetch(`${apiBase}/api/notif/v1/push/vapid-public-key/`),
STEP_TIMEOUT_MS,
"vapid-key fetch",
);
const { publicKey } = await keyRes.json(); const { publicKey } = await keyRes.json();
if (!publicKey) return false; if (!publicKey) return false;
const reg = await navigator.serviceWorker.ready; const reg = await withTimeout(
let sub = await reg.pushManager.getSubscription(); navigator.serviceWorker.ready,
STEP_TIMEOUT_MS,
"serviceWorker.ready",
);
let sub = await withTimeout(
reg.pushManager.getSubscription(),
STEP_TIMEOUT_MS,
"getSubscription",
);
if (!sub) { if (!sub) {
sub = await reg.pushManager.subscribe({ sub = await withTimeout(
reg.pushManager.subscribe({
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey), applicationServerKey: urlBase64ToUint8Array(publicKey),
}); }),
STEP_TIMEOUT_MS,
"pushManager.subscribe",
);
} }
const res = await fetch(`${apiBase}/api/notif/v1/push/subscribe/`, { const res = await withTimeout(
fetch(`${apiBase}/api/notif/v1/push/subscribe/`, {
method: "POST", method: "POST",
headers: authHeaders(), headers: authHeaders(),
body: JSON.stringify(sub.toJSON()), body: JSON.stringify(sub.toJSON()),
}); }),
STEP_TIMEOUT_MS,
"backend subscribe",
);
const ok = res.ok; const ok = res.ok;
setIsSubscribed(ok); setIsSubscribed(ok);
if (ok) {
toast({
title: "اعلان‌ها فعال شد",
description: "از این پس اعلان‌ها را دریافت می‌کنید.",
});
}
return ok; return ok;
} catch (e) { } catch (e) {
console.error("Push subscribe failed:", e); console.error("Push subscribe failed:", e);
// Anything unexpected — surface it so the seller knows to retry.
toast({
title: "فعال‌سازی اعلان با خطا مواجه شد",
description: "لطفاً چند لحظه بعد دوباره تلاش کنید.",
variant: "destructive",
});
return false; return false;
} finally { } finally {
setBusy(false); setBusy(false);
@ -122,8 +214,16 @@ export function usePushNotifications() {
if (!isPushSupported()) return; if (!isPushSupported()) return;
setBusy(true); setBusy(true);
try { try {
const reg = await navigator.serviceWorker.ready; const reg = await withTimeout(
const sub = await reg.pushManager.getSubscription(); navigator.serviceWorker.ready,
STEP_TIMEOUT_MS,
"serviceWorker.ready",
);
const sub = await withTimeout(
reg.pushManager.getSubscription(),
STEP_TIMEOUT_MS,
"getSubscription",
);
if (sub) { if (sub) {
await fetch(`${apiBase}/api/notif/v1/push/unsubscribe/`, { await fetch(`${apiBase}/api/notif/v1/push/unsubscribe/`, {
method: "POST", method: "POST",
@ -133,6 +233,8 @@ export function usePushNotifications() {
await sub.unsubscribe().catch(() => {}); await sub.unsubscribe().catch(() => {});
} }
setIsSubscribed(false); setIsSubscribed(false);
} catch (e) {
console.error("Push disable failed:", e);
} finally { } finally {
setBusy(false); setBusy(false);
} }