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..f9fdac492 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,42 @@ 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); + // 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) { 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; + // 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 (wasReconnect) { + if (shouldResync) { onReconnectRef.current?.(); } }; @@ -82,7 +105,16 @@ 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(); + // 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; @@ -91,6 +123,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");