8d9bc7951e
Bug: order creator (services manager) saw new orders instantly with
sound, but other users holding orders.receive — even with the app
foregrounded on the Orders screen — got nothing until they force-quit
and reopened the PWA, at which point the orders appeared. Root cause:
Socket.IO silently drops behind Tailscale/NAT on iPad PWA and the
client neither detected it nor refetched missed state on reconnect.
The creator was unaffected because their POST mutation updates their
queries locally without depending on the realtime channel.
Client (artifacts/tx-os/src/hooks/use-notifications-socket.ts):
- Tighten io() options: reconnection {Delay 500, DelayMax 3000,
Attempts Infinity, timeout 10s}.
- Track `wasDisconnected` flag; on `disconnect` flip it and log the
reason (skipping the clean "io client disconnect" path).
- On `connect`, if previously disconnected, invalidate every realtime-
driven query key: notifications, home stats, my/incoming orders,
notes, note-folders, exec meetings (list/alert-state/notifications),
apps, /me, roles, permissions. Warmup window is reset BEFORE the
refetch so any flushed notification_created events post-reconnect
don't chime.
- Add a `visibilitychange` listener: on foreground return, if the
socket is disconnected, force `socket.connect()`; if it's "connected"
but possibly half-open (silent drop), round-trip a 3s-timeout
`client_health_probe` ack — on timeout, `disconnect().connect()`.
- `connect_error` logger for field debugging.
Server (artifacts/api-server/src/index.ts):
- Tighten Socket.IO pingInterval=10s, pingTimeout=5s (was default
25s/20s) so dead-connection detection cycle drops from ~45s to ~15s.
- Add `client_health_probe` handler that acks immediately — pairs
with the client-side half-open probe.
Deviations from plan: skipped the optional UI connection indicator
(point 5) — not necessary to fix the reported bug; can ship later if
users still feel uncertain about connection state.
Architect approved with one minor caveat: severe (>3s) transient
latency on foreground could trigger a one-off socket cycle. Acceptable
tradeoff and explicitly documented in the comment.
Pre-existing TS errors in api-server/src/routes/push.ts are unrelated
to this task and not touched by this diff.
550 lines
22 KiB
TypeScript
550 lines
22 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { io } from "socket.io-client";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
getListNotificationsQueryKey,
|
|
getGetHomeStatsQueryKey,
|
|
getListMyServiceOrdersQueryKey,
|
|
getListIncomingServiceOrdersQueryKey,
|
|
getListAppsQueryKey,
|
|
getGetMeQueryKey,
|
|
getListRolesQueryKey,
|
|
getListPermissionsQueryKey,
|
|
getGetRolePermissionsQueryKey,
|
|
getGetRolePermissionAuditQueryKey,
|
|
getGetRoleUsageQueryKey,
|
|
} from "@workspace/api-client-react";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import { notificationPlayer } from "@/lib/notification-sounds";
|
|
import { toast } from "@/hooks/use-toast";
|
|
import i18n from "@/i18n";
|
|
import {
|
|
useIncomingNotePopup,
|
|
type IncomingNotePayload,
|
|
type IncomingReplyPayload,
|
|
} from "@/contexts/IncomingNotePopupContext";
|
|
|
|
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
|
|
|
// #628: warmup window after socket (re)connect during which chimes
|
|
// are suppressed so a page-load flush of pending events doesn't sound
|
|
// like an alarm. Lowered from 3000 → 1000 ms because on mobile the
|
|
// socket disconnects/reconnects often (background tab, tunnel hop,
|
|
// PWA wake-up); a 3-second window silently swallowed any order that
|
|
// arrived in the first few seconds after returning to the app. 1 s
|
|
// still covers the synchronous initial-burst at first connect.
|
|
const SOCKET_WARMUP_MS = 1000;
|
|
const nowMs = () =>
|
|
typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
|
|
export function useNotificationsSocket() {
|
|
const { user } = useAuth();
|
|
const queryClient = useQueryClient();
|
|
const connectedAtRef = useRef<number>(0);
|
|
const { enqueue: enqueueNotePopup, updateChecklistItems } =
|
|
useIncomingNotePopup();
|
|
const enqueueNotePopupRef = useRef(enqueueNotePopup);
|
|
enqueueNotePopupRef.current = enqueueNotePopup;
|
|
const updateChecklistItemsRef = useRef(updateChecklistItems);
|
|
updateChecklistItemsRef.current = updateChecklistItems;
|
|
const currentUserIdRef = useRef<number | null>(user?.id ?? null);
|
|
currentUserIdRef.current = user?.id ?? null;
|
|
// Dedupe note chimes by noteId so a socket reconnect that replays the
|
|
// same `note_received` event (or a duplicate emit from the server)
|
|
// doesn't beep twice for the same incoming note. Mirrors the
|
|
// `playedRef` pattern used by upcoming-meeting-alert.
|
|
const playedNoteIdsRef = useRef<Set<number>>(new Set());
|
|
// Same dedupe shape as `playedNoteIdsRef`, but keyed on replyId so a
|
|
// reconnect that replays a `note_replied` event doesn't re-chime.
|
|
const playedReplyIdsRef = useRef<Set<number>>(new Set());
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
// Suppress chimes for the first few seconds after the socket
|
|
// connects so any pending notifications that flush in on page
|
|
// load don't all play sounds back-to-back.
|
|
connectedAtRef.current = nowMs();
|
|
|
|
// ---- Coalesced invalidations (#427) ----
|
|
// When several socket events arrive in the same JS task (e.g. a
|
|
// `note_received` AND a `notification_created` AND a meeting
|
|
// alert all fire from one server fanout), invalidating each
|
|
// query key synchronously triggers a re-render storm that
|
|
// briefly stalls the UI on slower iPad/phone hardware — which
|
|
// contributed to the perceived "tap hang". We collect query
|
|
// keys in a Set keyed by JSON stringification (cheap dedupe)
|
|
// and flush them once per animation frame so React Query batches
|
|
// the refetches into one render pass.
|
|
const pendingKeys = new Map<string, readonly unknown[]>();
|
|
let rafHandle: number | null = null;
|
|
const flushPending = () => {
|
|
rafHandle = null;
|
|
const keys = Array.from(pendingKeys.values());
|
|
pendingKeys.clear();
|
|
for (const key of keys) {
|
|
queryClient.invalidateQueries({ queryKey: key as unknown[] });
|
|
}
|
|
};
|
|
const invalidate = (key: readonly unknown[]) => {
|
|
pendingKeys.set(JSON.stringify(key), key);
|
|
if (rafHandle !== null) return;
|
|
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
|
rafHandle = window.requestAnimationFrame(flushPending);
|
|
} else {
|
|
// SSR / test fallback: macro-task defer is still enough to
|
|
// coalesce events arriving in the same tick.
|
|
rafHandle = window.setTimeout(flushPending, 0);
|
|
}
|
|
};
|
|
|
|
const socket = io(window.location.origin, {
|
|
path: `${BASE}/api/socket.io`,
|
|
// #632: Behind Tailscale / proxies on iPad PWA the WebSocket can
|
|
// be silently dropped by NAT/idle timeouts. Tighten the
|
|
// reconnection budget so the client retries quickly instead of
|
|
// sitting on a dead connection while orders pile up server-side.
|
|
reconnection: true,
|
|
reconnectionDelay: 500,
|
|
reconnectionDelayMax: 3000,
|
|
reconnectionAttempts: Infinity,
|
|
timeout: 10000,
|
|
});
|
|
|
|
// #632: Track whether the socket has been disconnected at any
|
|
// point so the next successful `connect` can refetch every
|
|
// realtime-driven query — events that fire while the socket was
|
|
// dead are lost forever otherwise, which is exactly the bug
|
|
// (recipients seeing orders only after force-quitting the PWA).
|
|
let wasDisconnected = false;
|
|
// #632: Replay every event-driven query key on reconnect. Mirrors
|
|
// the keys touched by individual socket.on handlers below — keep
|
|
// this list in sync when adding new realtime channels. Role and
|
|
// permission keys are included so a `role_permissions_changed`
|
|
// event that fired while the socket was dead is recovered on
|
|
// reconnect (per architect review).
|
|
const refetchRealtimeState = () => {
|
|
invalidate(getListNotificationsQueryKey());
|
|
invalidate(getGetHomeStatsQueryKey());
|
|
invalidate(getListIncomingServiceOrdersQueryKey());
|
|
invalidate(getListMyServiceOrdersQueryKey());
|
|
invalidate(["notes"]);
|
|
invalidate(["note-folders"]);
|
|
invalidate(["note-folders", "shared-with-me"]);
|
|
invalidate(["/api/executive-meetings"]);
|
|
invalidate(["/api/executive-meetings/alert-state"]);
|
|
invalidate(["/api/executive-meetings/notifications"]);
|
|
invalidate(getListAppsQueryKey());
|
|
invalidate(getGetMeQueryKey());
|
|
invalidate(getListRolesQueryKey());
|
|
invalidate(getListPermissionsQueryKey());
|
|
};
|
|
|
|
socket.on("connect", () => {
|
|
// Reset the warmup window so a flush of pending notifications
|
|
// delivered immediately after reconnect doesn't chime.
|
|
connectedAtRef.current = nowMs();
|
|
if (wasDisconnected) {
|
|
wasDisconnected = false;
|
|
refetchRealtimeState();
|
|
}
|
|
});
|
|
|
|
socket.on("disconnect", (reason) => {
|
|
wasDisconnected = true;
|
|
// Surface unexpected disconnects in the console for field
|
|
// debugging without breaking anything. `io client disconnect` is
|
|
// the cleanup path on hook teardown — skip the noise for it.
|
|
if (reason !== "io client disconnect") {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("[socket] disconnected:", reason);
|
|
}
|
|
});
|
|
|
|
socket.on("connect_error", (err) => {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("[socket] connect_error:", err.message);
|
|
});
|
|
|
|
socket.on("notification_created", (payload?: { type?: string }) => {
|
|
invalidate(getListNotificationsQueryKey());
|
|
invalidate(getGetHomeStatsQueryKey());
|
|
if (payload?.type === "order") {
|
|
invalidate(getListMyServiceOrdersQueryKey(),);
|
|
invalidate(getListIncomingServiceOrdersQueryKey(),);
|
|
}
|
|
|
|
// Per-user sound + vibration. Plays whether the tab is in the
|
|
// foreground or background — users want to hear the chime even
|
|
// while looking at the app. Still silenced by global mute and
|
|
// per-channel opt-outs below.
|
|
if (user.notificationsMuted) return;
|
|
const isOrder = payload?.type === "order";
|
|
const isMeeting = payload?.type === "executive_meeting";
|
|
if (isOrder && !user.notifyOrdersEnabled) return;
|
|
if (isMeeting && !user.notifyMeetingsEnabled) return;
|
|
if (!isOrder && !isMeeting) return;
|
|
// Skip audio/vibration during the warmup window — UI/badge
|
|
// counts (above) still update, but we stay silent so a
|
|
// page-load flush doesn't sound like an alarm.
|
|
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
|
|
const soundId = isOrder
|
|
? user.notificationSoundOrder
|
|
: user.notificationSoundMeeting;
|
|
const vibrate = isOrder
|
|
? user.vibrationEnabledOrder
|
|
: user.vibrationEnabledMeeting;
|
|
// #628: order chimes use a tighter throttle window so a small
|
|
// burst of incoming orders still rings for each one. Meeting
|
|
// chimes keep the default 3s window — a single meeting event
|
|
// can fan out to several listeners and we don't want a
|
|
// buzzy double-chime. Buckets keep the two channels isolated
|
|
// so an order burst can't starve a meeting alert (or vice
|
|
// versa).
|
|
notificationPlayer.play(soundId, {
|
|
vibrate,
|
|
throttleMs: isOrder ? 800 : 3000,
|
|
throttleBucket: isOrder ? "order" : "meeting",
|
|
});
|
|
});
|
|
|
|
socket.on("order_updated", () => {
|
|
invalidate(getListMyServiceOrdersQueryKey(),);
|
|
invalidate(getListIncomingServiceOrdersQueryKey(),);
|
|
});
|
|
|
|
socket.on("order_deleted", () => {
|
|
invalidate(getListMyServiceOrdersQueryKey(),);
|
|
invalidate(getListIncomingServiceOrdersQueryKey(),);
|
|
});
|
|
|
|
socket.on("order_incoming_changed", () => {
|
|
invalidate(getListIncomingServiceOrdersQueryKey(),);
|
|
});
|
|
|
|
// Realtime note events: invalidate notes queries AND surface a toast so
|
|
// the recipient (or sender, on a reply) sees an immediate, dismissible
|
|
// confirmation regardless of which page they're on.
|
|
socket.on("note_received", (payload?: Partial<IncomingNotePayload>) => {
|
|
invalidate(["notes"]);
|
|
const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
|
|
let enqueued = false;
|
|
if (
|
|
payload &&
|
|
typeof payload.noteId === "number" &&
|
|
typeof payload.senderUserId === "number"
|
|
) {
|
|
const rawKind = (payload as { kind?: unknown }).kind;
|
|
// Default missing/unknown kind to "text" — the legacy server
|
|
// emit (pre-checklist) didn't include `kind`, and the popup
|
|
// body's text branch is the safe fallback.
|
|
const noteKind: "text" | "checklist" =
|
|
rawKind === "checklist" ? "checklist" : "text";
|
|
const rawItems = (payload as { items?: unknown }).items;
|
|
const items = Array.isArray(rawItems)
|
|
? (rawItems as IncomingNotePayload["items"])
|
|
: null;
|
|
const full: IncomingNotePayload = {
|
|
noteId: payload.noteId,
|
|
recipientRowId: payload.recipientRowId,
|
|
title: typeof payload.title === "string" ? payload.title : "",
|
|
content: typeof payload.content === "string" ? payload.content : "",
|
|
color: typeof payload.color === "string" ? payload.color : "default",
|
|
sentAt: payload.sentAt,
|
|
senderUserId: payload.senderUserId,
|
|
sender: payload.sender ?? null,
|
|
noteKind,
|
|
items,
|
|
};
|
|
enqueued = enqueueNotePopupRef.current(
|
|
full,
|
|
currentUserIdRef.current,
|
|
);
|
|
}
|
|
if (inWarmup) return;
|
|
// Sound + vibration for incoming notes — gated by global mute and
|
|
// the per-channel notes toggle. Deduped by noteId so a socket
|
|
// reconnect that replays the same event doesn't re-chime. Only
|
|
// plays when the popup actually enqueued (i.e. recipient !== sender).
|
|
if (
|
|
enqueued &&
|
|
payload &&
|
|
typeof payload.noteId === "number" &&
|
|
!user.notificationsMuted &&
|
|
user.notifyNotesEnabled &&
|
|
!playedNoteIdsRef.current.has(payload.noteId)
|
|
) {
|
|
playedNoteIdsRef.current.add(payload.noteId);
|
|
notificationPlayer.play(user.notificationSoundNote, {
|
|
vibrate: user.vibrationEnabledNote,
|
|
throttleBucket: "note",
|
|
});
|
|
}
|
|
// Only fall back to a toast when the floating popup did NOT
|
|
// accept this event (sender's own note, duplicate, malformed,
|
|
// etc.). When the popup IS shown, the toast is redundant — and
|
|
// on touch devices stacking a toast over an already-stacked
|
|
// popup + meeting alert produced extra layers that intermittently
|
|
// intercepted the next tap (#427).
|
|
if (!enqueued) {
|
|
toast({
|
|
title: i18n.t("notes.toast.received.title"),
|
|
description: i18n.t("notes.toast.received.desc"),
|
|
});
|
|
}
|
|
});
|
|
|
|
socket.on(
|
|
"note_replied",
|
|
(payload?: Partial<IncomingReplyPayload> & { recipientUserId?: number }) => {
|
|
invalidate(["notes"]);
|
|
const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
|
|
let enqueued = false;
|
|
// The server emits `note_replied` only to the *original note
|
|
// owner* (i.e. the sender). Build a reply popup payload — the
|
|
// queue dedupes on replyId so a socket reconnect that replays
|
|
// the same event won't re-show or re-chime.
|
|
if (
|
|
payload &&
|
|
typeof payload.noteId === "number" &&
|
|
typeof payload.replyId === "number"
|
|
) {
|
|
// The "sender" of the *original* note is the current user
|
|
// (the owner) — we don't have their full UserSummary on the
|
|
// payload, but the floating card never renders it for the
|
|
// reply variant; it shows the replier instead. Use the
|
|
// replier's id as a stand-in for senderUserId so the
|
|
// queue's "ignore my own outgoing" guard treats this event
|
|
// as inbound (the replier is *not* the current user).
|
|
const replierId = payload.replier?.id;
|
|
const senderUserId =
|
|
typeof replierId === "number" ? replierId : -1;
|
|
const full: IncomingReplyPayload = {
|
|
kind: "reply",
|
|
noteId: payload.noteId,
|
|
replyId: payload.replyId,
|
|
replyContent:
|
|
typeof payload.replyContent === "string"
|
|
? payload.replyContent
|
|
: "",
|
|
replier: payload.replier ?? null,
|
|
noteTitle:
|
|
typeof payload.noteTitle === "string" ? payload.noteTitle : "",
|
|
title:
|
|
typeof payload.noteTitle === "string" ? payload.noteTitle : "",
|
|
content:
|
|
typeof payload.replyContent === "string"
|
|
? payload.replyContent
|
|
: "",
|
|
color:
|
|
typeof payload.color === "string" ? payload.color : "default",
|
|
senderUserId,
|
|
sender: payload.replier ?? null,
|
|
};
|
|
enqueued = enqueueNotePopupRef.current(
|
|
full,
|
|
currentUserIdRef.current,
|
|
);
|
|
}
|
|
if (inWarmup) return;
|
|
// Sound + vibration mirror the new-note alert. Reuse the same
|
|
// notes channel prefs (notificationSoundNote /
|
|
// vibrationEnabledNote / notifyNotesEnabled) — the user
|
|
// confirmed they want a single sensory profile for both
|
|
// events. Dedupe by replyId via a per-session set.
|
|
const replyId = payload?.replyId;
|
|
if (
|
|
enqueued &&
|
|
typeof replyId === "number" &&
|
|
!user.notificationsMuted &&
|
|
user.notifyNotesEnabled &&
|
|
!playedReplyIdsRef.current.has(replyId)
|
|
) {
|
|
playedReplyIdsRef.current.add(replyId);
|
|
notificationPlayer.play(user.notificationSoundNote, {
|
|
vibrate: user.vibrationEnabledNote,
|
|
throttleBucket: "note",
|
|
});
|
|
}
|
|
// See `note_received` above: skip the redundant toast when the
|
|
// floating popup is the surface (#427).
|
|
if (!enqueued) {
|
|
toast({
|
|
title: i18n.t("notes.toast.replied.title"),
|
|
description: i18n.t("notes.toast.replied.desc"),
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
socket.on("note_status_changed", () => {
|
|
invalidate(["notes"]);
|
|
});
|
|
|
|
// a collaborator (owner or another recipient) toggled a
|
|
// checklist item — refetch so this user's open thread / popup /
|
|
// cards reflect the shared state. Server already updates the
|
|
// emitter optimistically, so it never receives its own echo. We
|
|
// also patch any open popup payload directly because the popup
|
|
// renders from the queued snapshot, not the React Query cache.
|
|
socket.on(
|
|
"note_checklist_changed",
|
|
(payload?: {
|
|
noteId?: number;
|
|
items?: { id: string; text: string; done: boolean }[];
|
|
}) => {
|
|
invalidate(["notes"]);
|
|
if (typeof payload?.noteId === "number") {
|
|
invalidate(["notes", "thread", payload.noteId]);
|
|
if (Array.isArray(payload.items)) {
|
|
updateChecklistItemsRef.current(payload.noteId, payload.items);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
// Folder-sharing live updates (#445). Owner shares/unshares a folder
|
|
// with this user → invalidate the rail's "Shared with me" list, and any
|
|
// open shared-folder note view for that folder so it either refreshes
|
|
// (still shared) or surfaces the revoked empty state (unshared) without
|
|
// a manual reload.
|
|
socket.on(
|
|
"note-folder-shared",
|
|
(payload?: { folderId?: number }) => {
|
|
invalidate(["note-folders", "shared-with-me"]);
|
|
if (typeof payload?.folderId === "number") {
|
|
invalidate(["notes", "shared-folder", payload.folderId]);
|
|
}
|
|
// this event also fanouts to the OWNER + every other
|
|
// recipient when an editor mutates the folder's notes (create /
|
|
// patch / delete / checklist toggle), so refresh the owner's
|
|
// personal notes list and the folders rail badge counts too.
|
|
invalidate(["notes"]);
|
|
invalidate(["note-folders"]);
|
|
},
|
|
);
|
|
// dedicated event for permission-only flips on an
|
|
// existing share (view ↔ edit). Same invalidation as `shared`,
|
|
// but kept distinct so the contract is explicit and so future UI
|
|
// can react with a toast like "Your access changed to Edit".
|
|
socket.on(
|
|
"note-folder-share-updated",
|
|
(payload?: { folderId?: number; permission?: "view" | "edit" }) => {
|
|
invalidate(["note-folders", "shared-with-me"]);
|
|
if (typeof payload?.folderId === "number") {
|
|
invalidate(["notes", "shared-folder", payload.folderId]);
|
|
}
|
|
},
|
|
);
|
|
socket.on(
|
|
"note-folder-unshared",
|
|
(payload?: { folderId?: number }) => {
|
|
invalidate(["note-folders", "shared-with-me"]);
|
|
if (typeof payload?.folderId === "number") {
|
|
invalidate(["notes", "shared-folder", payload.folderId]);
|
|
}
|
|
},
|
|
);
|
|
|
|
socket.on("apps_changed", () => {
|
|
invalidate(getListAppsQueryKey());
|
|
invalidate(getGetMeQueryKey());
|
|
});
|
|
|
|
socket.on(
|
|
"executive_meetings_changed",
|
|
(payload?: { date?: string }) => {
|
|
if (typeof payload?.date === "string" && payload.date.length > 0) {
|
|
invalidate(["/api/executive-meetings", payload.date],);
|
|
} else {
|
|
invalidate(["/api/executive-meetings"],);
|
|
}
|
|
},
|
|
);
|
|
|
|
socket.on("executive_meeting_notifications_changed", () => {
|
|
invalidate(["/api/executive-meetings/notifications"],);
|
|
});
|
|
|
|
// #277: Per-user push for the 5-min upcoming-meeting alert. The server
|
|
// only emits this to `user:${userId}`, so receiving it means *this*
|
|
// user (in this or another of their tabs / devices) acknowledged or
|
|
// dismissed an alert. Invalidate the alert-state query so the open
|
|
// alert card hides within ~1s instead of waiting on the 30s poll.
|
|
// Other users do not receive this event, so their alert stays visible.
|
|
socket.on(
|
|
"executive_meeting_alert_state_changed",
|
|
() => {
|
|
invalidate(["/api/executive-meetings/alert-state"],);
|
|
},
|
|
);
|
|
|
|
socket.on(
|
|
"role_permissions_changed",
|
|
(payload?: { roleId?: number }) => {
|
|
invalidate(getGetMeQueryKey());
|
|
invalidate(getListPermissionsQueryKey());
|
|
invalidate(getListRolesQueryKey());
|
|
const roleId = payload?.roleId;
|
|
if (typeof roleId === "number") {
|
|
invalidate(getGetRolePermissionsQueryKey(roleId),);
|
|
invalidate(getGetRolePermissionAuditQueryKey(roleId),);
|
|
invalidate(getGetRoleUsageQueryKey(roleId),);
|
|
}
|
|
},
|
|
);
|
|
|
|
// #632: When the PWA / tab comes back to the foreground, iPad's
|
|
// background suspension may have silently killed the WebSocket
|
|
// even though `socket.connected` is still `true` (half-open
|
|
// socket — the client hasn't sent a ping yet). Probing with a
|
|
// timed roundtrip is the only reliable way to tell. We use the
|
|
// socket.io v4 ack-timeout helper to round-trip a cheap event;
|
|
// if the server doesn't ack within 3s, force-cycle the transport
|
|
// so missed orders surface within ~3s of returning to the app
|
|
// instead of waiting on the ping-timeout window (~15s).
|
|
const onVisibility = () => {
|
|
if (typeof document === "undefined") return;
|
|
if (document.visibilityState !== "visible") return;
|
|
if (!socket.connected) {
|
|
wasDisconnected = true;
|
|
socket.connect();
|
|
return;
|
|
}
|
|
// Half-open probe: round-trip a cheap event through socket.io's
|
|
// built-in ack channel. The server doesn't need to register a
|
|
// handler — when no handler exists, the ack still fires with
|
|
// no args, so a missing ack within 3s means the transport is
|
|
// dead and we should force-cycle it.
|
|
socket.timeout(3000).emit(
|
|
"client_health_probe",
|
|
(err: Error | null) => {
|
|
if (err && socket.connected) {
|
|
wasDisconnected = true;
|
|
socket.disconnect().connect();
|
|
}
|
|
},
|
|
);
|
|
};
|
|
if (typeof document !== "undefined") {
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
}
|
|
|
|
return () => {
|
|
if (typeof document !== "undefined") {
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
}
|
|
if (rafHandle !== null) {
|
|
if (typeof window !== "undefined" && typeof window.cancelAnimationFrame === "function") {
|
|
window.cancelAnimationFrame(rafHandle);
|
|
} else {
|
|
window.clearTimeout(rafHandle);
|
|
}
|
|
rafHandle = null;
|
|
}
|
|
pendingKeys.clear();
|
|
socket.disconnect();
|
|
};
|
|
}, [user, queryClient]);
|
|
}
|