mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+11
-8
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user