diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 1181f051c3..1461f2215b 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -196,7 +196,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { // Appearance settings for home page pinning const [pinProviderQuotaToHome, setPinProviderQuotaToHome] = useState(false); const [showQuickStartOnHome, setShowQuickStartOnHome] = useState(true); // default on - const [showProviderTopologyOnHome, setShowProviderTopologyOnHome] = useState(true); // default on + // #4596: default hidden until appearance settings load, so the live-WS + // topology connection is never opened before we know the user wants it. + const [showProviderTopologyOnHome, setShowProviderTopologyOnHome] = useState(false); const [autoRefreshProviderQuota, setAutoRefreshProviderQuota] = useState(false); const [autoRefreshProviderQuotaInterval, setAutoRefreshProviderQuotaInterval] = useState(180); const [appearanceSettingsLoaded, setAppearanceSettingsLoaded] = useState(false); @@ -1165,6 +1167,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { providers={topologyProviders} lastProvider={lastProvider} errorProvider={errorProvider} + enabled={showProviderTopologyOnHome} /> )} diff --git a/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx b/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx index 0bd9309dec..53abbbeed2 100644 --- a/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx +++ b/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx @@ -19,13 +19,17 @@ export function HomeProviderTopologySection({ providers, lastProvider, errorProvider, + enabled = true, }: { providers: TopologyProvider[]; lastProvider: string; errorProvider: string; + enabled?: boolean; }) { const t = useTranslations("home"); - const { activeRequests: liveActiveRequests } = useLiveRequests(); + // #4596: gate the live-WS connection so it only opens while the topology + // section is actually shown on the home page. + const { activeRequests: liveActiveRequests } = useLiveRequests({ enabled }); return ( diff --git a/src/hooks/useLiveDashboard.ts b/src/hooks/useLiveDashboard.ts index e216b7a584..bd72bdb57f 100644 --- a/src/hooks/useLiveDashboard.ts +++ b/src/hooks/useLiveDashboard.ts @@ -50,6 +50,8 @@ export interface DashboardConnectionState { export interface UseLiveDashboardOptions { /** WebSocket URL (default: ws://hostname:20129) */ wsUrl?: string; + /** Whether the WebSocket connection should be active (default: true) */ + enabled?: boolean; /** API key for authentication */ apiKey?: string; /** Channels to subscribe to */ @@ -66,6 +68,7 @@ export interface UseLiveDashboardOptions { */ export function useLiveDashboard({ wsUrl = DEFAULT_WS_URL, + enabled = true, apiKey, channels = ["requests", "combo", "credentials"], autoReconnect = true, @@ -202,6 +205,23 @@ export function useLiveDashboard({ // Connect on mount and on reconnect trigger useEffect(() => { + mountedRef.current = true; + if (!enabled) { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + wsRef.current?.close(); + wsRef.current = null; + setConnection({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempt: 0, + }); + return; + } + connect(); return () => { mountedRef.current = false; @@ -210,7 +230,7 @@ export function useLiveDashboard({ } wsRef.current?.close(); }; - }, [connect]); + }, [connect, enabled]); // Connect (for manual retry) const reconnect = useCallback(() => { diff --git a/tests/unit/ui/home-topology-hidden-4596.test.tsx b/tests/unit/ui/home-topology-hidden-4596.test.tsx new file mode 100644 index 0000000000..cdb3a64662 --- /dev/null +++ b/tests/unit/ui/home-topology-hidden-4596.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useLiveRequests } from "../../../src/hooks/useLiveDashboard"; + +function LiveRequestsHarness({ enabled }: { enabled: boolean }) { + useLiveRequests({ enabled }); + return null; +} + +describe("home topology hidden networking (#4596)", () => { + const websocketMock = vi.fn(); + let root: ReturnType | null = null; + let container: HTMLDivElement | null = null; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + websocketMock.mockClear(); + vi.stubGlobal( + "WebSocket", + class WebSocketMock { + static OPEN = 1; + readyState = 0; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + + constructor(url: string) { + websocketMock(url); + } + + send() {} + close() {} + } + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root?.unmount(); + }); + container?.remove(); + root = null; + container = null; + vi.unstubAllGlobals(); + }); + + it("does NOT open a WebSocket when the topology section is disabled", () => { + act(() => { + root!.render(); + }); + expect(websocketMock).not.toHaveBeenCalled(); + }); + + it("opens a WebSocket when the topology section is enabled", () => { + act(() => { + root!.render(); + }); + expect(websocketMock).toHaveBeenCalledTimes(1); + }); +});