From 450b4f8aa7d472ef03acd8bfd668f3cddbe31efe Mon Sep 17 00:00:00 2001 From: Haoqing Wang <78337154+hqhq1025@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:21:19 +0800 Subject: [PATCH] fix(web): reconnect SSE immediately when tab becomes visible (#398) * fix(web): reconnect SSE immediately when tab becomes visible The SSE watchdog skips heartbeat checks while the tab is hidden. If the connection dies in the background, the user sees stale messages after switching back and has to wait up to 10 s for the next watchdog tick. Add a visibilitychange listener that checks heartbeat staleness immediately when the tab becomes visible and reconnects if stale. via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(web): map visibility-recovery reason in reconnecting banner The new 'visibility-recovery' reconnect reason was not mapped in getReasonLabel(), so the raw string would appear in the UI banner. Add localized labels for both en and zh-CN. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --------- Co-authored-by: HAPI --- web/src/components/ReconnectingBanner.tsx | 3 +++ web/src/hooks/useSSE.ts | 14 ++++++++++++++ web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + 4 files changed, 19 insertions(+) diff --git a/web/src/components/ReconnectingBanner.tsx b/web/src/components/ReconnectingBanner.tsx index e7a3ad01..76829771 100644 --- a/web/src/components/ReconnectingBanner.tsx +++ b/web/src/components/ReconnectingBanner.tsx @@ -5,6 +5,9 @@ function getReasonLabel(reason: string, t: (key: string) => string): string { if (reason === 'heartbeat-timeout') { return t('reconnecting.reason.heartbeatTimeout') } + if (reason === 'visibility-recovery') { + return t('reconnecting.reason.visibilityRecovery') + } if (reason === 'closed') { return t('reconnecting.reason.closed') } diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 66f310d2..aa166419 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -596,8 +596,22 @@ export function useSSE(options: { requestReconnect('heartbeat-timeout') }, HEARTBEAT_WATCHDOG_INTERVAL_MS) + // When the tab becomes visible again, check immediately whether the + // SSE connection went stale while hidden (the watchdog skips checks + // for hidden tabs). This avoids the user having to wait up to + // HEARTBEAT_WATCHDOG_INTERVAL_MS after switching back. + const onVisibilityChange = () => { + if (getVisibilityState() !== 'visible') return + if (eventSourceRef.current !== eventSource) return + if (Date.now() - lastActivityAtRef.current >= HEARTBEAT_STALE_MS) { + requestReconnect('visibility-recovery') + } + } + document.addEventListener('visibilitychange', onVisibilityChange) + return () => { clearInterval(watchdogTimer) + document.removeEventListener('visibilitychange', onVisibilityChange) if (invalidationTimerRef.current) { clearTimeout(invalidationTimerRef.current) invalidationTimerRef.current = null diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index f2612610..c8eff291 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -237,6 +237,7 @@ export default { 'reconnecting.reason.error': 'stream error', 'reconnecting.reason.closed': 'stream closed', 'reconnecting.reason.heartbeatTimeout': 'heartbeat timeout', + 'reconnecting.reason.visibilityRecovery': 'resuming after background', // Send blocked 'send.blocked.title': 'Cannot send message', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ea220f5a..32eaff2f 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -239,6 +239,7 @@ export default { 'reconnecting.reason.error': '流连接错误', 'reconnecting.reason.closed': '流连接已关闭', 'reconnecting.reason.heartbeatTimeout': '心跳超时', + 'reconnecting.reason.visibilityRecovery': '后台恢复中', // Send blocked 'send.blocked.title': '无法发送消息',