Files
clash-verge-rev/src/hooks/use-mihomo-ws-subscription.ts
T
Tunglies 4ed474706a refactor(frontend): consolidate query state ownership
Consolidate ordinary query snapshots on SWR and make Profiles own its query key, cache mutation, and invalidation policy.

Impact:
- remove the generic synchronous query Map while keeping dedicated Mihomo WebSocket snapshots
- eliminate duplicate Profiles refreshes while preserving retries and the 800 ms event throttle
- preserve preload-backed Verge updates and system proxy rollback behavior
- scope uptime polling to the Home Clash info card instead of the app lifetime
- leave RunState, Connections, Traffic, backend, and IPC contracts unchanged
- reduce frontend code by 72 net lines without adding dependencies
2026-09-06 04:32:22 +08:00

381 lines
10 KiB
TypeScript

import { useLocalStorage } from 'foxact/use-local-storage'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api'
import {
removeCacheData,
setCacheData,
useQuery,
} from '@/services/query-client'
const RECONNECT_DELAY_MS = 1000
interface SharedSubscriptionOwner {
handleMessage: (data: string) => void
onConnected?: (ws: MihomoWebSocket) => Promise<void> | void
cleanup?: () => void
isMounted: () => boolean
}
interface SharedSubscriptionEntry {
refs: number
ws: MihomoWebSocket | null
reconnectTimer: ReturnType<typeof setTimeout> | null
connecting: boolean
refHolders: Set<MutableRefObject<MihomoWebSocket | null>>
owners: Set<SharedSubscriptionOwner>
activeOwner: SharedSubscriptionOwner | null
closed: boolean
connectWs: () => Promise<void>
scheduleReconnect: () => Promise<void>
}
const sharedSubscriptions = new Map<string, SharedSubscriptionEntry>()
const subscriptionSnapshots = new Map<string, unknown>()
const initialSubscriptionDate = Date.now()
const getSubscriptionSnapshot = <T>(key: string) =>
subscriptionSnapshots.get(key) as T | undefined
const writeSubscriptionSnapshot = <T>(key: string, data: T) => {
subscriptionSnapshots.set(key, data)
void setCacheData<T>([key], data)
}
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<MihomoWebSocket>,
): 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
}
const owner = pickActiveOwner(entry)
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)
})
entry.ws = ws
syncSharedWsRefs(entry)
clearReconnectTimer()
} 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
* against the current subscription snapshot before updating SWR.
*/
type NextFn<T> = (
error?: any,
data?: T | ((current?: T) => T | undefined),
) => void
interface HandlerContext<T> {
next: NextFn<T>
scheduleReconnect: () => Promise<void>
isMounted: () => boolean
}
interface HandlerResult {
handleMessage: (data: string) => void
onConnected?: (ws: MihomoWebSocket) => Promise<void> | void
cleanup?: () => void
}
interface UseMihomoWsSubscriptionOptions<T> {
storageKey: string
buildSubscriptKey: (date: number) => string | null
fallbackData: T
connect: () => Promise<MihomoWebSocket>
/**
* When > 0, coalesce rapid WebSocket messages by wrapping the `next`
* function passed to `setupHandlers`. Only the most recent value is
* flushed, at most once per `throttleMs` milliseconds.
*
* Uses `setTimeout` (not `requestAnimationFrame`) so it keeps working
* when the window is backgrounded or minimized.
*/
throttleMs?: number
setupHandlers: (ctx: HandlerContext<T>) => HandlerResult
}
export const useMihomoWsSubscription = <T>(
options: UseMihomoWsSubscriptionOptions<T>,
) => {
const {
storageKey,
buildSubscriptKey,
fallbackData,
connect,
throttleMs,
setupHandlers,
} = options
const [date, setDate] = useLocalStorage(storageKey, initialSubscriptionDate)
const subscriptKey = buildSubscriptKey(date)
const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null
const lastSubscriptionCacheKeyRef = useRef<string | null>(null)
if (subscriptionCacheKey) {
lastSubscriptionCacheKeyRef.current = subscriptionCacheKey
}
const responseCacheKey =
subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current
const wsRef = useRef<MihomoWebSocket | null>(null)
const resolveNextData = useCallback(
(
data: T | ((current?: T) => T | undefined) | undefined,
cacheKey: string,
): T => {
if (typeof data === 'function') {
const updater = data as (current?: T) => T | undefined
const current = getSubscriptionSnapshot<T>(cacheKey)
return updater(current) ?? fallbackData
}
return data ?? fallbackData
},
[fallbackData],
)
const response = useQuery<T>({
queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'],
queryFn: () =>
getSubscriptionSnapshot<T>(responseCacheKey!) ?? fallbackData,
initialData: () =>
getSubscriptionSnapshot<T>(responseCacheKey ?? '$sub$__disabled__') ??
fallbackData,
staleTime: Infinity,
enabled: subscriptionCacheKey !== null,
})
useEffect(() => {
if (!subscriptionCacheKey) return
let isMounted = true
let entry = sharedSubscriptions.get(subscriptionCacheKey)
if (!entry) {
entry = createSharedSubscriptionEntry(connect)
sharedSubscriptions.set(subscriptionCacheKey, entry)
}
entry.refs += 1
entry.refHolders.add(wsRef)
wsRef.current = entry.ws
let throttleCleanup: (() => void) | undefined
let wrappedNext: NextFn<T>
const baseNext: NextFn<T> = (error, data) => {
if (error !== undefined && error !== null) {
return
}
if (data === undefined) return
const resolved = resolveNextData(data, subscriptionCacheKey)
writeSubscriptionSnapshot(subscriptionCacheKey, resolved)
}
if (throttleMs && throttleMs > 0) {
let pendingData: T | ((current?: T) => T | undefined) | undefined
let hasPending = false
let timerId: ReturnType<typeof setTimeout> | null = null
const flush = () => {
timerId = null
if (hasPending) {
const data = pendingData
pendingData = undefined
hasPending = false
baseNext(undefined, data)
}
}
wrappedNext = (
error?: any,
data?: T | ((current?: T) => T | undefined),
) => {
if (error !== undefined && error !== null) {
baseNext(error, data)
return
}
if (!timerId) {
baseNext(undefined, data)
timerId = setTimeout(flush, throttleMs)
} else {
pendingData = data
hasPending = true
}
}
throttleCleanup = () => {
if (timerId) {
clearTimeout(timerId)
timerId = null
}
}
} else {
wrappedNext = baseNext
}
const {
handleMessage: handleTextMessage,
onConnected,
cleanup,
} = setupHandlers({
next: wrappedNext,
scheduleReconnect: entry.scheduleReconnect,
isMounted: () => isMounted,
})
const owner: SharedSubscriptionOwner = {
handleMessage: handleTextMessage,
onConnected,
cleanup: () => {
throttleCleanup?.()
cleanup?.()
},
isMounted: () => isMounted,
}
entry.owners.add(owner)
if (!entry.activeOwner) {
entry.activeOwner = owner
}
void entry.connectWs()
return () => {
isMounted = false
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
}, [subscriptionCacheKey])
const refresh = useCallback(() => {
if (subscriptionCacheKey) {
subscriptionSnapshots.delete(subscriptionCacheKey)
void removeCacheData([subscriptionCacheKey])
}
setDate(Date.now())
}, [subscriptionCacheKey, setDate])
const setData = useCallback(
(data: T) => {
if (responseCacheKey) {
writeSubscriptionSnapshot(responseCacheKey, data)
}
},
[responseCacheKey],
)
return { response, refresh, setData, wsRef }
}