From a73fafaf9f551053d778b5142ee947ab5a965ee0 Mon Sep 17 00:00:00 2001 From: Tunglies Date: Fri, 3 Apr 2026 16:15:51 +0800 Subject: [PATCH] refactor: migrate SWR to TanStack Query v5 (#6713) Replace swr with @tanstack/react-query v5 across all hooks, providers, and components. Introduce singleton QueryClient, WS subscription pattern via useQuery+useEffect, and enforce component-layer cache access contract. --- .gitignore | 1 + eslint.config.ts | 4 +- package.json | 2 +- pnpm-lock.yaml | 32 +- src/components/home/ip-info-card.tsx | 22 +- src/components/home/system-info-card.tsx | 3 +- src/components/profile/profile-item.tsx | 15 +- .../setting/mods/clash-core-viewer.tsx | 8 +- .../setting/mods/sysproxy-viewer.tsx | 21 +- src/components/setting/setting-system.tsx | 2 - src/hooks/use-clash.ts | 55 ++- src/hooks/use-connection-data.ts | 9 +- src/hooks/use-icon-cache.ts | 28 +- src/hooks/use-log-data.ts | 9 +- src/hooks/use-mihomo-ws-subscription.ts | 284 +++++++----- src/hooks/use-network.ts | 23 +- src/hooks/use-profiles.ts | 63 ++- src/hooks/use-system-proxy-state.ts | 28 +- src/hooks/use-system-state.ts | 21 +- src/hooks/use-update.ts | 44 +- src/hooks/use-verge.ts | 48 +- src/main.tsx | 14 +- src/pages/_layout.tsx | 430 ++++++++---------- src/pages/_layout/hooks/use-layout-events.ts | 7 +- src/pages/profiles.tsx | 50 +- src/providers/app-data-provider.tsx | 100 ++-- src/services/query-client.ts | 12 + 27 files changed, 745 insertions(+), 590 deletions(-) create mode 100644 src/services/query-client.ts diff --git a/.gitignore b/.gitignore index 7268b6233..c82526c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ target CLAUDE.md .vfox.toml .vfox/ +.claude diff --git a/eslint.config.ts b/eslint.config.ts index 8f20d6117..8803c041f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -72,7 +72,7 @@ export default defineConfig([ '@eslint-react/no-children-for-each': 'error', '@eslint-react/no-children-map': 'error', '@eslint-react/no-children-only': 'error', - '@eslint-react/no-children-prop': 'error', + '@eslint-react/jsx-no-children-prop': 'error', '@eslint-react/no-children-to-array': 'error', '@eslint-react/no-class-component': 'error', '@eslint-react/no-clone-element': 'error', @@ -86,7 +86,7 @@ export default defineConfig([ '@eslint-react/no-unstable-default-props': 'warn', '@eslint-react/no-unused-class-component-members': 'error', '@eslint-react/no-unused-state': 'error', - '@eslint-react/no-useless-fragment': 'warn', + '@eslint-react/jsx-no-useless-fragment': 'warn', '@eslint-react/prefer-destructuring-assignment': 'warn', // TypeScript diff --git a/package.json b/package.json index d3177b3f3..2d322e6c2 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@mui/icons-material": "^7.3.9", "@mui/lab": "7.0.0-beta.17", "@mui/material": "^7.3.9", + "@tanstack/react-query": "^5.96.1", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "2.10.1", @@ -76,7 +77,6 @@ "react-router": "^7.13.1", "react-virtuoso": "^4.18.3", "rehype-raw": "^7.0.0", - "swr": "^2.4.1", "tauri-plugin-mihomo-api": "github:clash-verge-rev/tauri-plugin-mihomo#revert", "types-pac": "^1.0.3", "validator": "^13.15.26" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e652e9af..381dfbb65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@mui/material': specifier: ^7.3.9 version: 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-query': + specifier: ^5.96.1 + version: 5.96.1(react@19.2.4) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -134,9 +137,6 @@ importers: rehype-raw: specifier: ^7.0.0 version: 7.0.0 - swr: - specifier: ^2.4.1 - version: 2.4.1(react@19.2.4) tauri-plugin-mihomo-api: specifier: github:clash-verge-rev/tauri-plugin-mihomo#revert version: https://codeload.github.com/clash-verge-rev/tauri-plugin-mihomo/tar.gz/1cc80bc0fbe1245315617f4cecd93710a152325b @@ -1434,6 +1434,14 @@ packages: peerDependencies: '@svgr/core': '*' + '@tanstack/query-core@5.96.1': + resolution: {integrity: sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==} + + '@tanstack/react-query@5.96.1': + resolution: {integrity: sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -3345,11 +3353,6 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} - swr@2.4.1: - resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - systemjs@6.15.1: resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==} @@ -4963,6 +4966,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@tanstack/query-core@5.96.1': {} + + '@tanstack/react-query@5.96.1(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.96.1 + react: 19.2.4 + '@tanstack/react-table@8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/table-core': 8.21.3 @@ -7064,12 +7074,6 @@ snapshots: svg-parser@2.0.4: {} - swr@2.4.1(react@19.2.4): - dependencies: - dequal: 2.0.3 - react: 19.2.4 - use-sync-external-store: 1.6.0(react@19.2.4) - systemjs@6.15.1: {} tar@7.5.13: diff --git a/src/components/home/ip-info-card.tsx b/src/components/home/ip-info-card.tsx index a0baa767a..16d37309b 100644 --- a/src/components/home/ip-info-card.tsx +++ b/src/components/home/ip-info-card.tsx @@ -5,23 +5,22 @@ 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' import type { XOR } from 'foxts/ts-xor' import { + forwardRef, memo, useCallback, - useState, useEffectEvent, useMemo, - forwardRef, + useState, } from 'react' import { useTranslation } from 'react-i18next' -import useSWRImmutable from 'swr/immutable' import { getIpInfo } from '@/services/api' -import { SWR_EXTERNAL_API } from '@/services/config' import { EnhancedCard } from './enhanced-card' @@ -78,7 +77,7 @@ type CountDownState = XOR< const IPInfoCardContainer = forwardRef( ({ children }, ref) => { const { t } = useTranslation() - const { mutate } = useIPInfo() + const { refetch: mutate } = useIPInfo() return ( { remainingSeconds: IP_REFRESH_SECONDS, }) - const { data: ipInfo, error, isLoading, mutate } = useIPInfo() + const { data: ipInfo, error, isLoading, refetch: mutate } = useIPInfo() // function useEffectEvent const onCountdownTick = useEffectEvent(async () => { @@ -422,5 +421,14 @@ export const IpInfoCard = () => { } function useIPInfo() { - return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, SWR_EXTERNAL_API) + return useQuery({ + queryKey: [IP_INFO_CACHE_KEY], + queryFn: getIpInfo, + staleTime: Infinity, + gcTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: 1, + retryDelay: 30_000, + }) } diff --git a/src/components/home/system-info-card.tsx b/src/components/home/system-info-card.tsx index 024b02e2f..79d2a23ac 100644 --- a/src/components/home/system-info-card.tsx +++ b/src/components/home/system-info-card.tsx @@ -101,7 +101,8 @@ export const SystemInfoCard = () => { // 检查更新 const onCheckUpdate = useLockFn(async () => { try { - const info = await triggerCheckUpdate() + const result = await triggerCheckUpdate() + const info = result.data if (!info?.available) { showNotice.success( 'settings.components.verge.advanced.notifications.latestVersion', diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index ea7abad72..504a40c2d 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -21,7 +21,6 @@ import { useLockFn } from 'ahooks' import dayjs from 'dayjs' import { useCallback, useEffect, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' -import { mutate } from 'swr' import { ConfirmViewer } from '@/components/profile/confirm-viewer' import { EditorViewer } from '@/components/profile/editor-viewer' @@ -53,6 +52,7 @@ interface Props { selected: boolean activating: boolean itemData: IProfileItem + mutateProfiles: () => Promise onSelect: (force: boolean) => void onEdit: () => void onSave?: (prev?: string, curr?: string) => void @@ -68,6 +68,7 @@ export const ProfileItem = (props: Props) => { selected, activating, itemData, + mutateProfiles, onSelect, onEdit, onSave, @@ -383,7 +384,7 @@ export const ProfileItem = (props: Props) => { await updateProfile(itemData.uid, payload) // 更新成功,刷新列表 - mutate('getProfiles') + void mutateProfiles() } catch { // 更新完全失败(包括后端的回退尝试) // 不需要做处理,后端会通过事件通知系统发送错误 @@ -579,7 +580,7 @@ export const ProfileItem = (props: Props) => { if (customEvent.detail?.uid === itemData.uid) { setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })) // 刷新 profile 数据以获取最新的 updated 时间戳 - mutate('getProfiles') + void mutateProfiles() // 更新完成后刷新显示 if (showNextUpdate) { fetchNextUpdateTime() @@ -599,7 +600,13 @@ export const ProfileItem = (props: Props) => { handleUpdateCompleted, ) } - }, [fetchNextUpdateTime, itemData.uid, setLoadingCache, showNextUpdate]) + }, [ + fetchNextUpdateTime, + itemData.uid, + mutateProfiles, + setLoadingCache, + showNextUpdate, + ]) const handleSaveProfileDocument = useLockFn(async () => { const currentValue = profileDocument.value diff --git a/src/components/setting/mods/clash-core-viewer.tsx b/src/components/setting/mods/clash-core-viewer.tsx index 2779eaa8f..d4a9be42a 100644 --- a/src/components/setting/mods/clash-core-viewer.tsx +++ b/src/components/setting/mods/clash-core-viewer.tsx @@ -15,10 +15,10 @@ import { useLockFn } from 'ahooks' import type { Ref } from 'react' import { useImperativeHandle, useState } from 'react' import { useTranslation } from 'react-i18next' -import { mutate } from 'swr' import { closeAllConnections, upgradeCore } from 'tauri-plugin-mihomo-api' import { BaseDialog, DialogRef } from '@/components/base' +import { useClash, useClashInfo } from '@/hooks/use-clash' import { useVerge } from '@/hooks/use-verge' import { changeClashCore, restartCore } from '@/services/cmds' import { showNotice } from '@/services/notice-service' @@ -40,6 +40,8 @@ export function ClashCoreViewer({ ref }: { ref?: Ref }) { const { t } = useTranslation() const { verge, mutateVerge } = useVerge() + const { mutateVersion } = useClash() + const { invalidateClashConfig } = useClashInfo() const [open, setOpen] = useState(false) const [upgrading, setUpgrading] = useState(false) @@ -69,8 +71,8 @@ export function ClashCoreViewer({ ref }: { ref?: Ref }) { mutateVerge() setTimeout(async () => { - mutate('getClashConfig') - mutate('getVersion') + invalidateClashConfig() + mutateVersion() setChangingCore(null) }, 500) } catch (err) { diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 6bef25777..4487113a0 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -22,7 +22,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { mutate } from 'swr' import { BaseDialog, @@ -110,7 +109,8 @@ export const SysproxyViewer = forwardRef((props, ref) => { const [hostOptions, setHostOptions] = useState([]) const { clashConfig } = useAppData() - const { indicator: isProxyReallyEnabled } = useSystemProxyState() + const { indicator: isProxyReallyEnabled, invalidateProxyState } = + useSystemProxyState() const { enable_system_proxy: enabled, @@ -166,10 +166,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { await patchVergeConfig({ enable_system_proxy: false }) await sleep(200) await patchVergeConfig({ enable_system_proxy: true }) - await Promise.all([ - mutate('getSystemProxy'), - mutate('getAutotemProxy'), - ]) + await invalidateProxyState() } } catch (err) { showNotice.error(err) @@ -177,7 +174,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { } updateProxy() - }, [clashConfig?.mixedPort, value.pac]) + }, [clashConfig?.mixedPort, value.pac, invalidateProxyState]) const { systemProxyAddress } = useAppData() @@ -410,10 +407,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { } setTimeout(async () => { try { - await Promise.all([ - mutate('getSystemProxy'), - mutate('getAutotemProxy'), - ]) + await invalidateProxyState() // 如果需要重置代理且代理当前启用 if (needResetProxy && enabled) { @@ -430,10 +424,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { await patchVergeConfig({ enable_system_proxy: false }) await new Promise((resolve) => setTimeout(resolve, 50)) await patchVergeConfig({ enable_system_proxy: true }) - await Promise.all([ - mutate('getSystemProxy'), - mutate('getAutotemProxy'), - ]) + await invalidateProxyState() } } } catch (err) { diff --git a/src/components/setting/setting-system.tsx b/src/components/setting/setting-system.tsx index d7ca00fa3..c7ce131a1 100644 --- a/src/components/setting/setting-system.tsx +++ b/src/components/setting/setting-system.tsx @@ -1,6 +1,5 @@ import React, { useRef } from 'react' import { useTranslation } from 'react-i18next' -import { mutate } from 'swr' import { DialogRef, Switch, TooltipIcon } from '@/components/base' import ProxyControlSwitches from '@/components/shared/proxy-control-switches' @@ -62,7 +61,6 @@ const SettingSystem = ({ onError }: Props) => { // 先触发UI更新立即看到反馈 onChangeData({ enable_auto_launch: e }) await patchVerge({ enable_auto_launch: e }) - await mutate('getAutoLaunchStatus') return Promise.resolve() } catch (error) { // 如果出错,恢复原始状态 diff --git a/src/hooks/use-clash.ts b/src/hooks/use-clash.ts index 989b086de..8a6c5212e 100644 --- a/src/hooks/use-clash.ts +++ b/src/hooks/use-clash.ts @@ -1,5 +1,5 @@ +import { useQuery } from '@tanstack/react-query' import { useLockFn } from 'ahooks' -import useSWR, { mutate } from 'swr' import { getVersion } from 'tauri-plugin-mihomo-api' import { @@ -7,6 +7,12 @@ import { getRuntimeConfig, patchClashConfig, } from '@/services/cmds' +import { queryClient } from '@/services/query-client' + +type MutateClashUpdater = + | ((old: IConfigData | undefined) => IConfigData | undefined) + | IConfigData + | undefined const PORT_KEYS = [ 'port', @@ -38,7 +44,7 @@ const validatePortRange = (port: number) => { if (port < 1000) { throw new Error('The port should not < 1000') } - if (port > 65536) { + if (port > 65535) { throw new Error('The port should not > 65536') } } @@ -52,16 +58,35 @@ const validatePorts = (patch: ClashInfoPatch) => { } export const useRuntimeConfig = (shouldFetch: boolean = true) => { - return useSWR(shouldFetch ? 'getRuntimeConfig' : null, getRuntimeConfig) + return useQuery({ + queryKey: ['getRuntimeConfig'], + queryFn: getRuntimeConfig, + enabled: shouldFetch, + }) } export const useClash = () => { - const { data: clash, mutate: mutateClash } = useRuntimeConfig() + const { data: clash, refetch } = useRuntimeConfig() - const { data: versionData, mutate: mutateVersion } = useSWR( - 'getVersion', - getVersion, - ) + const { data: versionData, refetch: mutateVersion } = useQuery({ + queryKey: ['getVersion'], + queryFn: getVersion, + }) + + const mutateClash = (updater?: MutateClashUpdater, revalidate?: boolean) => { + if (updater === undefined) { + return refetch() + } + const next = + typeof updater === 'function' + ? updater(queryClient.getQueryData(['getRuntimeConfig'])) + : updater + queryClient.setQueryData(['getRuntimeConfig'], next) + if (revalidate !== false) { + return refetch() + } + return Promise.resolve() + } const patchClash = useLockFn(async (patch: Partial) => { await patchClashConfig(patch) @@ -82,10 +107,10 @@ export const useClash = () => { } export const useClashInfo = () => { - const { data: clashInfo, mutate: mutateInfo } = useSWR( - 'getClashInfo', - getClashInfo, - ) + const { data: clashInfo, refetch: mutateInfo } = useQuery({ + queryKey: ['getClashInfo'], + queryFn: getClashInfo, + }) const patchInfo = useLockFn(async (patch: ClashInfoPatch) => { if (!hasClashInfoPayload(patch)) return @@ -94,12 +119,16 @@ export const useClashInfo = () => { await patchClashConfig(patch) mutateInfo() - mutate('getClashConfig') + queryClient.invalidateQueries({ queryKey: ['getClashConfig'] }) }) + const invalidateClashConfig = () => + queryClient.invalidateQueries({ queryKey: ['getClashConfig'] }) + return { clashInfo, mutateInfo, patchInfo, + invalidateClashConfig, } } diff --git a/src/hooks/use-connection-data.ts b/src/hooks/use-connection-data.ts index b3e7d6909..45488322f 100644 --- a/src/hooks/use-connection-data.ts +++ b/src/hooks/use-connection-data.ts @@ -1,4 +1,4 @@ -import { mutate } from 'swr' +import { useQueryClient } from '@tanstack/react-query' import { MihomoWebSocket } from 'tauri-plugin-mihomo-api' import { useMihomoWsSubscription } from './use-mihomo-ws-subscription' @@ -33,7 +33,6 @@ const mergeConnectionSnapshot = ( const nextConnections = payload.connections ?? [] const previousActive = previous.activeConnections ?? [] const nextById = new Map(nextConnections.map((conn) => [conn.id, conn])) - const newIds = new Set(nextConnections.map((conn) => conn.id)) // Keep surviving connections in their previous relative order to reduce row reshuffle, // but constrain the array to the incoming snapshot length. @@ -60,10 +59,11 @@ const mergeConnectionSnapshot = ( })) const activeConnections = [...carried, ...newcomers] + const activeIds = new Set(activeConnections.map((conn) => conn.id)) const closedConnections = trimClosedConnections([ ...(previous.closedConnections ?? []), - ...previousActive.filter((conn) => !newIds.has(conn.id)), + ...previousActive.filter((conn) => !activeIds.has(conn.id)), ]) return { @@ -75,6 +75,7 @@ const mergeConnectionSnapshot = ( } export const useConnectionData = () => { + const queryClient = useQueryClient() const { response, refresh, subscriptionCacheKey } = useMihomoWsSubscription({ storageKey: 'mihomo_connection_date', @@ -99,7 +100,7 @@ export const useConnectionData = () => { const clearClosedConnections = () => { if (!subscriptionCacheKey) return - mutate(subscriptionCacheKey, { + queryClient.setQueryData([subscriptionCacheKey], { uploadTotal: response.data?.uploadTotal ?? 0, downloadTotal: response.data?.downloadTotal ?? 0, activeConnections: response.data?.activeConnections ?? [], diff --git a/src/hooks/use-icon-cache.ts b/src/hooks/use-icon-cache.ts index a606123f5..e42ce51c3 100644 --- a/src/hooks/use-icon-cache.ts +++ b/src/hooks/use-icon-cache.ts @@ -1,9 +1,8 @@ +import { useQuery } from '@tanstack/react-query' import { convertFileSrc } from '@tauri-apps/api/core' import { useMemo } from 'react' -import useSWR from 'swr' import { downloadIconCache } from '@/services/cmds' -import { SWR_DEFAULTS } from '@/services/config' export interface UseIconCacheOptions { icon?: string | null @@ -24,17 +23,13 @@ export const useIconCache = ({ const iconValue = icon?.trim() ?? '' const cacheKeyValue = cacheKey?.trim() ?? '' - const swrKey = useMemo(() => { - if (!enabled || !iconValue.startsWith('http') || cacheKeyValue === '') { - return null - } - - return ['icon-cache', iconValue, cacheKeyValue] as const + const isEnabled = useMemo(() => { + return enabled && iconValue.startsWith('http') && cacheKeyValue !== '' }, [enabled, iconValue, cacheKeyValue]) - const { data } = useSWR( - swrKey, - async () => { + const { data } = useQuery({ + queryKey: ['icon-cache', iconValue, cacheKeyValue], + queryFn: async () => { try { const fileName = `${cacheKeyValue}-${getFileNameFromUrl(iconValue)}` const iconPath = await downloadIconCache(iconValue, fileName) @@ -43,10 +38,15 @@ export const useIconCache = ({ return '' } }, - SWR_DEFAULTS, - ) + enabled: isEnabled, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: Infinity, + gcTime: Infinity, + retry: 2, + }) - if (!swrKey) { + if (!isEnabled) { return '' } diff --git a/src/hooks/use-log-data.ts b/src/hooks/use-log-data.ts index 6540216b0..d5c45f1cc 100644 --- a/src/hooks/use-log-data.ts +++ b/src/hooks/use-log-data.ts @@ -1,6 +1,6 @@ +import { useQueryClient } from '@tanstack/react-query' import dayjs from 'dayjs' import { useEffect, useRef } from 'react' -import { mutate } from 'swr' import { MihomoWebSocket, type LogLevel } from 'tauri-plugin-mihomo-api' import { getClashLogs } from '@/services/cmds' @@ -39,6 +39,7 @@ const appendLogs = ( ): ILogItem[] => clampLogs([...(current ?? []), ...incoming]) export const useLogData = () => { + const queryClient = useQueryClient() const [clashLog] = useClashLog() const enableLog = clashLog.enable const logLevel = clashLog.logLevel @@ -50,7 +51,6 @@ export const useLogData = () => { storageKey: 'mihomo_logs_date', buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null), fallbackData: [], - keepPreviousData: true, connect: () => MihomoWebSocket.connect_logs(logLevel), setupHandlers: ({ next, scheduleReconnect, isMounted }) => { let flushTimer: ReturnType | null = null @@ -91,6 +91,9 @@ export const useLogData = () => { } parsed.time = dayjs().format('MM-DD HH:mm:ss') buffer.push(parsed) + if (buffer.length > MAX_LOG_NUM) { + buffer.splice(0, buffer.length - MAX_LOG_NUM) + } if (!flushTimer) { flushTimer = setTimeout(flush, FLUSH_DELAY_MS) } @@ -133,7 +136,7 @@ export const useLogData = () => { const refreshGetClashLog = (clear = false) => { if (clear) { if (subscriptionCacheKey) { - mutate(subscriptionCacheKey, []) + queryClient.setQueryData([subscriptionCacheKey], []) } } else { refresh() diff --git a/src/hooks/use-mihomo-ws-subscription.ts b/src/hooks/use-mihomo-ws-subscription.ts index 74a0d214c..64ccbfe76 100644 --- a/src/hooks/use-mihomo-ws-subscription.ts +++ b/src/hooks/use-mihomo-ws-subscription.ts @@ -1,12 +1,19 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useLocalStorage } from 'foxact/use-local-storage' import { useCallback, useEffect, useRef } from 'react' -import { mutate, type MutatorCallback } from 'swr' -import useSWRSubscription from 'swr/subscription' import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api' export const RECONNECT_DELAY_MS = 1000 -type NextFn = (error?: any, data?: T | MutatorCallback) => void +/** + * 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 `queryClient.setQueryData`. + */ +type NextFn = ( + error?: any, + data?: T | ((current?: T) => T | undefined), +) => void interface HandlerContext { next: NextFn @@ -25,7 +32,6 @@ interface UseMihomoWsSubscriptionOptions { buildSubscriptKey: (date: number) => string | null fallbackData: T connect: () => Promise - keepPreviousData?: boolean /** * When > 0, coalesce rapid WebSocket messages by wrapping the `next` * function passed to `setupHandlers`. Only the most recent value is @@ -46,7 +52,6 @@ export const useMihomoWsSubscription = ( buildSubscriptKey, fallbackData, connect, - keepPreviousData = true, throttleMs, setupHandlers, } = options @@ -56,148 +61,185 @@ export const useMihomoWsSubscription = ( const subscriptKey = buildSubscriptKey(date) const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null + const queryClient = useQueryClient() + const wsRef = useRef(null) const wsFirstConnectionRef = useRef(true) const timeoutRef = useRef | null>(null) - const response = useSWRSubscription( - subscriptKey, - (_key, { next }) => { - let isMounted = true + 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 = queryClient.getQueryData([cacheKey]) + return updater(current) ?? fallbackData + } + return data ?? fallbackData + }, + [queryClient, fallbackData], + ) - const clearReconnectTimer = () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - timeoutRef.current = null + const response = useQuery({ + queryKey: subscriptionCacheKey + ? [subscriptionCacheKey] + : ['$sub$__disabled__'], + queryFn: () => + queryClient.getQueryData([subscriptionCacheKey!]) ?? fallbackData, + initialData: () => + queryClient.getQueryData([ + subscriptionCacheKey ?? '$sub$__disabled__', + ]) ?? fallbackData, + staleTime: Infinity, + gcTime: Infinity, + enabled: subscriptionCacheKey !== null, + }) + + useEffect(() => { + if (!subscriptionCacheKey) return + + let isMounted = true + + const clearReconnectTimer = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + } + + 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) + } + + let throttleCleanup: (() => void) | undefined + let wrappedNext: NextFn + + const baseNext: NextFn = (error, data) => { + if (error !== undefined && error !== null) { + return + } + if (data === undefined) return + const resolved = resolveNextData(data, subscriptionCacheKey) + queryClient.setQueryData([subscriptionCacheKey], resolved) + } + + if (throttleMs && throttleMs > 0) { + let pendingData: T | ((current?: T) => T | undefined) | undefined + let hasPending = false + let timerId: ReturnType | null = null + + const flush = () => { + timerId = null + if (hasPending) { + const data = pendingData + pendingData = undefined + hasPending = false + baseNext(undefined, data) } } - const closeSocket = async () => { - if (wsRef.current) { - await wsRef.current.close() - wsRef.current = null + 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 } } - const scheduleReconnect = async () => { - if (!isMounted) return - clearReconnectTimer() - await closeSocket() - if (!isMounted) return - timeoutRef.current = setTimeout(connectWs, RECONNECT_DELAY_MS) - } - - let throttleCleanup: (() => void) | undefined - let wrappedNext: NextFn = next - - if (throttleMs && throttleMs > 0) { - let pendingData: T | MutatorCallback | undefined - let hasPending = false - let timerId: ReturnType | null = null - - const flush = () => { + throttleCleanup = () => { + if (timerId) { + clearTimeout(timerId) timerId = null - if (hasPending) { - const data = pendingData - pendingData = undefined - hasPending = false - next(undefined, data) - } - } - - wrappedNext = (error?: any, data?: T | MutatorCallback) => { - if (error !== undefined && error !== null) { - next(error, data) - return - } - if (!timerId) { - next(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, - isMounted: () => isMounted, - }) + const { + handleMessage: handleTextMessage, + onConnected, + cleanup, + } = setupHandlers({ + next: wrappedNext, + scheduleReconnect, + isMounted: () => isMounted, + }) - const cleanupAll = () => { + const cleanupAll = () => { + clearReconnectTimer() + throttleCleanup?.() + cleanup?.() + void closeSocket() + } + + 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() - throttleCleanup?.() - cleanup?.() - void closeSocket() - } - const handleMessage = (msg: Message) => { - if (msg.type !== 'Text') return - handleTextMessage(msg.data) - } - - async function connectWs() { - try { - const ws_ = await connect() + if (onConnected) { + await onConnected(ws_) 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) - } + 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() - } - - return () => { - isMounted = false - wsFirstConnectionRef.current = true - cleanupAll() - } - }, - { - fallbackData, - keepPreviousData, - }, - ) - - useEffect(() => { - if (subscriptionCacheKey) { - mutate(subscriptionCacheKey) } + + if (wsFirstConnectionRef.current || !wsRef.current) { + wsFirstConnectionRef.current = false + cleanupAll() + void connectWs() + } + + return () => { + isMounted = false + wsFirstConnectionRef.current = true + cleanupAll() + } + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps }, [subscriptionCacheKey]) const refresh = useCallback(() => { diff --git a/src/hooks/use-network.ts b/src/hooks/use-network.ts index f88f4e600..105c7807c 100644 --- a/src/hooks/use-network.ts +++ b/src/hooks/use-network.ts @@ -1,17 +1,20 @@ -import useSWR from 'swr' +import { useQuery } from '@tanstack/react-query' import { getNetworkInterfacesInfo } from '@/services/cmds' export const useNetworkInterfaces = () => { - const { data, error, isLoading, mutate } = useSWR( - 'getNetworkInterfacesInfo', - getNetworkInterfacesInfo, - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - fallbackData: [], - }, - ) + const { + data, + error, + isLoading, + refetch: mutate, + } = useQuery({ + queryKey: ['getNetworkInterfacesInfo'], + queryFn: getNetworkInterfacesInfo, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + initialData: [], + }) return { networkInterfaces: data || [], diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index 40e76863e..70e90fd9e 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,4 +1,4 @@ -import useSWR, { mutate } from 'swr' +import { useQuery } from '@tanstack/react-query' import { selectNodeForGroup } from 'tauri-plugin-mihomo-api' import { @@ -7,32 +7,37 @@ import { patchProfile, patchProfilesConfig, } from '@/services/cmds' +import { queryClient } from '@/services/query-client' import { debugLog } from '@/utils/debug' export const useProfiles = () => { const { data: profiles, - mutate: mutateProfiles, + refetch, error, - isValidating, - } = useSWR('getProfiles', getProfiles, { - revalidateOnFocus: false, - revalidateOnReconnect: false, - dedupingInterval: 500, // 减少去重时间,提高响应性 - errorRetryCount: 3, - errorRetryInterval: 1000, - refreshInterval: 0, // 完全由手动控制 - onError: (error) => { - console.error('[useProfiles] SWR错误:', error) - }, - onSuccess: (data) => { + isFetching: isValidating, + } = useQuery({ + queryKey: ['getProfiles'], + queryFn: async () => { + const data = await getProfiles() debugLog( '[useProfiles] 配置数据更新成功,配置数量:', data?.items?.length || 0, ) + return data }, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: 500, + retry: 3, + retryDelay: 1000, + refetchInterval: false, }) + const mutateProfiles = async () => { + await refetch() + } + const patchProfiles = async ( value: Partial, signal?: AbortSignal, @@ -105,8 +110,14 @@ export const useProfiles = () => { `[ActivateSelected] 当前profile有 ${selected.length} 个代理选择配置`, ) + type SelectedEntry = { name?: string; now?: string } const selectedMap = Object.fromEntries( - selected.map((each) => [each.name!, each.now!]), + (selected as SelectedEntry[]) + .filter( + (each): each is SelectedEntry & { name: string; now: string } => + each.name != null && each.now != null, + ) + .map((each) => [each.name, each.now]), ) let hasChange = false @@ -168,10 +179,10 @@ export const useProfiles = () => { hasChange = true try { await selectNodeForGroup(name, savedProxy) - } catch (error: any) { + } catch (error: unknown) { console.warn( `[ActivateSelected] 切换代理组 ${name} 失败:`, - error.message, + error instanceof Error ? error.message : String(error), ) } } @@ -187,15 +198,21 @@ export const useProfiles = () => { debugLog(`[ActivateSelected] 完成代理切换,保存新的选择配置`) try { - await patchProfile(profileData.current!, { selected: newSelected }) + await patchProfile(current.uid, { selected: newSelected }) debugLog('[ActivateSelected] 代理选择配置保存成功') - await mutate('getProxies', calcuProxies()) - } catch (error: any) { - console.error('[ActivateSelected] 保存代理选择配置失败:', error.message) + queryClient.setQueryData(['getProxies'], await calcuProxies()) + } catch (error: unknown) { + console.error( + '[ActivateSelected] 保存代理选择配置失败:', + error instanceof Error ? error.message : String(error), + ) } - } catch (error: any) { - console.error('[ActivateSelected] 处理代理选择失败:', error.message) + } catch (error: unknown) { + console.error( + '[ActivateSelected] 处理代理选择失败:', + error instanceof Error ? error.message : String(error), + ) } } diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index 7b96182f8..40307e064 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -1,18 +1,21 @@ +import { useQuery } from '@tanstack/react-query' import { useRef } from 'react' -import useSWR, { mutate } from 'swr' import { closeAllConnections } from 'tauri-plugin-mihomo-api' import { useVerge } from '@/hooks/use-verge' import { useAppData } from '@/providers/app-data-context' import { getAutotemProxy } from '@/services/cmds' +import { queryClient } from '@/services/query-client' // 系统代理状态检测统一逻辑 export const useSystemProxyState = () => { const { verge, mutateVerge, patchVerge } = useVerge() const { sysproxy, clashConfig } = useAppData() - const { data: autoproxy } = useSWR('getAutotemProxy', getAutotemProxy, { - revalidateOnFocus: true, - revalidateOnReconnect: true, + const { data: autoproxy } = useQuery({ + queryKey: ['getAutotemProxy'], + queryFn: getAutotemProxy, + refetchOnWindowFocus: true, + refetchOnReconnect: true, }) const { @@ -41,7 +44,10 @@ export const useSystemProxyState = () => { const busyRef = useRef(false) const toggleSystemProxy = async (enabled: boolean) => { - mutateVerge({ ...verge, enable_system_proxy: enabled }, false) + mutateVerge( + (prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev), + false, + ) pendingRef.current = enabled if (busyRef.current) return @@ -58,13 +64,23 @@ export const useSystemProxyState = () => { } } finally { busyRef.current = false - await Promise.all([mutate('getSystemProxy'), mutate('getAutotemProxy')]) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['getSystemProxy'] }), + queryClient.invalidateQueries({ queryKey: ['getAutotemProxy'] }), + ]) } } + const invalidateProxyState = () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ['getSystemProxy'] }), + queryClient.invalidateQueries({ queryKey: ['getAutotemProxy'] }), + ]) + return { indicator, configState: enable_system_proxy ?? false, toggleSystemProxy, + invalidateProxyState, } } diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index 4300e2490..5eda165f7 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -1,5 +1,5 @@ +import { useQuery } from '@tanstack/react-query' import { useEffect, useRef } from 'react' -import useSWR from 'swr' import { getRunningMode, isAdmin, isServiceAvailable } from '@/services/cmds' import { showNotice } from '@/services/notice-service' @@ -27,12 +27,12 @@ export function useSystemState() { const disablingTunRef = useRef(false) const { - data: systemState, - mutate: mutateSystemState, + data: systemState = defaultSystemState, + refetch: mutateSystemState, isLoading, - } = useSWR( - 'getSystemState', - async () => { + } = useQuery({ + queryKey: ['getSystemState'], + queryFn: async () => { const [runningMode, isAdminMode, isServiceOk] = await Promise.all([ getRunningMode(), isAdmin(), @@ -40,12 +40,9 @@ export function useSystemState() { ]) return { runningMode, isAdminMode, isServiceOk } as SystemState }, - { - suspense: true, - refreshInterval: 30000, - fallback: defaultSystemState, - }, - ) + refetchInterval: 30000, + placeholderData: defaultSystemState, + }) const isSidecarMode = systemState.runningMode === 'Sidecar' const isServiceMode = systemState.runningMode === 'Service' diff --git a/src/hooks/use-update.ts b/src/hooks/use-update.ts index a7895f1ca..f84aea6ee 100644 --- a/src/hooks/use-update.ts +++ b/src/hooks/use-update.ts @@ -1,5 +1,6 @@ -import useSWR, { mutate as globalMutate, SWRConfiguration } from 'swr' +import { useQuery } from '@tanstack/react-query' +import { queryClient } from '@/services/query-client' import { checkUpdateSafe } from '@/services/update' import { useVerge } from './use-verge' @@ -12,8 +13,6 @@ export interface UpdateInfo { downloadAndInstall: (onEvent?: any) => Promise } -// --- Last check timestamp (shared via SWR + localStorage) --- - const LAST_CHECK_KEY = 'last_check_update' export const readLastCheckTime = (): number | null => { @@ -26,16 +25,13 @@ export const readLastCheckTime = (): number | null => { export const updateLastCheckTime = (timestamp?: number): number => { const now = timestamp ?? Date.now() localStorage.setItem(LAST_CHECK_KEY, now.toString()) - globalMutate(LAST_CHECK_KEY, now, false) + queryClient.setQueryData([LAST_CHECK_KEY], now) return now } // --- useUpdate hook --- -export const useUpdate = ( - enabled: boolean = true, - options?: SWRConfiguration, -) => { +export const useUpdate = (enabled: boolean = true) => { const { verge } = useVerge() const { auto_check_update } = verge || {} @@ -46,26 +42,28 @@ export const useUpdate = ( const { data: updateInfo, - mutate: checkUpdate, - isValidating, - } = useSWR(shouldCheck ? 'checkUpdate' : null, checkUpdateSafe, { - errorRetryCount: 2, - revalidateIfStale: false, - revalidateOnFocus: false, - focusThrottleInterval: 36e5, // 1 hour - refreshInterval: 24 * 60 * 60 * 1000, // 24 hours - dedupingInterval: 60 * 60 * 1000, // 1 hour - ...options, - onSuccess: (...args) => { + refetch: checkUpdate, + isFetching: isValidating, + } = useQuery({ + queryKey: ['checkUpdate'], + queryFn: async () => { + const result = await checkUpdateSafe() updateLastCheckTime() - options?.onSuccess?.(...args) + return result }, + enabled: shouldCheck, + retry: 2, + staleTime: 60 * 60 * 1000, + refetchInterval: 24 * 60 * 60 * 1000, + refetchOnWindowFocus: false, }) // Shared last check timestamp - const { data: lastCheckUpdate } = useSWR(LAST_CHECK_KEY, readLastCheckTime, { - revalidateOnFocus: false, - revalidateOnReconnect: false, + const { data: lastCheckUpdate } = useQuery({ + queryKey: [LAST_CHECK_KEY], + queryFn: readLastCheckTime, + refetchOnWindowFocus: false, + refetchOnReconnect: false, }) return { diff --git a/src/hooks/use-verge.ts b/src/hooks/use-verge.ts index 9855e2014..19b149a45 100644 --- a/src/hooks/use-verge.ts +++ b/src/hooks/use-verge.ts @@ -1,28 +1,52 @@ -import useSWR from 'swr' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' import { getVergeConfig, patchVergeConfig } from '@/services/cmds' import { getPreloadConfig, setPreloadConfig } from '@/services/preload' export const useVerge = () => { + const qc = useQueryClient() const initialVergeConfig = getPreloadConfig() - const { data: verge, mutate: mutateVerge } = useSWR( - 'getVergeConfig', - async () => { + + const { data: verge, refetch } = useQuery({ + queryKey: ['getVergeConfig'], + queryFn: async () => { const config = await getVergeConfig() setPreloadConfig(config) return config }, - { - fallbackData: initialVergeConfig ?? undefined, - revalidateOnMount: !initialVergeConfig, - }, - ) + initialData: initialVergeConfig ?? undefined, + staleTime: 5000, + }) - const patchVerge = async (value: Partial) => { - await patchVergeConfig(value) - mutateVerge() + const mutateVerge = ( + updaterOrData?: + | IVergeConfig + | ((prev: IVergeConfig | undefined) => IVergeConfig | undefined) + | undefined, + _revalidate?: boolean, + ) => { + if (updaterOrData === undefined) { + void refetch() + return + } + if (typeof updaterOrData === 'function') { + const prev = qc.getQueryData(['getVergeConfig']) + const next = updaterOrData(prev) + qc.setQueryData(['getVergeConfig'], next) + } else { + qc.setQueryData(['getVergeConfig'], updaterOrData) + } } + const patchVerge = useCallback( + async (value: Partial) => { + await patchVergeConfig(value) + await refetch() + }, + [refetch], + ) + return { verge, mutateVerge, diff --git a/src/main.tsx b/src/main.tsx index e1af73b9a..7c1788018 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import './assets/styles/index.scss' import './services/monaco' 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' @@ -18,6 +19,7 @@ import { resolveThemeMode, getPreloadConfig, } from './services/preload' +import { queryClient } from './services/query-client' import { LoadingCacheProvider, ThemeModeProvider, @@ -50,11 +52,13 @@ const initializeApp = (initialThemeMode: 'light' | 'dark') => { - - - - - + + + + + + + , diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index de43cd3dc..b20f0ad67 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -27,7 +27,6 @@ import type { CSSProperties } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Outlet, useLocation, useNavigate } from 'react-router' -import { SWRConfig } from 'swr' import iconDark from '@/assets/image/icon_dark.svg?react' import iconLight from '@/assets/image/icon_light.svg?react' @@ -259,249 +258,220 @@ const Layout = () => { } return ( - { - // FIXME the condition should not be handle gllobally - if (key !== 'getAutotemProxy') { - console.error(`SWR Error for ${key}:`, error) - return - } - - // FIXME we need a better way to handle the retry when first booting app - const silentKeys = ['getVersion', 'getClashConfig', 'getAutotemProxy'] - if (silentKeys.includes(key)) return - - console.error(`[SWR Error] Key: ${key}, Error:`, error) - }, - dedupingInterval: 2000, - }} - > - - {/* 左侧底部窗口控制按钮 */} - -
- - { - if ( - OS === 'windows' && - !['input', 'textarea'].includes( - e.currentTarget.tagName.toLowerCase(), - ) && - !e.currentTarget.isContentEditable - ) { - e.preventDefault() - } - }} - sx={[ - ({ palette }) => ({ bgcolor: palette.background.paper }), - OS === 'linux' - ? { - borderRadius: '8px', - width: '100vw', - height: '100vh', - } - : {}, - ]} - > - {/* Custom titlebar - rendered only when decorated is false, memoized for performance */} - {customTitlebar} + + { + if ( + OS === 'windows' && + !['input', 'textarea'].includes( + e.currentTarget.tagName.toLowerCase(), + ) && + !e.currentTarget.isContentEditable + ) { + e.preventDefault() + } + }} + sx={[ + ({ palette }) => ({ bgcolor: palette.background.paper }), + OS === 'linux' + ? { + borderRadius: '8px', + width: '100vw', + height: '100vh', + } + : {}, + ]} + > + {/* Custom titlebar - rendered only when decorated is false, memoized for performance */} + {customTitlebar} -
-
-
-
- - -
- -
- - {menuUnlocked && ( - ({ - px: 1.5, - py: 0.75, - mx: 'auto', - mb: 1, - maxWidth: 250, - borderRadius: 1.5, - fontSize: 12, - fontWeight: 600, - textAlign: 'center', - color: theme.palette.warning.contrastText, - bgcolor: - theme.palette.mode === 'light' - ? theme.palette.warning.main - : theme.palette.warning.dark, - })} - > - {t('layout.components.navigation.menu.reorderMode')} - - )} - - {menuUnlocked ? ( - - - - {menuOrder.map((path) => { - const item = navItemMap.get(path) - if (!item) { - return null - } - return ( - - ) - })} - - - - ) : ( - - {menuOrder.map((path) => { - const item = navItemMap.get(path) - if (!item) { - return null - } - return ( - - {t(item.label)} - - ) - })} - - )} - - +
+
+
- - {navCollapsed - ? t('layout.components.navigation.menu.expandNavBar') - : t('layout.components.navigation.menu.collapseNavBar')} - - - {menuUnlocked - ? t('layout.components.navigation.menu.lock') - : t('layout.components.navigation.menu.unlock')} - - - {t('layout.components.navigation.menu.restoreDefaultOrder')} - -
- -
- + +
+
-
-
-
- - - - {logsPageMountedRef.current && ( -
({ + px: 1.5, + py: 0.75, + mx: 'auto', + mb: 1, + maxWidth: 250, + borderRadius: 1.5, + fontSize: 12, + fontWeight: 600, + textAlign: 'center', + color: theme.palette.warning.contrastText, + bgcolor: + theme.palette.mode === 'light' + ? theme.palette.warning.main + : theme.palette.warning.dark, + })} + > + {t('layout.components.navigation.menu.reorderMode')} + + )} + + {menuUnlocked ? ( + + + - -
- )} -
+ {menuOrder.map((path) => { + const item = navItemMap.get(path) + if (!item) { + return null + } + return ( + + ) + })} + + + + ) : ( + + {menuOrder.map((path) => { + const item = navItemMap.get(path) + if (!item) { + return null + } + return ( + + {t(item.label)} + + ) + })} + + )} + + + + {navCollapsed + ? t('layout.components.navigation.menu.expandNavBar') + : t('layout.components.navigation.menu.collapseNavBar')} + + + {menuUnlocked + ? t('layout.components.navigation.menu.lock') + : t('layout.components.navigation.menu.unlock')} + + + {t('layout.components.navigation.menu.restoreDefaultOrder')} + + + +
+
- - - + +
+
+
+ + + + {logsPageMountedRef.current && ( +
+ +
+ )} +
+
+
+
+ ) } diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index dff8e2c16..1661384bf 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -1,9 +1,9 @@ import { listen } from '@tauri-apps/api/event' import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' import { useEffect } from 'react' -import { mutate } from 'swr' import { useListen } from '@/hooks/use-listen' +import { queryClient } from '@/services/query-client' export const useLayoutEvents = ( handleNotice: (payload: [string, string]) => void, @@ -14,8 +14,9 @@ export const useLayoutEvents = ( const unlisteners: Array<() => void> = [] let disposed = false const revalidateKeys = (keys: readonly string[]) => { - const keySet = new Set(keys) - mutate((key) => typeof key === 'string' && keySet.has(key)) + keys.forEach((key) => { + queryClient.invalidateQueries({ queryKey: [key] }) + }) } const register = ( diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 5f796ceeb..8e332a331 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -22,15 +22,22 @@ import { } from '@mui/icons-material' import { LoadingButton } from '@mui/lab' import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material' +import { useQuery } from '@tanstack/react-query' import { listen, TauriEvent } from '@tauri-apps/api/event' import { readText } from '@tauri-apps/plugin-clipboard-manager' import { readTextFile } from '@tauri-apps/plugin-fs' import { useLockFn } from 'ahooks' import { throttle } from 'lodash-es' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type RefObject, +} from 'react' import { useTranslation } from 'react-i18next' import { useLocation } from 'react-router' -import useSWR, { mutate } from 'swr' import { closeAllConnections } from 'tauri-plugin-mihomo-api' import { BasePage, BaseStyledTextField, DialogRef } from '@/components/base' @@ -55,6 +62,7 @@ import { updateProfile, } from '@/services/cmds' import { showNotice } from '@/services/notice-service' +import { queryClient } from '@/services/query-client' import { useSetLoadingCache, useThemeMode } from '@/services/states' import { debugLog } from '@/utils/debug' @@ -67,7 +75,7 @@ const debugProfileSwitch = (action: string, profile: string, extra?: any) => { // 检查请求是否已过期 const isRequestOutdated = ( currentSequence: number, - requestSequenceRef: any, + requestSequenceRef: RefObject, profile: string, ) => { if (currentSequence !== requestSequenceRef.current) { @@ -222,14 +230,14 @@ const ProfilePage = () => { debugLog('[紧急刷新] 开始强制刷新所有数据') try { - // 清除所有SWR缓存 - await mutate(() => true, undefined, { revalidate: false }) + // 只失效 profiles 相关 query,不影响 WS 订阅、IP 缓存等其他 query + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['getProfiles'] }), + queryClient.invalidateQueries({ queryKey: ['getRuntimeLogs'] }), + ]) // 强制重新获取配置数据 - await mutateProfiles(undefined, { - revalidate: true, - rollbackOnError: false, - }) + await mutateProfiles() // 等待状态稳定后增强配置 await new Promise((resolve) => setTimeout(resolve, 500)) @@ -249,10 +257,10 @@ const ProfilePage = () => { } }) - const { data: chainLogs = {}, mutate: mutateLogs } = useSWR( - 'getRuntimeLogs', - getRuntimeLogs, - ) + const { data: chainLogs = {}, refetch: mutateLogs } = useQuery({ + queryKey: ['getRuntimeLogs'], + queryFn: getRuntimeLogs, + }) const viewerRef = useRef(null) const configRef = useRef(null) @@ -316,9 +324,10 @@ const ProfilePage = () => { } // 强化的刷新策略 + // maxRetries 设为 1:useProfiles 内部 useQuery 已配置 retry:3,业务层只需 1 次额外重试 const performRobustRefresh = async () => { let retryCount = 0 - const maxRetries = 5 + const maxRetries = 1 const baseDelay = 200 while (retryCount < maxRetries) { @@ -326,10 +335,7 @@ const ProfilePage = () => { debugLog(`[导入刷新] 第${retryCount + 1}次尝试刷新配置数据`) // 强制刷新,绕过所有缓存 - await mutateProfiles(undefined, { - revalidate: true, - rollbackOnError: false, - }) + await mutateProfiles() // 等待状态稳定 await new Promise((resolve) => @@ -350,8 +356,11 @@ const ProfilePage = () => { // 所有重试失败后的最后尝试 console.warn(`[导入刷新] 常规刷新失败,尝试清除缓存重新获取`) try { - // 清除SWR缓存并重新获取 - await mutate('getProfiles', getProfiles(), { revalidate: true }) + // 清除缓存并重新获取 + await queryClient.fetchQuery({ + queryKey: ['getProfiles'], + queryFn: getProfiles, + }) await onEnhance(false) showNotice.error( 'profiles.page.feedback.notifications.importNeedsRefresh', @@ -1007,6 +1016,7 @@ const ProfilePage = () => { selected={profiles.current === item.uid} activating={activatings.includes(item.uid)} itemData={item} + mutateProfiles={mutateProfiles} onSelect={(f) => onSelect(item.uid, f)} onEdit={() => viewerRef.current?.edit(item)} onSave={async (prev, curr) => { diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index ca217279d..9c2d15ebd 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -1,6 +1,6 @@ +import { useQuery } from '@tanstack/react-query' import { listen } from '@tauri-apps/api/event' import React, { useCallback, useEffect, useMemo } from 'react' -import useSWR from 'swr' import { getBaseConfig, getRuleProviders, @@ -15,10 +15,24 @@ import { getRunningMode, getSystemProxy, } from '@/services/cmds' -import { SWR_DEFAULTS, SWR_MIHOMO } from '@/services/config' import { AppDataContext, AppDataContextType } from './app-data-context' +const TQ_MIHOMO = { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: 1500, + retry: 3, + retryDelay: 2000, +} as const + +const TQ_DEFAULTS = { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + staleTime: 5000, + retry: 2, +} as const + // 全局数据提供者组件 export const AppDataProvider = ({ children, @@ -27,35 +41,35 @@ export const AppDataProvider = ({ }) => { const { verge } = useVerge() - const { data: proxiesData, mutate: refreshProxy } = useSWR( - 'getProxies', - calcuProxies, - SWR_MIHOMO, - ) + const { data: proxiesData, refetch: refreshProxy } = useQuery({ + queryKey: ['getProxies'], + queryFn: calcuProxies, + ...TQ_MIHOMO, + }) - const { data: clashConfig, mutate: refreshClashConfig } = useSWR( - 'getClashConfig', - getBaseConfig, - SWR_MIHOMO, - ) + const { data: clashConfig, refetch: refreshClashConfig } = useQuery({ + queryKey: ['getClashConfig'], + queryFn: getBaseConfig, + ...TQ_MIHOMO, + }) - const { data: proxyProviders, mutate: refreshProxyProviders } = useSWR( - 'getProxyProviders', - calcuProxyProviders, - SWR_MIHOMO, - ) + const { data: proxyProviders, refetch: refreshProxyProviders } = useQuery({ + queryKey: ['getProxyProviders'], + queryFn: calcuProxyProviders, + ...TQ_MIHOMO, + }) - const { data: ruleProviders, mutate: refreshRuleProviders } = useSWR( - 'getRuleProviders', - getRuleProviders, - SWR_MIHOMO, - ) + const { data: ruleProviders, refetch: refreshRuleProviders } = useQuery({ + queryKey: ['getRuleProviders'], + queryFn: getRuleProviders, + ...TQ_MIHOMO, + }) - const { data: rulesData, mutate: refreshRules } = useSWR( - 'getRules', - getRules, - SWR_MIHOMO, - ) + const { data: rulesData, refetch: refreshRules } = useQuery({ + queryKey: ['getRules'], + queryFn: getRules, + ...TQ_MIHOMO, + }) useEffect(() => { let lastProfileId: string | null = null @@ -79,7 +93,7 @@ export const AppDataProvider = ({ } const addWindowListener = (eventName: string, handler: EventListener) => { - // eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener + // eslint-disable-next-line @eslint-react/web-api-no-leaked-event-listener window.addEventListener(eventName, handler) return () => window.removeEventListener(eventName, handler) } @@ -222,22 +236,24 @@ export const AppDataProvider = ({ } }, [refreshProxy, refreshClashConfig, refreshRules, refreshRuleProviders]) - const { data: sysproxy, mutate: refreshSysproxy } = useSWR( - 'getSystemProxy', - getSystemProxy, - SWR_DEFAULTS, - ) + const { data: sysproxy, refetch: refreshSysproxy } = useQuery({ + queryKey: ['getSystemProxy'], + queryFn: getSystemProxy, + ...TQ_DEFAULTS, + }) - const { data: runningMode } = useSWR( - 'getRunningMode', - getRunningMode, - SWR_DEFAULTS, - ) + const { data: runningMode } = useQuery({ + queryKey: ['getRunningMode'], + queryFn: getRunningMode, + ...TQ_DEFAULTS, + }) - const { data: uptimeData } = useSWR('appUptime', getAppUptime, { - ...SWR_DEFAULTS, - refreshInterval: 3000, - errorRetryCount: 1, + const { data: uptimeData } = useQuery({ + queryKey: ['appUptime'], + queryFn: getAppUptime, + ...TQ_DEFAULTS, + refetchInterval: 3000, + retry: 1, }) // 提供统一的刷新方法 diff --git a/src/services/query-client.ts b/src/services/query-client.ts new file mode 100644 index 000000000..4758c510d --- /dev/null +++ b/src/services/query-client.ts @@ -0,0 +1,12 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 2000, + retry: 3, + retryDelay: 5000, + refetchOnWindowFocus: false, + }, + }, +})