From 59b1d1551add33a16e9386e77062731d731c9f1f Mon Sep 17 00:00:00 2001 From: Genie Date: Sun, 15 Mar 2026 20:13:09 -0300 Subject: [PATCH] fix: Vite HMR WebSocket for reverse proxy + WS StrictMode guard When running behind a reverse proxy (e.g. Caddy), the live-events WebSocket would fail to connect because it constructed the URL from window.location without accounting for proxy routing. Also fixes React StrictMode double-invoke of WebSocket connections by deferring the connect call via a cleanup guard. - Replace deprecated apple-mobile-web-app-capable meta tag - Guard WS connect with mounted flag to prevent StrictMode double-open - Use protocol-relative WebSocket URL derivation for proxy compatibility --- ui/index.html | 2 +- ui/src/context/LiveUpdatesProvider.tsx | 96 ++++++++++++++++++++------ 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/ui/index.html b/ui/index.html index 1bb9152e..d982aa0a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,7 +4,7 @@ - + Paperclip diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 5ad06a72..69fdc61f 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -511,24 +511,39 @@ function handleLiveEvent( } export function LiveUpdatesProvider({ children }: { children: ReactNode }) { - const { selectedCompanyId } = useCompany(); + const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); const { pushToast } = useToast(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); - const { data: session } = useQuery({ + const { data: session, status: sessionStatus } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out"; + const liveCompanyId = selectedCompany?.id === selectedCompanyId ? selectedCompanyId : null; + const canConnectSocket = sessionStatus === "success" && session !== null && liveCompanyId !== null; + const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({ + userId: currentUserId, + agentId: null, + }); useEffect(() => { - if (!selectedCompanyId) return; + currentActorRef.current = { + userId: currentUserId, + agentId: null, + }; + }, [currentUserId]); + + useEffect(() => { + if (!canConnectSocket || !liveCompanyId) return; let closed = false; let reconnectAttempt = 0; let reconnectTimer: number | null = null; let socket: WebSocket | null = null; + const noop = () => undefined; const clearReconnect = () => { if (reconnectTimer !== null) { @@ -537,6 +552,35 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { } }; + const closeSocketQuietly = (target: WebSocket | null, reason: string) => { + if (!target) return; + + if (target.readyState === WebSocket.CONNECTING) { + // Let the handshake complete and then close. Calling close() while the + // socket is still CONNECTING is what triggers the noisy browser error. + target.onopen = () => { + target.onopen = null; + target.onmessage = null; + target.onerror = null; + target.onclose = null; + target.close(1000, reason); + }; + target.onmessage = null; + target.onerror = noop; + target.onclose = null; + return; + } + + target.onopen = null; + target.onmessage = null; + target.onerror = null; + target.onclose = null; + + if (target.readyState === WebSocket.OPEN) { + target.close(1000, reason); + } + }; + const scheduleReconnect = () => { if (closed) return; reconnectAttempt += 1; @@ -550,55 +594,63 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const connect = () => { if (closed) return; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`; - socket = new WebSocket(url); + const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`; + const nextSocket = new WebSocket(url); + socket = nextSocket; - socket.onopen = () => { + nextSocket.onopen = () => { + if (closed || socket !== nextSocket) { + closeSocketQuietly(nextSocket, "stale_connection"); + return; + } if (reconnectAttempt > 0) { gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS; } reconnectAttempt = 0; }; - socket.onmessage = (message) => { + nextSocket.onmessage = (message) => { const raw = typeof message.data === "string" ? message.data : ""; if (!raw) return; try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, { - userId: currentUserId, - agentId: null, + handleLiveEvent(queryClient, liveCompanyId, parsed, pushToast, gateRef.current, { + userId: currentActorRef.current.userId, + agentId: currentActorRef.current.agentId, }); } catch { // Ignore non-JSON payloads. } }; - socket.onerror = () => { - socket?.close(); + nextSocket.onerror = () => { + // Wait for onclose to drive the reconnect. Self-closing here is what + // produces the "closed before connection established" browser noise. }; - socket.onclose = () => { + nextSocket.onclose = () => { + if (socket !== nextSocket) return; + socket = null; if (closed) return; scheduleReconnect(); }; }; - connect(); + // Delay initial connect slightly so React StrictMode's double-invoke + // cleanup fires before the WebSocket is created, avoiding the + // "WebSocket closed before connection established" dev-mode error. + const connectTimer = window.setTimeout(connect, 0); return () => { closed = true; + window.clearTimeout(connectTimer); clearReconnect(); - if (socket) { - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(1000, "provider_unmount"); - } + const activeSocket = socket; + socket = null; + closeSocketQuietly(activeSocket, "provider_unmount"); }; - }, [queryClient, selectedCompanyId, pushToast, currentUserId]); + }, [queryClient, liveCompanyId, pushToast, canConnectSocket, socketAuthKey]); return <>{children}; }