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 { useAuthToken } from "./use-root-data";
import { getApiBaseUrl } from "~/utils/api-config";
import { toast } from "./use-toast";
/**
* 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
* worker's PushManager -> POST the subscription to the backend. Disabling
* 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";
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> {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
@ -62,8 +95,8 @@ export function usePushNotifications() {
return;
}
setPermission(Notification.permission);
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
withTimeout(navigator.serviceWorker.ready, STEP_TIMEOUT_MS, "serviceWorker.ready")
.then((reg) => withTimeout(reg.pushManager.getSubscription(), STEP_TIMEOUT_MS, "getSubscription"))
.then((sub) => {
setIsSubscribed(!!sub);
if (sub && token) {
@ -80,38 +113,97 @@ export function usePushNotifications() {
const subscribe = useCallback(async (): Promise<boolean> => {
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);
try {
const perm = await Notification.requestPermission();
const perm = await withTimeout(
Notification.requestPermission(),
STEP_TIMEOUT_MS,
"requestPermission",
);
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
// {"public_key": ...} but its global CamelCaseJSONRenderer rewrites that
// 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();
if (!publicKey) return false;
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
const reg = await withTimeout(
navigator.serviceWorker.ready,
STEP_TIMEOUT_MS,
"serviceWorker.ready",
);
let sub = await withTimeout(
reg.pushManager.getSubscription(),
STEP_TIMEOUT_MS,
"getSubscription",
);
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
sub = await withTimeout(
reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
}),
STEP_TIMEOUT_MS,
"pushManager.subscribe",
);
}
const res = await fetch(`${apiBase}/api/notif/v1/push/subscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(sub.toJSON()),
});
const res = await withTimeout(
fetch(`${apiBase}/api/notif/v1/push/subscribe/`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(sub.toJSON()),
}),
STEP_TIMEOUT_MS,
"backend subscribe",
);
const ok = res.ok;
setIsSubscribed(ok);
if (ok) {
toast({
title: "اعلان‌ها فعال شد",
description: "از این پس اعلان‌ها را دریافت می‌کنید.",
});
}
return ok;
} catch (e) {
console.error("Push subscribe failed:", e);
// Anything unexpected — surface it so the seller knows to retry.
toast({
title: "فعال‌سازی اعلان با خطا مواجه شد",
description: "لطفاً چند لحظه بعد دوباره تلاش کنید.",
variant: "destructive",
});
return false;
} finally {
setBusy(false);
@ -122,8 +214,16 @@ export function usePushNotifications() {
if (!isPushSupported()) return;
setBusy(true);
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
const reg = await withTimeout(
navigator.serviceWorker.ready,
STEP_TIMEOUT_MS,
"serviceWorker.ready",
);
const sub = await withTimeout(
reg.pushManager.getSubscription(),
STEP_TIMEOUT_MS,
"getSubscription",
);
if (sub) {
await fetch(`${apiBase}/api/notif/v1/push/unsubscribe/`, {
method: "POST",
@ -133,6 +233,8 @@ export function usePushNotifications() {
await sub.unsubscribe().catch(() => {});
}
setIsSubscribed(false);
} catch (e) {
console.error("Push disable failed:", e);
} finally {
setBusy(false);
}