diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index b54bd865c8b..619c7d50080 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; +import { type ReactNode, type RefObject, useEffect, useEffectEvent, useRef } from "react"; import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; import { @@ -53,6 +53,8 @@ function describeExhaustedToast(): string { return "Retries exhausted trying to reconnect"; } +type ThreadToastId = ReturnType; + function getConnectionDisplayName(status: WsConnectionStatus): string { return status.connectionLabel?.trim() || "T3 Server"; } @@ -90,6 +92,70 @@ function describeSlowRpcAckToast(requests: ReadonlyArray): st return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; } +function buildReconnectToast( + status: WsConnectionStatus, + nowMs: number, + triggerManualReconnect: () => void, +) { + return 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", + }); +} + +function useReconnectToastCountdown( + status: WsConnectionStatus, + toastIdRef: RefObject, + triggerManualReconnect: () => void, +) { + const nowMsRef = useRef(Date.now()); + + useEffect(() => { + if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) { + return; + } + + const refreshReconnectToast = () => { + nowMsRef.current = Date.now(); + const toastId = toastIdRef.current; + if (!toastId) { + return; + } + + const currentStatus = getWsConnectionStatus(); + if (getWsConnectionUiState(currentStatus) !== "reconnecting") { + return; + } + + toastManager.update( + toastId, + buildReconnectToast(currentStatus, nowMsRef.current, triggerManualReconnect), + ); + }; + + refreshReconnectToast(); + const intervalId = window.setInterval(refreshReconnectToast, 1_000); + + return () => { + window.clearInterval(intervalId); + }; + }, [status.nextRetryAt, status.reconnectPhase, toastIdRef, triggerManualReconnect]); + + return nowMsRef; +} + function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray }) { return (
    @@ -147,9 +213,8 @@ export function shouldRestartStalledReconnect( export function WebSocketConnectionCoordinator() { const status = useWsConnectionStatus(); - const [nowMs, setNowMs] = useState(() => Date.now()); const lastForcedReconnectAtRef = useRef(0); - const toastIdRef = useRef | null>(null); + const toastIdRef = useRef(null); const toastResetTimerRef = useRef(null); const previousUiStateRef = useRef(getWsConnectionUiState(status)); const previousDisconnectedAtRef = useRef(status.disconnectedAt); @@ -200,6 +265,11 @@ export function WebSocketConnectionCoordinator() { runReconnect(false); }); + const reconnectToastNowMsRef = useReconnectToastCountdown( + status, + toastIdRef, + triggerManualReconnect, + ); useEffect(() => { const handleOnline = () => { @@ -220,21 +290,6 @@ export function WebSocketConnectionCoordinator() { }; }, []); - 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" || @@ -308,22 +363,7 @@ export function WebSocketConnectionCoordinator() { title: buildReconnectTitle(status), 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", - }); + : buildReconnectToast(status, reconnectToastNowMsRef.current, triggerManualReconnect); if (toastIdRef.current) { toastManager.update(toastIdRef.current, toastPayload); @@ -365,7 +405,7 @@ export function WebSocketConnectionCoordinator() { previousUiStateRef.current = uiState; previousDisconnectedAtRef.current = status.disconnectedAt; - }, [nowMs, status]); + }, [reconnectToastNowMsRef, status]); useEffect(() => { return () => { diff --git a/docs/perf/react-scan/websocket-reconnect-after.webm b/docs/perf/react-scan/websocket-reconnect-after.webm new file mode 100644 index 00000000000..0177bcec195 Binary files /dev/null and b/docs/perf/react-scan/websocket-reconnect-after.webm differ diff --git a/docs/perf/react-scan/websocket-reconnect-before.webm b/docs/perf/react-scan/websocket-reconnect-before.webm new file mode 100644 index 00000000000..ba078294334 Binary files /dev/null and b/docs/perf/react-scan/websocket-reconnect-before.webm differ