diff --git a/src/components/home/clash-info-card.tsx b/src/components/home/clash-info-card.tsx index 505f28e77..b184ccc42 100644 --- a/src/components/home/clash-info-card.tsx +++ b/src/components/home/clash-info-card.tsx @@ -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,避免频繁计算 diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx index 7bb705202..b5ca565da 100644 --- a/src/components/layout/service-migration-dialog.tsx +++ b/src/components/layout/service-migration-dialog.tsx @@ -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(runStateQueryKey, data) + await setCacheData(runStateQueryKey, data) setStateRefreshFailed(false) return data } catch (error) { diff --git a/src/hooks/use-clash.ts b/src/hooks/use-clash.ts index d099cc086..82dbb29d5 100644 --- a/src/hooks/use-clash.ts +++ b/src/hooks/use-clash.ts @@ -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(['getRuntimeConfig'])) - : updater - setCacheData(['getRuntimeConfig'], next) + void setCacheData(['getRuntimeConfig'], updater) if (revalidate !== false) { return refetch() } diff --git a/src/hooks/use-log-data.ts b/src/hooks/use-log-data.ts index 5a03266b1..4a6c821ea 100644 --- a/src/hooks/use-log-data.ts +++ b/src/hooks/use-log-data.ts @@ -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({ 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([subscriptionCacheKey], []) - } + setData([]) } else { hasLoadedInitialLogsRef.current = false refresh() diff --git a/src/hooks/use-mihomo-ws-subscription.ts b/src/hooks/use-mihomo-ws-subscription.ts index 692273952..9cf4debaf 100644 --- a/src/hooks/use-mihomo-ws-subscription.ts +++ b/src/hooks/use-mihomo-ws-subscription.ts @@ -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() +const subscriptionSnapshots = new Map() const initialSubscriptionDate = Date.now() +const getSubscriptionSnapshot = (key: string) => + subscriptionSnapshots.get(key) as T | undefined + +const writeSubscriptionSnapshot = (key: string, data: T) => { + subscriptionSnapshots.set(key, data) + void setCacheData([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 = ( error?: any, @@ -208,7 +216,7 @@ export const useMihomoWsSubscription = ( ): T => { if (typeof data === 'function') { const updater = data as (current?: T) => T | undefined - const current = getCacheData([cacheKey]) + const current = getSubscriptionSnapshot(cacheKey) return updater(current) ?? fallbackData } return data ?? fallbackData @@ -218,9 +226,10 @@ export const useMihomoWsSubscription = ( const response = useQuery({ queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'], - queryFn: () => getCacheData([responseCacheKey!]) ?? fallbackData, + queryFn: () => + getSubscriptionSnapshot(responseCacheKey!) ?? fallbackData, initialData: () => - getCacheData([responseCacheKey ?? '$sub$__disabled__']) ?? + getSubscriptionSnapshot(responseCacheKey ?? '$sub$__disabled__') ?? fallbackData, staleTime: Infinity, enabled: subscriptionCacheKey !== null, @@ -249,7 +258,7 @@ export const useMihomoWsSubscription = ( } if (data === undefined) return const resolved = resolveNextData(data, subscriptionCacheKey) - setCacheData([subscriptionCacheKey], resolved) + writeSubscriptionSnapshot(subscriptionCacheKey, resolved) } if (throttleMs && throttleMs > 0) { @@ -352,10 +361,20 @@ export const useMihomoWsSubscription = ( 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 } } diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index ca11f374e..ece68cd6f 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -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( - ['getProfiles'], - (current) => (current ? { ...current, ...value } : current), + await setCacheData(profilesQueryKey, (current) => + current ? { ...current, ...value } : current, ) } else if (outcome.status !== 'busy') { await mutateProfiles() diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index c58cd5d70..7c3c7bba5 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -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(['getVergeConfig'])?.enable_system_proxy ?? - false + let confirmed = verge?.enable_system_proxy ?? false mutateVerge( (prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev), false, diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index 691f01f2d..a1a0faa59 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -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 +} diff --git a/src/hooks/use-verge.ts b/src/hooks/use-verge.ts index e966b9929..0e0e4247c 100644 --- a/src/hooks/use-verge.ts +++ b/src/hooks/use-verge.ts @@ -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(['getVergeConfig']) - const next = updaterOrData(prev) - setCacheData(['getVergeConfig'], next) - } else { - setCacheData(['getVergeConfig'], updaterOrData) - } + void setCacheData( + ['getVergeConfig'], + typeof updaterOrData === 'function' + ? (current) => updaterOrData(current ?? verge) + : updaterOrData, + ) } const patchVerge = useCallback( diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index 84ad96aaf..24b2d1859 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -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(runStateQueryKey, payload) + void setCacheData(runStateQueryKey, payload) }, 'verge://notice-message': handleNotice, }, diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 71750fc24..9da7da3d3 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -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', diff --git a/src/providers/app-data-context.ts b/src/providers/app-data-context.ts index 001337572..267de3591 100644 --- a/src/providers/app-data-context.ts +++ b/src/providers/app-data-context.ts @@ -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( null, ) export const SystemContext = createContext(null) -export const UptimeContext = createContext(null) export const CoreDataStatusContext = createContext(null) export const RefreshersContext = createContext( @@ -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') diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index 68c04a475..fe2dfb6bc 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -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 = ({ - - - - {children} - - - + + + {children} + + diff --git a/src/services/query-client.ts b/src/services/query-client.ts index ab71735b4..2bbdf1f81 100644 --- a/src/services/query-client.ts +++ b/src/services/query-client.ts @@ -1,4 +1,3 @@ -import { unstable_serialize } from 'swr' import useSWR, { type SWRConfiguration, type SWRResponse, @@ -33,19 +32,6 @@ type QueryResult = SWRResponse & { refetch: () => Promise<{ data: T | undefined }> } -const serializeQueryKey = (queryKey: QueryKey) => unstable_serialize(queryKey) - -const queryCache = new Map() - -const setCachedData = (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 = (queryKey: QueryKey): T | undefined => { - return queryCache.get(serializeQueryKey(queryKey)) as T | undefined -} - -const updateCachedData = ( - queryKey: QueryKey, - updaterOrData: QueryDataUpdater, -) => { - const current = getCacheData(queryKey) - const next = - typeof updaterOrData === 'function' - ? (updaterOrData as (current: T | undefined) => T | undefined)(current) - : updaterOrData - setCachedData(queryKey, next) - return next -} - export const setCacheData = ( queryKey: QueryKey, updaterOrData: QueryDataUpdater, -) => { - const next = updateCachedData(queryKey, updaterOrData) - void swrMutate(queryKey, next, { +) => + swrMutate(queryKey, updaterOrData, { populateCache: true, revalidate: false, }) - return next -} -export const setCacheDataAsync = async ( - queryKey: QueryKey, - updaterOrData: QueryDataUpdater, -) => { - 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 ( queryKey: QueryKey, queryFn: () => Promise | T, ) => { const data = await queryFn() - setCacheData(queryKey, data) + await setCacheData(queryKey, data) return data } @@ -144,11 +87,6 @@ export function useQuery(options: QueryOptions): QueryResult { 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(enabled ? queryKey : null, queryFn, { dedupingInterval: staleTime, errorRetryCount: retry === false ? 0 : retry, @@ -176,21 +114,12 @@ export function useQuery(options: QueryOptions): QueryResult { 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() }), } }