From 40fd1d5012cc17ea5b87ec5f81b9bb389ce71d94 Mon Sep 17 00:00:00 2001 From: Tunglies Date: Tue, 3 Nov 2026 10:48:45 +0000 Subject: [PATCH] refactor: frontend cleanup (#8447) * docs(agents): add frontend liveness constraints Add six liveness rules to the frontend guardrails covering dead state, unused exposed surface, unreachable fallback paths, duplicate side effects per interaction, unreachable mounts and unbounded resource lifecycles. These target defect classes that the existing CI gates (knip, eslint, arch:check) cannot detect mechanically. * refactor(frontend): remove dead states, refs and unreachable branches Drop state that can only hold its initial value (profiles page disabled flag, traffic graph currentFPS), a write-only window controls ref with its unconsumed imperative handle, a no-op appendData ref method, an unreachable gradient branch, an invisible fadeIn shell, constant-true subexpressions in the system info card, an empty click handler and a pointless useMemo. * refactor(frontend): drop unused params, props and api surface Remove the never-read _revalidate argument of mutateVerge and its 16 call-site placeholders, seven never-passed props of the split chip editor, the unconsumed refreshDecorated context member, the now-orphaned toggleFullscreen control, the unused translate argument of the notice message handler, the reserved-but-unset ServiceConfig.timeout field and unreachable branches in parseNoticeExtras. * refactor(types): remove dead provider view types superseded by bindings Drop the handwritten IProxyProviderItem and IRuleProviderItem interfaces: IProxyProviderItem is a stale duplicate of the generated ProxyProviderView contract, and neither has any references left in the tree. Keep IProxyItem and IProxyGroupItem: IConfigData still declares 'proxy-groups'?: IProxyGroupItem[], which builds on IProxyItem. * fix(rules): refresh once per visibility trigger The mount effect fired refreshRules and refreshRuleProviders unconditionally and again under the page-visible flag, double-firing both queries on mount and on every hide-to-show transition. Gate the refresh on visibility once so each trigger performs exactly one revalidation. * fix(setting): write auto launch patch once per toggle GuardState applies the optimistic onChange write before onGuard runs, so repeating the same onChangeData patch inside the guard handler duplicated every cache write and subscriber notification for the auto launch switch. * fix(setting): build viewer form defaults from a single source sysproxy, misc and tunnels viewers duplicated their form default literals in useState and open(), and the two copies in misc-viewer had already diverged (app log max size, app log max count, auto log clean). Extract a buildInitialValues factory per viewer and unify on the open() values, which are the only ones users could ever see. * fix(setting): mount only reachable proxy control viewers ProxyControlSwitches unconditionally mounted both SysproxyViewer and TunViewer while its switch rows are mutually exclusive, so every instance carried a dialog that could never open, each firing getEmbeddedServerPort or a clash query subscription on mount. Mount each viewer under the same condition as the switch row that opens it. SettingSystem also mounted its own SysproxyViewer and TunViewer whose refs were never opened there; the reachable entries are the ProxyControlSwitches rows, so the dead pair is removed. Viewer mounts across the app drop from eight (setting-system: two own + two per instance, proxy-tun-card: two) to three (one per rendered switch variant). * fix(delay): write pending state once per node in batch checks checkListDelay pre-marked every node as checking, help() re-marked it when picked up and measureDelay marked it a third time on entry, so each batch run queued three identical pending updates per node. Keep the pre-mark (immediate feedback for queued nodes) and drop the redundant marks along the chain. * fix(services): make retries viable and align polling freshness window Share no abort signal across retry attempts: after a timeout abort the old signal stays dead and every later fetch rejects immediately, so retries only burned backoff time. Also drop the system state query's staleTime: the wrapper maps it to SWR's dedupingInterval, which swallowed every other 3s poll tick while it exceeded the interval. * refactor(traffic): drop unreachable inline monitor fallback The inline traffic monitor duplicated the worker's message loop, snapshot throttling and aging logic but only runs when Worker is undefined or its constructor throws, which no supported Tauri webview or browser hits. Keep the worker as the single implementation and remove the mode switch and the pending-message queue that could only ever buffer the init message. * refactor(shared): replace the traffic error boundary with ErrorBoundary TrafficErrorBoundary maintained a second boundary system beside BaseErrorBoundary: a report builder that only console.logged constants in the Tauri webview, an onError that re-logged what componentDidCatch already logged, and a fallback wrapper with a single consumer. Fold both mount points into react-error-boundary (the primitive behind BaseErrorBoundary) with local fallbacks that keep the panel styling, message text and retry reset, log each error once, and drop the retry attempt cap and the stack-details collapse along with it. * refactor(hooks): drop the write-only error channel and key-rotating refresh The mihomo ws subscription's next callback accepted an error half that baseNext silently discarded while traffic, memory and log consumers kept constructing fallback payloads for it. Refresh minted a fresh SWR key per call via a localStorage timestamp, abandoning old keys in the cache forever. Reduce next to its data half and reset the snapshot under a stable key instead. * refactor(hooks): trim dead surface from proxy selection Drop the connection-cleanup option nobody ever disabled (behavior now unconditional), the returned changeProxy no consumer called and the two one-caller argument adapters; both call sites use the shared select entry directly. * refactor(services): drop the bespoke retry engine from the query wrapper The custom onErrorRetry existed only to honor a function-form retryDelay, which exactly one caller (the app data provider) used for exponential backoff. Fall back to SWR's built-in error retry and express that caller's cadence with constant options. --- src/AGENTS.md | 24 ++ .../base/base-split-chip-editor.tsx | 43 +-- src/components/home/current-proxy-card.tsx | 12 +- .../home/enhanced-canvas-traffic-graph.tsx | 64 +--- .../home/enhanced-traffic-stats.tsx | 61 +++- src/components/home/system-info-card.tsx | 19 +- src/components/home/test-card.tsx | 6 +- src/components/layout/layout-sidebar.tsx | 5 +- src/components/layout/layout-traffic.tsx | 37 ++- src/components/layout/window-controller.tsx | 40 +-- src/components/proxy/proxy-groups.tsx | 6 +- .../proxy/use-proxy-group-header-layout.ts | 6 +- .../setting/mods/backup-config-viewer.tsx | 20 +- src/components/setting/mods/layout-viewer.tsx | 2 +- src/components/setting/mods/misc-viewer.tsx | 47 ++- .../setting/mods/sysproxy-viewer.tsx | 35 +- .../setting/mods/tunnels-viewer.tsx | 30 +- src/components/setting/mods/web-ui-viewer.tsx | 6 +- src/components/setting/setting-system.tsx | 16 +- .../setting/setting-verge-basic.tsx | 2 +- .../shared/proxy-control-switches.tsx | 6 +- .../shared/traffic-error-boundary.tsx | 301 ------------------ src/hooks/use-icon-cache.ts | 6 +- src/hooks/use-log-data.ts | 12 +- src/hooks/use-memory-data.ts | 10 +- src/hooks/use-mihomo-ws-subscription.ts | 100 ++---- src/hooks/use-proxy-selection.test.ts | 4 +- src/hooks/use-proxy-selection.ts | 32 +- src/hooks/use-system-proxy-state.ts | 10 +- src/hooks/use-system-state.ts | 3 +- src/hooks/use-traffic-data.ts | 10 +- src/hooks/use-traffic-monitor.ts | 248 ++------------- src/hooks/use-verge.ts | 1 - src/hooks/use-window.ts | 24 +- src/pages/_layout.tsx | 25 +- src/pages/profiles.tsx | 6 +- src/pages/rules.tsx | 5 +- src/providers/app-data-provider.tsx | 4 +- src/providers/window/window-context.ts | 2 - src/providers/window/window-provider.tsx | 8 - src/services/api.ts | 75 ++--- src/services/bus/create-bus-handlers.test.ts | 6 +- src/services/delay.ts | 6 +- src/services/notice-handlers.ts | 2 - src/services/notice-service.ts | 9 - src/services/query-client.ts | 20 +- src/types/global.d.ts | 24 -- 47 files changed, 373 insertions(+), 1067 deletions(-) delete mode 100644 src/components/shared/traffic-error-boundary.tsx 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 = () => { }} />