From 14abc4c4ee82deb48e13d054a2f336c9d7888cc5 Mon Sep 17 00:00:00 2001 From: wonfen Date: Wed, 4 Nov 2026 03:32:12 +0800 Subject: [PATCH] refactor(react): replace avoidable effects with derived state and handlers Also drops a duplicate WebDAV history request on open and the per-keystroke DNS YAML dump. --- .../connection/connection-table.tsx | 15 - src/components/home/current-proxy-card.tsx | 137 ++++----- .../home/enhanced-canvas-traffic-graph.tsx | 20 +- src/components/home/system-info-card.tsx | 38 ++- src/components/profile/profile-item.tsx | 12 +- src/components/profile/profile-viewer.tsx | 33 +- src/components/proxy/proxy-chain-model.ts | 30 ++ src/components/proxy/proxy-chain.tsx | 43 --- src/components/proxy/proxy-groups-chain.tsx | 15 +- src/components/proxy/proxy-groups.tsx | 13 +- .../setting/mods/backup-history-viewer.tsx | 9 +- src/components/setting/mods/dns-viewer.tsx | 290 ++++++++---------- .../setting/mods/sysproxy-viewer.tsx | 20 +- .../setting/mods/tunnels-viewer.tsx | 29 +- src/pages/_layout/hooks/use-nav-menu-order.ts | 8 +- src/pages/proxies.tsx | 35 +-- 16 files changed, 329 insertions(+), 418 deletions(-) diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx index b1bcbdf1d..37481c47e 100644 --- a/src/components/connection/connection-table.tsx +++ b/src/components/connection/connection-table.tsx @@ -464,21 +464,6 @@ export const ConnectionTable = (props: Props) => { ] }, [t]) - useEffect(() => { - setColumnOrder((prevValue) => { - const baseFields = baseColumns.map((col) => col.field) - const prev = Array.isArray(prevValue) ? prevValue : [] - const reconciled = reconcileColumnOrder(prev, baseFields) - if ( - reconciled.length === prev.length && - reconciled.every((field, i) => field === prev[i]) - ) { - return prevValue - } - return reconciled - }) - }, [baseColumns, setColumnOrder]) - const orderedColumns = useMemo(() => { const baseFields = baseColumns.map((column) => column.field) const reconciledOrder = reconcileColumnOrder(columnOrder, baseFields) diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index c0143f3a0..970fa0f14 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @eslint-react/set-state-in-effect */ import { AccessTimeRounded, ArrowDropDown, @@ -31,7 +30,15 @@ import { useTheme, } from '@mui/material' import { useLockFn } from 'ahooks' -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useEffectEvent, + useId, + useMemo, + useRef, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' @@ -570,20 +577,22 @@ export const CurrentProxyCard = () => { return profileValue } - if (profileKey !== baseKey) { - const legacyValue = localStorage.getItem(baseKey) - if (legacyValue != null) { - localStorage.removeItem(baseKey) - localStorage.setItem(profileKey, legacyValue) - return legacyValue - } - } - - return null + return profileKey !== baseKey ? localStorage.getItem(baseKey) : null }, [getProfileStorageKey], ) + // Hand the pre-profile-scoping group key to the first profile that shows the card. + useEffect(() => { + const profileKey = getProfileStorageKey(STORAGE_KEY_GROUP) + if (profileKey === STORAGE_KEY_GROUP) return + if (localStorage.getItem(profileKey) != null) return + const legacyValue = localStorage.getItem(STORAGE_KEY_GROUP) + if (legacyValue == null) return + localStorage.removeItem(STORAGE_KEY_GROUP) + localStorage.setItem(profileKey, legacyValue) + }, [getProfileStorageKey]) + const writeProfileScopedItem = useCallback( (baseKey: string, value: string) => { if (typeof window === 'undefined') return @@ -615,25 +624,17 @@ export const CurrentProxyCard = () => { return savedSortType ? (Number(savedSortType) as ProxySortType) : 0 }) - const [selectedGroupName, setSelectedGroupName] = useState('') + const [lastGroupName, setLastGroupName] = useState('') const [openSelect, setOpenSelect] = useState(null) - - useEffect(() => { + const [openSelectMode, setOpenSelectMode] = useState(mode) + if (openSelectMode !== mode) { + setOpenSelectMode(mode) setOpenSelect(null) - }, [mode]) + } const delayButtonRef = useRef(null) - const delays = useGroupDelays(selectedGroupName || null) const autoCheckInProgressRef = useRef(false) - const latestTimeoutRef = useRef( - verge?.default_latency_timeout || 10000, - ) - const latestProxyMemberRef = useRef(null) - - useEffect(() => { - latestTimeoutRef.current = verge?.default_latency_timeout || 10000 - }, [verge?.default_latency_timeout]) const selectableGroups = useMemo(() => { if (!proxyView) return [] @@ -644,6 +645,39 @@ export const CurrentProxyCard = () => { ) }, [proxyView]) + const selectedGroupName = useMemo(() => { + if (isDirectMode) return 'DIRECT' + if (!proxyView) return lastGroupName + if (isGlobalMode) return proxyView.global?.name ?? 'GLOBAL' + + const hasGroup = (name: string | null) => + !!name && selectableGroups.some((group) => group.name === name) + if (hasGroup(lastGroupName)) return lastGroupName + const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP) + if (hasGroup(savedGroup)) return savedGroup! + + const primaryKeywords = ['auto', 'select', 'proxy', '节点选择', '自动选择'] + const primaryGroup = + selectableGroups.find((group) => + primaryKeywords.some((keyword) => + group.name.toLowerCase().includes(keyword.toLowerCase()), + ), + ) ?? selectableGroups[0] + return primaryGroup?.name ?? '' + }, [ + isDirectMode, + isGlobalMode, + lastGroupName, + proxyView, + readProfileScopedItem, + selectableGroups, + ]) + // Keep the shown group, auto picks included, until it leaves the profile. + if (proxyView && lastGroupName !== selectedGroupName) { + setLastGroupName(selectedGroupName) + } + const delays = useGroupDelays(selectedGroupName || null) + const selectedGroup = useMemo(() => { if (!proxyView || isDirectMode) return null if (isGlobalMode) return proxyView.global @@ -680,46 +714,6 @@ export const CurrentProxyCard = () => { [optionsForGroup, selectedGroup], ) - useEffect(() => { - if (!proxyView) return - if (isDirectMode) { - setSelectedGroupName('DIRECT') - return - } - if (isGlobalMode) { - setSelectedGroupName(proxyView.global?.name ?? 'GLOBAL') - return - } - - const savedGroup = readProfileScopedItem(STORAGE_KEY_GROUP) - const primaryKeywords = ['auto', 'select', 'proxy', '节点选择', '自动选择'] - const primaryGroup = - selectableGroups.find((group) => - primaryKeywords.some((keyword) => - group.name.toLowerCase().includes(keyword.toLowerCase()), - ), - ) ?? selectableGroups[0] - const nextGroup = selectableGroups.some( - ({ name }) => name === selectedGroupName, - ) - ? selectedGroupName - : selectableGroups.some(({ name }) => name === savedGroup) - ? savedGroup! - : (primaryGroup?.name ?? '') - if (nextGroup !== selectedGroupName) { - setSelectedGroupName(nextGroup) - if (nextGroup) writeProfileScopedItem(STORAGE_KEY_GROUP, nextGroup) - } - }, [ - isDirectMode, - isGlobalMode, - proxyView, - readProfileScopedItem, - selectableGroups, - selectedGroupName, - writeProfileScopedItem, - ]) - const currentOption = useMemo(() => { if (!proxyView) return undefined if (isDirectMode) { @@ -743,12 +737,10 @@ export const CurrentProxyCard = () => { : undefined }, [isDirectMode, proxyView, selectedGroup]) - latestProxyMemberRef.current = currentOption?.member ?? null - const handleGroupChange = useCallback( (newGroupName: string) => { if (isGlobalMode || isDirectMode) return - setSelectedGroupName(newGroupName) + setLastGroupName(newGroupName) writeProfileScopedItem(STORAGE_KEY_GROUP, newGroupName) }, [isDirectMode, isGlobalMode, writeProfileScopedItem], @@ -793,7 +785,7 @@ export const CurrentProxyCard = () => { color: 'text.secondary', } - const checkCurrentProxyDelay = useCallback(async () => { + const checkCurrentProxyDelay = useEffectEvent(async () => { if (autoCheckInProgressRef.current) return if (isDirectMode) return @@ -802,7 +794,7 @@ export const CurrentProxyCard = () => { if (!groupName || !proxyName) return - const proxyMember = latestProxyMemberRef.current + const proxyMember = currentOption?.member if (!proxyMember || !isInteractableMember(proxyMember)) { debugLog( `[CurrentProxyCard] 自动延迟检测跳过,组: ${groupName}, 节点: ${proxyName} 未找到`, @@ -812,7 +804,7 @@ export const CurrentProxyCard = () => { autoCheckInProgressRef.current = true - const timeout = latestTimeoutRef.current || 10000 + const timeout = verge?.default_latency_timeout || 10000 try { debugLog( @@ -828,7 +820,7 @@ export const CurrentProxyCard = () => { autoCheckInProgressRef.current = false refreshProxy() } - }, [isDirectMode, refreshProxy, selectedGroupName, selectedProxyName]) + }) useEffect(() => { if (isDirectMode) return @@ -858,7 +850,6 @@ export const CurrentProxyCard = () => { if (intervalTimer) clearTimeout(intervalTimer) } }, [ - checkCurrentProxyDelay, autoDelayIntervalMs, isDirectMode, selectedGroupName, diff --git a/src/components/home/enhanced-canvas-traffic-graph.tsx b/src/components/home/enhanced-canvas-traffic-graph.tsx index ecabcee64..5990227ee 100644 --- a/src/components/home/enhanced-canvas-traffic-graph.tsx +++ b/src/components/home/enhanced-canvas-traffic-graph.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, + useEffectEvent, useImperativeHandle, useMemo, useReducer, @@ -120,7 +121,6 @@ export const EnhancedCanvasTrafficGraph = memo( const drawFrameRef = useRef(undefined) const hoverFrameRef = useRef(undefined) const mouseMoveFrameRef = useRef(undefined) - const scheduleDrawGraphRef = useRef<() => void>(() => {}) const pendingMousePositionRef = useRef<{ clientX: number clientY: number @@ -189,11 +189,11 @@ export const EnhancedCanvasTrafficGraph = memo( } }, [displayData]) - const handleFocusStateChange = useCallback((focused: boolean) => { + const handleFocusStateChange = useEffectEvent((focused: boolean) => { isWindowFocusedRef.current = focused - scheduleDrawGraphRef.current() - }, []) + scheduleDrawGraph() + }) useEffect(() => { if (typeof window === 'undefined' || typeof document === 'undefined') { @@ -217,7 +217,7 @@ export const EnhancedCanvasTrafficGraph = memo( window.removeEventListener('blur', handleBlur) document.removeEventListener('visibilitychange', handleVisibilityChange) } - }, [handleFocusStateChange]) + }, []) const calculateY = useCallback( ( @@ -906,25 +906,21 @@ export const EnhancedCanvasTrafficGraph = memo( scheduleDrawGraph() }, [scheduleDrawGraph]) - useEffect(() => { - scheduleDrawGraphRef.current = scheduleDrawGraph - }, [scheduleDrawGraph]) + const redrawGraph = useEffectEvent(() => scheduleDrawGraph()) useEffect(() => { const canvas = canvasRef.current if (!canvas || typeof window === 'undefined') return if (typeof ResizeObserver === 'undefined') { - const handleResize = () => scheduleDrawGraphRef.current() + const handleResize = () => redrawGraph() window.addEventListener('resize', handleResize) return () => { window.removeEventListener('resize', handleResize) } } - const resizeObserver = new ResizeObserver(() => - scheduleDrawGraphRef.current(), - ) + const resizeObserver = new ResizeObserver(() => redrawGraph()) resizeObserver.observe(canvas) return () => { diff --git a/src/components/home/system-info-card.tsx b/src/components/home/system-info-card.tsx index ee1593cc8..cc3ad45cc 100644 --- a/src/components/home/system-info-card.tsx +++ b/src/components/home/system-info-card.tsx @@ -8,7 +8,7 @@ import { } from '@mui/icons-material' import { Typography, Stack, Divider, Chip, IconButton } from '@mui/material' import { useLockFn } from 'ahooks' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' @@ -22,10 +22,23 @@ import { import { useVerge } from '@/hooks/use-verge' import { getSystemInfo } from '@/services/cmds' import { showNotice } from '@/services/notice-service' +import { useQuery } from '@/services/query-client' import { version as appVersion } from '@root/package.json' import { EnhancedCard } from './enhanced-card' +const getOsInfo = async () => { + const info = await getSystemInfo() + const sysName = info.system_name + let sysVersion = info.system_version + + if (sysName && sysVersion.toLowerCase().startsWith(sysName.toLowerCase())) { + sysVersion = sysVersion.substring(sysName.length).trim() + } + + return `${sysName} ${sysVersion}` +} + export const SystemInfoCard = () => { const { t } = useTranslation() const { verge, patchVerge } = useVerge() @@ -36,31 +49,16 @@ export const SystemInfoCard = () => { const { checkUpdate: triggerCheckUpdate, lastCheckUpdate } = useUpdate(true) - const [osInfo, setOsInfo] = useState('') + const { data: osInfo = '' } = useQuery({ + queryKey: ['getSystemInfo'], + queryFn: getOsInfo, + }) const lastCheckUpdateText = useMemo( () => (lastCheckUpdate ? new Date(lastCheckUpdate).toLocaleString() : '-'), [lastCheckUpdate], ) - useEffect(() => { - getSystemInfo() - .then((info) => { - const sysName = info.system_name - let sysVersion = info.system_version - - if ( - sysName && - sysVersion.toLowerCase().startsWith(sysName.toLowerCase()) - ) { - sysVersion = sysVersion.substring(sysName.length).trim() - } - - setOsInfo(`${sysName} ${sysVersion}`) - }) - .catch(console.error) - }, []) - useEffect(() => { if (!verge?.auto_check_update) return if (readLastCheckTime() !== null) return diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index bc001d157..474d8dc47 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -21,6 +21,7 @@ import { memo, useCallback, useEffect, + useEffectEvent, useReducer, useRef, useState, @@ -113,7 +114,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { const setProfileLoading = useSetProfileLoading() const [showNextUpdate, setShowNextUpdate] = useState(false) - const showNextUpdateRef = useRef(false) const [nextUpdateTime, setNextUpdateTime] = useState('') const refreshTimeoutRef = useRef | undefined>( undefined, @@ -203,10 +203,6 @@ const ProfileItemBase = (props: ProfileItemProps) => { setShowNextUpdate(!showNextUpdate) } - useEffect(() => { - showNextUpdateRef.current = showNextUpdate - }, [showNextUpdate]) - useEffect(() => { if (showNextUpdate) { fetchNextUpdateTime() @@ -218,8 +214,10 @@ const ProfileItemBase = (props: ProfileItemProps) => { updated, ]) + const isNextUpdateShown = useEffectEvent(() => showNextUpdate) + useEffect(() => { - if (timerUpdateRevision === 0 || !showNextUpdateRef.current) return + if (timerUpdateRevision === 0 || !isNextUpdateShown()) return if (refreshTimeoutRef.current !== undefined) { clearTimeout(refreshTimeoutRef.current) @@ -236,7 +234,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { }, [fetchNextUpdateTime, timerUpdateRevision]) useEffect(() => { - if (completedUpdateRevision === 0 || !showNextUpdateRef.current) return + if (completedUpdateRevision === 0 || !isNextUpdateShown()) return fetchNextUpdateTime() }, [completedUpdateRevision, fetchNextUpdateTime]) diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 6846c0763..582437fbe 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -10,7 +10,7 @@ import { } from '@mui/material' import { useLockFn } from 'ahooks' import type { Ref } from 'react' -import { useEffect, useImperativeHandle, useRef, useState } from 'react' +import { useImperativeHandle, useRef, useState } from 'react' import { Controller, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -77,17 +77,6 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { }, })) - const selfProxy = watch('option.self_proxy') - const withProxy = watch('option.with_proxy') - - useEffect(() => { - if (selfProxy) setValue('option.with_proxy', false) - }, [selfProxy, setValue]) - - useEffect(() => { - if (withProxy) setValue('option.self_proxy', false) - }, [setValue, withProxy]) - const handleOk = useLockFn( handleSubmit(async (form) => { setLoading(true) @@ -419,7 +408,15 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { {t('profiles.modals.profileForm.fields.useSystemProxy')} - + { + field.onChange(checked) + if (checked) setValue('option.self_proxy', false) + }} + color="primary" + /> )} /> @@ -432,7 +429,15 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { {t('profiles.modals.profileForm.fields.useClashProxy')} - + { + field.onChange(checked) + if (checked) setValue('option.with_proxy', false) + }} + color="primary" + /> )} /> diff --git a/src/components/proxy/proxy-chain-model.ts b/src/components/proxy/proxy-chain-model.ts index e18d4ab7a..90ed18e6e 100644 --- a/src/components/proxy/proxy-chain-model.ts +++ b/src/components/proxy/proxy-chain-model.ts @@ -1,3 +1,5 @@ +import * as yaml from 'js-yaml' + import { getRecord, rebindNode, @@ -34,3 +36,31 @@ export const rebindProxyChainItems = ( delay: record?.history.at(-1)?.delay, } }) + +interface ParsedChainConfig { + proxies?: Array<{ + name: string + type: string + [key: string]: any + }> +} + +export const parseChainConfigItems = (configData: string): ProxyChainItem[] => { + try { + // JSON is valid YAML, so one parser covers both persisted formats. + const parsedConfig = yaml.load(configData) as ParsedChainConfig | null + const timestamp = Date.now() + + return ( + parsedConfig?.proxies?.map((proxy, index) => ({ + id: `${proxy.name}_${timestamp}_${index}`, + name: proxy.name, + type: proxy.type, + delay: undefined, + })) || [] + ) + } catch (error) { + console.error('Failed to process chain config data:', error) + return [] + } +} diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index 1eb188165..bc901946b 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -25,7 +25,6 @@ import { keyframes, useTheme, } from '@mui/material' -import * as yaml from 'js-yaml' import { type Ref, useCallback, @@ -60,18 +59,9 @@ const chainPointerSensor = PointerSensor.configure({ type RuntimeConfigWithProxySequence = IConfigData & { proxies?: unknown } -interface ParsedChainConfig { - proxies?: Array<{ - name: string - type: string - [key: string]: any - }> -} - interface ProxyChainProps { proxyChain: ProxyChainItem[] onUpdateChain: (chain: ProxyChainItem[]) => void - chainConfigData?: string | null onMarkUnsavedChanges?: () => void mode?: string selectedGroup?: string | null @@ -98,21 +88,6 @@ const arrowFadeIn = keyframes` } ` -const toChainItems = ( - parsedConfig: ParsedChainConfig | null | undefined, -): ProxyChainItem[] => { - const timestamp = Date.now() - - return ( - parsedConfig?.proxies?.map((proxy, index) => ({ - id: `${proxy.name}_${timestamp}_${index}`, - name: proxy.name, - type: proxy.type, - delay: undefined, - })) || [] - ) -} - interface ChainCardProps { proxy: ProxyChainItem index: number @@ -328,7 +303,6 @@ const SortableProxyChainItem = ({ export const ProxyChain = ({ proxyChain, onUpdateChain, - chainConfigData, onMarkUnsavedChanges, mode, selectedGroup, @@ -580,23 +554,6 @@ export const ProxyChain = ({ recordSelection, ]) - // 处理链式代理配置数据 - useEffect(() => { - if (chainConfigData) { - try { - // JSON is valid YAML, so one parser covers both persisted formats. - const parsedConfig = yaml.load(chainConfigData) as ParsedChainConfig - const chainItems = toChainItems(parsedConfig) - - if (chainItems.length > 0) { - onUpdateChain(chainItems) - } - } catch (error) { - console.error('Failed to process chain config data:', error) - } - } - }, [chainConfigData, onUpdateChain]) - return ( void availableGroups: any[] activeSelectedGroup: string | null @@ -305,7 +305,7 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) { const { t } = useTranslation() const { mode, - chainConfigData, + chainConfigItems, onExitChainMode, availableGroups, activeSelectedGroup, @@ -338,6 +338,16 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) { return [] }) + // Each fetched runtime chain replaces the stored one once. + const configItems = chainConfigItems ?? null + const [appliedConfigItems, setAppliedConfigItems] = useState< + ProxyChainItem[] | null + >(null) + if (configItems !== appliedConfigItems) { + setAppliedConfigItems(configItems) + if (configItems?.length) setProxyChain(configItems) + } + const candidateNodes = useMemo( () => renderList.flatMap((item) => { @@ -520,7 +530,6 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) { any>(fn: T): T { interface Props { mode: string isChainMode?: boolean - chainConfigData?: string | null + chainConfigItems?: ProxyChainItem[] | null onExitChainMode: () => void } @@ -192,10 +193,10 @@ function useProxyRenderState( function ChainProxyGroups(props: { mode: string - chainConfigData?: string | null + chainConfigItems?: ProxyChainItem[] | null onExitChainMode: () => void }) { - const { mode, chainConfigData, onExitChainMode } = props + const { mode, chainConfigItems, onExitChainMode } = props const { proxyView } = useProxiesData() const [selectedGroup, setSelectedGroup] = useState(null) @@ -349,7 +350,7 @@ function ChainProxyGroups(props: { }> { - const { mode, isChainMode = false, chainConfigData, onExitChainMode } = props + const { mode, isChainMode = false, chainConfigItems, onExitChainMode } = props const { profiles, isLoading: isProfilesLoading } = useProfiles() const { isProxyViewPending } = useProxiesData() const { isRunningModePending } = useSystemData() @@ -670,7 +671,7 @@ export const ProxyGroups = (props: Props) => { return isChainMode ? ( ) : ( diff --git a/src/components/setting/mods/backup-history-viewer.tsx b/src/components/setting/mods/backup-history-viewer.tsx index 6147ab4be..402e88e62 100644 --- a/src/components/setting/mods/backup-history-viewer.tsx +++ b/src/components/setting/mods/backup-history-viewer.tsx @@ -146,7 +146,12 @@ export const BackupHistoryViewer = ({ setRows([]) return } - if (!isLocal && webdavStatus === 'failed' && !options?.force) { + // Read the status here: as a dependency, the write below re-runs the fetch. + if ( + !isLocal && + getWebdavStatus(webdavSignature) === 'failed' && + !options?.force + ) { setRows([]) return } @@ -180,7 +185,7 @@ export const BackupHistoryViewer = ({ setLoading(false) } }, - [buildRow, isLocal, open, shouldSkipWebDav, webdavSignature, webdavStatus], + [buildRow, isLocal, open, shouldSkipWebDav, webdavSignature], ) useEffect(() => { diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 71f01217e..71f507612 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -190,6 +190,110 @@ const DEFAULT_DNS_CONFIG = { }, } +type DnsFormValues = { + enable: boolean + listen: string + enhancedMode: 'fake-ip' | 'redir-host' + fakeIpRange: string + fakeIpRange6: string + fakeIpFilterMode: 'blacklist' | 'whitelist' + preferH3: boolean + respectRules: boolean + useHosts: boolean + useSystemHosts: boolean + ipv6: boolean + fakeIpFilter: string + nameserver: string + fallback: string + defaultNameserver: string + proxyServerNameserver: string + directNameserver: string + directNameserverFollowPolicy: boolean + fallbackGeoip: boolean + fallbackGeoipCode: string + fallbackIpcidr: string + fallbackDomain: string + nameserverPolicy: string + hosts: string // hosts设置,独立于dns +} + +const DEFAULT_FORM_VALUES: DnsFormValues = { + enable: DEFAULT_DNS_CONFIG.enable, + listen: DEFAULT_DNS_CONFIG.listen, + enhancedMode: DEFAULT_DNS_CONFIG['enhanced-mode'], + fakeIpRange: DEFAULT_DNS_CONFIG['fake-ip-range'], + fakeIpRange6: DEFAULT_DNS_CONFIG['fake-ip-range6'], + fakeIpFilterMode: DEFAULT_DNS_CONFIG['fake-ip-filter-mode'], + preferH3: DEFAULT_DNS_CONFIG['prefer-h3'], + respectRules: DEFAULT_DNS_CONFIG['respect-rules'], + useHosts: DEFAULT_DNS_CONFIG['use-hosts'], + useSystemHosts: DEFAULT_DNS_CONFIG['use-system-hosts'], + ipv6: DEFAULT_DNS_CONFIG.ipv6, + fakeIpFilter: DEFAULT_DNS_CONFIG['fake-ip-filter'].join(', '), + defaultNameserver: DEFAULT_DNS_CONFIG['default-nameserver'].join(', '), + nameserver: DEFAULT_DNS_CONFIG.nameserver.join(', '), + fallback: DEFAULT_DNS_CONFIG.fallback.join(', '), + proxyServerNameserver: + DEFAULT_DNS_CONFIG['proxy-server-nameserver']?.join(', ') || '', + directNameserver: DEFAULT_DNS_CONFIG['direct-nameserver']?.join(', ') || '', + directNameserverFollowPolicy: + DEFAULT_DNS_CONFIG['direct-nameserver-follow-policy'] || false, + fallbackGeoip: DEFAULT_DNS_CONFIG['fallback-filter'].geoip, + fallbackGeoipCode: DEFAULT_DNS_CONFIG['fallback-filter']['geoip-code'], + fallbackIpcidr: + DEFAULT_DNS_CONFIG['fallback-filter'].ipcidr?.join(', ') || '', + fallbackDomain: + DEFAULT_DNS_CONFIG['fallback-filter'].domain?.join(', ') || '', + nameserverPolicy: '', + hosts: '', +} + +function buildDnsConfig(values: DnsFormValues) { + const dnsConfig: any = { + enable: values.enable, + listen: values.listen, + 'enhanced-mode': values.enhancedMode, + 'fake-ip-range': values.fakeIpRange, + 'fake-ip-range6': values.fakeIpRange6, + 'fake-ip-filter-mode': values.fakeIpFilterMode, + 'prefer-h3': values.preferH3, + 'respect-rules': values.respectRules, + 'use-hosts': values.useHosts, + 'use-system-hosts': values.useSystemHosts, + ipv6: values.ipv6, + 'fake-ip-filter': parseList(values.fakeIpFilter), + 'default-nameserver': parseList(values.defaultNameserver), + nameserver: parseList(values.nameserver), + 'nameserver-policy': parseNameserverPolicy(values.nameserverPolicy), + 'direct-nameserver-follow-policy': values.directNameserverFollowPolicy, + 'fallback-filter': { + geoip: values.fallbackGeoip, + 'geoip-code': values.fallbackGeoipCode, + ipcidr: parseList(values.fallbackIpcidr), + domain: parseList(values.fallbackDomain), + }, + + fallback: parseList(values.fallback), + 'proxy-server-nameserver': parseList(values.proxyServerNameserver), + 'direct-nameserver': parseList(values.directNameserver), + } + + return dnsConfig +} + +function buildDnsYaml(values: DnsFormValues) { + const config: Record = {} + + const dnsConfig = buildDnsConfig(values) + if (Object.keys(dnsConfig).length > 0) { + config.dns = dnsConfig + } + + config.hosts = parseHosts(values.hosts) + + return yaml.dump(config, { forceQuotes: true }) +} + export function DnsViewer({ ref }: { ref?: Ref }) { const { t } = useTranslation() const { mutateClash } = useClash() @@ -204,63 +308,8 @@ export function DnsViewer({ ref }: { ref?: Ref }) { const [open, setOpen] = useState(false) const [visualization, setVisualization] = useState(true) - const skipYamlSyncRef = useRef(false) const editorRef = useRef(null) - const [values, setValues] = useState<{ - enable: boolean - listen: string - enhancedMode: 'fake-ip' | 'redir-host' - fakeIpRange: string - fakeIpRange6: string - fakeIpFilterMode: 'blacklist' | 'whitelist' - preferH3: boolean - respectRules: boolean - useHosts: boolean - useSystemHosts: boolean - ipv6: boolean - fakeIpFilter: string - nameserver: string - fallback: string - defaultNameserver: string - proxyServerNameserver: string - directNameserver: string - directNameserverFollowPolicy: boolean - fallbackGeoip: boolean - fallbackGeoipCode: string - fallbackIpcidr: string - fallbackDomain: string - nameserverPolicy: string - hosts: string // hosts设置,独立于dns - }>({ - enable: DEFAULT_DNS_CONFIG.enable, - listen: DEFAULT_DNS_CONFIG.listen, - enhancedMode: DEFAULT_DNS_CONFIG['enhanced-mode'], - fakeIpRange: DEFAULT_DNS_CONFIG['fake-ip-range'], - fakeIpRange6: DEFAULT_DNS_CONFIG['fake-ip-range6'], - fakeIpFilterMode: DEFAULT_DNS_CONFIG['fake-ip-filter-mode'], - preferH3: DEFAULT_DNS_CONFIG['prefer-h3'], - respectRules: DEFAULT_DNS_CONFIG['respect-rules'], - useHosts: DEFAULT_DNS_CONFIG['use-hosts'], - useSystemHosts: DEFAULT_DNS_CONFIG['use-system-hosts'], - ipv6: DEFAULT_DNS_CONFIG.ipv6, - fakeIpFilter: DEFAULT_DNS_CONFIG['fake-ip-filter'].join(', '), - defaultNameserver: DEFAULT_DNS_CONFIG['default-nameserver'].join(', '), - nameserver: DEFAULT_DNS_CONFIG.nameserver.join(', '), - fallback: DEFAULT_DNS_CONFIG.fallback.join(', '), - proxyServerNameserver: - DEFAULT_DNS_CONFIG['proxy-server-nameserver']?.join(', ') || '', - directNameserver: DEFAULT_DNS_CONFIG['direct-nameserver']?.join(', ') || '', - directNameserverFollowPolicy: - DEFAULT_DNS_CONFIG['direct-nameserver-follow-policy'] || false, - fallbackGeoip: DEFAULT_DNS_CONFIG['fallback-filter'].geoip, - fallbackGeoipCode: DEFAULT_DNS_CONFIG['fallback-filter']['geoip-code'], - fallbackIpcidr: - DEFAULT_DNS_CONFIG['fallback-filter'].ipcidr?.join(', ') || '', - fallbackDomain: - DEFAULT_DNS_CONFIG['fallback-filter'].domain?.join(', ') || '', - nameserverPolicy: '', - hosts: '', - }) + const [values, setValues] = useState(DEFAULT_FORM_VALUES) // Prevent YAML-to-form sync before configuration loads. const [yamlContent, setYamlContent] = useReducer( @@ -269,7 +318,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { ) const updateValuesFromConfig = useCallback((config: any) => { - if (!config) return + if (!config) return undefined const dnsConfig = config.dns || {} const hostsConfig = config.hosts || {} @@ -289,7 +338,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { ? fakeIpFilterMode : DEFAULT_DNS_CONFIG['fake-ip-filter-mode'] - setValues({ + const next: DnsFormValues = { enable: dnsConfig.enable ?? DEFAULT_DNS_CONFIG.enable, listen: dnsConfig.listen ?? DEFAULT_DNS_CONFIG.listen, enhancedMode: validEnhancedMode, @@ -341,127 +390,27 @@ export function DnsViewer({ ref }: { ref?: Ref }) { nameserverPolicy: formatNameserverPolicy(dnsConfig['nameserver-policy']) || '', hosts: formatHosts(hostsConfig) || '', - }) + } + setValues(next) + return next }, []) - const generateDnsConfig = useCallback(() => { - const dnsConfig: any = { - enable: values.enable, - listen: values.listen, - 'enhanced-mode': values.enhancedMode, - 'fake-ip-range': values.fakeIpRange, - 'fake-ip-range6': values.fakeIpRange6, - 'fake-ip-filter-mode': values.fakeIpFilterMode, - 'prefer-h3': values.preferH3, - 'respect-rules': values.respectRules, - 'use-hosts': values.useHosts, - 'use-system-hosts': values.useSystemHosts, - ipv6: values.ipv6, - 'fake-ip-filter': parseList(values.fakeIpFilter), - 'default-nameserver': parseList(values.defaultNameserver), - nameserver: parseList(values.nameserver), - 'nameserver-policy': parseNameserverPolicy(values.nameserverPolicy), - 'direct-nameserver-follow-policy': values.directNameserverFollowPolicy, - 'fallback-filter': { - geoip: values.fallbackGeoip, - 'geoip-code': values.fallbackGeoipCode, - ipcidr: parseList(values.fallbackIpcidr), - domain: parseList(values.fallbackDomain), - }, - - fallback: parseList(values.fallback), - 'proxy-server-nameserver': parseList(values.proxyServerNameserver), - 'direct-nameserver': parseList(values.directNameserver), - } - - return dnsConfig - }, [values]) - - const updateYamlFromValues = useCallback(() => { - const config: Record = {} - - const dnsConfig = generateDnsConfig() - if (Object.keys(dnsConfig).length > 0) { - config.dns = dnsConfig - } - - config.hosts = parseHosts(values.hosts) - - setYamlContent(yaml.dump(config, { forceQuotes: true })) - }, [generateDnsConfig, values.hosts]) - const resetToDefaults = useCallback(() => { - setValues({ - enable: DEFAULT_DNS_CONFIG.enable, - listen: DEFAULT_DNS_CONFIG.listen, - enhancedMode: DEFAULT_DNS_CONFIG['enhanced-mode'], - fakeIpRange: DEFAULT_DNS_CONFIG['fake-ip-range'], - fakeIpRange6: DEFAULT_DNS_CONFIG['fake-ip-range6'], - fakeIpFilterMode: DEFAULT_DNS_CONFIG['fake-ip-filter-mode'], - preferH3: DEFAULT_DNS_CONFIG['prefer-h3'], - respectRules: DEFAULT_DNS_CONFIG['respect-rules'], - useHosts: DEFAULT_DNS_CONFIG['use-hosts'], - useSystemHosts: DEFAULT_DNS_CONFIG['use-system-hosts'], - ipv6: DEFAULT_DNS_CONFIG.ipv6, - fakeIpFilter: DEFAULT_DNS_CONFIG['fake-ip-filter'].join(', '), - defaultNameserver: DEFAULT_DNS_CONFIG['default-nameserver'].join(', '), - nameserver: DEFAULT_DNS_CONFIG.nameserver.join(', '), - fallback: DEFAULT_DNS_CONFIG.fallback.join(', '), - proxyServerNameserver: - DEFAULT_DNS_CONFIG['proxy-server-nameserver']?.join(', ') || '', - directNameserver: - DEFAULT_DNS_CONFIG['direct-nameserver']?.join(', ') || '', - directNameserverFollowPolicy: - DEFAULT_DNS_CONFIG['direct-nameserver-follow-policy'] || false, - fallbackGeoip: DEFAULT_DNS_CONFIG['fallback-filter'].geoip, - fallbackGeoipCode: DEFAULT_DNS_CONFIG['fallback-filter']['geoip-code'], - fallbackIpcidr: - DEFAULT_DNS_CONFIG['fallback-filter'].ipcidr?.join(', ') || '', - fallbackDomain: - DEFAULT_DNS_CONFIG['fallback-filter'].domain?.join(', ') || '', - nameserverPolicy: '', - hosts: '', - }) - - updateYamlFromValues() - }, [updateYamlFromValues]) + setValues(DEFAULT_FORM_VALUES) + setYamlContent(buildDnsYaml(DEFAULT_FORM_VALUES)) + }, []) const updateValuesFromYaml = useCallback(() => { try { const parsedYaml = yaml.load(yamlContent) as any if (!parsedYaml || parsedYaml.isInitValue) return - skipYamlSyncRef.current = true updateValuesFromConfig(parsedYaml) } catch { showNotice.error('settings.modals.dns.errors.invalidYaml') } }, [yamlContent, updateValuesFromConfig]) - useEffect(() => { - if (skipYamlSyncRef.current) { - skipYamlSyncRef.current = false - return - } - updateYamlFromValues() - }, [updateYamlFromValues]) - - const latestUpdateValuesFromYamlRef = useRef(updateValuesFromYaml) - const latestUpdateYamlFromValuesRef = useRef(updateYamlFromValues) - - useEffect(() => { - latestUpdateValuesFromYamlRef.current = updateValuesFromYaml - latestUpdateYamlFromValuesRef.current = updateYamlFromValues - }, [updateValuesFromYaml, updateYamlFromValues]) - - useEffect(() => { - if (visualization) { - latestUpdateValuesFromYamlRef.current() - } else { - latestUpdateYamlFromValuesRef.current() - } - }, [visualization]) - useEffect(() => { return () => { editorRef.current?.dispose() @@ -476,8 +425,8 @@ export function DnsViewer({ ref }: { ref?: Ref }) { if (dnsConfig !== null) { const config = yaml.load(dnsConfig) as any - updateValuesFromConfig(config) - setYamlContent(dnsConfig) + const loaded = updateValuesFromConfig(config) + setYamlContent(loaded ? buildDnsYaml(loaded) : dnsConfig) } else { resetToDefaults() } @@ -506,7 +455,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { if (visualization) { config = {} - const dnsConfig = generateDnsConfig() + const dnsConfig = buildDnsConfig(values) if (Object.keys(dnsConfig).length > 0) { config.dns = dnsConfig } @@ -619,7 +568,12 @@ export function DnsViewer({ ref }: { ref?: Ref }) { variant="contained" size="small" onClick={() => { - setVisualization((prev) => !prev) + if (visualization) { + setYamlContent(buildDnsYaml(values)) + } else { + updateValuesFromYaml() + } + setVisualization(!visualization) }} > {visualization diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index eb7997e88..8b67302a2 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -13,13 +13,7 @@ import { Typography, } from '@mui/material' import { useLockFn } from 'ahooks' -import { - forwardRef, - useEffect, - useImperativeHandle, - useMemo, - useState, -} from 'react' +import { forwardRef, useImperativeHandle, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { @@ -41,6 +35,7 @@ import { getSystemHostname, } from '@/services/cmds' import { showNotice } from '@/services/notice-service' +import { useQuery } from '@/services/query-client' import { debugLog } from '@/utils/debug' import getSystem from '@/utils/get-system' @@ -128,13 +123,10 @@ export const SysproxyViewer = forwardRef((props, ref) => { } = verge ?? {} const [value, setValue] = useState(() => buildInitialValues(null)) - const [embeddedServerPort, setEmbeddedServerPort] = useState( - null, - ) - - useEffect(() => { - getEmbeddedServerPort().then(setEmbeddedServerPort).catch(console.error) - }, []) + const { data: embeddedServerPort } = useQuery({ + queryKey: ['getEmbeddedServerPort'], + queryFn: getEmbeddedServerPort, + }) const separator = useMemo(() => (isWindows ? ';' : ','), [isWindows]) diff --git a/src/components/setting/mods/tunnels-viewer.tsx b/src/components/setting/mods/tunnels-viewer.tsx index fe149c51b..1934cf863 100644 --- a/src/components/setting/mods/tunnels-viewer.tsx +++ b/src/components/setting/mods/tunnels-viewer.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @eslint-react/set-state-in-effect */ import { Delete, ExpandLess, ExpandMore } from '@mui/icons-material' import { Button, @@ -14,7 +13,6 @@ import { } from '@mui/material' import { forwardRef, - useEffect, useImperativeHandle, useMemo, useRef, @@ -157,29 +155,20 @@ export const TunnelsViewer = forwardRef((_, ref) => { const selectedProxyToken = selectedProxyOption ? proxyOptionToken(selectedProxyOption) : '' - const selectedGroupExists = - !values.group || proxyGroups.some(({ name }) => name === values.group) + // A group or member missing from the proxy view reads as the default. + const effectiveGroup = proxyGroups.some(({ name }) => name === values.group) + ? values.group + : '' + const effectiveProxy = selectedProxyOption ? values.proxy : null const latestProxyStateRef = useRef({ - binding: values.proxy, + binding: effectiveProxy, options: proxyOptions, }) latestProxyStateRef.current = { - binding: values.proxy, + binding: effectiveProxy, options: proxyOptions, } - useEffect(() => { - if (selectedGroupExists && (!values.proxy || selectedProxyOption)) return - setValues((current) => { - if (current.group !== values.group || current.proxy !== values.proxy) { - return current - } - return selectedGroupExists - ? { ...current, proxy: null } - : { ...current, group: '', proxy: null } - }) - }, [selectedGroupExists, selectedProxyOption, values.group, values.proxy]) - const handleSave = async () => { try { await patchClash({ tunnels: draftTunnels }) @@ -490,7 +479,7 @@ export const TunnelsViewer = forwardRef((_, ref) => {