From e71e701fd051e10b4bf4f9fc6df8ee77babbc51e Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:06:17 +0800 Subject: [PATCH] refactor: migrate query cache to swr --- package.json | 2 +- pnpm-lock.yaml | 32 ++--- src/components/home/ip-info-card.tsx | 2 +- src/components/proxy/proxy-groups.tsx | 2 +- src/hooks/use-clash.ts | 2 +- src/hooks/use-icon-cache.ts | 2 +- src/hooks/use-log-data.ts | 3 +- src/hooks/use-mihomo-ws-subscription.ts | 9 +- src/hooks/use-network.ts | 3 +- src/hooks/use-profiles.ts | 2 +- src/hooks/use-system-proxy-state.ts | 2 +- src/hooks/use-system-state.ts | 2 +- src/hooks/use-update.ts | 3 +- src/hooks/use-verge.ts | 9 +- src/main.tsx | 8 +- src/pages/profiles.tsx | 2 +- src/providers/app-data-provider.tsx | 2 +- src/services/query-client.ts | 184 ++++++++++++++++++++++-- 18 files changed, 213 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index 9941614b4..3dd0b6df5 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "@monaco-editor/react": "^4.7.0", "@mui/icons-material": "^9.0.1", "@mui/material": "^9.0.1", - "@tanstack/react-query": "^5.100.14", "@tanstack/react-virtual": "^3.13.25", "@tauri-apps/api": "2.11.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", @@ -74,6 +73,7 @@ "react-markdown": "10.1.0", "react-router": "^8.0.0", "rehype-raw": "^7.0.0", + "swr": "^2.4.2", "tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo", "types-pac": "^1.0.3", "validator": "^13.15.35" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2776fe1a7..0cd72c93e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: '@mui/material': specifier: ^9.0.1 version: 9.0.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@tanstack/react-query': - specifier: ^5.100.14 - version: 5.100.14(react@19.2.7) '@tanstack/react-virtual': specifier: ^3.13.25 version: 3.13.25(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -128,6 +125,9 @@ importers: rehype-raw: specifier: ^7.0.0 version: 7.0.0 + swr: + specifier: ^2.4.2 + version: 2.4.2(react@19.2.7) tauri-plugin-mihomo-api: specifier: github:clash-verge-rev/tauri-plugin-mihomo version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/ff75ee3edd61923e6c76a7f3cf3c3fbd0f8e22c0 @@ -1475,14 +1475,6 @@ packages: peerDependencies: '@svgr/core': '*' - '@tanstack/query-core@5.100.14': - resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} - - '@tanstack/react-query@5.100.14': - resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==} - peerDependencies: - react: ^18 || ^19 - '@tanstack/react-virtual@3.13.25': resolution: {integrity: sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==} peerDependencies: @@ -3349,6 +3341,11 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + swr@2.4.2: + resolution: {integrity: sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + systemjs@6.15.1: resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==} @@ -4992,13 +4989,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/query-core@5.100.14': {} - - '@tanstack/react-query@5.100.14(react@19.2.7)': - dependencies: - '@tanstack/query-core': 5.100.14 - react: 19.2.7 - '@tanstack/react-virtual@3.13.25(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/virtual-core': 3.15.0 @@ -7032,6 +7022,12 @@ snapshots: svg-parser@2.0.4: {} + swr@2.4.2(react@19.2.7): + dependencies: + dequal: 2.0.3 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + systemjs@6.15.1: {} tar@7.5.15: diff --git a/src/components/home/ip-info-card.tsx b/src/components/home/ip-info-card.tsx index 4252ca16e..040139912 100644 --- a/src/components/home/ip-info-card.tsx +++ b/src/components/home/ip-info-card.tsx @@ -5,7 +5,6 @@ import { VisibilityOutlined, } from '@mui/icons-material' import { Box, Button, IconButton, Skeleton, Typography } from '@mui/material' -import { useQuery } from '@tanstack/react-query' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { useEffect } from 'foxact/use-abortable-effect' import { useIntersection } from 'foxact/use-intersection' @@ -21,6 +20,7 @@ import { import { useTranslation } from 'react-i18next' import { getIpInfo } from '@/services/api' +import { useQuery } from '@/services/query-client' import { EnhancedCard } from './enhanced-card' diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 63a3bb2de..71ad947e5 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -10,7 +10,6 @@ import { Typography, } from '@mui/material' import { useTheme } from '@mui/material/styles' -import { useQuery } from '@tanstack/react-query' import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual' import { useLockFn } from 'ahooks' import { @@ -34,6 +33,7 @@ import { useVerge } from '@/hooks/use-verge' import { useProxiesData } from '@/providers/app-data-context' import { calcuProxies, updateProxyChainConfigInRuntime } from '@/services/cmds' import delayManager from '@/services/delay' +import { useQuery } from '@/services/query-client' import { debugLog } from '@/utils/debug' import { ScrollTopButton } from '../layout/scroll-top-button' diff --git a/src/hooks/use-clash.ts b/src/hooks/use-clash.ts index dd770dffd..4cd85db14 100644 --- a/src/hooks/use-clash.ts +++ b/src/hooks/use-clash.ts @@ -1,4 +1,3 @@ -import { useQuery } from '@tanstack/react-query' import { useLockFn } from 'ahooks' import { getVersion } from 'tauri-plugin-mihomo-api' @@ -8,6 +7,7 @@ import { getRuntimeConfig, patchClashConfig, } from '@/services/cmds' +import { useQuery } from '@/services/query-client' import { queryClient } from '@/services/query-client' type MutateClashUpdater = diff --git a/src/hooks/use-icon-cache.ts b/src/hooks/use-icon-cache.ts index 9faf49052..f5ecbfd6e 100644 --- a/src/hooks/use-icon-cache.ts +++ b/src/hooks/use-icon-cache.ts @@ -1,8 +1,8 @@ -import { useQuery } from '@tanstack/react-query' import { convertFileSrc } from '@tauri-apps/api/core' import { useMemo } from 'react' import { downloadIconCache } from '@/services/cmds' +import { useQuery } from '@/services/query-client' export interface UseIconCacheOptions { icon?: string | null diff --git a/src/hooks/use-log-data.ts b/src/hooks/use-log-data.ts index 8e7800aed..f77bae820 100644 --- a/src/hooks/use-log-data.ts +++ b/src/hooks/use-log-data.ts @@ -1,9 +1,9 @@ -import { useQueryClient } from '@tanstack/react-query' import dayjs from 'dayjs' import { useEffect, useRef } from 'react' import { MihomoWebSocket, type LogLevel } from 'tauri-plugin-mihomo-api' import { getClashLogs } from '@/services/cmds' +import { queryClient } from '@/services/query-client' import { useClashLog } from './use-clash-log' import { useMihomoWsSubscription } from './use-mihomo-ws-subscription' @@ -48,7 +48,6 @@ const appendLogs = ( } export const useLogData = () => { - const queryClient = useQueryClient() const [clashLog] = useClashLog() const enableLog = clashLog.enable const logLevel = clashLog.logLevel.toUpperCase() as LogLevel diff --git a/src/hooks/use-mihomo-ws-subscription.ts b/src/hooks/use-mihomo-ws-subscription.ts index 2ff853e37..8d25421de 100644 --- a/src/hooks/use-mihomo-ws-subscription.ts +++ b/src/hooks/use-mihomo-ws-subscription.ts @@ -1,8 +1,9 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' 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 { queryClient, useQuery } from '@/services/query-client' + export const RECONNECT_DELAY_MS = 1000 interface SharedSubscriptionOwner { @@ -195,8 +196,6 @@ export const useMihomoWsSubscription = ( const responseCacheKey = subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current - const queryClient = useQueryClient() - const wsRef = useRef(null) const resolveNextData = useCallback( @@ -211,7 +210,7 @@ export const useMihomoWsSubscription = ( } return data ?? fallbackData }, - [queryClient, fallbackData], + [fallbackData], ) const response = useQuery({ @@ -355,7 +354,7 @@ export const useMihomoWsSubscription = ( queryClient.removeQueries({ queryKey: [subscriptionCacheKey] }) } setDate(Date.now()) - }, [queryClient, subscriptionCacheKey, setDate]) + }, [subscriptionCacheKey, setDate]) return { response, refresh, subscriptionCacheKey: responseCacheKey, wsRef } } diff --git a/src/hooks/use-network.ts b/src/hooks/use-network.ts index 47235f490..427d256e1 100644 --- a/src/hooks/use-network.ts +++ b/src/hooks/use-network.ts @@ -1,6 +1,5 @@ -import { useQuery } from '@tanstack/react-query' - import { getNetworkInterfacesInfo } from '@/services/cmds' +import { useQuery } from '@/services/query-client' export const useNetworkInterfaces = () => { const { diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 70e90fd9e..f40c50351 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,4 +1,3 @@ -import { useQuery } from '@tanstack/react-query' import { selectNodeForGroup } from 'tauri-plugin-mihomo-api' import { @@ -7,6 +6,7 @@ import { patchProfile, patchProfilesConfig, } from '@/services/cmds' +import { useQuery } from '@/services/query-client' import { queryClient } from '@/services/query-client' import { debugLog } from '@/utils/debug' diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index 308dc6b35..4ed56392d 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -1,10 +1,10 @@ -import { useQuery } from '@tanstack/react-query' import { useRef } from 'react' import { closeAllConnections } from 'tauri-plugin-mihomo-api' import { useVerge } from '@/hooks/use-verge' import { useClashConfigData, useSystemData } from '@/providers/app-data-context' import { getAutotemProxy } from '@/services/cmds' +import { useQuery } from '@/services/query-client' import { queryClient } from '@/services/query-client' // 系统代理状态检测统一逻辑 diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index 675946fba..bb4a8c08a 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -1,8 +1,8 @@ -import { useQuery } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import { getRunningMode, isAdmin, isServiceAvailable } from '@/services/cmds' import { showNotice } from '@/services/notice-service' +import { useQuery } from '@/services/query-client' import { useVerge } from './use-verge' diff --git a/src/hooks/use-update.ts b/src/hooks/use-update.ts index f84aea6ee..06327b070 100644 --- a/src/hooks/use-update.ts +++ b/src/hooks/use-update.ts @@ -1,5 +1,4 @@ -import { useQuery } from '@tanstack/react-query' - +import { useQuery } from '@/services/query-client' import { queryClient } from '@/services/query-client' import { checkUpdateSafe } from '@/services/update' diff --git a/src/hooks/use-verge.ts b/src/hooks/use-verge.ts index 19b149a45..1fda52c2a 100644 --- a/src/hooks/use-verge.ts +++ b/src/hooks/use-verge.ts @@ -1,11 +1,10 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { getVergeConfig, patchVergeConfig } from '@/services/cmds' import { getPreloadConfig, setPreloadConfig } from '@/services/preload' +import { queryClient, useQuery } from '@/services/query-client' export const useVerge = () => { - const qc = useQueryClient() const initialVergeConfig = getPreloadConfig() const { data: verge, refetch } = useQuery({ @@ -31,11 +30,11 @@ export const useVerge = () => { return } if (typeof updaterOrData === 'function') { - const prev = qc.getQueryData(['getVergeConfig']) + const prev = queryClient.getQueryData(['getVergeConfig']) const next = updaterOrData(prev) - qc.setQueryData(['getVergeConfig'], next) + queryClient.setQueryData(['getVergeConfig'], next) } else { - qc.setQueryData(['getVergeConfig'], updaterOrData) + queryClient.setQueryData(['getVergeConfig'], updaterOrData) } } diff --git a/src/main.tsx b/src/main.tsx index 1c472bfdf..b99fd5edd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,11 +1,11 @@ import './assets/styles/index.scss' import { ResizeObserver } from '@juggle/resize-observer' -import { QueryClientProvider } from '@tanstack/react-query' import { ComposeContextProvider } from 'foxact/compose-context-provider' import React from 'react' import { createRoot } from 'react-dom/client' import { RouterProvider } from 'react-router' +import { SWRConfig } from 'swr' import { MihomoWebSocket } from 'tauri-plugin-mihomo-api' import { BaseErrorBoundary } from './components/base' @@ -18,7 +18,7 @@ import { resolveThemeMode, getPreloadConfig, } from './services/preload' -import { queryClient } from './services/query-client' +import { queryClientConfig } from './services/query-client' import { LoadingCacheProvider, ThemeModeProvider, @@ -51,13 +51,13 @@ const initializeApp = (initialThemeMode: 'light' | 'dark') => { - + - + , diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 78c383006..6bcb0b9e9 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -21,7 +21,6 @@ import { TextSnippetOutlined, } from '@mui/icons-material' import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material' -import { useQuery } from '@tanstack/react-query' import { TauriEvent } from '@tauri-apps/api/event' import { readText } from '@tauri-apps/plugin-clipboard-manager' import { readTextFile } from '@tauri-apps/plugin-fs' @@ -65,6 +64,7 @@ import { updateProfile, } from '@/services/cmds' import { showNotice } from '@/services/notice-service' +import { useQuery } from '@/services/query-client' import { queryClient } from '@/services/query-client' import { useLoadingCache, diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index d13633c4b..66f6cd729 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -1,4 +1,3 @@ -import { useQuery } from '@tanstack/react-query' import { listen } from '@tauri-apps/api/event' import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { @@ -15,6 +14,7 @@ import { getRunningMode, getSystemProxy, } from '@/services/cmds' +import { useQuery } from '@/services/query-client' import { queryClient } from '@/services/query-client' import { diff --git a/src/services/query-client.ts b/src/services/query-client.ts index 4758c510d..d3b613741 100644 --- a/src/services/query-client.ts +++ b/src/services/query-client.ts @@ -1,12 +1,176 @@ -import { QueryClient } from '@tanstack/react-query' +import { unstable_serialize } from 'swr' +import useSWR, { + type SWRConfiguration, + type SWRResponse, + mutate as swrMutate, +} from 'swr' -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 2000, - retry: 3, - retryDelay: 5000, - refetchOnWindowFocus: false, - }, +type QueryKey = string | readonly unknown[] + +type QueryOptions = { + queryKey: QueryKey + queryFn: () => Promise | T + enabled?: boolean + initialData?: T | (() => T | undefined) + placeholderData?: T | (() => T | undefined) + staleTime?: number + gcTime?: number + retry?: number | false + retryDelay?: number | ((attempt: number) => number) + refetchInterval?: number | false + refetchIntervalInBackground?: boolean + refetchOnWindowFocus?: boolean + refetchOnReconnect?: boolean +} + +type QueryResult = Omit, 'mutate'> & { + isFetching: boolean + isPending: boolean + refetch: () => Promise<{ data: T | undefined }> + mutate: SWRResponse['mutate'] +} + +type QueryFilters = { + queryKey: QueryKey +} + +const keyToSWRKey = (queryKey: QueryKey) => { + return Array.isArray(queryKey) ? [...queryKey] : queryKey +} + +const serializeQueryKey = (queryKey: QueryKey) => { + return unstable_serialize(keyToSWRKey(queryKey)) +} + +const resolveFallbackData = ( + initialData: QueryOptions['initialData'], + placeholderData: QueryOptions['placeholderData'], +) => { + const data = initialData ?? placeholderData + return typeof data === 'function' ? (data as () => T | undefined)() : data +} + +const resolveRetryInterval = ( + retryDelay: QueryOptions['retryDelay'], +) => { + if (typeof retryDelay !== 'function') return retryDelay + return retryDelay(0) +} + +export const queryCache = new Map() + +export const queryClientConfig: SWRConfiguration = { + dedupingInterval: 2000, + errorRetryCount: 3, + errorRetryInterval: 5000, + revalidateOnFocus: false, +} + +export const queryClient = { + getQueryData(queryKey: QueryKey): T | undefined { + return queryCache.get(serializeQueryKey(queryKey)) as T | undefined }, -}) + + setQueryData( + queryKey: QueryKey, + updaterOrData: T | undefined | ((current: T | undefined) => T | undefined), + ) { + const current = this.getQueryData(queryKey) + const next = + typeof updaterOrData === 'function' + ? (updaterOrData as (current: T | undefined) => T | undefined)(current) + : updaterOrData + + if (next === undefined) { + queryCache.delete(serializeQueryKey(queryKey)) + } else { + queryCache.set(serializeQueryKey(queryKey), next) + } + + void swrMutate(keyToSWRKey(queryKey), next, { + populateCache: true, + revalidate: false, + }) + return next + }, + + invalidateQueries({ queryKey }: QueryFilters) { + return swrMutate(keyToSWRKey(queryKey)) + }, + + removeQueries({ queryKey }: QueryFilters) { + queryCache.delete(serializeQueryKey(queryKey)) + return swrMutate(keyToSWRKey(queryKey), undefined, { + populateCache: true, + revalidate: false, + }) + }, + + async fetchQuery({ + queryKey, + queryFn, + }: { + queryKey: QueryKey + queryFn: () => Promise | T + }) { + const data = await queryFn() + this.setQueryData(queryKey, data) + return data + }, +} + +export const invalidateQueries = (queryKeys: readonly QueryKey[]) => + Promise.all( + queryKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ) + +export function useQuery(options: QueryOptions): QueryResult { + const { + queryKey, + queryFn, + enabled = true, + initialData, + placeholderData, + retry, + retryDelay, + refetchInterval, + refetchIntervalInBackground, + refetchOnWindowFocus, + refetchOnReconnect, + staleTime, + } = options + + const fallbackData = resolveFallbackData(initialData, placeholderData) + const serializedKey = serializeQueryKey(queryKey) + if (enabled && fallbackData !== undefined && !queryCache.has(serializedKey)) { + queryCache.set(serializedKey, fallbackData) + } + + const swr = useSWR(enabled ? keyToSWRKey(queryKey) : null, queryFn, { + dedupingInterval: staleTime, + errorRetryCount: retry === false ? 0 : retry, + errorRetryInterval: resolveRetryInterval(retryDelay), + fallbackData, + keepPreviousData: placeholderData !== undefined, + revalidateOnFocus: refetchOnWindowFocus, + revalidateOnReconnect: refetchOnReconnect, + refreshInterval: refetchInterval || 0, + refreshWhenHidden: refetchIntervalInBackground ?? false, + onSuccess: (data) => { + queryCache.set(serializedKey, data) + }, + }) + + return { + ...swr, + isFetching: swr.isValidating, + isPending: swr.isLoading, + refetch: async () => { + const data = await swr.mutate() + if (data !== undefined) { + queryCache.set(serializedKey, data) + } + return { data } + }, + } +}