import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; import { getWsConnectionStatus, getWsConnectionUiState, setBrowserOnlineStatus, type WsConnectionStatus, type WsConnectionUiState, useWsConnectionStatus, WS_RECONNECT_MAX_ATTEMPTS, } from "../rpc/wsConnectionState"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { getPrimaryEnvironmentConnection } from "../environments/runtime"; const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; type WsAutoReconnectTrigger = "focus" | "online"; const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, { day: "numeric", hour: "numeric", minute: "2-digit", month: "short", second: "2-digit", }); function formatConnectionMoment(isoDate: string | null): string | null { if (!isoDate) { return null; } return connectionTimeFormatter.format(new Date(isoDate)); } function formatRetryCountdown(nextRetryAt: string, nowMs: number): string { const remainingMs = Math.max(0, new Date(nextRetryAt).getTime() - nowMs); return `${Math.max(1, Math.ceil(remainingMs / 1000))}s`; } function describeOfflineToast(): string { return "WebSocket disconnected. Waiting for network."; } function formatReconnectAttemptLabel(status: WsConnectionStatus): string { const reconnectAttempt = Math.max( 1, Math.min(status.reconnectAttemptCount, WS_RECONNECT_MAX_ATTEMPTS), ); return `Attempt ${reconnectAttempt}/${status.reconnectMaxAttempts}`; } function describeExhaustedToast(): string { return "Retries exhausted trying to reconnect"; } function buildReconnectTitle(_status: WsConnectionStatus): string { return "Disconnected from T3 Server"; } function describeRecoveredToast( previousDisconnectedAt: string | null, connectedAt: string | null, ): string { const reconnectedAtLabel = formatConnectionMoment(connectedAt); const disconnectedAtLabel = formatConnectionMoment(previousDisconnectedAt); if (disconnectedAtLabel && reconnectedAtLabel) { return `Disconnected at ${disconnectedAtLabel} and reconnected at ${reconnectedAtLabel}.`; } if (reconnectedAtLabel) { return `Connection restored at ${reconnectedAtLabel}.`; } return "Connection restored."; } function describeSlowRpcAckToast(requests: ReadonlyArray): string { const count = requests.length; const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; } function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray }) { return ( ); } export function shouldAutoReconnect( status: WsConnectionStatus, trigger: WsAutoReconnectTrigger, ): boolean { const uiState = getWsConnectionUiState(status); if (trigger === "online") { return ( uiState === "offline" || uiState === "reconnecting" || uiState === "error" || status.reconnectPhase === "exhausted" ); } return ( status.online && status.hasConnected && (uiState === "reconnecting" || status.reconnectPhase === "exhausted") ); } export function shouldRestartStalledReconnect( status: WsConnectionStatus, expectedNextRetryAt: string, ): boolean { return ( status.reconnectPhase === "waiting" && status.nextRetryAt === expectedNextRetryAt && status.online && status.hasConnected ); } export function WebSocketConnectionCoordinator() { const status = useWsConnectionStatus(); const [nowMs, setNowMs] = useState(() => Date.now()); const lastForcedReconnectAtRef = useRef(0); const toastIdRef = useRef | null>(null); const toastResetTimerRef = useRef(null); const previousUiStateRef = useRef(getWsConnectionUiState(status)); const previousDisconnectedAtRef = useRef(status.disconnectedAt); const runReconnect = useEffectEvent((showFailureToast: boolean) => { if (toastResetTimerRef.current !== null) { window.clearTimeout(toastResetTimerRef.current); toastResetTimerRef.current = null; } lastForcedReconnectAtRef.current = Date.now(); void getPrimaryEnvironmentConnection() .reconnect() .catch((error) => { if (!showFailureToast) { console.warn("Automatic WebSocket reconnect failed", { error }); return; } toastManager.add( stackedThreadToast({ type: "error", title: "Reconnect failed", description: error instanceof Error ? error.message : "Unable to restart the WebSocket.", data: { dismissAfterVisibleMs: 8_000, hideCopyButton: true, }, }), ); }); }); const syncBrowserOnlineStatus = useEffectEvent(() => { setBrowserOnlineStatus(navigator.onLine !== false); }); const triggerManualReconnect = useEffectEvent(() => { runReconnect(true); }); const triggerAutoReconnect = useEffectEvent((trigger: WsAutoReconnectTrigger) => { const currentStatus = trigger === "online" ? setBrowserOnlineStatus(true) : getWsConnectionStatus(); if (!shouldAutoReconnect(currentStatus, trigger)) { return; } if (Date.now() - lastForcedReconnectAtRef.current < FORCED_WS_RECONNECT_DEBOUNCE_MS) { return; } runReconnect(false); }); useEffect(() => { const handleOnline = () => { triggerAutoReconnect("online"); }; const handleFocus = () => { triggerAutoReconnect("focus"); }; syncBrowserOnlineStatus(); window.addEventListener("online", handleOnline); window.addEventListener("offline", syncBrowserOnlineStatus); window.addEventListener("focus", handleFocus); return () => { window.removeEventListener("online", handleOnline); window.removeEventListener("offline", syncBrowserOnlineStatus); window.removeEventListener("focus", handleFocus); }; }, []); useEffect(() => { if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) { return; } setNowMs(Date.now()); const intervalId = window.setInterval(() => { setNowMs(Date.now()); }, 1_000); return () => { window.clearInterval(intervalId); }; }, [status.nextRetryAt, status.reconnectPhase]); useEffect(() => { if ( status.reconnectPhase !== "waiting" || status.nextRetryAt === null || !status.online || !status.hasConnected ) { return; } const nextRetryAt = status.nextRetryAt; const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500; const timeoutId = window.setTimeout(() => { const currentStatus = getWsConnectionStatus(); if (!shouldRestartStalledReconnect(currentStatus, nextRetryAt)) { return; } runReconnect(false); }, timeoutMs); return () => { window.clearTimeout(timeoutId); }; }, [ status.hasConnected, status.nextRetryAt, status.online, status.reconnectAttemptCount, status.reconnectPhase, ]); useEffect(() => { const uiState = getWsConnectionUiState(status); const previousUiState = previousUiStateRef.current; const previousDisconnectedAt = previousDisconnectedAtRef.current; const shouldShowReconnectToast = status.hasConnected && uiState === "reconnecting"; const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null; const shouldShowExhaustedToast = status.hasConnected && status.reconnectPhase === "exhausted"; if ( toastResetTimerRef.current !== null && (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) ) { window.clearTimeout(toastResetTimerRef.current); toastResetTimerRef.current = null; } if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { const toastPayload = shouldShowOfflineToast ? stackedThreadToast({ data: { hideCopyButton: true, }, description: describeOfflineToast(), timeout: 0, title: "Offline", type: "warning", }) : shouldShowExhaustedToast ? stackedThreadToast({ actionProps: { children: "Retry", onClick: triggerManualReconnect, }, data: { hideCopyButton: true, }, description: describeExhaustedToast(), timeout: 0, title: "Disconnected from T3 Server", type: "error", }) : stackedThreadToast({ actionProps: { children: "Retry now", onClick: triggerManualReconnect, }, data: { hideCopyButton: true, }, description: status.nextRetryAt === null ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, timeout: 0, title: buildReconnectTitle(status), type: "loading", }); if (toastIdRef.current) { toastManager.update(toastIdRef.current, toastPayload); } else { toastIdRef.current = toastManager.add(toastPayload); } } else if (toastIdRef.current) { toastManager.close(toastIdRef.current); toastIdRef.current = null; } if ( uiState === "connected" && (previousUiState === "offline" || previousUiState === "reconnecting") && previousDisconnectedAt !== null ) { const successToast = { description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), title: "Reconnected to T3 Server", type: "success" as const, timeout: 0, data: { dismissAfterVisibleMs: 8_000, hideCopyButton: true, }, }; if (toastIdRef.current) { toastManager.update(toastIdRef.current, successToast); } else { toastIdRef.current = toastManager.add(successToast); } toastResetTimerRef.current = window.setTimeout(() => { toastIdRef.current = null; toastResetTimerRef.current = null; }, 8_250); } previousUiStateRef.current = uiState; previousDisconnectedAtRef.current = status.disconnectedAt; }, [nowMs, status]); useEffect(() => { return () => { if (toastResetTimerRef.current !== null) { window.clearTimeout(toastResetTimerRef.current); } }; }, []); return null; } export function SlowRpcAckToastCoordinator() { const slowRequests = useSlowRpcAckRequests(); const status = useWsConnectionStatus(); const toastIdRef = useRef | null>(null); useEffect(() => { if (getWsConnectionUiState(status) !== "connected") { if (toastIdRef.current) { toastManager.close(toastIdRef.current); toastIdRef.current = null; } return; } if (slowRequests.length === 0) { if (toastIdRef.current) { toastManager.close(toastIdRef.current); toastIdRef.current = null; } return; } const nextToast = { data: { expandableContent: , expandableDescriptionTrigger: true, expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, }, description: describeSlowRpcAckToast(slowRequests), timeout: 0, title: "Some requests are slow", type: "warning" as const, }; if (toastIdRef.current) { toastManager.update(toastIdRef.current, nextToast); } else { toastIdRef.current = toastManager.add(nextToast); } }, [slowRequests, status]); return null; } export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { return children; }