From 02c8d8ec2732ba534bab2bbb5fcd787f4f2f59fa Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 4 Sep 2026 04:03:01 -0700 Subject: [PATCH] feat: read the header's connection quality from the heartbeat roundtrip the socket provider already measures instead of an interval that compared itself against its own last tick and so reported a healthy socket as a slow one on almost every pass --- web/src/hooks/RealtimeManager.tsx | 47 ++++++++++++++++--------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/web/src/hooks/RealtimeManager.tsx b/web/src/hooks/RealtimeManager.tsx index 9abd0dca..bf2e23c9 100644 --- a/web/src/hooks/RealtimeManager.tsx +++ b/web/src/hooks/RealtimeManager.tsx @@ -7,6 +7,12 @@ import { useRealtimeEvents } from './useRealtimeEvents' import { PresenceProvider } from './PresenceProvider' import useUnseenCount from '@/lib/api/hooks/app/unibox/useUnseenCount' +// Heartbeat roundtrip bands behind the header's connection indicator. A healthy +// socket answers in tens of milliseconds, and the provider's own watchdog gives +// up at 8s and reconnects, so "poor" stays well under that. +const DEGRADED_LATENCY_MS = 500 +const POOR_LATENCY_MS = 2000 + export function RealtimeManager({ children }: { children: React.ReactNode }) { const { isConnected, reconnectAttempt, joinChannel, leaveChannel } = useSocket() const { user } = useUserProfile() @@ -17,10 +23,9 @@ export function RealtimeManager({ children }: { children: React.ReactNode }) { const addJoinedChannel = useAppStore((s) => s.addJoinedChannel) const removeJoinedChannel = useAppStore((s) => s.removeJoinedChannel) const setUnseenCount = useAppStore((s) => s.setUnseenCount) + const wsLatencyMs = useAppStore((s) => s.wsLatencyMs) const queryClient = useQueryClient() - const heartbeatRef = useRef | null>(null) - const lastHeartbeatRef = useRef(Date.now()) const hadConnectionRef = useRef(false) // Catch-up on reconnect: events are fire-and-forget, so anything emitted @@ -101,30 +106,26 @@ export function RealtimeManager({ children }: { children: React.ReactNode }) { } }, [isConnected, currentOrg?.id, joinChannel, leaveChannel, addJoinedChannel, removeJoinedChannel]) - // Connection quality monitoring + // Connection quality, from the heartbeat roundtrip the socket provider + // measures. The previous version compared its own 5s interval against + // itself, so ordinary timer drift read as "degraded" and the header claimed + // "Slow connection" on a perfectly healthy socket. useEffect(() => { if (!isConnected) return - - lastHeartbeatRef.current = Date.now() - - heartbeatRef.current = setInterval(() => { - const elapsed = Date.now() - lastHeartbeatRef.current - if (elapsed > 15000) { - setConnectionQuality('poor') - } else if (elapsed > 5000) { - setConnectionQuality('degraded') - } else { - setConnectionQuality('good') - } - lastHeartbeatRef.current = Date.now() - }, 5000) - - return () => { - if (heartbeatRef.current) { - clearInterval(heartbeatRef.current) - } + // Null until the first heartbeat reply lands: a connected socket with + // nothing to report yet, not a slow one. + if (wsLatencyMs === null) { + setConnectionQuality('good') + return } - }, [isConnected, setConnectionQuality]) + if (wsLatencyMs > POOR_LATENCY_MS) { + setConnectionQuality('poor') + } else if (wsLatencyMs > DEGRADED_LATENCY_MS) { + setConnectionQuality('degraded') + } else { + setConnectionQuality('good') + } + }, [isConnected, wsLatencyMs, setConnectionQuality]) // Seed the unread inbox count from the server. The store value is otherwise // session-only (realtime increments it but it starts at 0), so seeding makes