From ae5c750d3401cfc4193af78c8c90264eb2a5a8d3 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 25 Jun 2026 11:28:47 +0330 Subject: [PATCH] fix(notifications): don't lose live alerts until a page refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SignalR connection used the default auto-reconnect, which gives up after ~30s and, even when it did reconnect, never re-ran JoinCafe — so the client dropped out of the café group and silently stopped receiving notifications until a manual refresh. Now it retries forever (capped backoff), re-joins the group on reconnect (and catches up via invalidate), and re-establishes the connection when the network returns or the tab is refocused. As a safety net, the unread/bell and tab-badge polls now run in background tabs too (refetchIntervalInBackground). Co-Authored-By: Claude Opus 4.8 --- .../src/lib/hooks/use-notifications-feed.ts | 3 ++ .../src/lib/notifications/use-tab-badge.ts | 1 + .../src/lib/realtime/use-order-alerts.ts | 39 ++++++++++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/web/dashboard/src/lib/hooks/use-notifications-feed.ts b/web/dashboard/src/lib/hooks/use-notifications-feed.ts index 970b000..6cafd3a 100644 --- a/web/dashboard/src/lib/hooks/use-notifications-feed.ts +++ b/web/dashboard/src/lib/hooks/use-notifications-feed.ts @@ -40,6 +40,9 @@ export function useNotificationsFeed(options: UseNotificationsFeedOptions = {}) queryFn: () => fetchNotifications(cafeId!, unreadOnly, limit), enabled: !!cafeId, refetchInterval: 60_000, + // Keep polling even when the tab is in the background, so the unread count + // stays current if the live socket missed something — no refresh needed. + refetchIntervalInBackground: true, }); const refresh = useCallback(() => { diff --git a/web/dashboard/src/lib/notifications/use-tab-badge.ts b/web/dashboard/src/lib/notifications/use-tab-badge.ts index 0bdaae6..b22a073 100644 --- a/web/dashboard/src/lib/notifications/use-tab-badge.ts +++ b/web/dashboard/src/lib/notifications/use-tab-badge.ts @@ -94,6 +94,7 @@ export function useTabBadge() { queryFn: () => fetchNotifications(cafeId!, false, 50), enabled: !!cafeId, refetchInterval: 60_000, + refetchIntervalInBackground: true, // update the tab badge even when unfocused }); const unread = data?.unreadCount ?? 0; diff --git a/web/dashboard/src/lib/realtime/use-order-alerts.ts b/web/dashboard/src/lib/realtime/use-order-alerts.ts index 8630052..b781294 100644 --- a/web/dashboard/src/lib/realtime/use-order-alerts.ts +++ b/web/dashboard/src/lib/realtime/use-order-alerts.ts @@ -54,10 +54,23 @@ export function useOrderAlerts() { accessTokenFactory: () => (typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null) ?? "", }) - .withAutomaticReconnect() + // Retry FOREVER (capped backoff). The default policy gives up after ~30s, + // leaving the connection dead until a page refresh → missed notifications. + .withAutomaticReconnect({ + nextRetryDelayInMilliseconds: (ctx) => + Math.min(30000, 1000 * 2 ** Math.min(ctx.previousRetryCount, 5)), + }) .build(); let stopped = false; + const joinCafe = () => connection.invoke("JoinCafe", cafeId).catch(() => {}); + + // On reconnect the server group membership is gone — re-join or we silently + // stop receiving notifications. Also catch up anything missed while down. + connection.onreconnected(() => { + void joinCafe(); + void qc.invalidateQueries({ queryKey: ["notifications", cafeId] }); + }); const severityFor = (type: string) => { if (type === "table_call_waiter") return notify.warning; @@ -104,14 +117,36 @@ export function useOrderAlerts() { void connection .start() .then(() => { - if (!stopped) return connection.invoke("JoinCafe", cafeId); + if (!stopped) return joinCafe(); }) .catch(() => { // connection/auth failed — alerts simply won't fire; no UI breakage }); + // If the connection fully dropped (gave up, or the device slept), bring it + // back when the network returns or the tab is focused again. + const ensureConnected = () => { + if (stopped) return; + if (connection.state === signalR.HubConnectionState.Disconnected) { + void connection + .start() + .then(() => { + if (!stopped) return joinCafe(); + }) + .then(() => qc.invalidateQueries({ queryKey: ["notifications", cafeId] })) + .catch(() => {}); + } + }; + const onVisible = () => { + if (document.visibilityState === "visible") ensureConnected(); + }; + window.addEventListener("online", ensureConnected); + document.addEventListener("visibilitychange", onVisible); + return () => { stopped = true; + window.removeEventListener("online", ensureConnected); + document.removeEventListener("visibilitychange", onVisible); void connection.stop(); }; }, [cafeId, locale, qc]);