import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { relayClient } from "@/shared/api/relayClient"; import { isRelayConnectionDegraded, type ConnectionState, } from "@/shared/api/relayClientShared"; import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation"; import { isRateLimited, waitForRateLimit, } from "@/shared/api/relayRateLimitGate"; export const AUTO_HEAL_MIN_INTERVAL_MS = 15_000; /** * Tracks degraded→connected transitions and fires `onHeal` at most once per * `minIntervalMs`. When a transition is suppressed by the rate limiter, a * deferred heal is scheduled for the remaining window so the *last* recovery * always wins — stale query errors do not persist after reconnect. * * Injectable deps make this testable without React or DOM. */ export class RelayAutoHealScheduler { private lastHealAt = -Infinity; private deferredId: number | null = null; private readonly onHeal: () => void; private readonly minIntervalMs: number; private readonly setTimeoutFn: (fn: () => void, ms: number) => number; private readonly clearTimeoutFn: (id: number) => void; private readonly nowFn: () => number; constructor( onHeal: () => void, minIntervalMs: number, setTimeoutFn: (fn: () => void, ms: number) => number, clearTimeoutFn: (id: number) => void, nowFn: () => number = () => Date.now(), ) { this.onHeal = onHeal; this.minIntervalMs = minIntervalMs; this.setTimeoutFn = setTimeoutFn; this.clearTimeoutFn = clearTimeoutFn; this.nowFn = nowFn; } /** * Call on every connection-state change. Fires `onHeal` (immediately or * deferred) when the transition is degraded→connected. */ onTransition(prev: ConnectionState, next: ConnectionState): void { if (!(isRelayConnectionDegraded(prev) && next === "connected")) return; // A new recovery supersedes any pending deferred heal. if (this.deferredId !== null) { this.clearTimeoutFn(this.deferredId); this.deferredId = null; } const now = this.nowFn(); const elapsed = now - this.lastHealAt; if (elapsed < this.minIntervalMs) { // Rate-limited — schedule for when the window expires. const remaining = this.minIntervalMs - elapsed; this.deferredId = this.setTimeoutFn(() => { this.deferredId = null; this.lastHealAt = this.nowFn(); this.onHeal(); }, remaining); } else { this.lastHealAt = now; this.onHeal(); } } /** Cancel any pending deferred heal (call on unmount). */ dispose(): void { if (this.deferredId !== null) { this.clearTimeoutFn(this.deferredId); this.deferredId = null; } } } /** * Auto-heal: when the connection recovers from a degraded state, invalidate * relay-dependent queries so errored queries (e.g. messages, which don't poll) * refetch automatically without requiring a manual reconnect action. * * Rate-limited to prevent a flappy connection (e.g. VPN toggling) from * firing a relay-wide invalidation across active queries with retry:1 every * time the relay briefly recovers. * * When a recovery is suppressed by the rate limiter (an earlier flap consumed * the budget), a deferred heal is scheduled for the remaining window so the * *last* recovery always wins and stale query errors do not persist. */ export function useRelayAutoHeal(): void { const queryClient = useQueryClient(); const schedulerRef = React.useRef(null); if (schedulerRef.current === null) { schedulerRef.current = new RelayAutoHealScheduler( () => { if (isRateLimited()) { // Connection recovered but the relay is still under back-pressure. // Defer the invalidate until the rate-limit window clears so queries // don't immediately refetch and generate another burst. void waitForRateLimit().then(() => { void queryClient.invalidateQueries({ predicate: isRelayDependentQuery, }); }); } else { void queryClient.invalidateQueries({ predicate: isRelayDependentQuery, }); } }, AUTO_HEAL_MIN_INTERVAL_MS, window.setTimeout.bind(window), window.clearTimeout.bind(window), ); } React.useEffect(() => { // Observe the RAW connection-state emitter, not the 2s-debounced // useRelayConnection() hook. A sub-2s flap never surfaces through the // debounced hook (its degraded report is cancelled by the recovery), yet // resetConnection() already rejected every in-flight query — the heal // must still fire or errored panes persist until a manual reconnect. let prev: ConnectionState | null = null; const unsubscribe = relayClient.subscribeToConnectionState((next) => { if (prev !== null) { schedulerRef.current?.onTransition(prev, next); } prev = next; }); return () => { unsubscribe(); schedulerRef.current?.dispose(); }; }, []); }