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
This commit is contained in:
Tunglies
2026-09-06 04:32:22 +08:00
parent 00a8518e7a
commit 4ed474706a
14 changed files with 114 additions and 186 deletions
+2 -2
View File
@@ -5,11 +5,11 @@ import { useTranslation } from 'react-i18next'
import { useClash } from '@/hooks/use-clash'
import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port'
import { useAppUptime } from '@/hooks/use-system-state'
import {
useClashConfigData,
useRulesData,
useSystemData,
useUptimeData,
} from '@/providers/app-data-context'
import { EnhancedCard } from './enhanced-card'
@@ -28,7 +28,7 @@ export const ClashInfoCard = () => {
const { clashConfig } = useClashConfigData()
const displayedMixedPort = useDisplayedMixedPort()
const { rules } = useRulesData()
const { uptime } = useUptimeData()
const uptime = useAppUptime()
const { systemProxyAddress } = useSystemData()
// 使用useMemo缓存格式化后的uptime,避免频繁计算
@@ -15,7 +15,7 @@ import {
type RunState,
} from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { setCacheDataAsync, useQuery } from '@/services/query-client'
import { setCacheData, useQuery } from '@/services/query-client'
export const ServiceMigrationDialog = () => {
const { t } = useTranslation()
@@ -50,7 +50,7 @@ export const ServiceMigrationDialog = () => {
const refreshRunState = async () => {
try {
const data = await getRuntimeState()
await setCacheDataAsync<RunState>(runStateQueryKey, data)
await setCacheData<RunState>(runStateQueryKey, data)
setStateRefreshFailed(false)
return data
} catch (error) {
+1 -6
View File
@@ -9,7 +9,6 @@ import {
patchClashConfig,
} from '@/services/cmds'
import {
getCacheData,
revalidateQuery,
setCacheData,
useQuery,
@@ -97,11 +96,7 @@ export const useClash = () => {
if (updater === undefined) {
return refetch()
}
const next =
typeof updater === 'function'
? updater(getCacheData<IConfigData>(['getRuntimeConfig']))
: updater
setCacheData(['getRuntimeConfig'], next)
void setCacheData<IConfigData>(['getRuntimeConfig'], updater)
if (revalidate !== false) {
return refetch()
}
+2 -7
View File
@@ -3,7 +3,6 @@ import { useEffect, useRef } from 'react'
import { MihomoWebSocket, type LogLevel } from 'tauri-plugin-mihomo-api'
import { getClashLogs } from '@/services/cmds'
import { setCacheData } from '@/services/query-client'
import { useClashLog } from './use-clash-log'
import { useMihomoWsSubscription } from './use-mihomo-ws-subscription'
@@ -54,9 +53,7 @@ export const useLogData = () => {
const allowedTypes = LOG_LEVEL_FILTERS[logLevel] ?? DEFAULT_LOG_TYPES
const hasLoadedInitialLogsRef = useRef(false)
const { response, refresh, subscriptionCacheKey } = useMihomoWsSubscription<
ILogItem[]
>({
const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({
storageKey: 'mihomo_logs_date',
buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null),
fallbackData: [],
@@ -154,9 +151,7 @@ export const useLogData = () => {
const refreshGetClashLog = (clear = false) => {
if (clear) {
if (subscriptionCacheKey) {
setCacheData<ILogItem[]>([subscriptionCacheKey], [])
}
setData([])
} else {
hasLoadedInitialLogsRef.current = false
refresh()
+27 -8
View File
@@ -3,7 +3,6 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api'
import {
getCacheData,
removeCacheData,
setCacheData,
useQuery,
@@ -32,8 +31,17 @@ interface SharedSubscriptionEntry {
}
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
@@ -141,7 +149,7 @@ const createSharedSubscriptionEntry = (
/**
* 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 cache entry before calling `setCacheData`.
* against the current subscription snapshot before updating SWR.
*/
type NextFn<T> = (
error?: any,
@@ -208,7 +216,7 @@ export const useMihomoWsSubscription = <T>(
): T => {
if (typeof data === 'function') {
const updater = data as (current?: T) => T | undefined
const current = getCacheData<T>([cacheKey])
const current = getSubscriptionSnapshot<T>(cacheKey)
return updater(current) ?? fallbackData
}
return data ?? fallbackData
@@ -218,9 +226,10 @@ export const useMihomoWsSubscription = <T>(
const response = useQuery<T>({
queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'],
queryFn: () => getCacheData<T>([responseCacheKey!]) ?? fallbackData,
queryFn: () =>
getSubscriptionSnapshot<T>(responseCacheKey!) ?? fallbackData,
initialData: () =>
getCacheData<T>([responseCacheKey ?? '$sub$__disabled__']) ??
getSubscriptionSnapshot<T>(responseCacheKey ?? '$sub$__disabled__') ??
fallbackData,
staleTime: Infinity,
enabled: subscriptionCacheKey !== null,
@@ -249,7 +258,7 @@ export const useMihomoWsSubscription = <T>(
}
if (data === undefined) return
const resolved = resolveNextData(data, subscriptionCacheKey)
setCacheData<T>([subscriptionCacheKey], resolved)
writeSubscriptionSnapshot(subscriptionCacheKey, resolved)
}
if (throttleMs && throttleMs > 0) {
@@ -352,10 +361,20 @@ export const useMihomoWsSubscription = <T>(
const refresh = useCallback(() => {
if (subscriptionCacheKey) {
removeCacheData([subscriptionCacheKey])
subscriptionSnapshots.delete(subscriptionCacheKey)
void removeCacheData([subscriptionCacheKey])
}
setDate(Date.now())
}, [subscriptionCacheKey, setDate])
return { response, refresh, subscriptionCacheKey: responseCacheKey, wsRef }
const setData = useCallback(
(data: T) => {
if (responseCacheKey) {
writeSubscriptionSnapshot(responseCacheKey, data)
}
},
[responseCacheKey],
)
return { response, refresh, setData, wsRef }
}
+16 -5
View File
@@ -1,9 +1,21 @@
import { useCallback, useRef } from 'react'
import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds'
import { setCacheDataAsync, useQuery } from '@/services/query-client'
import {
fetchCacheData,
revalidateQuery,
setCacheData,
useQuery,
} from '@/services/query-client'
import { debugLog } from '@/utils/debug'
const profilesQueryKey = ['getProfiles'] as const
export const revalidateProfiles = () => revalidateQuery(profilesQueryKey)
export const fetchProfilesIntoCache = () =>
fetchCacheData(profilesQueryKey, getProfiles)
export const useProfiles = () => {
const {
data: profiles,
@@ -11,7 +23,7 @@ export const useProfiles = () => {
error,
isFetching: isValidating,
} = useQuery({
queryKey: ['getProfiles'],
queryKey: profilesQueryKey,
queryFn: async () => {
const data = await getProfiles()
debugLog(
@@ -40,9 +52,8 @@ export const useProfiles = () => {
const outcome = await patchProfilesConfig(value)
if (outcome.status === 'valid') {
await setCacheDataAsync<IProfilesConfig>(
['getProfiles'],
(current) => (current ? { ...current, ...value } : current),
await setCacheData<IProfilesConfig>(profilesQueryKey, (current) =>
current ? { ...current, ...value } : current,
)
} else if (outcome.status !== 'busy') {
await mutateProfiles()
+1 -4
View File
@@ -10,7 +10,6 @@ import {
patchVergeConfig,
} from '@/services/cmds'
import {
getCacheData,
removeCacheData,
revalidateQueries,
useQuery,
@@ -51,9 +50,7 @@ export const useSystemProxyState = () => {
const toggleSystemProxy = async (enabled: boolean) => {
// Roll failed optimistic writes back to the latest confirmed state.
let confirmed =
getCacheData<IVergeConfig>(['getVergeConfig'])?.enable_system_proxy ??
false
let confirmed = verge?.enable_system_proxy ?? false
mutateVerge(
(prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev),
false,
+16
View File
@@ -1,4 +1,5 @@
import {
getAppUptime,
getRuntimeState,
type RunState,
type RunningMode,
@@ -8,6 +9,7 @@ import { useQuery } from '@/services/query-client'
import { useVisibility } from './use-visibility'
export const runStateQueryKey = ['getRuntimeState'] as const
const appUptimeQueryKey = ['appUptime'] as const
/** Fail closed until the first snapshot so TUN never flashes as available. */
const unknownRunState: RunState = {
@@ -52,3 +54,17 @@ export function useSystemState() {
isLoading,
}
}
export function useAppUptime() {
const { data: uptime = 0 } = useQuery({
queryKey: appUptimeQueryKey,
queryFn: getAppUptime,
staleTime: 5000,
refetchInterval: 3000,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: 1,
})
return uptime
}
+7 -8
View File
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { getVergeConfig, patchVergeConfig } from '@/services/cmds'
import { getPreloadConfig, setPreloadConfig } from '@/services/preload'
import { getCacheData, setCacheData, useQuery } from '@/services/query-client'
import { setCacheData, useQuery } from '@/services/query-client'
export const useVerge = () => {
const initialVergeConfig = getPreloadConfig()
@@ -30,13 +30,12 @@ export const useVerge = () => {
void refetch()
return
}
if (typeof updaterOrData === 'function') {
const prev = getCacheData<IVergeConfig>(['getVergeConfig'])
const next = updaterOrData(prev)
setCacheData(['getVergeConfig'], next)
} else {
setCacheData(['getVergeConfig'], updaterOrData)
}
void setCacheData<IVergeConfig>(
['getVergeConfig'],
typeof updaterOrData === 'function'
? (current) => updaterOrData(current ?? verge)
: updaterOrData,
)
}
const patchVerge = useCallback(
+22 -2
View File
@@ -1,20 +1,40 @@
import { useEffect } from 'react'
import { revalidateProfiles } from '@/hooks/use-profiles'
import { runStateQueryKey } from '@/hooks/use-system-state'
import type { RunState } from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { revalidateQueries, setCacheDataAsync } from '@/services/query-client'
import { revalidateQueries, setCacheData } from '@/services/query-client'
export const useLayoutEvents = (
handleNotice: (payload: [string, string]) => void,
) => {
useEffect(() => {
let lastProfileId: string | null = null
let lastProfileUpdateTime = 0
const refreshThrottle = 800
const revalidateKeys = (keys: readonly string[]) => {
void revalidateQueries(keys.map((key) => [key]))
}
const handleProfileChanged = (newProfileId: string) => {
const now = Date.now()
if (
lastProfileId === newProfileId &&
now - lastProfileUpdateTime < refreshThrottle
) {
return
}
lastProfileId = newProfileId
lastProfileUpdateTime = now
void revalidateProfiles()
}
return subscribeVergeEvents(
{
'profile-changed': handleProfileChanged,
'verge://refresh-profiles': () => void revalidateProfiles(),
'verge://refresh-clash-config': () => {
revalidateKeys([
'getProxyView',
@@ -36,7 +56,7 @@ export const useLayoutEvents = (
},
// Transitions carry the full run-state snapshot, so write it directly to cache.
'verge://run-state-changed': (payload) => {
void setCacheDataAsync<RunState>(runStateQueryKey, payload)
void setCacheData<RunState>(runStateQueryKey, payload)
},
'verge://notice-message': handleNotice,
},
+4 -11
View File
@@ -40,12 +40,11 @@ import {
} from '@/components/profile/profile-viewer'
import { ConfigViewer } from '@/components/setting/mods/config-viewer'
import { useListen } from '@/hooks/use-listen'
import { useProfiles } from '@/hooks/use-profiles'
import { fetchProfilesIntoCache, useProfiles } from '@/hooks/use-profiles'
import {
createProfile,
deleteProfile,
enhanceProfiles,
getProfiles,
getRuntimeLogs,
importProfile,
reorderProfile,
@@ -53,11 +52,7 @@ import {
} from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { errorDetail, showNotice } from '@/services/notice-service'
import {
fetchCacheData,
revalidateQueries,
useQuery,
} from '@/services/query-client'
import { revalidateQuery, useQuery } from '@/services/query-client'
import {
useLoadingCache,
useSetLoadingCache,
@@ -175,9 +170,7 @@ const ProfilePage = () => {
debugLog('[紧急刷新] 开始强制刷新所有数据')
try {
await revalidateQueries([['getProfiles'], ['getRuntimeLogs']])
await mutateProfiles()
await Promise.all([revalidateQuery(['getRuntimeLogs']), mutateProfiles()])
await new Promise((resolve) => setTimeout(resolve, 500))
await onEnhance(false)
@@ -294,7 +287,7 @@ const ProfilePage = () => {
console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`)
try {
await fetchCacheData(['getProfiles'], getProfiles)
await fetchProfilesIntoCache()
await onEnhance(false)
showNotice.error(
'profiles.page.feedback.notifications.importNeedsRefresh',
-8
View File
@@ -26,10 +26,6 @@ export interface SystemContextType {
systemProxyAddress: string
}
export interface UptimeContextType {
uptime: number
}
export interface CoreDataStatusContextType {
isCoreDataPending: boolean
}
@@ -49,7 +45,6 @@ export const ClashConfigContext = createContext<ClashConfigContextType | null>(
null,
)
export const SystemContext = createContext<SystemContextType | null>(null)
export const UptimeContext = createContext<UptimeContextType | null>(null)
export const CoreDataStatusContext =
createContext<CoreDataStatusContextType | null>(null)
export const RefreshersContext = createContext<RefreshersContextType | null>(
@@ -80,9 +75,6 @@ export const useClashConfigData = (): ClashConfigContextType =>
export const useSystemData = (): SystemContextType =>
useCtx(SystemContext, 'useSystemData')
export const useUptimeData = (): UptimeContextType =>
useCtx(UptimeContext, 'useUptimeData')
export const useAppRefreshers = (): RefreshersContextType =>
useCtx(RefreshersContext, 'useAppRefreshers')
+7 -45
View File
@@ -8,14 +8,9 @@ import {
import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash'
import { runStateQueryKey } from '@/hooks/use-system-state'
import { useVerge } from '@/hooks/use-verge'
import {
getAppUptime,
getProxyView,
getRuntimeState,
getSystemProxy,
} from '@/services/cmds'
import { getProxyView, getRuntimeState, getSystemProxy } from '@/services/cmds'
import { subscribeVergeEvents } from '@/services/events'
import { revalidateQueries, useQuery } from '@/services/query-client'
import { useQuery } from '@/services/query-client'
import { resolveDisplayedMixedPort } from '@/utils/mixed-port'
import {
@@ -25,7 +20,6 @@ import {
RefreshersContext,
RulesContext,
SystemContext,
UptimeContext,
} from './app-data-context'
const TQ_MIHOMO = {
@@ -109,14 +103,6 @@ export const AppDataProvider = ({
})
const runningMode = runState?.mode
const { data: uptimeData } = useQuery({
queryKey: ['appUptime'],
queryFn: getAppUptime,
...TQ_DEFAULTS,
refetchInterval: 3000,
retry: 1,
})
const refreshProxy = useStableFn(_refetchProxyView)
const refreshClashConfig = useStableFn(_refetchClashConfig)
const refreshRules = useStableFn(_refetchRules)
@@ -124,22 +110,8 @@ export const AppDataProvider = ({
const refreshRuleProviders = useStableFn(_refetchRuleProviders)
useEffect(() => {
let lastProfileId: string | null = null
let lastProfileUpdateTime = 0
let lastProxyUpdateTime = 0
const refreshThrottle = 800
const handleProfileChanged = (newProfileId: string) => {
const now = Date.now()
if (
lastProfileId === newProfileId &&
now - lastProfileUpdateTime < refreshThrottle
) {
return
}
lastProfileId = newProfileId
lastProfileUpdateTime = now
void revalidateQueries([['getProfiles']])
}
const handleRefreshProxy = () => {
const now = Date.now()
@@ -148,13 +120,7 @@ export const AppDataProvider = ({
refreshProxy().catch(() => {})
}
const handleRefreshProfiles = () => {
void revalidateQueries([['getProfiles']])
}
return subscribeVergeEvents({
'profile-changed': handleProfileChanged,
'verge://refresh-profiles': handleRefreshProfiles,
'verge://refresh-proxy-config': handleRefreshProxy,
})
}, [refreshProxy])
@@ -244,8 +210,6 @@ export const AppDataProvider = ({
}
}, [sysproxy, runningMode, isRunningModePending, verge, displayedMixedPort])
const uptimeValue = useMemo(() => ({ uptime: uptimeData || 0 }), [uptimeData])
const coreDataStatusValue = useMemo(
() => ({
isCoreDataPending: isProxyViewPending || isClashConfigPending,
@@ -277,13 +241,11 @@ export const AppDataProvider = ({
<RulesContext value={rulesValue}>
<ClashConfigContext value={clashConfigValue}>
<SystemContext value={systemValue}>
<UptimeContext value={uptimeValue}>
<CoreDataStatusContext value={coreDataStatusValue}>
<RefreshersContext value={refreshersValue}>
{children}
</RefreshersContext>
</CoreDataStatusContext>
</UptimeContext>
<CoreDataStatusContext value={coreDataStatusValue}>
<RefreshersContext value={refreshersValue}>
{children}
</RefreshersContext>
</CoreDataStatusContext>
</SystemContext>
</ClashConfigContext>
</RulesContext>
+7 -78
View File
@@ -1,4 +1,3 @@
import { unstable_serialize } from 'swr'
import useSWR, {
type SWRConfiguration,
type SWRResponse,
@@ -33,19 +32,6 @@ type QueryResult<T> = SWRResponse<T> & {
refetch: () => Promise<{ data: T | undefined }>
}
const serializeQueryKey = (queryKey: QueryKey) => unstable_serialize(queryKey)
const queryCache = new Map<string, unknown>()
const setCachedData = <T>(queryKey: QueryKey, data: T | undefined) => {
const cacheKey = serializeQueryKey(queryKey)
if (data === undefined) {
queryCache.delete(cacheKey)
} else {
queryCache.set(cacheKey, data)
}
}
export const swrConfig: SWRConfiguration = {
dedupingInterval: 2000,
errorRetryCount: 3,
@@ -53,72 +39,29 @@ export const swrConfig: SWRConfiguration = {
revalidateOnFocus: false,
}
export const getCacheData = <T>(queryKey: QueryKey): T | undefined => {
return queryCache.get(serializeQueryKey(queryKey)) as T | undefined
}
const updateCachedData = <T>(
queryKey: QueryKey,
updaterOrData: QueryDataUpdater<T>,
) => {
const current = getCacheData<T>(queryKey)
const next =
typeof updaterOrData === 'function'
? (updaterOrData as (current: T | undefined) => T | undefined)(current)
: updaterOrData
setCachedData(queryKey, next)
return next
}
export const setCacheData = <T>(
queryKey: QueryKey,
updaterOrData: QueryDataUpdater<T>,
) => {
const next = updateCachedData(queryKey, updaterOrData)
void swrMutate(queryKey, next, {
) =>
swrMutate<T>(queryKey, updaterOrData, {
populateCache: true,
revalidate: false,
})
return next
}
export const setCacheDataAsync = async <T>(
queryKey: QueryKey,
updaterOrData: QueryDataUpdater<T>,
) => {
const next = updateCachedData(queryKey, updaterOrData)
await swrMutate(queryKey, next, {
populateCache: true,
revalidate: false,
})
return next
}
export const revalidateQuery = async (queryKey: QueryKey) => {
const data = await swrMutate(queryKey)
if (data !== undefined) {
setCachedData(queryKey, data)
}
return data
}
export const revalidateQuery = (queryKey: QueryKey) => swrMutate(queryKey)
export const revalidateQueries = (queryKeys: readonly QueryKey[]) =>
Promise.all(queryKeys.map(revalidateQuery))
export const removeCacheData = (queryKey: QueryKey) => {
setCachedData(queryKey, undefined)
return swrMutate(queryKey, undefined, {
populateCache: true,
revalidate: false,
})
}
export const removeCacheData = (queryKey: QueryKey) =>
setCacheData(queryKey, undefined)
export const fetchCacheData = async <T>(
queryKey: QueryKey,
queryFn: () => Promise<T> | T,
) => {
const data = await queryFn()
setCacheData(queryKey, data)
await setCacheData(queryKey, data)
return data
}
@@ -144,11 +87,6 @@ export function useQuery<T>(options: QueryOptions<T>): QueryResult<T> {
typeof fallbackDataSource === 'function'
? (fallbackDataSource as () => T | undefined)()
: fallbackDataSource
const serializedKey = serializeQueryKey(queryKey)
if (enabled && fallbackData !== undefined && !queryCache.has(serializedKey)) {
setCachedData(queryKey, fallbackData)
}
const swr = useSWR<T>(enabled ? queryKey : null, queryFn, {
dedupingInterval: staleTime,
errorRetryCount: retry === false ? 0 : retry,
@@ -176,21 +114,12 @@ export function useQuery<T>(options: QueryOptions<T>): QueryResult<T> {
revalidateOnReconnect: refetchOnReconnect,
refreshInterval: refetchInterval || 0,
refreshWhenHidden: refetchIntervalInBackground ?? false,
onSuccess: (data) => {
setCachedData(queryKey, data)
},
})
return {
...swr,
isFetching: swr.isValidating,
isPending: swr.isLoading,
refetch: async () => {
const data = await swr.mutate()
if (data !== undefined) {
setCachedData(queryKey, data)
}
return { data }
},
refetch: async () => ({ data: await swr.mutate() }),
}
}