diff --git a/.husky/pre-push b/.husky/pre-push index dfa2bf73..32181a55 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -11,18 +11,24 @@ if git diff --cached --name-only | grep -q '^src-tauri/'; then fi fi -# 只在 push 到 origin 并且 origin 指向目标仓库时执行格式检查 -if [ "$1" = "origin" ] && echo "$2" | grep -Eq 'github\.com[:/]+clash-verge-rev/clash-verge-rev(\.git)?$'; then - echo "[pre-push] Detected push to origin (clash-verge-rev/clash-verge-rev)" - echo "[pre-push] Running pnpm format:check..." - pnpm format:check - if [ $? -ne 0 ]; then - echo "❌ Code format check failed. Please fix formatting before pushing." - exit 1 +# Only run format check if the remote exists and is the main repo +remote_name="$1" +if git remote get-url "$remote_name" >/dev/null 2>&1; then + remote_url=$(git remote get-url "$remote_name") + if [[ "$remote_url" =~ github\.com[:/]+clash-verge-rev/clash-verge-rev(\.git)?$ ]]; then + echo "[pre-push] Detected push to clash-verge-rev/clash-verge-rev ($remote_url)" + echo "[pre-push] Running pnpm format:check..." + pnpm format:check + if [ $? -ne 0 ]; then + echo "❌ Code format check failed. Please fix formatting before pushing." + exit 1 + fi + else + echo "[pre-push] Not pushing to target repo. Skipping format check." fi else - echo "[pre-push] Not pushing to target repo. Skipping format check." + echo "[pre-push] Remote $remote_name does not exist. Skipping format check." fi exit 0 diff --git a/UPDATELOG.md b/UPDATELOG.md index 7c4ac300..b1e5317c 100644 --- a/UPDATELOG.md +++ b/UPDATELOG.md @@ -53,7 +53,8 @@ - 优化 托盘 统一响应 - 优化 静默启动+自启动轻量模式 运行方式 -- 升级依赖 +- 降低前端潜在内存泄漏风险,提升运行时性能 +- 优化 React 状态、副作用、数据获取、清理等流程。 ## v2.3.0 diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx index eb2cd35b..a1f4b52a 100644 --- a/src/components/connection/connection-table.tsx +++ b/src/components/connection/connection-table.tsx @@ -1,10 +1,11 @@ import dayjs from "dayjs"; -import { useMemo, useState, useEffect } from "react"; +import { useMemo, useState } from "react"; import { DataGrid, GridColDef, GridColumnResizeParams } from "@mui/x-data-grid"; import { useThemeMode } from "@/services/states"; import { truncateStr } from "@/utils/truncate-str"; import parseTraffic from "@/utils/parse-traffic"; import { t } from "i18next"; +import { useLocalStorage } from "foxact/use-local-storage"; interface Props { connections: IConnectionsItem[]; @@ -21,11 +22,13 @@ export const ConnectionTable = (props: Props) => { Partial> >({}); - const [columnWidths, setColumnWidths] = useState>( - () => { - const saved = localStorage.getItem("connection-table-widths"); - return saved ? JSON.parse(saved) : {}; - }, + const [columnWidths, setColumnWidths] = useLocalStorage< + Record + >( + "connection-table-widths", + // server-side value, this is the default value used by server-side rendering (if any) + // Do not omit (otherwise a Suspense boundary will be triggered) + {}, ); const [columns] = useState([ @@ -116,14 +119,6 @@ export const ConnectionTable = (props: Props) => { }, ]); - useEffect(() => { - console.log("Saving column widths:", columnWidths); - localStorage.setItem( - "connection-table-widths", - JSON.stringify(columnWidths), - ); - }, [columnWidths]); - const handleColumnResize = (params: GridColumnResizeParams) => { const { colDef, width } = params; console.log("Column resize:", colDef.field, width); diff --git a/src/components/home/enhanced-traffic-stats.tsx b/src/components/home/enhanced-traffic-stats.tsx index 763c3fd4..7e903466 100644 --- a/src/components/home/enhanced-traffic-stats.tsx +++ b/src/components/home/enhanced-traffic-stats.tsx @@ -29,6 +29,7 @@ import parseTraffic from "@/utils/parse-traffic"; import { isDebugEnabled, gc } from "@/services/api"; import { ReactNode } from "react"; import { useAppData } from "@/providers/app-data-provider"; +import useSWR from "swr"; interface MemoryUsage { inuse: number; @@ -161,7 +162,6 @@ export const EnhancedTrafficStats = () => { const { verge } = useVerge(); const trafficRef = useRef(null); const pageVisible = useVisibility(); - const [isDebug, setIsDebug] = useState(false); // 使用AppDataProvider const { connections, uptime } = useAppData(); @@ -178,19 +178,16 @@ export const EnhancedTrafficStats = () => { // 是否显示流量图表 const trafficGraph = verge?.traffic_graph ?? true; - // WebSocket引用 - const socketRefs = useRef<{ - traffic: ReturnType | null; - memory: ReturnType | null; - }>({ - traffic: null, - memory: null, - }); - // 检查是否支持调试 - useEffect(() => { - isDebugEnabled().then((flag) => setIsDebug(flag)); - }, []); + // TODO: merge this hook with layout-traffic.tsx + const { data: isDebug } = useSWR( + `clash-verge-rev-internal://isDebugEnabled`, + () => isDebugEnabled(), + { + // default value before is fetched + fallbackData: false, + }, + ); // 处理流量数据更新 - 使用节流控制更新频率 const handleTrafficUpdate = useCallback((event: MessageEvent) => { @@ -260,14 +257,23 @@ export const EnhancedTrafficStats = () => { const { server, secret = "" } = clashInfo; if (!server) return; + // WebSocket 引用 + let sockets: { + traffic: ReturnType | null; + memory: ReturnType | null; + } = { + traffic: null, + memory: null, + }; + // 清理现有连接的函数 const cleanupSockets = () => { - Object.values(socketRefs.current).forEach((socket) => { + Object.values(sockets).forEach((socket) => { if (socket) { socket.close(); } }); - socketRefs.current = { traffic: null, memory: null }; + sockets = { traffic: null, memory: null }; }; // 关闭现有连接 @@ -277,44 +283,40 @@ export const EnhancedTrafficStats = () => { console.log( `[Traffic][${EnhancedTrafficStats.name}] 正在连接: ${server}/traffic`, ); - socketRefs.current.traffic = createAuthSockette( - `${server}/traffic`, - secret, - { - onmessage: handleTrafficUpdate, - onopen: (event) => { - console.log( - `[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接已建立`, - event, - ); - }, - onerror: (event) => { - console.error( - `[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`, - event, + sockets.traffic = createAuthSockette(`${server}/traffic`, secret, { + onmessage: handleTrafficUpdate, + onopen: (event) => { + console.log( + `[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接已建立`, + event, + ); + }, + onerror: (event) => { + console.error( + `[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`, + event, + ); + setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } })); + }, + onclose: (event) => { + console.log( + `[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接关闭`, + event.code, + event.reason, + ); + if (event.code !== 1000 && event.code !== 1001) { + console.warn( + `[Traffic][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`, ); setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } })); - }, - onclose: (event) => { - console.log( - `[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接关闭`, - event.code, - event.reason, - ); - if (event.code !== 1000 && event.code !== 1001) { - console.warn( - `[Traffic][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`, - ); - setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } })); - } - }, + } }, - ); + }); console.log( `[Memory][${EnhancedTrafficStats.name}] 正在连接: ${server}/memory`, ); - socketRefs.current.memory = createAuthSockette(`${server}/memory`, secret, { + sockets.memory = createAuthSockette(`${server}/memory`, secret, { onmessage: handleMemoryUpdate, onopen: (event) => { console.log( @@ -353,18 +355,6 @@ export const EnhancedTrafficStats = () => { return cleanupSockets; }, [clashInfo, pageVisible, handleTrafficUpdate, handleMemoryUpdate]); - // 组件卸载时清理所有定时器/引用 - useEffect(() => { - return () => { - try { - Object.values(socketRefs.current).forEach((socket) => { - if (socket) socket.close(); - }); - socketRefs.current = { traffic: null, memory: null }; - } catch {} - }; - }, []); - // 执行垃圾回收 const handleGarbageCollection = useCallback(async () => { if (isDebug) { diff --git a/src/components/layout/layout-traffic.tsx b/src/components/layout/layout-traffic.tsx index 54308d86..cc563024 100644 --- a/src/components/layout/layout-traffic.tsx +++ b/src/components/layout/layout-traffic.tsx @@ -14,6 +14,7 @@ import useSWRSubscription from "swr/subscription"; import { createAuthSockette } from "@/utils/websocket"; import { useTranslation } from "react-i18next"; import { isDebugEnabled, gc } from "@/services/api"; +import useSWR from "swr"; interface MemoryUsage { inuse: number; @@ -31,12 +32,15 @@ export const LayoutTraffic = () => { const trafficRef = useRef(null); const pageVisible = useVisibility(); - const [isDebug, setIsDebug] = useState(false); - useEffect(() => { - isDebugEnabled().then((flag) => setIsDebug(flag)); - return () => {}; - }, [isDebug]); + const { data: isDebug } = useSWR( + "clash-verge-rev-internal://isDebugEnabled", + () => isDebugEnabled(), + { + // default value before is fetched + fallbackData: false, + }, + ); const { data: traffic = { up: 0, down: 0 } } = useSWRSubscription< ITrafficItem, diff --git a/src/components/profile/groups-editor-viewer.tsx b/src/components/profile/groups-editor-viewer.tsx index 1a7c445f..75c5a3b9 100644 --- a/src/components/profile/groups-editor-viewer.tsx +++ b/src/components/profile/groups-editor-viewer.tsx @@ -48,6 +48,10 @@ import MonacoEditor from "react-monaco-editor"; import { useThemeMode } from "@/services/states"; import { Controller, useForm } from "react-hook-form"; import { showNotice } from "@/services/noticeService"; +import { + requestIdleCallback, + cancelIdleCallback, +} from "foxact/request-idle-callback"; interface Props { proxiesUid: string; @@ -195,11 +199,11 @@ export const GroupsEditorViewer = (props: Props) => { // 防止异常导致UI卡死 } }; - if (window.requestIdleCallback) { - window.requestIdleCallback(serialize); - } else { - setTimeout(serialize, 0); - } + + const handle = requestIdleCallback(serialize); + return () => { + cancelIdleCallback(handle); + }; } }, [prependSeq, appendSeq, deleteSeq]); diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index fc2279d0..5ab494d2 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -480,31 +480,34 @@ export const ProxyGroups = (props: Props) => { } }, [handleWheel]); - // 添加窗口大小变化监听和最大高度计算 - const updateMaxHeight = useCallback(() => { - if (!alphabetSelectorRef.current) return; - - const windowHeight = window.innerHeight; - const bottomMargin = 60; // 底部边距 - const topMargin = bottomMargin * 2; // 顶部边距是底部的2倍 - const availableHeight = windowHeight - (topMargin + bottomMargin); - - // 调整选择器的位置,使其偏下 - const offsetPercentage = - (((topMargin - bottomMargin) / windowHeight) * 100) / 2; - alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`; - - setMaxHeight(`${availableHeight}px`); - }, []); - // 监听窗口大小变化 + // layout effect runs before paint useEffect(() => { + // 添加窗口大小变化监听和最大高度计算 + const updateMaxHeight = () => { + if (!alphabetSelectorRef.current) return; + + const windowHeight = window.innerHeight; + const bottomMargin = 60; // 底部边距 + const topMargin = bottomMargin * 2; // 顶部边距是底部的2倍 + const availableHeight = windowHeight - (topMargin + bottomMargin); + + // 调整选择器的位置,使其偏下 + const offsetPercentage = + (((topMargin - bottomMargin) / windowHeight) * 100) / 2; + alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`; + + setMaxHeight(`${availableHeight}px`); + }; + updateMaxHeight(); + window.addEventListener("resize", updateMaxHeight); + return () => { window.removeEventListener("resize", updateMaxHeight); }; - }, [updateMaxHeight]); + }, []); if (mode === "direct") { return ; diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index 90bcc457..1e7f6375 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -110,7 +110,8 @@ export const useRenderList = (mode: string) => { (mode === "rule" && !groups.length) || (mode === "global" && proxies.length < 2) ) { - setTimeout(() => refreshProxy(), 500); + const handle = setTimeout(() => refreshProxy(), 500); + return () => clearTimeout(handle); } }, [proxiesData, mode, refreshProxy]); diff --git a/src/components/setting/mods/backup-viewer.tsx b/src/components/setting/mods/backup-viewer.tsx index 71a6c136..b5a52c07 100644 --- a/src/components/setting/mods/backup-viewer.tsx +++ b/src/components/setting/mods/backup-viewer.tsx @@ -3,7 +3,7 @@ import { useImperativeHandle, useState, useCallback, - useEffect, + useMemo, } from "react"; import { useTranslation } from "react-i18next"; import { BaseDialog, DialogRef } from "@/components/base"; @@ -30,7 +30,6 @@ export const BackupViewer = forwardRef((props, ref) => { const [isLoading, setIsLoading] = useState(false); const [backupFiles, setBackupFiles] = useState([]); - const [dataSource, setDataSource] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); @@ -91,14 +90,14 @@ export const BackupViewer = forwardRef((props, ref) => { .sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1)); }; - useEffect(() => { - setDataSource( + const dataSource = useMemo( + () => backupFiles.slice( page * DEFAULT_ROWS_PER_PAGE, page * DEFAULT_ROWS_PER_PAGE + DEFAULT_ROWS_PER_PAGE, ), - ); - }, [page, backupFiles]); + [backupFiles, page], + ); return ( ((props, ref) => { { - fetchAndSetBackupFiles(); - }} - onSaveSuccess={async () => { - fetchAndSetBackupFiles(); - }} - onRefresh={async () => { - fetchAndSetBackupFiles(); - }} - onInit={async () => { - fetchAndSetBackupFiles(); - }} + onBackupSuccess={fetchAndSetBackupFiles} + onSaveSuccess={fetchAndSetBackupFiles} + onRefresh={fetchAndSetBackupFiles} + onInit={fetchAndSetBackupFiles} /> ((props, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); - const [networkInterfaces, setNetworkInterfaces] = useState< - INetworkInterface[] - >([]); const [isV4, setIsV4] = useState(true); useImperativeHandle(ref, () => ({ @@ -22,12 +20,13 @@ export const NetworkInterfaceViewer = forwardRef((props, ref) => { close: () => setOpen(false), })); - useEffect(() => { - if (!open) return; - getNetworkInterfacesInfo().then((res) => { - setNetworkInterfaces(res); - }); - }, [open]); + const { data: networkInterfaces } = useSWR( + "clash-verge-rev-internal://network-interfaces", + getNetworkInterfacesInfo, + { + fallbackData: [], // default data before fetch + }, + ); return (