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 <noreply@hapi.run>

* 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 <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
Haoqing Wang
2026-04-05 13:21:19 +08:00
committed by GitHub
co-authored by HAPI
parent 29b87d92fc
commit 450b4f8aa7
4 changed files with 19 additions and 0 deletions
@@ -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')
}
+14
View File
@@ -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
+1
View File
@@ -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',
+1
View File
@@ -239,6 +239,7 @@ export default {
'reconnecting.reason.error': '流连接错误',
'reconnecting.reason.closed': '流连接已关闭',
'reconnecting.reason.heartbeatTimeout': '心跳超时',
'reconnecting.reason.visibilityRecovery': '后台恢复中',
// Send blocked
'send.blocked.title': '无法发送消息',