From ada5f274d3236a2dd9c63ac4d031ecdfbb809d2d Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 31 May 2026 20:15:39 +0800 Subject: [PATCH] Debounce terminal SSE subscription reconnects --- .../__tests__/ssePatchArchitecture.test.ts | 12 +++++++++ frontend/hooks/use-sse-patches.ts | 27 +++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index c835d3b4..bc670069 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -99,6 +99,18 @@ export function runTests() { assert(hook.includes("useLatestPatch"), "frontend patch hook must export useLatestPatch(city)"); assert(hook.includes("revision"), "frontend patch hook must track revisions and skip stale patches"); assert(hook.includes("setTimeout"), "frontend patch hook must implement explicit reconnect backoff"); + assert( + hook.includes("SSE_SUBSCRIPTION_RECONNECT_DELAY_MS") && + hook.includes("scheduleSubscriptionReconnect") && + hook.includes("clearSubscriptionReconnectTimer"), + "frontend patch hook must debounce visible-city subscription changes before reopening SSE", + ); + const subscriptionBlock = hook.match(/function registerCitySubscription[\s\S]*?\n}\r?\n\r?\nfunction normalizeLegacyPatch/)?.[0] || ""; + assert( + subscriptionBlock.includes("scheduleSubscriptionReconnect()") && + !subscriptionBlock.includes("ensureSsePatchConnection();"), + "city subscription mount/unmount should schedule one coalesced SSE reconnect instead of reconnecting per chart", + ); const bffEventsRoute = readFrontendFile("app", "api", "events", "route.ts"); assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI"); diff --git a/frontend/hooks/use-sse-patches.ts b/frontend/hooks/use-sse-patches.ts index e64c8c0c..4fa9946f 100644 --- a/frontend/hooks/use-sse-patches.ts +++ b/frontend/hooks/use-sse-patches.ts @@ -4,6 +4,7 @@ import { useEffect, useSyncExternalStore } from "react"; import { resolveBackendApiUrl } from "@/lib/backend-api"; const V1_EVENT_TYPE = "city_observation_patch.v1"; +const SSE_SUBSCRIPTION_RECONNECT_DELAY_MS = 150; export type CityPatch = { type?: string; @@ -38,6 +39,7 @@ const subscribedCities = new Map(); let eventSource: EventSource | null = null; let reconnectTimer: ReturnType | null = null; +let subscriptionReconnectTimer: ReturnType | null = null; let reconnectAttempt = 0; let patchVersion = 0; let resyncVersion = 0; @@ -74,6 +76,12 @@ function clearReconnectTimer() { reconnectTimer = null; } +function clearSubscriptionReconnectTimer() { + if (!subscriptionReconnectTimer) return; + clearTimeout(subscriptionReconnectTimer); + subscriptionReconnectTimer = null; +} + function buildSseUrl(baseUrl: string) { const params = new URLSearchParams(); const cities = subscribedCityList(); @@ -113,6 +121,7 @@ function closeEventSource() { function reconnectNow() { if (typeof window === "undefined") return; clearReconnectTimer(); + clearSubscriptionReconnectTimer(); closeEventSource(); connectSsePatches(); } @@ -159,6 +168,7 @@ function connectSsePatches() { function ensureSsePatchConnection() { if (subscribedCities.size === 0) { + clearSubscriptionReconnectTimer(); closeEventSource(); clearReconnectTimer(); return; @@ -167,6 +177,19 @@ function ensureSsePatchConnection() { reconnectNow(); } +function scheduleSubscriptionReconnect() { + if (typeof window === "undefined") return; + if (subscribedCities.size === 0) { + ensureSsePatchConnection(); + return; + } + clearSubscriptionReconnectTimer(); + subscriptionReconnectTimer = setTimeout(() => { + subscriptionReconnectTimer = null; + ensureSsePatchConnection(); + }, SSE_SUBSCRIPTION_RECONNECT_DELAY_MS); +} + function registerCitySubscription(city: string) { const cityKey = normalizeCityKey(city); if (!cityKey) return () => {}; @@ -174,7 +197,7 @@ function registerCitySubscription(city: string) { const previousCount = subscribedCities.get(cityKey) ?? 0; subscribedCities.set(cityKey, previousCount + 1); if (previousCount === 0) { - ensureSsePatchConnection(); + scheduleSubscriptionReconnect(); } return () => { @@ -184,7 +207,7 @@ function registerCitySubscription(city: string) { } else { subscribedCities.set(cityKey, nextCount); } - ensureSsePatchConnection(); + scheduleSubscriptionReconnect(); }; }