diff --git a/web/src/App.tsx b/web/src/App.tsx index 070f6764..2bccf74b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { useAuth } from '@/hooks/useAuth' import { useAuthSource } from '@/hooks/useAuthSource' import { useServerUrl } from '@/hooks/useServerUrl' import { useSSE } from '@/hooks/useSSE' +import { useReconnectingState } from '@/hooks/useReconnectingState' import { useSyncingState } from '@/hooks/useSyncingState' import { usePushNotifications } from '@/hooks/usePushNotifications' import { useViewportHeight } from '@/hooks/useViewportHeight' @@ -139,8 +140,12 @@ function AppInner() { const sessionMatch = matchRoute({ to: '/sessions/$sessionId' }) const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null const { isSyncing, startSync, endSync } = useSyncingState() - const [sseDisconnected, setSseDisconnected] = useState(false) - const [sseDisconnectReason, setSseDisconnectReason] = useState(null) + const { + isReconnecting: sseDisconnected, + reason: sseDisconnectReason, + reportConnect: reportSseConnect, + reportDisconnect: reportSseDisconnect + } = useReconnectingState() const syncTokenRef = useRef(0) const isFirstConnectRef = useRef(true) const baseUrlRef = useRef(baseUrl) @@ -204,8 +209,7 @@ function AppInner() { const handleSseConnect = useCallback(() => { // Clear disconnected state on successful connection - setSseDisconnected(false) - setSseDisconnectReason(null) + reportSseConnect() // Increment token to track this specific connection const token = ++syncTokenRef.current @@ -241,15 +245,14 @@ function AppInner() { endSync() } }) - }, [api, queryClient, selectedSessionId, startSync, endSync]) + }, [api, queryClient, selectedSessionId, startSync, endSync, reportSseConnect]) const handleSseDisconnect = useCallback((reason: string) => { // Only show reconnecting banner if we've already connected once if (!isFirstConnectRef.current) { - setSseDisconnected(true) - setSseDisconnectReason(reason) + reportSseDisconnect(reason) } - }, []) + }, [reportSseDisconnect]) const handleSseEvent = useCallback((event: SyncEvent) => { if (event.type !== 'messages-invalidated') { diff --git a/web/src/hooks/useReconnectingState.test.ts b/web/src/hooks/useReconnectingState.test.ts new file mode 100644 index 00000000..3c40be55 --- /dev/null +++ b/web/src/hooks/useReconnectingState.test.ts @@ -0,0 +1,91 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RECONNECTING_BANNER_DELAY_MS, useReconnectingState } from './useReconnectingState' + +describe('useReconnectingState', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('starts out connected', () => { + const { result } = renderHook(() => useReconnectingState()) + + expect(result.current.isReconnecting).toBe(false) + expect(result.current.reason).toBeNull() + }) + + it('stays quiet while the browser silently reconnects', () => { + // EventSource fires onerror while it retries on its own, typically + // recovering within a few seconds. Flashing a full-width banner for + // that reads as "the network is broken" when nothing was lost. + const { result } = renderHook(() => useReconnectingState()) + + act(() => result.current.reportDisconnect('error')) + expect(result.current.isReconnecting).toBe(false) + + act(() => { + vi.advanceTimersByTime(RECONNECTING_BANNER_DELAY_MS - 1) + }) + expect(result.current.isReconnecting).toBe(false) + + act(() => result.current.reportConnect()) + act(() => { + vi.advanceTimersByTime(RECONNECTING_BANNER_DELAY_MS) + }) + expect(result.current.isReconnecting).toBe(false) + }) + + it('surfaces the banner once the outage outlasts the grace period', () => { + const { result } = renderHook(() => useReconnectingState()) + + act(() => result.current.reportDisconnect('heartbeat-timeout')) + act(() => { + vi.advanceTimersByTime(RECONNECTING_BANNER_DELAY_MS) + }) + + expect(result.current.isReconnecting).toBe(true) + expect(result.current.reason).toBe('heartbeat-timeout') + }) + + it('keeps the first reason when further disconnects arrive', () => { + const { result } = renderHook(() => useReconnectingState()) + + act(() => result.current.reportDisconnect('error')) + act(() => result.current.reportDisconnect('closed')) + act(() => { + vi.advanceTimersByTime(RECONNECTING_BANNER_DELAY_MS) + }) + + expect(result.current.isReconnecting).toBe(true) + expect(result.current.reason).toBe('error') + }) + + it('clears the banner as soon as the stream comes back', () => { + const { result } = renderHook(() => useReconnectingState()) + + act(() => result.current.reportDisconnect('error')) + act(() => { + vi.advanceTimersByTime(RECONNECTING_BANNER_DELAY_MS) + }) + expect(result.current.isReconnecting).toBe(true) + + act(() => result.current.reportConnect()) + expect(result.current.isReconnecting).toBe(false) + expect(result.current.reason).toBeNull() + }) + + it('drops a pending banner when the hook unmounts', () => { + const { result, unmount } = renderHook(() => useReconnectingState()) + + act(() => result.current.reportDisconnect('error')) + unmount() + + expect(() => { + vi.advanceTimersByTime(RECONNECTING_BANNER_DELAY_MS) + }).not.toThrow() + }) +}) diff --git a/web/src/hooks/useReconnectingState.ts b/web/src/hooks/useReconnectingState.ts new file mode 100644 index 00000000..e12d2a8d --- /dev/null +++ b/web/src/hooks/useReconnectingState.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * EventSource reports an error the moment a connection drops, including while + * it is already retrying on its own - `readyState` is still CONNECTING and the + * stream is usually back within a few seconds. Showing the banner immediately + * turns those self-healing blips into a full-width "connection lost" warning, + * which reads as a broken network even though nothing was lost. + * + * Waiting out a short grace period keeps genuine outages visible (the banner + * still appears, just a moment later) while silent recoveries stay silent. + * The sibling syncing banner debounces the same way in `useSyncingState`. + */ +export const RECONNECTING_BANNER_DELAY_MS = 4_000 + +export function useReconnectingState(): { + isReconnecting: boolean + reason: string | null + reportConnect: () => void + reportDisconnect: (reason: string) => void +} { + const [isReconnecting, setIsReconnecting] = useState(false) + const [reason, setReason] = useState(null) + const delayTimeoutRef = useRef | null>(null) + + const clearPending = useCallback(() => { + if (delayTimeoutRef.current) { + clearTimeout(delayTimeoutRef.current) + delayTimeoutRef.current = null + } + }, []) + + const reportConnect = useCallback(() => { + clearPending() + setIsReconnecting(false) + setReason(null) + }, [clearPending]) + + const reportDisconnect = useCallback((nextReason: string) => { + // A reconnect attempt can fail repeatedly; keep the grace period + // anchored to the first drop so the banner is not pushed back forever, + // and keep the reason that started the outage. + if (delayTimeoutRef.current) { + return + } + delayTimeoutRef.current = setTimeout(() => { + delayTimeoutRef.current = null + setIsReconnecting(true) + setReason(nextReason) + }, RECONNECTING_BANNER_DELAY_MS) + }, []) + + useEffect(() => { + return clearPending + }, [clearPending]) + + return { isReconnecting, reason, reportConnect, reportDisconnect } +}