From 079556b43a560a17c96289e0abc9c8d6ff493724 Mon Sep 17 00:00:00 2001 From: Haoqing Wang <78337154+hqhq1025@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:25:40 +0800 Subject: [PATCH] fix(web): stop flashing the reconnecting banner on self-healing blips (#1230) EventSource reports an error the moment a connection drops, including while it is already retrying by itself - readyState is still CONNECTING and the stream is typically back within a few seconds. useSSE forwards that straight to onDisconnect, and App turned it into a full-width "reconnecting" banner synchronously, so a blip the browser recovered from on its own still read as a broken network. Route the disconnect through a small hook that waits out a grace period first. Genuine outages still surface the banner, just a moment later; recoveries that beat the timer stay silent. The sibling syncing banner already debounces this way in useSyncingState. The grace period is anchored to the first drop, so repeated failed retries cannot push the banner back indefinitely. --- web/src/App.tsx | 19 +++-- web/src/hooks/useReconnectingState.test.ts | 91 ++++++++++++++++++++++ web/src/hooks/useReconnectingState.ts | 58 ++++++++++++++ 3 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 web/src/hooks/useReconnectingState.test.ts create mode 100644 web/src/hooks/useReconnectingState.ts 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 } +}