Unified · Split
| @@ -1,4 +1,4 @@ | |||
| 1 | import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; | 1 | import { type ReactNode, type RefObject, useEffect, useEffectEvent, useRef } from "react"; |
| 2 | 2 | ||
| 3 | import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; | 3 | import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; |
| 4 | import { | 4 | import { |
| @@ -32,11 +32,6 @@ function formatConnectionMoment(isoDate: string | null): string | null { · 27 unmodified lines | |||
| 32 | return connectionTimeFormatter.format(new Date(isoDate)); | 32 | return connectionTimeFormatter.format(new Date(isoDate)); |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | function formatRetryCountdown(nextRetryAt: string, nowMs: number): string { | ||
| 36 | const remainingMs = Math.max(0, new Date(nextRetryAt).getTime() - nowMs); | ||
| 37 | return `${Math.max(1, Math.ceil(remainingMs / 1000))}s`; | ||
| 38 | } | ||
| 39 | |||
| 40 | function describeOfflineToast(): string { | 35 | function describeOfflineToast(): string { |
| 41 | return "WebSocket disconnected. Waiting for network."; | 36 | return "WebSocket disconnected. Waiting for network."; |
| 42 | } | 37 | } |
| @@ -53,6 +48,17 @@ function describeExhaustedToast(): string { · 10 unmodified lines | |||
| 53 | return "Retries exhausted trying to reconnect"; | 48 | return "Retries exhausted trying to reconnect"; |
| 54 | } | 49 | } |
| 55 | 50 | ||
| 51 | function describeReconnectToast(status: WsConnectionStatus): string { | ||
| 52 | const attemptLabel = formatReconnectAttemptLabel(status); | ||
| 53 | if (status.nextRetryAt === null) { | ||
| 54 | return `Reconnecting... ${attemptLabel}`; | ||
| 55 | } | ||
| 56 | |||
| 57 | return `Next reconnect attempt is scheduled for ${ | ||
| 58 | formatConnectionMoment(status.nextRetryAt) ?? status.nextRetryAt | ||
| 59 | }. ${attemptLabel}`; | ||
| 60 | } | ||
| 61 | |||
| 56 | function getConnectionDisplayName(status: WsConnectionStatus): string { | 62 | function getConnectionDisplayName(status: WsConnectionStatus): string { |
| 57 | return status.connectionLabel?.trim() || "T3 Server"; | 63 | return status.connectionLabel?.trim() || "T3 Server"; |
| 58 | } | 64 | } |
| @@ -145,15 +151,37 @@ export function shouldRestartStalledReconnect( · 86 unmodified lines | |||
| 145 | ); | 151 | ); |
| 146 | } | 152 | } |
| 147 | 153 | ||
| 148 | export function WebSocketConnectionCoordinator() { | 154 | interface WebSocketConnectionRefs { |
| 149 | const status = useWsConnectionStatus(); | 155 | readonly lastForcedReconnectAtRef: RefObject<number>; |
| 150 | const [nowMs, setNowMs] = useState(() => Date.now()); | 156 | readonly previousDisconnectedAtRef: RefObject<string | null>; |
| 157 | readonly previousUiStateRef: RefObject<WsConnectionUiState>; | ||
| 158 | readonly toastIdRef: RefObject<ReturnType<typeof toastManager.add> | null>; | ||
| 159 | readonly toastResetTimerRef: RefObject<number | null>; | ||
| 160 | } | ||
| 161 | |||
| 162 | function useWebSocketConnectionRefs(status: WsConnectionStatus): WebSocketConnectionRefs { | ||
| 151 | const lastForcedReconnectAtRef = useRef(0); | 163 | const lastForcedReconnectAtRef = useRef(0); |
| 152 | const toastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null); | 164 | const toastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null); |
| 153 | const toastResetTimerRef = useRef<number | null>(null); | 165 | const toastResetTimerRef = useRef<number | null>(null); |
| 154 | const previousUiStateRef = useRef<WsConnectionUiState>(getWsConnectionUiState(status)); | 166 | const previousUiStateRef = useRef<WsConnectionUiState | null>(null); |
| 155 | const previousDisconnectedAtRef = useRef<string | null>(status.disconnectedAt); | 167 | const previousDisconnectedAtRef = useRef<string | null>(status.disconnectedAt); |
| 168 | if (previousUiStateRef.current === null) { | ||
| 169 | previousUiStateRef.current = getWsConnectionUiState(status); | ||
| 170 | } | ||
| 156 | 171 | ||
| 172 | return { | ||
| 173 | lastForcedReconnectAtRef, | ||
| 174 | previousDisconnectedAtRef, | ||
| 175 | previousUiStateRef: previousUiStateRef as RefObject<WsConnectionUiState>, | ||
| 176 | toastIdRef, | ||
| 177 | toastResetTimerRef, | ||
| 178 | }; | ||
| 179 | } | ||
| 180 | |||
| 181 | function useWebSocketReconnectActions( | ||
| 182 | lastForcedReconnectAtRef: RefObject<number>, | ||
| 183 | toastResetTimerRef: RefObject<number | null>, | ||
| 184 | ) { | ||
| 157 | const runReconnect = useEffectEvent((showFailureToast: boolean) => { | 185 | const runReconnect = useEffectEvent((showFailureToast: boolean) => { |
| 158 | if (toastResetTimerRef.current !== null) { | 186 | if (toastResetTimerRef.current !== null) { |
| 159 | window.clearTimeout(toastResetTimerRef.current); | 187 | window.clearTimeout(toastResetTimerRef.current); |
| @@ -201,6 +229,18 @@ export function WebSocketConnectionCoordinator() { · 41 unmodified lines | |||
| 201 | runReconnect(false); | 229 | runReconnect(false); |
| 202 | }); | 230 | }); |
| 203 | 231 | ||
| 232 | return { | ||
| 233 | runReconnect, | ||
| 234 | syncBrowserOnlineStatus, | ||
| 235 | triggerAutoReconnect, | ||
| 236 | triggerManualReconnect, | ||
| 237 | }; | ||
| 238 | } | ||
| 239 | |||
| 240 | function useWebSocketAutoReconnectEvents( | ||
| 241 | syncBrowserOnlineStatus: () => void, | ||
| 242 | triggerAutoReconnect: (trigger: WsAutoReconnectTrigger) => void, | ||
| 243 | ) { | ||
| 204 | useEffect(() => { | 244 | useEffect(() => { |
| 205 | const handleOnline = () => { | 245 | const handleOnline = () => { |
| 206 | triggerAutoReconnect("online"); | 246 | triggerAutoReconnect("online"); |
| @@ -219,22 +259,12 @@ export function WebSocketConnectionCoordinator() { · 12 unmodified lines | |||
| 219 | window.removeEventListener("focus", handleFocus); | 259 | window.removeEventListener("focus", handleFocus); |
| 220 | }; | 260 | }; |
| 221 | }, []); | 261 | }, []); |
| 262 | } | ||
| 222 | 263 | ||
| 223 | useEffect(() => { | 264 | function useStalledReconnectGuard( |
| 224 | if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) { | 265 | status: WsConnectionStatus, |
| 225 | return; | 266 | runReconnect: (showFailureToast: boolean) => void, |
| 226 | } | 267 | ) { |
| 227 | |||
| 228 | setNowMs(Date.now()); | ||
| 229 | const intervalId = window.setInterval(() => { | ||
| 230 | setNowMs(Date.now()); | ||
| 231 | }, 1_000); | ||
| 232 | |||
| 233 | return () => { | ||
| 234 | window.clearInterval(intervalId); | ||
| 235 | }; | ||
| 236 | }, [status.nextRetryAt, status.reconnectPhase]); | ||
| 237 | |||
| 238 | useEffect(() => { | 268 | useEffect(() => { |
| 239 | if ( | 269 | if ( |
| 240 | status.reconnectPhase !== "waiting" || | 270 | status.reconnectPhase !== "waiting" || |
| @@ -266,7 +296,14 @@ export function WebSocketConnectionCoordinator() { · 25 unmodified lines | |||
| 266 | status.reconnectAttemptCount, | 296 | status.reconnectAttemptCount, |
| 267 | status.reconnectPhase, | 297 | status.reconnectPhase, |
| 268 | ]); | 298 | ]); |
| 299 | } | ||
| 269 | 300 | ||
| 301 | function useWebSocketConnectionToast( | ||
| 302 | status: WsConnectionStatus, | ||
| 303 | triggerManualReconnect: () => void, | ||
| 304 | refs: WebSocketConnectionRefs, | ||
| 305 | ) { | ||
| 306 | const { previousDisconnectedAtRef, previousUiStateRef, toastIdRef, toastResetTimerRef } = refs; | ||
| 270 | useEffect(() => { | 307 | useEffect(() => { |
| 271 | const uiState = getWsConnectionUiState(status); | 308 | const uiState = getWsConnectionUiState(status); |
| 272 | const previousUiState = previousUiStateRef.current; | 309 | const previousUiState = previousUiStateRef.current; |
| @@ -316,10 +353,7 @@ export function WebSocketConnectionCoordinator() { · 43 unmodified lines | |||
| 316 | data: { | 353 | data: { |
| 317 | hideCopyButton: true, | 354 | hideCopyButton: true, |
| 318 | }, | 355 | }, |
| 319 | description: | 356 | description: describeReconnectToast(status), |
| 320 | status.nextRetryAt === null | ||
| 321 | ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` | ||
| 322 | : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, | ||
| 323 | timeout: 0, | 357 | timeout: 0, |
| 324 | title: buildReconnectTitle(status), | 358 | title: buildReconnectTitle(status), |
| 325 | type: "loading", | 359 | type: "loading", |
| @@ -365,8 +399,10 @@ export function WebSocketConnectionCoordinator() { · 39 unmodified lines | |||
| 365 | 399 | ||
| 366 | previousUiStateRef.current = uiState; | 400 | previousUiStateRef.current = uiState; |
| 367 | previousDisconnectedAtRef.current = status.disconnectedAt; | 401 | previousDisconnectedAtRef.current = status.disconnectedAt; |
| 368 | }, [nowMs, status]); | 402 | }, [status]); |
| 403 | } | ||
| 369 | 404 | ||
| 405 | function useToastResetTimerCleanup(toastResetTimerRef: RefObject<number | null>) { | ||
| 370 | useEffect(() => { | 406 | useEffect(() => { |
| 371 | return () => { | 407 | return () => { |
| 372 | if (toastResetTimerRef.current !== null) { | 408 | if (toastResetTimerRef.current !== null) { |
| @@ -374,13 +410,26 @@ export function WebSocketConnectionCoordinator() { · 1 unmodified line | |||
| 374 | } | 410 | } |
| 375 | }; | 411 | }; |
| 376 | }, []); | 412 | }, []); |
| 413 | } | ||
| 414 | |||
| 415 | export function WebSocketConnectionCoordinator() { | ||
| 416 | const status = useWsConnectionStatus(); | ||
| 417 | const refs = useWebSocketConnectionRefs(status); | ||
| 418 | const { runReconnect, syncBrowserOnlineStatus, triggerAutoReconnect, triggerManualReconnect } = | ||
| 419 | useWebSocketReconnectActions(refs.lastForcedReconnectAtRef, refs.toastResetTimerRef); | ||
| 420 | |||
| 421 | useWebSocketAutoReconnectEvents(syncBrowserOnlineStatus, triggerAutoReconnect); | ||
| 422 | useStalledReconnectGuard(status, runReconnect); | ||
| 423 | useWebSocketConnectionToast(status, triggerManualReconnect, refs); | ||
| 424 | useToastResetTimerCleanup(refs.toastResetTimerRef); | ||
| 377 | 425 | ||
| 378 | return null; | 426 | return null; |
| 379 | } | 427 | } |
| 380 | 428 | ||
| 381 | export function SlowRpcAckToastCoordinator() { | 429 | function useSlowRpcAckToast( |
| 382 | const slowRequests = useSlowRpcAckRequests(); | 430 | status: WsConnectionStatus, |
| 383 | const status = useWsConnectionStatus(); | 431 | slowRequests: ReadonlyArray<SlowRpcAckRequest>, |
| 432 | ) { | ||
| 384 | const toastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null); | 433 | const toastIdRef = useRef<ReturnType<typeof toastManager.add> | null>(null); |
| 385 | 434 | ||
| 386 | useEffect(() => { | 435 | useEffect(() => { |
| @@ -418,6 +467,13 @@ export function SlowRpcAckToastCoordinator() { · 31 unmodified lines | |||
| 418 | toastIdRef.current = toastManager.add(nextToast); | 467 | toastIdRef.current = toastManager.add(nextToast); |
| 419 | } | 468 | } |
| 420 | }, [slowRequests, status]); | 469 | }, [slowRequests, status]); |
| 470 | } | ||
| 471 | |||
| 472 | export function SlowRpcAckToastCoordinator() { | ||
| 473 | const slowRequests = useSlowRpcAckRequests(); | ||
| 474 | const status = useWsConnectionStatus(); | ||
| 475 | |||
| 476 | useSlowRpcAckToast(status, slowRequests); | ||
| 421 | 477 | ||
| 422 | return null; | 478 | return null; |
| 423 | } | 479 | } |