'use client' /** * Singleton WebSocket provider. * * The entire app shares ONE connection to /ws/prices. Components subscribe * to specific message types via `useWsSubscribe` — no component owns the socket. * * Why: previously DashboardClient (via usePriceSocket) and SignalMonitor each * opened their own WebSocket, giving the server two connections per page load * and causing each component to receive every broadcast twice. */ import { createContext, useContext, useEffect, useRef, type ReactNode, } from 'react' type Handler = (msg: unknown) => void interface WsContextValue { subscribe: (type: string, fn: Handler) => () => void } const WsContext = createContext(null) const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000' export function WsProvider({ children }: { children: ReactNode }) { // Stable map: message-type → set of handlers. Never replaced, only mutated. const subs = useRef>>(new Map()) useEffect(() => { let dead = false let retryTimer: ReturnType | null = null // Hold the current socket at useEffect scope so cleanup can close it. // Previously `ws` lived inside connect() and cleanup couldn't reach it, // leaking one connection per StrictMode remount and one per [locale] // layout swap (which remounts WsProvider). let socket: WebSocket | null = null function connect() { if (dead) return socket = new WebSocket(`${WS_URL}/ws/prices`) const ws = socket // local alias for handler closures ws.onmessage = (e) => { try { const msg = JSON.parse(e.data) as { type?: string } const t = msg.type ?? '__unknown__' subs.current.get(t)?.forEach((fn) => fn(msg)) } catch { // ignore malformed frames } } ws.onclose = () => { if (!dead) retryTimer = setTimeout(connect, 3000) } ws.onerror = () => ws.close() } connect() return () => { dead = true if (retryTimer) clearTimeout(retryTimer) // Actively close any live socket so the OS-level connection releases // immediately on unmount instead of waiting for the next server keepalive. if (socket && socket.readyState <= WebSocket.OPEN) { socket.onclose = null // suppress the auto-reconnect we'd otherwise queue socket.close() } socket = null } }, []) function subscribe(type: string, fn: Handler): () => void { if (!subs.current.has(type)) subs.current.set(type, new Set()) subs.current.get(type)!.add(fn) return () => subs.current.get(type)?.delete(fn) } return {children} } /** * Subscribe to a specific WebSocket message type from the shared connection. * * @param type The `msg.type` string to listen for (e.g. 'price', 'new_post', 'funding_signal') * @param handler Called with the full parsed message object on every matching frame. * Always receives the latest handler reference — no stale-closure issues. * * Usage: * useWsSubscribe('price', (msg) => { ... }) */ export function useWsSubscribe(type: string, handler: Handler): void { const ctx = useContext(WsContext) // Keep a ref so the subscription closure never goes stale even if handler // is an inline function that changes every render. const handlerRef = useRef(handler) handlerRef.current = handler useEffect(() => { if (!ctx) return return ctx.subscribe(type, (msg) => handlerRef.current(msg)) }, [ctx, type]) // type is stable in practice; ctx never changes }