diff --git a/src/AGENTS.md b/src/AGENTS.md index 51a3b125d..ccddd08ef 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -21,3 +21,27 @@ These rules supplement the repository's root `AGENTS.md`. - **Command ownership.** `services` owns backend command invocation and transport wrappers. Components and hooks use those boundaries; plugin mutations follow the same intent ownership as backend commands. +- **State liveness.** Every `useState`/`useRef` member has a reachable write + that differs from its initial value and a read that observes the difference. + A value that can only ever hold its initial value is a constant: declare the + constant and delete the state. A ref nothing reads is deleted, not kept. +- **Surface liveness.** Anything a module exposes — props, parameters, context + members, imperative-handle methods — ships with at least one external caller + in the same change. "Reserved for future use" fields, `_`-prefixed params + that callers still pass, and error or callback channels that every receiver + ignores are removed, not maintained. +- **Path liveness.** A fallback or branch names the concrete runtime condition + that reaches it. If no supported runtime can reach it, the branch is not + added — or it replaces the primary path. Constant subexpressions and cleanup + of resources that cannot exist yet are simplified away. +- **Effect economy.** One user interaction produces one state write and one + command. A handler does not re-apply a value its wrapper already applied, + and an effect does not re-run a refresh another branch of the same effect + just ran. +- **Mount economy.** Components that issue commands or subscriptions on mount + are mounted only where their UI is reachable. A dialog no rendered control + can open stays unmounted, not mounted-and-idle. +- **Resource bounds.** Every cache key, timer and subscription a feature + allocates is reused or released on refresh; refresh never allocates an + unbounded sequence of new keys. Retries re-create per-attempt resources so + a retry can actually succeed. diff --git a/src/components/base/base-split-chip-editor.tsx b/src/components/base/base-split-chip-editor.tsx index 75c21d020..3f6a86d7b 100644 --- a/src/components/base/base-split-chip-editor.tsx +++ b/src/components/base/base-split-chip-editor.tsx @@ -22,23 +22,16 @@ interface BaseSplitChipEditorProps { error?: boolean helperText?: ReactNode placeholder?: string - rows?: number separator?: string - splitPattern?: RegExp - defaultMode?: BaseSplitChipEditorMode - showModeToggle?: boolean ariaLabel?: string - addLabel?: ReactNode - emptyLabel?: ReactNode - modeLabels?: Partial> renderHeader?: (modeToggle: ReactNode) => ReactNode } const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/ -const splitValue = (value: string, splitPattern: RegExp) => +const splitValue = (value: string) => value - .split(splitPattern) + .split(DEFAULT_SPLIT_PATTERN) .map((item) => item.trim()) .filter(Boolean) @@ -49,35 +42,25 @@ export const BaseSplitChipEditor = ({ error = false, helperText, placeholder, - rows = 4, separator = ',', - splitPattern = DEFAULT_SPLIT_PATTERN, - defaultMode = 'visual', - showModeToggle = true, ariaLabel, - addLabel, - emptyLabel, - modeLabels, renderHeader, }: BaseSplitChipEditorProps) => { const { t } = useTranslation() - const [mode, setMode] = useState(defaultMode) + const [mode, setMode] = useState('visual') const [draft, setDraft] = useState('') const resolvedLabels = useMemo( () => ({ - visual: modeLabels?.visual ?? t('shared.editorModes.visualization'), - advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'), - add: addLabel ?? t('shared.actions.new'), - empty: emptyLabel ?? t('shared.statuses.empty'), + visual: t('shared.editorModes.visualization'), + advanced: t('shared.editorModes.advanced'), + add: t('shared.actions.new'), + empty: t('shared.statuses.empty'), }), - [t, modeLabels, addLabel, emptyLabel], + [t], ) - const values = useMemo( - () => splitValue(value, splitPattern), - [value, splitPattern], - ) + const values = useMemo(() => splitValue(value), [value]) const items = useMemo(() => { const counts = new Map() @@ -92,7 +75,7 @@ export const BaseSplitChipEditor = ({ }, [values]) const handleAddDraft = () => { - const nextValues = splitValue(draft, splitPattern) + const nextValues = splitValue(draft) if (!nextValues.length) { return } @@ -113,7 +96,7 @@ export const BaseSplitChipEditor = ({ const resolvedAriaLabel = ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined) - const modeToggle = showModeToggle ? ( + const modeToggle = ( - ) : null + ) return ( <> @@ -202,7 +185,7 @@ export const BaseSplitChipEditor = ({ disabled={disabled} size="small" multiline - rows={rows} + rows={4} sx={{ width: '100%' }} value={value} helperText={helperText} diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 4b891a623..c0143f3a0 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -596,7 +596,7 @@ export const CurrentProxyCard = () => { [getProfileStorageKey], ) - const { handleSelectChange } = useProxySelection({ + const { select } = useProxySelection({ onSuccess: () => { refreshProxy() }, @@ -766,15 +766,9 @@ export const CurrentProxyCard = () => { const previousProxy = selectedGroup.now const nextName = option.member.ref.name // The profile selection is the durable source across core restarts and run modes. - handleSelectChange( - selectedGroup.name, - previousProxy!, - selectedGroup.fixed!, - )({ - target: { value: nextName }, - }) + select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!) }, - [handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions], + [isDirectMode, select, selectedGroup, unsortedProxyOptions], ) const goToProxies = useCallback(() => { diff --git a/src/components/home/enhanced-canvas-traffic-graph.tsx b/src/components/home/enhanced-canvas-traffic-graph.tsx index c4cc1a74c..ecabcee64 100644 --- a/src/components/home/enhanced-canvas-traffic-graph.tsx +++ b/src/components/home/enhanced-canvas-traffic-graph.tsx @@ -14,7 +14,6 @@ import { useTranslation } from 'react-i18next' import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor' import { useVerge } from '@/hooks/use-verge' -import { debugLog } from '@/utils/debug' import parseTraffic from '@/utils/parse-traffic' import { formatTrafficHourMinute, @@ -23,14 +22,7 @@ import { isSameTrafficData, } from '@/utils/traffic-sampler' -interface ITrafficItem { - up: number - down: number - timestamp?: number -} - export interface EnhancedCanvasTrafficGraphRef { - appendData: (data: ITrafficItem) => void toggleStyle: () => void } @@ -142,7 +134,6 @@ export const EnhancedCanvasTrafficGraph = memo( [], ) const debounceTimeoutRef = useRef(null) - const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS) const colors = useMemo( () => ({ @@ -183,8 +174,6 @@ export const EnhancedCanvasTrafficGraph = memo( if (displayData.length === 0) { lastDataTimestampRef.current = 0 dataStaleRef.current = false - // eslint-disable-next-line @eslint-react/set-state-in-effect - setCurrentFPS(GRAPH_CONFIG.targetFPS) return } @@ -200,18 +189,11 @@ export const EnhancedCanvasTrafficGraph = memo( } }, [displayData]) - const handleFocusStateChange = useCallback( - (focused: boolean) => { - isWindowFocusedRef.current = focused + const handleFocusStateChange = useCallback((focused: boolean) => { + isWindowFocusedRef.current = focused - if (focused || !pause_render_traffic_stats_on_blur) { - setCurrentFPS(GRAPH_CONFIG.targetFPS) - } - - scheduleDrawGraphRef.current() - }, - [pause_render_traffic_stats_on_blur], - ) + scheduleDrawGraphRef.current() + }, []) useEffect(() => { if (typeof window === 'undefined' || typeof document === 'undefined') { @@ -684,19 +666,13 @@ export const EnhancedCanvasTrafficGraph = memo( ctx.beginPath() ctx.moveTo(getX(0), getY(0)) - if (chartStyle === 'bezier') { - for (let i = 1; i < data.length; i++) { - const currentX = getX(i) - const currentY = getY(i) - const nextIndex = Math.min(i + 1, lastIndex) - const controlX = (currentX + getX(nextIndex)) / 2 - const controlY = (currentY + getY(nextIndex)) / 2 - ctx.quadraticCurveTo(currentX, currentY, controlX, controlY) - } - } else { - for (let i = 1; i < data.length; i++) { - ctx.lineTo(getX(i), getY(i)) - } + for (let i = 1; i < data.length; i++) { + const currentX = getX(i) + const currentY = getY(i) + const nextIndex = Math.min(i + 1, lastIndex) + const controlX = (currentX + getX(nextIndex)) / 2 + const controlY = (currentY + getY(nextIndex)) / 2 + ctx.quadraticCurveTo(currentX, currentY, controlX, controlY) } ctx.lineTo(getX(lastIndex), height - padding.bottom) @@ -984,21 +960,7 @@ export const EnhancedCanvasTrafficGraph = memo( setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier')) }, []) - const appendData = useCallback((data: ITrafficItem) => { - debugLog( - '[EnhancedCanvasTrafficGraphV2] appendData called (using global data):', - data, - ) - }, []) - - useImperativeHandle( - ref, - () => ({ - appendData, - toggleStyle, - }), - [appendData, toggleStyle], - ) + useImperativeHandle(ref, () => ({ toggleStyle }), [toggleStyle]) const getTimeRangeText = useCallback(() => { return t('home.components.traffic.patterns.minutes', { @@ -1142,7 +1104,7 @@ export const EnhancedCanvasTrafficGraph = memo( {t('home.components.traffic.diagnostics', { points: displayData.length, compressed: samplerStats.compressedBufferSize, - fps: currentFPS, + fps: GRAPH_CONFIG.targetFPS, })} diff --git a/src/components/home/enhanced-traffic-stats.tsx b/src/components/home/enhanced-traffic-stats.tsx index 39d68a850..dd5c5ebd4 100644 --- a/src/components/home/enhanced-traffic-stats.tsx +++ b/src/components/home/enhanced-traffic-stats.tsx @@ -3,10 +3,14 @@ import { ArrowUpwardRounded, CloudDownloadRounded, CloudUploadRounded, + ErrorOutlineRounded, LinkRounded, MemoryRounded, } from '@mui/icons-material' import { + Alert, + Box, + Button, Grid, PaletteColor, Paper, @@ -15,9 +19,9 @@ import { useTheme, } from '@mui/material' import { ReactNode, memo, useMemo, useRef } from 'react' +import { ErrorBoundary, FallbackProps } from 'react-error-boundary' import { useTranslation } from 'react-i18next' -import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary' import { useConnectionSummaryData } from '@/hooks/use-connection-data' import { useMemoryData } from '@/hooks/use-memory-data' import { useTrafficData } from '@/hooks/use-traffic-data' @@ -127,6 +131,50 @@ const CompactStatCard = memo( // 添加显示名称 CompactStatCard.displayName = 'CompactStatCard' +const fallbackSx = { + p: 2, + minHeight: 200, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + border: '1px dashed', + borderColor: 'error.main', + borderRadius: 2, + bgcolor: 'error.light', +} + +const reloadPage = () => window.location.reload() + +const TrafficFallback = ({ error, resetErrorBoundary }: FallbackProps) => { + const { t } = useTranslation() + + return ( + + + + {t('shared.feedback.errors.trafficStats')} + + + {t('shared.feedback.errors.trafficStatsDescription')} + + + {error instanceof Error + ? error.message + : t('shared.feedback.errors.unknown')} + + + + + + + ) +} + export const EnhancedTrafficStats = () => { const { t } = useTranslation() const theme = useTheme() @@ -254,10 +302,11 @@ export const EnhancedTrafficStats = () => { }, [t, parsedData, displayMemory]) return ( - { - console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo) - }} + + console.error('[EnhancedTrafficStats]', error, errorInfo) + } > {trafficGraph && ( @@ -273,6 +322,6 @@ export const EnhancedTrafficStats = () => { ))} - + ) } diff --git a/src/components/home/system-info-card.tsx b/src/components/home/system-info-card.tsx index d71aa039a..ee1593cc8 100644 --- a/src/components/home/system-info-card.tsx +++ b/src/components/home/system-info-card.tsx @@ -86,16 +86,11 @@ export const SystemInfoCard = () => { }, [verge, patchVerge]) const handleRunningModeClick = useCallback(async () => { - if (isSidecarMode || (isAdminMode && isSidecarMode)) { + if (isSidecarMode) { await installServiceAndRestartCore() await mutateSystemState() } - }, [ - isSidecarMode, - isAdminMode, - installServiceAndRestartCore, - mutateSystemState, - ]) + }, [isSidecarMode, installServiceAndRestartCore, mutateSystemState]) const onCheckUpdate = useLockFn(async () => { try { @@ -122,18 +117,16 @@ export const SystemInfoCard = () => { const runningModeStyle = useMemo( () => ({ - cursor: - isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default', - textDecoration: - isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none', + cursor: isSidecarMode ? 'pointer' : 'default', + textDecoration: isSidecarMode ? 'underline' : 'none', display: 'flex', alignItems: 'center', gap: 0.5, '&:hover': { - opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1, + opacity: isSidecarMode ? 0.7 : 1, }, }), - [isSidecarMode, isAdminMode], + [isSidecarMode], ) const getModeIcon = () => { diff --git a/src/components/home/test-card.tsx b/src/components/home/test-card.tsx index 5371f995e..77e854357 100644 --- a/src/components/home/test-card.tsx +++ b/src/components/home/test-card.tsx @@ -87,7 +87,7 @@ export const TestCard = () => { x.uid === uid ? { ...x, ...patch } : x, ) - mutateVerge({ ...verge, test_list: newList }, false) + mutateVerge({ ...verge, test_list: newList }) }, [testList, verge, mutateVerge], ) @@ -96,7 +96,7 @@ export const TestCard = () => { (uid: string) => { const newList = testList.filter((x) => x.uid !== uid) patchVerge({ test_list: newList }) - mutateVerge({ ...verge, test_list: newList }, false) + mutateVerge({ ...verge, test_list: newList }) }, [testList, verge, patchVerge, mutateVerge], ) @@ -129,7 +129,7 @@ export const TestCard = () => { newList.splice(newIndex, 0, removed) // 优化:先本地更新,再异步 patch,避免UI卡死 - mutateVerge({ ...verge, test_list: newList }, false) + mutateVerge({ ...verge, test_list: newList }) const patchFn = () => { try { patchVerge({ test_list: newList }) diff --git a/src/components/layout/layout-sidebar.tsx b/src/components/layout/layout-sidebar.tsx index f1a2be2f2..17a1df7d6 100644 --- a/src/components/layout/layout-sidebar.tsx +++ b/src/components/layout/layout-sidebar.tsx @@ -35,10 +35,7 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => { const handleMenuOrderOptimisticUpdate = useCallback( (order: string[]) => { - mutateVerge( - (prev) => (prev ? { ...prev, menu_order: order } : prev), - false, - ) + mutateVerge((prev) => (prev ? { ...prev, menu_order: order } : prev)) }, [mutateVerge], ) diff --git a/src/components/layout/layout-traffic.tsx b/src/components/layout/layout-traffic.tsx index 5a248e561..9c6564edc 100644 --- a/src/components/layout/layout-traffic.tsx +++ b/src/components/layout/layout-traffic.tsx @@ -1,14 +1,15 @@ import { ArrowDownwardRounded, ArrowUpwardRounded, + ErrorOutlineRounded, MemoryRounded, } from '@mui/icons-material' import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material' import { Box, Typography } from '@mui/material' import { useEffect, useRef } from 'react' +import { ErrorBoundary } from 'react-error-boundary' import { useTranslation } from 'react-i18next' -import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary' import { useMemoryData } from '@/hooks/use-memory-data' import { useTrafficData } from '@/hooks/use-traffic-data' import { useVerge } from '@/hooks/use-verge' @@ -17,6 +18,30 @@ import parseTraffic from '@/utils/parse-traffic' import { TrafficGraph, type TrafficRef } from './traffic-graph' +const unavailableSx = { + p: 1, + minHeight: 60, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + bgcolor: 'error.light', + borderRadius: 1, + color: 'error.contrastText', +} + +const TrafficUnavailableFallback = () => { + const { t } = useTranslation() + + return ( + + + + {t('shared.feedback.errors.trafficUnavailable')} + + + ) +} + export const LayoutTraffic = () => { const { t } = useTranslation() const { verge } = useVerge() @@ -75,7 +100,12 @@ export const LayoutTraffic = () => { } return ( - + + console.error('[LayoutTraffic]', error, errorInfo) + } + > {trafficGraph && pageVisible && (
{ cursor: 'auto', }} color={'disabled'} - onClick={async () => {}} > {inuse} @@ -139,6 +168,6 @@ export const LayoutTraffic = () => { )} - + ) } diff --git a/src/components/layout/window-controller.tsx b/src/components/layout/window-controller.tsx index 1df4b5f65..d6797790e 100644 --- a/src/components/layout/window-controller.tsx +++ b/src/components/layout/window-controller.tsx @@ -1,11 +1,6 @@ import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material' import { Box, IconButton } from '@mui/material' -import { - forwardRef, - type PointerEvent, - useCallback, - useImperativeHandle, -} from 'react' +import { type PointerEvent, useCallback } from 'react' import { useWindowControls } from '@/hooks/use-window' import getSystem from '@/utils/get-system' @@ -63,36 +58,9 @@ export const WindowResizeHandles = () => { ) } -export const WindowControls = forwardRef(function WindowControls(props, ref) { +export const WindowControls = () => { const OS = getSystem() - const { - currentWindow, - maximized, - minimize, - close, - toggleFullscreen, - toggleMaximize, - } = useWindowControls() - - useImperativeHandle( - ref, - () => ({ - currentWindow, - maximized, - minimize, - close, - toggleFullscreen, - toggleMaximize, - }), - [ - currentWindow, - maximized, - minimize, - close, - toggleFullscreen, - toggleMaximize, - ], - ) + const { maximized, minimize, close, toggleMaximize } = useWindowControls() // 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。 // 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。 @@ -186,4 +154,4 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) { )} ) -}) +} diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 494fc551e..a5e07c6e4 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -462,7 +462,7 @@ function NormalProxyGroups(props: { mode: string }) { } }, [handleScroll]) - const { handleProxyGroupChange } = useProxySelection({ + const { select } = useProxySelection({ onSuccess: () => { onProxies() }, @@ -477,9 +477,9 @@ function NormalProxyGroups(props: { mode: string }) { if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return if (!isInteractableMember(member)) return - handleProxyGroupChange(group, { name: member.ref.name }) + select(group.name, member.ref.name, group.now!, group.fixed!) }, - [handleProxyGroupChange], + [select], ) const handleLocation = useStableCallback((group: ProxyGroupView) => { diff --git a/src/components/proxy/use-proxy-group-header-layout.ts b/src/components/proxy/use-proxy-group-header-layout.ts index d3bb1b1c4..ab6bd700e 100644 --- a/src/components/proxy/use-proxy-group-header-layout.ts +++ b/src/components/proxy/use-proxy-group-header-layout.ts @@ -30,10 +30,8 @@ export const useProxyGroupHeaderLayout = () => { if (canceled || !operation.source || !operation.target) return const position = toolsOnLeft ? 'right' : 'left' - mutateVerge( - (prev) => - prev ? { ...prev, proxy_group_tools_position: position } : prev, - false, + mutateVerge((prev) => + prev ? { ...prev, proxy_group_tools_position: position } : prev, ) try { await patchVerge({ proxy_group_tools_position: position }) diff --git a/src/components/setting/mods/backup-config-viewer.tsx b/src/components/setting/mods/backup-config-viewer.tsx index 84b7d579a..a51705733 100644 --- a/src/components/setting/mods/backup-config-viewer.tsx +++ b/src/components/setting/mods/backup-config-viewer.tsx @@ -114,17 +114,15 @@ export const BackupConfigViewer = memo( errorNotice: false, }, ) - await mutateVerge( - (current) => - current - ? { - ...current, - webdav_url: trimmedUrl, - webdav_username: trimmedUsername, - webdav_password: data.password, - } - : current, - false, + await mutateVerge((current) => + current + ? { + ...current, + webdav_url: trimmedUrl, + webdav_username: trimmedUsername, + webdav_password: data.password, + } + : current, ) setWebdavStatus(signature, 'unknown') showNotice.success('settings.modals.backup.messages.webdavConfigSaved') diff --git a/src/components/setting/mods/layout-viewer.tsx b/src/components/setting/mods/layout-viewer.tsx index f818d61de..504d4a382 100644 --- a/src/components/setting/mods/layout-viewer.tsx +++ b/src/components/setting/mods/layout-viewer.tsx @@ -107,7 +107,7 @@ export const LayoutViewer = forwardRef((_, ref) => { showNotice.error(err) } const onChangeData = (patch: Partial) => { - mutateVerge({ ...verge, ...patch }, false) + mutateVerge({ ...verge, ...patch }) } return ( diff --git a/src/components/setting/mods/misc-viewer.tsx b/src/components/setting/mods/misc-viewer.tsx index 91247c877..8ebd99057 100644 --- a/src/components/setting/mods/misc-viewer.tsx +++ b/src/components/setting/mods/misc-viewer.tsx @@ -15,44 +15,33 @@ import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base' import { useVerge } from '@/hooks/use-verge' import { showNotice } from '@/services/notice-service' +const buildInitialValues = (verge?: IVergeConfig | null) => ({ + appLogLevel: verge?.app_log_level ?? 'warn', + appLogMaxSize: verge?.app_log_max_size ?? 128, + appLogMaxCount: verge?.app_log_max_count ?? 8, + autoCloseConnection: verge?.auto_close_connection ?? true, + autoCheckUpdate: verge?.auto_check_update ?? true, + enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true, + proxyLayoutColumn: verge?.proxy_layout_column || 6, + enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false, + autoDelayDetectionIntervalMinutes: + verge?.auto_delay_detection_interval_minutes ?? 5, + defaultLatencyTest: verge?.default_latency_test || '', + autoLogClean: verge?.auto_log_clean || 0, + defaultLatencyTimeout: verge?.default_latency_timeout || 10000, +}) + export const MiscViewer = forwardRef((props, ref) => { const { t } = useTranslation() const { verge, patchVerge } = useVerge() const [open, setOpen] = useState(false) - const [values, setValues] = useState({ - appLogLevel: 'warn', - appLogMaxSize: 8, - appLogMaxCount: 12, - autoCloseConnection: true, - autoCheckUpdate: true, - enableBuiltinEnhanced: true, - proxyLayoutColumn: 6, - enableAutoDelayDetection: false, - autoDelayDetectionIntervalMinutes: 5, - defaultLatencyTest: '', - autoLogClean: 2, - defaultLatencyTimeout: 10000, - }) + const [values, setValues] = useState(() => buildInitialValues(null)) useImperativeHandle(ref, () => ({ open: () => { setOpen(true) - setValues({ - appLogLevel: verge?.app_log_level ?? 'warn', - appLogMaxSize: verge?.app_log_max_size ?? 128, - appLogMaxCount: verge?.app_log_max_count ?? 8, - autoCloseConnection: verge?.auto_close_connection ?? true, - autoCheckUpdate: verge?.auto_check_update ?? true, - enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true, - proxyLayoutColumn: verge?.proxy_layout_column || 6, - enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false, - autoDelayDetectionIntervalMinutes: - verge?.auto_delay_detection_interval_minutes ?? 5, - defaultLatencyTest: verge?.default_latency_test || '', - autoLogClean: verge?.auto_log_clean || 0, - defaultLatencyTimeout: verge?.default_latency_timeout || 10000, - }) + setValues(buildInitialValues(verge)) }, close: () => setOpen(false), })) diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 09f5472c4..eb7997e88 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -87,6 +87,17 @@ const splitBypass = (value?: string) => .map((item) => item.trim()) .filter(Boolean) +const buildInitialValues = (verge?: IVergeConfig | null) => ({ + guard: verge?.enable_proxy_guard, + enable_bypass_check: verge?.enable_bypass_check ?? true, + bypass: verge?.system_proxy_bypass, + duration: verge?.proxy_guard_duration ?? 10, + use_default: verge?.use_default_bypass ?? true, + pac: verge?.proxy_auto_config, + pac_content: verge?.pac_file_content ?? DEFAULT_PAC, + proxy_host: verge?.proxy_host ?? '127.0.0.1', +}) + export const SysproxyViewer = forwardRef((props, ref) => { const { t } = useTranslation() const systemName = getSystem() @@ -116,16 +127,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { proxy_host, } = verge ?? {} - const [value, setValue] = useState({ - guard: enable_proxy_guard, - enable_bypass_check: enable_bypass_check ?? true, - bypass: system_proxy_bypass, - duration: proxy_guard_duration ?? 10, - use_default: use_default_bypass ?? true, - pac: proxy_auto_config, - pac_content: pac_file_content ?? DEFAULT_PAC, - proxy_host: proxy_host ?? '127.0.0.1', - }) + const [value, setValue] = useState(() => buildInitialValues(null)) const [embeddedServerPort, setEmbeddedServerPort] = useState( null, ) @@ -192,16 +194,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ open: () => { setOpen(true) - setValue({ - guard: enable_proxy_guard, - enable_bypass_check: enable_bypass_check ?? true, - bypass: system_proxy_bypass, - duration: proxy_guard_duration ?? 10, - use_default: use_default_bypass ?? true, - pac: proxy_auto_config, - pac_content: pac_file_content ?? DEFAULT_PAC, - proxy_host: proxy_host ?? '127.0.0.1', - }) + setValue(buildInitialValues(verge)) fetchNetworkInterfaces() }, close: () => setOpen(false), @@ -339,7 +332,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { Promise.resolve().then(async () => { try { if (Object.keys(patch).length > 0) { - mutateVerge({ ...verge, ...patch }, false) + mutateVerge({ ...verge, ...patch }) } if (Object.keys(patch).length > 0) { await patchVerge(patch) diff --git a/src/components/setting/mods/tunnels-viewer.tsx b/src/components/setting/mods/tunnels-viewer.tsx index 1efb1a81c..fe149c51b 100644 --- a/src/components/setting/mods/tunnels-viewer.tsx +++ b/src/components/setting/mods/tunnels-viewer.tsx @@ -76,34 +76,28 @@ const proxyOptionToken = ({ memberIndex, member }: TunnelProxyOption) => member.kind === 'node' ? member.node.recordId : member.ref.name }` +const buildInitialValues = (): TunnelFormValues => ({ + localAddr: '', + localPort: '', + targetAddr: '', + targetPort: '', + network: 'tcp+udp', + group: '', + proxy: null, +}) + export const TunnelsViewer = forwardRef((_, ref) => { const { t } = useTranslation() const { clash, mutateClash, patchClash } = useClash() const [open, setOpen] = useState(false) const [expanded, setExpanded] = useState(false) - const [values, setValues] = useState({ - localAddr: '', - localPort: '', - targetAddr: '', - targetPort: '', - network: 'tcp+udp', - group: '', - proxy: null, - }) + const [values, setValues] = useState(buildInitialValues) const [draftTunnels, setDraftTunnels] = useState([]) useImperativeHandle(ref, () => ({ open: () => { - setValues(() => ({ - localAddr: '', - localPort: '', - targetAddr: '', - targetPort: '', - network: 'tcp+udp', - group: '', - proxy: null, - })) + setValues(buildInitialValues()) setDraftTunnels(() => clash?.tunnels ?? []) setOpen(true) // 如果没有隧道,则自动展开 diff --git a/src/components/setting/mods/web-ui-viewer.tsx b/src/components/setting/mods/web-ui-viewer.tsx index 0cb24653f..7fdbfd5b2 100644 --- a/src/components/setting/mods/web-ui-viewer.tsx +++ b/src/components/setting/mods/web-ui-viewer.tsx @@ -50,21 +50,21 @@ export function WebUIViewer({ ref }: { ref?: Ref }) { const handleAdd = useLockFn(async (value: string) => { const newList = [...webUIList, value] - mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false) + mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old)) await patchVerge({ web_ui_list: newList }) }) const handleChange = useLockFn(async (index: number, value?: string) => { const newList = [...webUIList] newList[index] = value ?? '' - mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false) + mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old)) await patchVerge({ web_ui_list: newList }) }) const handleDelete = useLockFn(async (index: number) => { const newList = [...webUIList] newList.splice(index, 1) - mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false) + mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old)) await patchVerge({ web_ui_list: newList }) }) diff --git a/src/components/setting/setting-system.tsx b/src/components/setting/setting-system.tsx index c7ce131a1..d013321d2 100644 --- a/src/components/setting/setting-system.tsx +++ b/src/components/setting/setting-system.tsx @@ -1,14 +1,12 @@ -import React, { useRef } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' -import { DialogRef, Switch, TooltipIcon } from '@/components/base' +import { Switch, TooltipIcon } from '@/components/base' import ProxyControlSwitches from '@/components/shared/proxy-control-switches' import { useVerge } from '@/hooks/use-verge' import { GuardState } from './mods/guard-state' import { SettingList, SettingItem } from './mods/setting-comp' -import { SysproxyViewer } from './mods/sysproxy-viewer' -import { TunViewer } from './mods/tun-viewer' interface Props { onError?: (err: Error) => void @@ -21,22 +19,16 @@ const SettingSystem = ({ onError }: Props) => { const { enable_auto_launch, enable_silent_start } = verge ?? {} - const sysproxyRef = useRef(null) - const tunRef = useRef(null) - const onSwitchFormat = ( _e: React.ChangeEvent, value: boolean, ) => value const onChangeData = (patch: Partial) => { - mutateVerge({ ...verge, ...patch }, false) + mutateVerge({ ...verge, ...patch }) } return ( - - - { }} onGuard={async (e) => { try { - // 先触发UI更新立即看到反馈 - onChangeData({ enable_auto_launch: e }) await patchVerge({ enable_auto_launch: e }) return Promise.resolve() } catch (error) { diff --git a/src/components/setting/setting-verge-basic.tsx b/src/components/setting/setting-verge-basic.tsx index 0dba7f952..ab3a394c1 100644 --- a/src/components/setting/setting-verge-basic.tsx +++ b/src/components/setting/setting-verge-basic.tsx @@ -72,7 +72,7 @@ const SettingVergeBasic = ({ onError }: Props) => { const backupRef = useRef(null) const onChangeData = (patch: any) => { - mutateVerge({ ...verge, ...patch }, false) + mutateVerge({ ...verge, ...patch }) } const onCopyClashEnv = useCallback(async () => { diff --git a/src/components/shared/proxy-control-switches.tsx b/src/components/shared/proxy-control-switches.tsx index 8f77df4dd..493942f85 100644 --- a/src/components/shared/proxy-control-switches.tsx +++ b/src/components/shared/proxy-control-switches.tsx @@ -163,7 +163,7 @@ const ProxyControlSwitches = ({ }) return false } - mutateVerge({ ...verge, enable_tun_mode: value }, false) + mutateVerge({ ...verge, enable_tun_mode: value }) await patchVerge({ enable_tun_mode: value }) } @@ -229,8 +229,8 @@ const ProxyControlSwitches = ({ /> )} - - + {isSystemProxyMode && } + {isTunMode && } ) } diff --git a/src/components/shared/traffic-error-boundary.tsx b/src/components/shared/traffic-error-boundary.tsx deleted file mode 100644 index 75d77b110..000000000 --- a/src/components/shared/traffic-error-boundary.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import { - ErrorOutlineRounded, - RefreshRounded, - BugReportRounded, -} from '@mui/icons-material' -import { Box, Typography, Button, Alert, Collapse } from '@mui/material' -import React, { Component, ErrorInfo, ReactNode } from 'react' -import { useTranslation } from 'react-i18next' - -interface Props { - children: ReactNode - fallbackComponent?: ReactNode - onError?: (error: Error, errorInfo: ErrorInfo) => void -} - -interface State { - hasError: boolean - error: Error | null - errorInfo: ErrorInfo | null - showDetails: boolean -} - -export class TrafficErrorBoundary extends Component { - private retryCount = 0 - private maxRetries = 3 - - constructor(props: Props) { - super(props) - this.state = { - hasError: false, - error: null, - errorInfo: null, - showDetails: false, - } - } - - static getDerivedStateFromError(error: Error): Partial { - return { hasError: true, error } - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error('[TrafficErrorBoundary] 捕获到组件错误:', error, errorInfo) - - this.setState({ - error, - errorInfo, - }) - - if (this.props.onError) { - this.props.onError(error, errorInfo) - } - - this.reportError(error, errorInfo) - } - - private reportError = (error: Error, errorInfo: ErrorInfo) => { - const errorReport = { - message: error.message, - stack: error.stack, - componentStack: errorInfo.componentStack, - timestamp: new Date().toISOString(), - userAgent: navigator.userAgent, - url: window.location.href, - } - - console.error('[TrafficErrorBoundary] 错误报告:', errorReport) - } - - private handleRetry = () => { - if (this.retryCount < this.maxRetries) { - this.retryCount++ - console.log( - `[TrafficErrorBoundary] 尝试重试 (${this.retryCount}/${this.maxRetries})`, - ) - - this.setState({ - hasError: false, - error: null, - errorInfo: null, - showDetails: false, - }) - } else { - console.warn('[TrafficErrorBoundary] 已达到最大重试次数') - } - } - - private handleRefresh = () => { - window.location.reload() - } - - private toggleDetails = () => { - this.setState((prev) => ({ showDetails: !prev.showDetails })) - } - - render() { - if (this.state.hasError) { - if (this.props.fallbackComponent) { - return this.props.fallbackComponent - } - - return ( - - ) - } - - return this.props.children - } -} - -interface TrafficErrorFallbackProps { - error: Error | null - errorInfo: ErrorInfo | null - showDetails: boolean - canRetry: boolean - retryCount: number - maxRetries: number - onRetry: () => void - onRefresh: () => void - onToggleDetails: () => void -} - -const TrafficErrorFallback: React.FC = ({ - error, - errorInfo, - showDetails, - canRetry, - retryCount, - maxRetries, - onRetry, - onRefresh, - onToggleDetails, -}) => { - const { t } = useTranslation() - - return ( - - - - - {t('shared.feedback.errors.trafficStats')} - - - - {t('shared.feedback.errors.trafficStatsDescription')} - - - - - {t('shared.feedback.errors.label')}:{' '} - {error instanceof Error - ? error.message - : t('shared.feedback.errors.unknown')} - - {retryCount > 0 && ( - - {t('shared.labels.retryAttempts')}: {retryCount}/{maxRetries} - - )} - - - - {canRetry && ( - - )} - - - - - - - - - - {t('shared.feedback.errors.details')}: - - - {error?.stack} - - - {errorInfo?.componentStack && ( - <> - - {t('shared.feedback.errors.componentStack')}: - - - {errorInfo.componentStack} - - - )} - - - - ) -} - -export const LightweightTrafficErrorBoundary: React.FC<{ - children: ReactNode -}> = ({ children }) => { - const { t } = useTranslation() - - return ( - - - - {t('shared.feedback.errors.trafficUnavailable')} - - - } - > - {children} - - ) -} diff --git a/src/hooks/use-icon-cache.ts b/src/hooks/use-icon-cache.ts index 49a93a7ce..85bd3358a 100644 --- a/src/hooks/use-icon-cache.ts +++ b/src/hooks/use-icon-cache.ts @@ -1,5 +1,4 @@ import { convertFileSrc } from '@tauri-apps/api/core' -import { useMemo } from 'react' import { downloadIconCache } from '@/services/cmds' import { useQuery } from '@/services/query-client' @@ -23,9 +22,8 @@ export const useIconCache = ({ const iconValue = icon?.trim() ?? '' const cacheKeyValue = cacheKey?.trim() ?? '' - const isEnabled = useMemo(() => { - return enabled && iconValue.startsWith('http') && cacheKeyValue !== '' - }, [enabled, iconValue, cacheKeyValue]) + const isEnabled = + enabled && iconValue.startsWith('http') && cacheKeyValue !== '' const { data } = useQuery({ queryKey: ['icon-cache', iconValue, cacheKeyValue], diff --git a/src/hooks/use-log-data.ts b/src/hooks/use-log-data.ts index 4a6c821ea..e954457f3 100644 --- a/src/hooks/use-log-data.ts +++ b/src/hooks/use-log-data.ts @@ -54,8 +54,7 @@ export const useLogData = () => { const hasLoadedInitialLogsRef = useRef(false) const { response, refresh, setData } = useMihomoWsSubscription({ - storageKey: 'mihomo_logs_date', - buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null), + subscriptionKey: enableLog ? 'getClashLog' : null, fallbackData: [], connect: () => MihomoWebSocket.connect_logs(logLevel), setupHandlers: ({ next, scheduleReconnect, isMounted }) => { @@ -77,14 +76,13 @@ export const useLogData = () => { } const pendingLogs = buffer.splice(0, buffer.length) flushTimeStr = null - next(null, (current) => appendLogs(current, pendingLogs)) + next((current) => appendLogs(current, pendingLogs)) flushTimer = null } return { handleMessage: (data) => { if (data.startsWith('Websocket error')) { - next(data) void scheduleReconnect() return } @@ -108,9 +106,7 @@ export const useLogData = () => { if (!flushTimer) { flushTimer = setTimeout(flush, FLUSH_DELAY_MS) } - } catch (error) { - next(error) - } + } catch {} }, async onConnected() { if (hasLoadedInitialLogsRef.current) { @@ -119,7 +115,7 @@ export const useLogData = () => { const logs = await getClashLogs() hasLoadedInitialLogsRef.current = true if (isMounted()) { - next(null, (current) => { + next((current) => { if (!current || current.length === 0) { return clampLogs(filterLogsByLevel(logs, allowedTypes)) } diff --git a/src/hooks/use-memory-data.ts b/src/hooks/use-memory-data.ts index db6ea6cb9..f11596aac 100644 --- a/src/hooks/use-memory-data.ts +++ b/src/hooks/use-memory-data.ts @@ -33,15 +33,13 @@ export const useMemoryData = (options?: { enabled?: boolean }) => { const enabled = options?.enabled ?? true const { response, refresh } = useMihomoWsSubscription({ - storageKey: 'mihomo_memory_date', - buildSubscriptKey: (date) => (enabled ? `getClashMemory-${date}` : null), + subscriptionKey: enabled ? 'getClashMemory' : null, fallbackData: FALLBACK_MEMORY_USAGE, connect: () => MihomoWebSocket.connect_memory(), throttleMs: 500, setupHandlers: ({ next, scheduleReconnect }) => ({ handleMessage: (data) => { if (data.startsWith('Websocket error')) { - next(data, FALLBACK_MEMORY_USAGE) void scheduleReconnect() return } @@ -51,10 +49,8 @@ export const useMemoryData = (options?: { enabled?: boolean }) => { if (shouldSkipDuplicateMemory(parsed)) { return } - next(null, parsed) - } catch (error) { - next(error, FALLBACK_MEMORY_USAGE) - } + next(parsed) + } catch {} }, }), }) diff --git a/src/hooks/use-mihomo-ws-subscription.ts b/src/hooks/use-mihomo-ws-subscription.ts index c0a4cea09..ea6858af0 100644 --- a/src/hooks/use-mihomo-ws-subscription.ts +++ b/src/hooks/use-mihomo-ws-subscription.ts @@ -1,12 +1,7 @@ -import { useLocalStorage } from 'foxact/use-local-storage' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api' -import { - removeCacheData, - setCacheData, - useQuery, -} from '@/services/query-client' +import { setCacheData, useQuery } from '@/services/query-client' const RECONNECT_DELAY_MS = 1000 @@ -31,7 +26,6 @@ interface SharedSubscriptionEntry { const sharedSubscriptions = new Map() const subscriptionSnapshots = new Map() -const initialSubscriptionDate = Date.now() const getSubscriptionSnapshot = (key: string) => subscriptionSnapshots.get(key) as T | undefined @@ -136,15 +130,13 @@ const createSharedSubscriptionEntry = ( return entry } +type Updater = (current?: T) => T + /** - * Mirrors SWR's MutatorCallback: consumers can pass either a plain value or a - * functional updater `(current?: T) => T`. The functional form is resolved - * against the current subscription snapshot before updating SWR. + * Pass either a plain value or a functional updater; the updater is resolved + * against the current subscription snapshot before the cache is written. */ -type NextFn = ( - error?: any, - data?: T | ((current?: T) => T | undefined), -) => void +type NextFn = (data: T | Updater) => void interface HandlerContext { next: NextFn @@ -159,8 +151,7 @@ interface HandlerResult { } interface UseMihomoWsSubscriptionOptions { - storageKey: string - buildSubscriptKey: (date: number) => string | null + subscriptionKey: string | null fallbackData: T connect: () => Promise /** @@ -178,18 +169,12 @@ interface UseMihomoWsSubscriptionOptions { export const useMihomoWsSubscription = ( options: UseMihomoWsSubscriptionOptions, ) => { - const { - storageKey, - buildSubscriptKey, - fallbackData, - connect, - throttleMs, - setupHandlers, - } = options + const { subscriptionKey, fallbackData, connect, throttleMs, setupHandlers } = + options - const [date, setDate] = useLocalStorage(storageKey, initialSubscriptionDate) - const subscriptKey = buildSubscriptKey(date) - const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null + const subscriptionCacheKey = subscriptionKey + ? `$sub$${subscriptionKey}` + : null const lastSubscriptionCacheKeyRef = useRef(null) if (subscriptionCacheKey) { lastSubscriptionCacheKeyRef.current = subscriptionCacheKey @@ -197,20 +182,9 @@ export const useMihomoWsSubscription = ( const responseCacheKey = subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current - const resolveNextData = useCallback( - ( - data: T | ((current?: T) => T | undefined) | undefined, - cacheKey: string, - ): T => { - if (typeof data === 'function') { - const updater = data as (current?: T) => T | undefined - const current = getSubscriptionSnapshot(cacheKey) - return updater(current) ?? fallbackData - } - return data ?? fallbackData - }, - [fallbackData], - ) + // Bumping the epoch re-runs the subscription effect, rebuilding the shared + // entry (and its captured `connect`/handler closures) under the same key. + const [refreshEpoch, setRefreshEpoch] = useState(0) const response = useQuery({ queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'], @@ -238,44 +212,35 @@ export const useMihomoWsSubscription = ( 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) + const baseNext: NextFn = (data) => { + const resolved = + typeof data === 'function' + ? (data as Updater)( + getSubscriptionSnapshot(subscriptionCacheKey), + ) + : (data as T) writeSubscriptionSnapshot(subscriptionCacheKey, resolved) } if (throttleMs && throttleMs > 0) { - let pendingData: T | ((current?: T) => T | undefined) | undefined - let hasPending = false + let pendingData: T | Updater | undefined let timerId: ReturnType | null = null const flush = () => { timerId = null - if (hasPending) { + if (pendingData !== undefined) { const data = pendingData pendingData = undefined - hasPending = false - baseNext(undefined, data) + baseNext(data) } } - wrappedNext = ( - error?: any, - data?: T | ((current?: T) => T | undefined), - ) => { - if (error !== undefined && error !== null) { - baseNext(error, data) - return - } + wrappedNext = (data) => { if (!timerId) { - baseNext(undefined, data) + baseNext(data) timerId = setTimeout(flush, throttleMs) } else { pendingData = data - hasPending = true } } @@ -341,15 +306,14 @@ export const useMihomoWsSubscription = ( } // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps - }, [subscriptionCacheKey]) + }, [subscriptionCacheKey, refreshEpoch]) const refresh = useCallback(() => { if (subscriptionCacheKey) { - subscriptionSnapshots.delete(subscriptionCacheKey) - void removeCacheData([subscriptionCacheKey]) + writeSubscriptionSnapshot(subscriptionCacheKey, fallbackData) } - setDate(Date.now()) - }, [subscriptionCacheKey, setDate]) + setRefreshEpoch((epoch) => epoch + 1) + }, [subscriptionCacheKey, fallbackData]) const setData = useCallback( (data: T) => { diff --git a/src/hooks/use-proxy-selection.test.ts b/src/hooks/use-proxy-selection.test.ts index d176baf66..c382fc76f 100644 --- a/src/hooks/use-proxy-selection.test.ts +++ b/src/hooks/use-proxy-selection.test.ts @@ -35,8 +35,8 @@ it('applies and records independent group selections while another group is pend const secondSuccess = vi.fn() const first = useProxySelection({ onSuccess: firstSuccess }) const second = useProxySelection({ onSuccess: secondSuccess }) - first.changeProxy('Group A', 'Node A') - second.changeProxy('Group B', 'Node B') + first.select('Group A', 'Node A') + second.select('Group B', 'Node B') await vi.waitFor(() => expect(record).toHaveBeenCalledWith('Group B', 'Node B'), ) diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index 3b70daa82..d773ef361 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -35,7 +35,6 @@ const cleanupConnections = async (previousProxy: string) => { interface ProxySelectionOptions { onSuccess?: () => void onError?: (error: any) => void - enableConnectionCleanup?: boolean } interface ProxyChangeRequest { @@ -53,7 +52,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { const pendingRequestRef = useRef(null) const isProcessingRef = useRef(false) - const { onSuccess, onError, enableConnectionCleanup = true } = options + const { onSuccess, onError } = options const autoCloseConnection = verge?.auto_close_connection ?? false @@ -98,7 +97,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { ) } - if (enableConnectionCleanup && autoCloseConnection && previousProxy) { + if (autoCloseConnection && previousProxy) { void cleanupConnections(previousProxy) } } catch (error) { @@ -112,7 +111,6 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { [ autoCloseConnection, forgetSelection, - enableConnectionCleanup, onError, onSuccess, recordSelection, @@ -138,7 +136,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { } }, [executeChange]) - const changeProxy = useCallback( + const select = useCallback( ( groupName: string, proxyName: string, @@ -156,27 +154,5 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { [flushChangeQueue], ) - const handleSelectChange = useCallback( - (groupName: string, previousProxy?: string, fixed?: string) => - (event: { target: { value: string } }) => { - changeProxy(groupName, event.target.value, previousProxy, fixed) - }, - [changeProxy], - ) - - const handleProxyGroupChange = useCallback( - ( - group: { name: string; now?: string | null; fixed?: string | null }, - proxy: { name: string }, - ) => { - changeProxy(group.name, proxy.name, group.now!, group.fixed!) - }, - [changeProxy], - ) - - return { - changeProxy, - handleSelectChange, - handleProxyGroupChange, - } + return { select } } diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index 974b0d63a..75ab9c6bc 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -52,9 +52,8 @@ export const useSystemProxyState = () => { const toggleSystemProxy = async (enabled: boolean) => { // Roll failed optimistic writes back to the latest confirmed state. let confirmed = verge?.enable_system_proxy ?? false - mutateVerge( - (prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev), - false, + mutateVerge((prev) => + prev ? { ...prev, enable_system_proxy: enabled } : prev, ) pendingRef.current = enabled @@ -78,9 +77,8 @@ export const useSystemProxyState = () => { } } } catch (error) { - mutateVerge( - (prev) => (prev ? { ...prev, enable_system_proxy: confirmed } : prev), - false, + mutateVerge((prev) => + prev ? { ...prev, enable_system_proxy: confirmed } : prev, ) // Queued requests were based on a state that never landed. pendingRef.current = null diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index a2079c882..3afa93e84 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -41,7 +41,8 @@ export function useAppUptime() { const { data: uptime = 0 } = useQuery({ queryKey: appUptimeQueryKey, queryFn: getAppUptime, - staleTime: 5000, + // The wrapper maps staleTime to SWR's dedupingInterval, which drops + // refetchInterval ticks while it exceeds the interval. refetchInterval: 3000, refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/src/hooks/use-traffic-data.ts b/src/hooks/use-traffic-data.ts index bd48d09a7..ce20ec723 100644 --- a/src/hooks/use-traffic-data.ts +++ b/src/hooks/use-traffic-data.ts @@ -32,15 +32,13 @@ export const useTrafficData = (options?: { enabled?: boolean }) => { graphData: { appendData }, } = useTrafficMonitorEnhanced({ subscribe: false, enabled }) const { response, refresh } = useMihomoWsSubscription({ - storageKey: 'mihomo_traffic_date', - buildSubscriptKey: (date) => (enabled ? `getClashTraffic-${date}` : null), + subscriptionKey: enabled ? 'getClashTraffic' : null, fallbackData: FALLBACK_TRAFFIC, connect: () => MihomoWebSocket.connect_traffic(), throttleMs: 200, setupHandlers: ({ next, scheduleReconnect }) => ({ handleMessage: (data) => { if (data.startsWith('Websocket error')) { - next(data, FALLBACK_TRAFFIC) void scheduleReconnect() return } @@ -51,10 +49,8 @@ export const useTrafficData = (options?: { enabled?: boolean }) => { return } appendData(parsed) - next(null, parsed) - } catch (error) { - next(error, FALLBACK_TRAFFIC) - } + next(parsed) + } catch {} }, }), }) diff --git a/src/hooks/use-traffic-monitor.ts b/src/hooks/use-traffic-monitor.ts index 97fe30f7c..242fedaa1 100644 --- a/src/hooks/use-traffic-monitor.ts +++ b/src/hooks/use-traffic-monitor.ts @@ -3,7 +3,6 @@ import { Traffic } from 'tauri-plugin-mihomo-api' import { useVisibility } from '@/hooks/use-visibility' import { debugLog } from '@/utils/debug' -import { TrafficDataSampler, isSameTrafficData } from '@/utils/traffic-sampler' // 引用计数管理器 class ReferenceCounter { @@ -32,119 +31,11 @@ const WORKER_CONFIG = { defaultRangeMinutes: 10, } -class InlineTrafficMonitor { - private config = { ...WORKER_CONFIG } - private sampler = new TrafficDataSampler(this.config) - private throttleTimer: ReturnType | null = null - private agingTimer: ReturnType | null = null - private currentRange = this.config.defaultRangeMinutes - private lastTimestamp: number | undefined - private lastBroadcast: ITrafficDataPoint[] = [] - - constructor( - private emit: (snapshot: ITrafficWorkerSnapshotMessage) => void, - ) {} - - start(rangeMinutes?: number) { - this.currentRange = rangeMinutes ?? this.currentRange - this.handle({ - type: 'init', - config: { - ...this.config, - defaultRangeMinutes: this.currentRange, - }, - }) - } - - stop() { - if (this.throttleTimer !== null) { - clearTimeout(this.throttleTimer) - this.throttleTimer = null - } - if (this.agingTimer !== null) { - clearInterval(this.agingTimer) - this.agingTimer = null - } - this.sampler.clear() - this.lastTimestamp = undefined - } - - handle(message: TrafficWorkerRequestMessage) { - switch (message.type) { - case 'init': { - this.config = { ...message.config } - this.sampler = new TrafficDataSampler(this.config) - this.currentRange = message.config.defaultRangeMinutes - // Re-broadcast when points age out of the range, not on every tick - this.agingTimer = setInterval(() => { - if (this.throttleTimer !== null) return - const slice = this.sampler.getDataForTimeRange(this.currentRange) - if (!isSameTrafficData(this.lastBroadcast, slice)) { - this.emitSnapshot() - } - }, 1000) - this.emitSnapshot() - break - } - case 'append': { - const timestamp = message.payload.timestamp ?? Date.now() - const dataPoint: ITrafficDataPoint = { - up: message.payload.up || 0, - down: message.payload.down || 0, - timestamp, - } - - this.lastTimestamp = timestamp - this.sampler.addDataPoint(dataPoint) - this.scheduleSnapshot() - break - } - case 'setRange': { - if (this.currentRange !== message.minutes) { - this.currentRange = message.minutes - this.emitSnapshot() - } - break - } - case 'requestSnapshot': { - this.emitSnapshot() - break - } - default: - break - } - } - - private emitSnapshot() { - const dataPoints = this.sampler.getDataForTimeRange(this.currentRange) - - this.emit({ - type: 'snapshot', - dataPoints, - samplerStats: this.sampler.getStats(), - lastTimestamp: this.lastTimestamp, - }) - this.lastBroadcast = dataPoints - } - - private scheduleSnapshot() { - if (this.throttleTimer !== null) return - this.throttleTimer = setTimeout(() => { - this.throttleTimer = null - this.emitSnapshot() - }, this.config.snapshotIntervalMs) - } -} - class TrafficWorkerClient { private worker: Worker | null = null - private inlineMonitor: InlineTrafficMonitor | null = null - private mode: 'worker' | 'inline' | null = null private listeners = new Set< (snapshot: ITrafficWorkerSnapshotMessage) => void >() - private pendingMessages: TrafficWorkerRequestMessage[] = [] - private ready = false private currentRange = WORKER_CONFIG.defaultRangeMinutes start(rangeMinutes?: number) { @@ -155,101 +46,52 @@ class TrafficWorkerClient { this.currentRange = rangeMinutes ?? this.currentRange - if (this.ready) return + if (this.worker) return const initMessage: TrafficWorkerRequestMessage = { type: 'init', - config: { - rawDataMinutes: WORKER_CONFIG.rawDataMinutes, - compressedDataMinutes: WORKER_CONFIG.compressedDataMinutes, - compressionRatio: WORKER_CONFIG.compressionRatio, - snapshotIntervalMs: WORKER_CONFIG.snapshotIntervalMs, - defaultRangeMinutes: this.currentRange, - }, + config: { ...WORKER_CONFIG, defaultRangeMinutes: this.currentRange }, } - if (typeof Worker !== 'undefined') { - try { - this.worker = new Worker( - new URL('../services/traffic-monitor-worker.ts', import.meta.url), - { type: 'module' }, - ) - this.mode = 'worker' + this.worker = new Worker( + new URL('../services/traffic-monitor-worker.ts', import.meta.url), + { type: 'module' }, + ) - this.worker.onmessage = ( - event: MessageEvent, - ) => { - const message = event.data - if (message.type === 'snapshot') { - if (import.meta.env.MODE === 'perf') { - document.documentElement.dataset.perfWorker = String( - message.lastTimestamp ?? 0, - ) - document.documentElement.dataset.perfPoints = String( - message.dataPoints.length, - ) - const last = message.dataPoints.at(-1) - document.documentElement.dataset.perfLastUp = String( - last?.up ?? 0, - ) - document.documentElement.dataset.perfLastDown = String( - last?.down ?? 0, - ) - } - this.listeners.forEach((listener) => { - listener(message) - }) - } + this.worker.onmessage = ( + event: MessageEvent, + ) => { + const message = event.data + if (message.type === 'snapshot') { + if (import.meta.env.MODE === 'perf') { + document.documentElement.dataset.perfWorker = String( + message.lastTimestamp ?? 0, + ) + document.documentElement.dataset.perfPoints = String( + message.dataPoints.length, + ) + const last = message.dataPoints.at(-1) + document.documentElement.dataset.perfLastUp = String(last?.up ?? 0) + document.documentElement.dataset.perfLastDown = String( + last?.down ?? 0, + ) } - - this.worker.onerror = (error) => { - debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`) - } - - this.ready = true - this.post(initMessage) - this.flushQueue() - return - } catch (error) { - debugLog( - `[TrafficWorkerClient] Worker initialization failed, falling back to inline sampler: ${String(error)}`, - ) - this.worker = null - this.mode = null + this.listeners.forEach((listener) => { + listener(message) + }) } - } else { - debugLog( - '[TrafficWorkerClient] Worker not supported, using inline sampler', - ) } - this.startInline(initMessage) - } + this.worker.onerror = (error) => { + debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`) + } - private startInline(initMessage: TrafficWorkerRequestMessage) { - this.inlineMonitor = new InlineTrafficMonitor((snapshot) => { - this.listeners.forEach((listener) => { - listener(snapshot) - }) - }) - this.mode = 'inline' - this.ready = true this.post(initMessage) - this.flushQueue() } stop() { - if (this.worker) { - this.worker.terminate() - } - if (this.inlineMonitor) { - this.inlineMonitor.stop() - } + this.worker?.terminate() this.worker = null - this.inlineMonitor = null - this.mode = null - this.ready = false - this.pendingMessages = [] } onSnapshot(listener: (snapshot: ITrafficWorkerSnapshotMessage) => void) { @@ -260,37 +102,11 @@ class TrafficWorkerClient { } private post(message: TrafficWorkerRequestMessage) { - if (!this.ready) { - this.pendingMessages.push(message) - return - } - - if (this.mode === 'worker' && this.worker) { - this.worker.postMessage(message) - return - } - - if (this.mode === 'inline' && this.inlineMonitor) { - this.inlineMonitor.handle(message) - return - } - - this.pendingMessages.push(message) - } - - private flushQueue() { - if (!this.ready || this.pendingMessages.length === 0) { - return - } - const queued = [...this.pendingMessages] - this.pendingMessages = [] - queued.forEach((message) => { - this.post(message) - }) + this.worker?.postMessage(message) } private ensureStarted() { - if (!this.ready) { + if (!this.worker) { this.start(this.currentRange) } } diff --git a/src/hooks/use-verge.ts b/src/hooks/use-verge.ts index 119b4ce1f..553c558b6 100644 --- a/src/hooks/use-verge.ts +++ b/src/hooks/use-verge.ts @@ -25,7 +25,6 @@ export const useVerge = () => { | IVergeConfig | ((prev: IVergeConfig | undefined) => IVergeConfig | undefined) | undefined, - _revalidate?: boolean, ) => { if (updaterOrData === undefined) { void refetch() diff --git a/src/hooks/use-window.ts b/src/hooks/use-window.ts index 15bee2c18..d3fa738a8 100644 --- a/src/hooks/use-window.ts +++ b/src/hooks/use-window.ts @@ -11,36 +11,24 @@ const useWindow = () => { } export const useWindowControls = () => { - const { - maximized, - minimize, - toggleMaximize, - close, - toggleFullscreen, - currentWindow, - } = useWindow() + const { maximized, minimize, toggleMaximize, close, currentWindow } = + useWindow() return { maximized, minimize, toggleMaximize, close, - toggleFullscreen, currentWindow, } satisfies Pick< WindowContextType, - | 'maximized' - | 'minimize' - | 'toggleMaximize' - | 'close' - | 'toggleFullscreen' - | 'currentWindow' + 'maximized' | 'minimize' | 'toggleMaximize' | 'close' | 'currentWindow' > } export const useWindowDecorations = () => { - const { decorated, toggleDecorations, refreshDecorated } = useWindow() - return { decorated, toggleDecorations, refreshDecorated } satisfies Pick< + const { decorated, toggleDecorations } = useWindow() + return { decorated, toggleDecorations } satisfies Pick< WindowContextType, - 'decorated' | 'toggleDecorations' | 'refreshDecorated' + 'decorated' | 'toggleDecorations' > } diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 59625514e..8e49d1e81 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,8 +1,7 @@ import { Paper, ThemeProvider } from '@mui/material' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import { useCallback, useEffect, useMemo, useRef } from 'react' -import { useTranslation } from 'react-i18next' +import { useCallback, useEffect, useMemo } from 'react' import { Outlet, useNavigate } from 'react-router' import { BaseErrorBoundary } from '@/components/base' @@ -38,7 +37,6 @@ const OS = getSystem() const Layout = () => { const mode = useThemeMode() const isDark = mode !== 'light' - const { t } = useTranslation() const { theme } = useCustomTheme() const { verge } = useVerge() const { language } = verge ?? {} @@ -46,7 +44,6 @@ const Layout = () => { const { switchLanguage } = useI18n() const navigate = useNavigate() const themeReady = useMemo(() => Boolean(theme), [theme]) - const windowControlsRef = useRef(null) const { decorated } = useWindowDecorations() const customTitlebar = useMemo( @@ -57,7 +54,7 @@ const Layout = () => { className="the_titlebar-drag-region" data-tauri-drag-region="true" /> - +
) : null, [decorated], @@ -69,12 +66,12 @@ const Layout = () => { (payload: [string, string]) => { const [status, msg] = payload try { - handleNoticeMessage(status, msg, t, navigate) + handleNoticeMessage(status, msg, navigate) } catch (error) { console.error('[通知处理] 失败:', error) } }, - [t, navigate], + [navigate], ) useEventBus(handleNotice) @@ -110,20 +107,6 @@ const Layout = () => { -
- { const { t } = useTranslation() const location = useLocation() const [url, setUrl] = useState('') - const [disabled, setDisabled] = useState(false) const [profileDndRevision, setProfileDndRevision] = useState(0) const [activatings, setActivatings] = useState([]) const [switchTarget, setSwitchTarget] = useState(null) @@ -257,7 +256,6 @@ const ProfilePage = () => { ) } } finally { - setDisabled(false) setLoading(false) } } @@ -824,7 +822,7 @@ const ProfilePage = () => { if (event.key !== 'Enter' || event.nativeEvent.isComposing) { return } - if (!url || disabled || loading) { + if (!url || loading) { return } event.preventDefault() @@ -857,7 +855,7 @@ const ProfilePage = () => { }} />