diff --git a/src/components/layout/traffic-graph.tsx b/src/components/layout/traffic-graph.tsx index 17d2b04be..3752080ca 100644 --- a/src/components/layout/traffic-graph.tsx +++ b/src/components/layout/traffic-graph.tsx @@ -12,6 +12,7 @@ const upLineWidth = 4 const downLineAlpha = 1 const downLineWidth = 4 +const frameIntervalMs = 1000 / 15 const defaultList = Array(maxPoint + 2).fill({ up: 0, down: 0 }) @@ -69,6 +70,7 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { useEffect(() => { let raf = 0 + let frameTimer: ReturnType | null = null const canvas = canvasRef.current! if (!canvas) return @@ -144,14 +146,37 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { } } + const scheduleDraw = (lastTime: number, delay = 0) => { + if (frameTimer !== null) { + clearTimeout(frameTimer) + frameTimer = null + } + + if (delay > 0) { + frameTimer = setTimeout(() => { + frameTimer = null + raf = requestAnimationFrame(() => { + raf = 0 + drawGraph(lastTime) + }) + }, delay) + return + } + + raf = requestAnimationFrame(() => { + raf = 0 + drawGraph(lastTime) + }) + } + const drawGraph = (lastTime: number) => { const list = listRef.current const lineStyle = styleRef.current const now = Date.now() const diff = now - lastTime - if (diff < 33) { - raf = requestAnimationFrame(() => drawGraph(lastTime)) + if (diff < frameIntervalMs) { + scheduleDraw(lastTime, frameIntervalMs - diff) return } const temp = Math.min((diff / 1000) * dx + countRef.current, dx) @@ -196,13 +221,18 @@ export function TrafficGraph({ ref }: { ref?: Ref }) { context.stroke() context.closePath() - raf = requestAnimationFrame(() => drawGraph(now)) + scheduleDraw(now, frameIntervalMs) } - drawGraph(Date.now()) + drawGraph(Date.now() - frameIntervalMs) return () => { - cancelAnimationFrame(raf) + if (frameTimer !== null) { + clearTimeout(frameTimer) + } + if (raf) { + cancelAnimationFrame(raf) + } } }, [palette]) diff --git a/src/hooks/use-mihomo-ws-subscription.ts b/src/hooks/use-mihomo-ws-subscription.ts index f27df80b7..0f40625cf 100644 --- a/src/hooks/use-mihomo-ws-subscription.ts +++ b/src/hooks/use-mihomo-ws-subscription.ts @@ -1,10 +1,138 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { useLocalStorage } from 'foxact/use-local-storage' -import { useCallback, useEffect, useRef } from 'react' +import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api' export const RECONNECT_DELAY_MS = 1000 +interface SharedSubscriptionOwner { + handleMessage: (data: string) => void + onConnected?: (ws: MihomoWebSocket) => Promise | void + cleanup?: () => void + isMounted: () => boolean +} + +interface SharedSubscriptionEntry { + refs: number + ws: MihomoWebSocket | null + reconnectTimer: ReturnType | null + connecting: boolean + refHolders: Set> + owners: Set + activeOwner: SharedSubscriptionOwner | null + closed: boolean + connectWs: () => Promise + scheduleReconnect: () => Promise +} + +const sharedSubscriptions = new Map() + +const syncSharedWsRefs = (entry: SharedSubscriptionEntry) => { + entry.refHolders.forEach((ref) => { + ref.current = entry.ws + }) +} + +const pickActiveOwner = (entry: SharedSubscriptionEntry) => { + if (entry.activeOwner?.isMounted()) return entry.activeOwner + + for (const owner of entry.owners) { + if (owner.isMounted()) { + entry.activeOwner = owner + return owner + } + } + + entry.activeOwner = null + return null +} + +const closeSharedSocket = async (entry: SharedSubscriptionEntry) => { + const ws = entry.ws + if (!ws) return + + entry.ws = null + syncSharedWsRefs(entry) + await ws.close() +} + +const createSharedSubscriptionEntry = ( + connect: () => Promise, +): SharedSubscriptionEntry => { + const entry: SharedSubscriptionEntry = { + refs: 0, + ws: null, + reconnectTimer: null, + connecting: false, + refHolders: new Set(), + owners: new Set(), + activeOwner: null, + closed: false, + connectWs: async () => {}, + scheduleReconnect: async () => {}, + } + + const clearReconnectTimer = () => { + if (entry.reconnectTimer) { + clearTimeout(entry.reconnectTimer) + entry.reconnectTimer = null + } + } + + entry.connectWs = async () => { + if (entry.closed || entry.connecting || entry.ws) return + + entry.connecting = true + try { + const ws = await connect() + if (entry.closed) { + await ws.close() + return + } + + entry.ws = ws + syncSharedWsRefs(entry) + clearReconnectTimer() + + const owner = pickActiveOwner(entry) + if (owner?.onConnected) { + await owner.onConnected(ws) + if (entry.closed) { + await ws.close() + return + } + } + + ws.addListener((msg: Message) => { + if (msg.type !== 'Text') return + const activeOwner = pickActiveOwner(entry) + if (!activeOwner) return + + activeOwner.handleMessage(msg.data) + }) + } catch (ignoreError) { + if (!entry.closed && !entry.ws) { + clearReconnectTimer() + entry.reconnectTimer = setTimeout(entry.connectWs, RECONNECT_DELAY_MS) + } + } finally { + entry.connecting = false + } + } + + entry.scheduleReconnect = async () => { + if (entry.closed) return + + clearReconnectTimer() + await closeSharedSocket(entry) + if (!entry.closed) { + entry.reconnectTimer = setTimeout(entry.connectWs, RECONNECT_DELAY_MS) + } + } + + return entry +} + /** * Mirrors SWR's MutatorCallback: consumers can pass either a plain value or a * functional updater `(current?: T) => T`. The functional form is resolved @@ -64,8 +192,6 @@ export const useMihomoWsSubscription = ( const queryClient = useQueryClient() const wsRef = useRef(null) - const wsFirstConnectionRef = useRef(true) - const timeoutRef = useRef | null>(null) const resolveNextData = useCallback( ( @@ -101,28 +227,15 @@ export const useMihomoWsSubscription = ( if (!subscriptionCacheKey) return let isMounted = true - - const clearReconnectTimer = () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - timeoutRef.current = null - } + let entry = sharedSubscriptions.get(subscriptionCacheKey) + if (!entry) { + entry = createSharedSubscriptionEntry(connect) + sharedSubscriptions.set(subscriptionCacheKey, entry) } - const closeSocket = async () => { - if (wsRef.current) { - await wsRef.current.close() - wsRef.current = null - } - } - - const scheduleReconnect = async () => { - if (!isMounted) return - clearReconnectTimer() - await closeSocket() - if (!isMounted) return - timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS) - } + entry.refs += 1 + entry.refHolders.add(wsRef) + wsRef.current = entry.ws let throttleCleanup: (() => void) | undefined let wrappedNext: NextFn @@ -184,59 +297,51 @@ export const useMihomoWsSubscription = ( cleanup, } = setupHandlers({ next: wrappedNext, - scheduleReconnect, + scheduleReconnect: entry.scheduleReconnect, isMounted: () => isMounted, }) - const cleanupAll = () => { - clearReconnectTimer() - throttleCleanup?.() - cleanup?.() - void closeSocket() + const owner: SharedSubscriptionOwner = { + handleMessage: handleTextMessage, + onConnected, + cleanup: () => { + throttleCleanup?.() + cleanup?.() + }, + isMounted: () => isMounted, } - const handleMessage = (msg: Message) => { - if (msg.type !== 'Text') return - handleTextMessage(msg.data) - } - - async function connectWs() { - try { - const ws_ = await connect() - if (!isMounted) { - await ws_.close() - return - } - - wsRef.current = ws_ - clearReconnectTimer() - - if (onConnected) { - await onConnected(ws_) - if (!isMounted) { - await ws_.close() - return - } - } - - ws_.addListener(handleMessage) - } catch (ignoreError) { - if (!wsRef.current && isMounted) { - timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS) - } - } - } - - if (wsFirstConnectionRef.current || !wsRef.current) { - wsFirstConnectionRef.current = false - cleanupAll() - void connectWs() + entry.owners.add(owner) + if (!entry.activeOwner) { + entry.activeOwner = owner } + void entry.connectWs() return () => { isMounted = false - wsFirstConnectionRef.current = true - cleanupAll() + entry.refHolders.delete(wsRef) + wsRef.current = null + entry.owners.delete(owner) + owner.cleanup?.() + + if (entry.activeOwner === owner) { + entry.activeOwner = null + const nextOwner = pickActiveOwner(entry) + if (entry.ws && nextOwner?.onConnected) { + void nextOwner.onConnected(entry.ws) + } + } + + entry.refs -= 1 + if (entry.refs <= 0) { + entry.closed = true + if (entry.reconnectTimer) { + clearTimeout(entry.reconnectTimer) + entry.reconnectTimer = null + } + sharedSubscriptions.delete(subscriptionCacheKey) + void closeSharedSocket(entry) + } } // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps