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.
This commit is contained in:
Haoqing Wang
2026-08-01 23:25:40 +08:00
committed by GitHub
parent 8e261a1f7e
commit 079556b43a
3 changed files with 160 additions and 8 deletions
+11 -8
View File
@@ -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<string | null>(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') {
@@ -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()
})
})
+58
View File
@@ -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<string | null>(null)
const delayTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 }
}