Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion interface/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const queryClient = new QueryClient({
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: true,
refetchOnWindowFocus: false,
refetchInterval: false,
},
},
});
Expand Down
70 changes: 65 additions & 5 deletions interface/src/hooks/useEventSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,25 +40,42 @@ export function useEventSource(url: string, options: UseEventSourceOptions) {
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const eventSourceRef = useRef<EventSource | null>(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?.();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
Expand Down Expand Up @@ -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;
Expand All @@ -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");
Expand Down
Loading