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<SlowRpcAckRequest>): 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<SlowRpcAckRequest> }) { return ( <ul className="space-y-2.5 text-xs text-muted-foreground"> {requests.map((req) => ( <li className="min-w-0 border-border/50 border-b pb-2 last:border-b-0 last:pb-0" key={req.requestId} > <div className="wrap-break-word font-medium text-foreground">{req.tag}</div> <div className="mt-0.5 font-mono text-[10px] leading-snug opacity-90"> {req.requestId} </div> <div className="mt-0.5 text-[10px] opacity-75"> Started {formatConnectionMoment(req.startedAt) ?? req.startedAt} </div> </li> ))} </ul> );} 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<ReturnType<typeof toastManager.add> | null>(null); const toastResetTimerRef = useRef<number | null>(null); const previousUiStateRef = useRef<WsConnectionUiState>(getWsConnectionUiState(status)); const previousDisconnectedAtRef = useRef<string | null>(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<ReturnType<typeof toastManager.add> | 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: <SlowRpcAckRequestDetails requests={slowRequests} />, 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;}
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<SlowRpcAckRequest>): 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<SlowRpcAckRequest> }) {
return (
<ul className="space-y-2.5 text-xs text-muted-foreground">
{requests.map((req) => (
<li
className="min-w-0 border-border/50 border-b pb-2 last:border-b-0 last:pb-0"
key={req.requestId}
>
<div className="wrap-break-word font-medium text-foreground">{req.tag}</div>
<div className="mt-0.5 font-mono text-[10px] leading-snug opacity-90">
{req.requestId}
</div>
<div className="mt-0.5 text-[10px] opacity-75">
Started {formatConnectionMoment(req.startedAt) ?? req.startedAt}
</div>
</li>
))}
</ul>
);
}
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<ReturnType<typeof toastManager.add> | null>(null);
const toastResetTimerRef = useRef<number | null>(null);
const previousUiStateRef = useRef<WsConnectionUiState>(getWsConnectionUiState(status));
const previousDisconnectedAtRef = useRef<string | null>(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<ReturnType<typeof toastManager.add> | 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: <SlowRpcAckRequestDetails requests={slowRequests} />,
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;
}