From 04b8cdb873401a15b1189c84799857665eb16420 Mon Sep 17 00:00:00 2001 From: bhagwan Date: Sun, 28 Jun 2026 11:41:46 -0400 Subject: [PATCH 1/2] fix(interface): prevent refresh and reconnect banner on iOS app switch Disable React Query polling and focus refetch so backgrounding the app does not trigger data refreshes when the tab regains focus. Add a visibility-aware EventSource handler that closes the SSE connection cleanly on hide and reconnects immediately on show, skipping the exponential backoff. Errors from stale/closed connections are ignored. onReconnect only fires after genuine error recovery, not visibility-driven reconnects. --- interface/src/App.tsx | 3 +- interface/src/hooks/useEventSource.ts | 48 ++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/interface/src/App.tsx b/interface/src/App.tsx index 45d7e823b..9f6c4cb92 100644 --- a/interface/src/App.tsx +++ b/interface/src/App.tsx @@ -12,7 +12,8 @@ const queryClient = new QueryClient({ queries: { staleTime: 30_000, retry: 1, - refetchOnWindowFocus: true, + refetchOnWindowFocus: false, + refetchInterval: false, }, }, }); diff --git a/interface/src/hooks/useEventSource.ts b/interface/src/hooks/useEventSource.ts index 1647f8d64..0cc581994 100644 --- a/interface/src/hooks/useEventSource.ts +++ b/interface/src/hooks/useEventSource.ts @@ -20,6 +20,12 @@ const BACKOFF_MULTIPLIER = 2; /** * SSE hook with exponential backoff, connection state tracking, * and reconnect notification for state recovery. + * + * On mobile browsers, background tabs often have their SSE connections + * frozen or dropped. We listen for visibility changes and close the + * EventSource cleanly on hide, then reconnect immediately on show. This + * avoids waiting through the exponential backoff and prevents a stale + * "reconnecting" banner from lingering when the user returns. */ export function useEventSource(url: string, options: UseEventSourceOptions) { const { handlers, enabled = true, onReconnect } = options; @@ -34,25 +40,23 @@ export function useEventSource(url: string, options: UseEventSourceOptions) { const reconnectTimeout = useRef | null>(null); const eventSourceRef = useRef(null); const retryDelayRef = useRef(INITIAL_RETRY_MS); - const hadConnectionRef = useRef(false); const connect = useCallback(() => { if (eventSourceRef.current) { eventSourceRef.current.close(); } - setConnectionState(hadConnectionRef.current ? "reconnecting" : "connecting"); + setConnectionState("connecting"); const source = new EventSource(url); eventSourceRef.current = source; source.onopen = () => { - const wasReconnect = hadConnectionRef.current; - hadConnectionRef.current = true; + const wasErrorReconnect = retryDelayRef.current !== INITIAL_RETRY_MS; retryDelayRef.current = INITIAL_RETRY_MS; setConnectionState("connected"); - if (wasReconnect) { + if (wasErrorReconnect) { onReconnectRef.current?.(); } }; @@ -82,6 +86,12 @@ export function useEventSource(url: string, options: UseEventSourceOptions) { }); source.onerror = () => { + // Ignore errors from connections we've already replaced or closed + // (e.g. via the visibility handler or a newer reconnect attempt). + if (eventSourceRef.current !== source) { + return; + } + source.close(); setConnectionState("reconnecting"); @@ -91,6 +101,34 @@ export function useEventSource(url: string, options: UseEventSourceOptions) { }; }, [url]); + // Close the EventSource cleanly when the tab is hidden so the browser + // doesn't leave a frozen connection in a half-open state. Reconnect + // immediately when the tab becomes visible again. State is left as-is + // on hide; connect() sets "connecting" (hidden by ConnectionBanner when + // hasData is true) and onopen restores "connected". + useEffect(() => { + if (!enabled || typeof document === "undefined") return; + + const handleVisibilityChange = () => { + if (document.hidden) { + if (reconnectTimeout.current) { + clearTimeout(reconnectTimeout.current); + reconnectTimeout.current = null; + } + eventSourceRef.current?.close(); + eventSourceRef.current = null; + } else { + retryDelayRef.current = INITIAL_RETRY_MS; + connect(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [enabled, connect]); + useEffect(() => { if (!enabled) { setConnectionState("disconnected"); From 67a8639ba30fd8ab5312ac8f0d6bc8216914cd26 Mon Sep 17 00:00:00 2001 From: bhagwan Date: Sun, 28 Jun 2026 20:44:37 -0400 Subject: [PATCH 2/2] fix(interface): preserve error-reconnect signal across app switches in SSE hook The visibility handler resets retryDelayRef when the app returns to the foreground so reconnects are immediate. This caused onReconnect to be skipped after a foreground reconnect because the code used retryDelayRef as the error-reconnect signal. Add a dedicated pendingErrorReconnectRef that is set in onerror and cleared in onopen after handling it. This flag survives visibility changes so missed-event re-sync still fires when a foreground reconnect recovers from a prior connection error. Refs: interface/src/hooks/useEventSource.ts --- interface/src/hooks/useEventSource.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/interface/src/hooks/useEventSource.ts b/interface/src/hooks/useEventSource.ts index 0cc581994..f9fdac492 100644 --- a/interface/src/hooks/useEventSource.ts +++ b/interface/src/hooks/useEventSource.ts @@ -40,6 +40,12 @@ export function useEventSource(url: string, options: UseEventSourceOptions) { const reconnectTimeout = useRef | null>(null); const eventSourceRef = useRef(null); const retryDelayRef = useRef(INITIAL_RETRY_MS); + // Tracks whether the current reconnect attempt was triggered by an actual + // connection error (as opposed to a visibility-change reconnect). This is + // separate from retryDelayRef because the visibility handler resets the + // backoff delay when the app returns to the foreground, but we still need + // to know whether a prior error occurred so onReconnect can re-sync data. + const pendingErrorReconnectRef = useRef(false); const connect = useCallback(() => { if (eventSourceRef.current) { @@ -52,11 +58,24 @@ export function useEventSource(url: string, options: UseEventSourceOptions) { eventSourceRef.current = source; source.onopen = () => { - const wasErrorReconnect = retryDelayRef.current !== INITIAL_RETRY_MS; + // Ignore open events from connections we've already replaced or + // closed (e.g. via the visibility handler or a newer reconnect + // attempt). Only the currently active source may update state. + if (eventSourceRef.current !== source) { + return; + } + + // pendingErrorReconnectRef is set in onerror and cleared here after + // onopen handles it. We use a dedicated flag (instead of checking + // retryDelayRef) because the visibility handler resets the backoff + // delay when the app returns to the foreground, and we must still + // re-sync data if the reconnect is recovering from a prior error. + const shouldResync = pendingErrorReconnectRef.current; + pendingErrorReconnectRef.current = false; retryDelayRef.current = INITIAL_RETRY_MS; setConnectionState("connected"); - if (wasErrorReconnect) { + if (shouldResync) { onReconnectRef.current?.(); } }; @@ -93,6 +112,9 @@ export function useEventSource(url: string, options: UseEventSourceOptions) { } source.close(); + // Mark that this reconnect is due to an actual error so onopen can + // decide whether to call onReconnect for missed-event re-sync. + pendingErrorReconnectRef.current = true; setConnectionState("reconnecting"); const delay = retryDelayRef.current;