diff --git a/Changelog.md b/Changelog.md index af0dc277d..213811441 100644 --- a/Changelog.md +++ b/Changelog.md @@ -48,6 +48,7 @@ - 优化连接、规则和日志筛选:切换页面后保留过滤条件 - 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组 +- 优化连接和规则页面的资源占用,减少连接列表更新及排序时的卡顿 **🍎 macOS** diff --git a/src/components/connection/connection-relative-time.test.ts b/src/components/connection/connection-relative-time.test.ts new file mode 100644 index 000000000..b525c06e6 --- /dev/null +++ b/src/components/connection/connection-relative-time.test.ts @@ -0,0 +1,72 @@ +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { useSyncExternalStore } from 'react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +dayjs.extend(relativeTime) + +vi.mock('react', () => ({ + memo: (component: unknown) => component, + useSyncExternalStore: vi.fn((_subscribe, getSnapshot) => getSnapshot()), +})) + +let visibility: EventTarget & { hidden: boolean } + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.useFakeTimers() + vi.stubGlobal('window', globalThis) + visibility = Object.assign(new EventTarget(), { hidden: false }) + vi.stubGlobal('document', visibility) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +it('shares one clock, stops while hidden, refreshes on visibility, and cleans up the last subscriber', async () => { + const { RelativeTime } = await import('./connection-relative-time') + RelativeTime({ start: '2026-01-01T00:00:00Z' }) + const [subscribe, getSnapshot] = vi.mocked(useSyncExternalStore).mock.calls[0] + const first = vi.fn() + const second = vi.fn() + const unsubscribeFirst = subscribe(first) + const unsubscribeSecond = subscribe(second) + expect(vi.getTimerCount()).toBe(1) + vi.advanceTimersByTime(5_000) + expect(first).toHaveBeenCalledOnce() + expect(second).toHaveBeenCalledOnce() + + visibility.hidden = true + visibility.dispatchEvent(new Event('visibilitychange')) + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(60_000) + expect(first).toHaveBeenCalledOnce() + visibility.hidden = false + visibility.dispatchEvent(new Event('visibilitychange')) + expect(getSnapshot()).toBe(Date.now()) + expect(first).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(1) + unsubscribeFirst() + expect(vi.getTimerCount()).toBe(1) + unsubscribeSecond() + expect(vi.getTimerCount()).toBe(0) + visibility.dispatchEvent(new Event('visibilitychange')) + expect(vi.getTimerCount()).toBe(0) + expect(second).toHaveBeenCalledTimes(2) +}) + +it('does not start a clock when mounted hidden', async () => { + visibility.hidden = true + const { RelativeTime } = await import('./connection-relative-time') + RelativeTime({ start: '2026-01-01T00:00:00Z' }) + const [subscribe] = vi.mocked(useSyncExternalStore).mock.calls[0] + const unsubscribe = subscribe(vi.fn()) + expect(vi.getTimerCount()).toBe(0) + unsubscribe() + visibility.hidden = false + visibility.dispatchEvent(new Event('visibilitychange')) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/components/connection/connection-relative-time.tsx b/src/components/connection/connection-relative-time.tsx index 80533dbb6..b8ee533af 100644 --- a/src/components/connection/connection-relative-time.tsx +++ b/src/components/connection/connection-relative-time.tsx @@ -8,7 +8,7 @@ let timerId: number | null = null const listeners = new Set() const startTimer = () => { - if (timerId !== null) return + if (timerId !== null || document.hidden || listeners.size === 0) return currentTime = Date.now() timerId = window.setInterval(() => { @@ -18,19 +18,34 @@ const startTimer = () => { } const stopTimer = () => { - if (listeners.size > 0 || timerId === null) return + if (timerId === null) return window.clearInterval(timerId) timerId = null } +const handleVisibilityChange = () => { + if (document.hidden) { + stopTimer() + } else { + startTimer() + listeners.forEach((listener) => listener()) + } +} + const subscribeRelativeTime = (listener: RelativeTimeListener) => { listeners.add(listener) + if (listeners.size === 1) { + document.addEventListener('visibilitychange', handleVisibilityChange) + } startTimer() return () => { listeners.delete(listener) - stopTimer() + if (listeners.size === 0) { + stopTimer() + document.removeEventListener('visibilitychange', handleVisibilityChange) + } } } diff --git a/src/components/connection/connection-table.test.ts b/src/components/connection/connection-table.test.ts new file mode 100644 index 000000000..985838249 --- /dev/null +++ b/src/components/connection/connection-table.test.ts @@ -0,0 +1,91 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { expect, it, vi } from 'vitest' + +import { formatConnectionTraffic } from './connection-row-view' +import { ConnectionTable } from './connection-table' + +const sorting = vi.hoisted(() => ({ id: 'download', desc: false })) + +vi.mock('react', async (importOriginal) => ({ + ...(await importOriginal()), + useState: (initial: unknown) => [ + initial === null ? sorting : initial, + vi.fn(), + ], +})) +vi.mock('foxact/use-local-storage', () => ({ + useLocalStorage: (_key: string, initial: unknown) => [initial, vi.fn()], +})) +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) +vi.mock('./connection-column-manager', () => ({ + ConnectionColumnManager: () => null, +})) +vi.mock('./connection-relative-time', () => ({ + RelativeTime: () => null, +})) +vi.mock('./connection-row-view', async (importOriginal) => { + const original = + await importOriginal() + return { + ...original, + formatConnectionTraffic: vi.fn(original.formatConnectionTraffic), + } +}) + +it('sorts numeric columns above the cache bound while formatting only virtual rows', () => { + const connections: IConnectionsItem[] = Array.from( + { length: 2_101 }, + (_, i) => ({ + id: `${i}`, + metadata: { + network: 'tcp', + type: 'HTTP', + host: `host-${i}`, + sourceIP: '', + sourcePort: '', + destinationPort: '443', + }, + chains: [], + rule: '', + rulePayload: '', + start: '', + upload: (i * 3) % 2_101, + download: (i * 5) % 2_101, + curUpload: (i * 7) % 2_101, + curDownload: (i * 11) % 2_101, + }), + ) + + for (const [column, field] of [ + ['download', 'download'], + ['upload', 'upload'], + ['dlSpeed', 'curDownload'], + ['ulSpeed', 'curUpload'], + ] as const) { + for (const desc of [false, true]) { + sorting.id = column + sorting.desc = desc + vi.mocked(formatConnectionTraffic).mockClear() + const html = renderToStaticMarkup( + createElement(ConnectionTable, { + connections, + onShowDetail: vi.fn(), + columnManagerOpen: false, + onCloseColumnManager: vi.fn(), + }), + ) + const expected = connections + .slice() + .sort((a, b) => ((a[field] ?? 0) - (b[field] ?? 0)) * (desc ? -1 : 1)) + expect(html.match(/host-\d+:443/g)).toEqual( + expected.slice(0, 6).map((row) => `${row.metadata.host}:443`), + ) + expect(formatConnectionTraffic).toHaveBeenCalledTimes(24) + } + } + expect(connections[0].id).toBe('0') + expect(connections.at(-1)?.id).toBe('2100') +}) diff --git a/src/components/connection/connection-table.tsx b/src/components/connection/connection-table.tsx index 37481c47e..e0a659800 100644 --- a/src/components/connection/connection-table.tsx +++ b/src/components/connection/connection-table.tsx @@ -223,6 +223,16 @@ const compareConnectionCellValue = ( right: IConnectionsItem, getSnapshot: (row: IConnectionsItem) => TableRowSnapshot, ) => { + switch (field) { + case 'download': + return (left.download ?? 0) - (right.download ?? 0) + case 'upload': + return (left.upload ?? 0) - (right.upload ?? 0) + case 'dlSpeed': + return (left.curDownload ?? 0) - (right.curDownload ?? 0) + case 'ulSpeed': + return (left.curUpload ?? 0) - (right.curUpload ?? 0) + } const leftValue = getConnectionCellValue(field, getSnapshot(left)) const rightValue = getConnectionCellValue(field, getSnapshot(right)) @@ -491,6 +501,8 @@ export const ConnectionTable = (props: Props) => { const [sorting, setSorting] = useState(null) const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 }) const scrollContainerRef = useRef(null) + const resizeCleanupRef = useRef<(() => void) | null>(null) + useEffect(() => () => resizeCleanupRef.current?.(), []) const rowSnapshotCacheRef = useRef(new Map()) const getRowSnapshot = useCallback((row: IConnectionsItem) => { const cache = rowSnapshotCacheRef.current @@ -664,6 +676,7 @@ export const ConnectionTable = (props: Props) => { minWidth: number, maxWidth: number | undefined, ) => { + resizeCleanupRef.current?.() const handleMove = (clientX: number) => { const nextWidth = Math.max( minWidth, @@ -686,8 +699,10 @@ export const ConnectionTable = (props: Props) => { window.removeEventListener('touchmove', handleTouchMove) window.removeEventListener('touchend', cleanup) window.removeEventListener('touchcancel', cleanup) + resizeCleanupRef.current = null } + resizeCleanupRef.current = cleanup window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', cleanup) window.addEventListener('touchmove', handleTouchMove, { passive: true }) diff --git a/src/components/rule/rule-item.tsx b/src/components/rule/rule-item.tsx index 14dbbc8ad..12c283c6a 100644 --- a/src/components/rule/rule-item.tsx +++ b/src/components/rule/rule-item.tsx @@ -16,7 +16,8 @@ const COLOR = [ ] interface Props { - value: Rule & { lineNo: number } + value: Rule + lineNo: number } const parseColor = (text: string) => { @@ -31,7 +32,7 @@ const parseColor = (text: string) => { } const RuleItem = (props: Props) => { - const { value } = props + const { value, lineNo } = props return ( @@ -40,7 +41,7 @@ const RuleItem = (props: Props) => { variant="body2" sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: 'center' }} > - {value.lineNo} + {lineNo} diff --git a/src/hooks/use-connection-data.test.ts b/src/hooks/use-connection-data.test.ts new file mode 100644 index 000000000..d232cd212 --- /dev/null +++ b/src/hooks/use-connection-data.test.ts @@ -0,0 +1,154 @@ +import { useSyncExternalStore } from 'react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const socket = vi.hoisted(() => ({ + addListener: + vi.fn< + (listener: (message: { type: string; data: string }) => void) => void + >(), + close: vi.fn(), +})) + +vi.mock('react', () => ({ + useCallback: (callback: unknown) => callback, + useMemo: (factory: () => unknown) => factory(), + useSyncExternalStore: vi.fn((_subscribe, getSnapshot) => getSnapshot()), +})) +vi.mock('tauri-plugin-mihomo-api', () => ({ + MihomoWebSocket: { connect_connections: async () => socket }, +})) + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(10_000) + vi.stubGlobal('window', globalThis) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +const connection = (id: string, upload = 0): IConnectionsItem => ({ + id, + metadata: { + network: 'tcp', + type: 'HTTP', + host: `${id}.example`, + sourceIP: '127.0.0.1', + sourcePort: '1234', + destinationPort: '443', + }, + chains: ['DIRECT'], + rule: 'Match', + rulePayload: '', + start: '2026-01-01T00:00:00Z', + upload, + download: 0, +}) + +const monitor = async () => { + const { useConnectionData: readConnectionData } = await import( + './use-connection-data' + ) + const { clearClosedConnections } = readConnectionData() + const [subscribe, read] = vi + .mocked( + useSyncExternalStore< + ReturnType['response']['data'] + >, + ) + .mock.calls.at(-1)! + const listener = vi.fn() + const unsubscribe = subscribe(listener) + await Promise.resolve() + const onMessage = socket.addListener.mock.calls[0][0] + const send = (connections: IConnectionsItem[], uploadTotal = 0) => { + vi.advanceTimersByTime(500) + onMessage({ + type: 'Text', + data: JSON.stringify({ connections, uploadTotal, downloadTotal: 0 }), + }) + return read() + } + return { send, read, listener, unsubscribe, clearClosedConnections } +} + +it('reuses unchanged snapshots and lists without notifying, including totals-only updates', async () => { + const { send, listener, unsubscribe } = await monitor() + const first = send([connection('a'), connection('b')]) + Object.freeze(first.activeConnections) + first.activeConnections.forEach(Object.freeze) + expect(send([connection('a'), connection('b')])).toBe(first) + expect(listener).toHaveBeenCalledTimes(1) + + const totalsOnly = send([connection('a'), connection('b')], 100) + expect(totalsOnly).not.toBe(first) + expect(totalsOnly.activeConnections).toBe(first.activeConnections) + expect(totalsOnly.closedConnections).toBe(first.closedConnections) + expect(totalsOnly.uploadTotal).toBe(100) + expect(listener).toHaveBeenCalledTimes(2) + unsubscribe() +}) + +it('updates traffic, decays speed to zero, and preserves immutable history through reorder and removal', async () => { + const { send, unsubscribe } = await monitor() + const first = send([connection('a'), connection('b')]) + const changed = send([connection('a', 50), connection('b')]) + expect(changed.activeConnections[0].curUpload).toBe(50) + expect(changed.activeConnections[0].metadata).toBe( + first.activeConnections[0].metadata, + ) + expect(changed.activeConnections[0].chains).toBe( + first.activeConnections[0].chains, + ) + expect(changed.activeConnections[1]).toBe(first.activeConnections[1]) + expect(first.activeConnections[0].upload).toBe(0) + + const idle = send([connection('a', 50), connection('b')]) + expect(idle.activeConnections[0].curUpload).toBe(0) + expect(send([connection('a', 50), connection('b')])).toBe(idle) + const reordered = send([connection('b'), connection('a', 50)]) + expect(reordered.activeConnections).toEqual([ + idle.activeConnections[1], + idle.activeConnections[0], + ]) + expect(reordered.closedConnections).toBe(idle.closedConnections) + const removed = send([connection('b'), connection('c')]) + expect(removed.closedConnections).toEqual([idle.activeConnections[0]]) + expect(removed.closedConnections[0]).toBe(idle.activeConnections[0]) + const next = send([connection('b', 10), connection('c')]) + expect(next.closedConnections).toBe(removed.closedConnections) + unsubscribe() +}) + +it('evicts the oldest closed entries at 500 and cancels pending delivery on unsubscribe', async () => { + const { send, read, listener, unsubscribe, clearClosedConnections } = + await monitor() + send(Array.from({ length: 502 }, (_, i) => connection(`${i}`))) + const closed = send([]) + expect(closed.closedConnections).toHaveLength(500) + expect(closed.closedConnections[0].id).toBe('2') + send([connection('new-a'), connection('new-b')]) + const evicted = send([]) + expect(evicted.closedConnections).toHaveLength(500) + expect(evicted.closedConnections[0].id).toBe('4') + expect(evicted.closedConnections.at(-1)?.id).toBe('new-b') + expect(closed.closedConnections[0].id).toBe('2') + + clearClosedConnections() + expect(read().closedConnections).toEqual([]) + const calls = listener.mock.calls.length + socket.addListener.mock.calls[0][0]({ + type: 'Text', + data: JSON.stringify({ connections: [connection('pending')] }), + }) + expect(vi.getTimerCount()).toBe(1) + unsubscribe() + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(1_000) + expect(listener).toHaveBeenCalledTimes(calls) + expect(socket.close).toHaveBeenCalledOnce() +}) diff --git a/src/hooks/use-connection-data.ts b/src/hooks/use-connection-data.ts index d74cde2fe..2f766a13f 100644 --- a/src/hooks/use-connection-data.ts +++ b/src/hooks/use-connection-data.ts @@ -150,25 +150,49 @@ const mergeConnectionSnapshot = ( const nextConnections = payload.connections ?? [] const previousActive = previous.activeConnections ?? [] const previousClosed = previous.closedConnections ?? [] - const previousActiveById = new Map() + const sameOrder = + nextConnections.length === previousActive.length && + nextConnections.every( + (connection, i) => connection.id === previousActive[i].id, + ) + let previousActiveById: Map | undefined - for (let i = 0; i < previousActive.length; i++) { - const previousConnection = previousActive[i] - previousActiveById.set(previousConnection.id, previousConnection) + if (!sameOrder) { + previousActiveById = new Map() + for (let i = 0; i < previousActive.length; i++) { + const previousConnection = previousActive[i] + previousActiveById.set(previousConnection.id, previousConnection) + } } - const activeConnections: IConnectionsItem[] = [] + let activeConnections = sameOrder ? previousActive : [] for (let i = 0; i < nextConnections.length; i++) { const connection = nextConnections[i] - const previousConnection = previousActiveById.get(connection.id) - if (previousConnection) previousActiveById.delete(connection.id) - activeConnections.push(normalizeConnection(connection, previousConnection)) + const previousConnection = sameOrder + ? previousActive[i] + : previousActiveById?.get(connection.id) + previousActiveById?.delete(connection.id) + const normalized = normalizeConnection(connection, previousConnection) + if (activeConnections[i] === normalized) continue + if (activeConnections === previousActive) { + activeConnections = previousActive.slice() + } + activeConnections[i] = normalized } - if (previousActiveById.size === 0) { + const uploadTotal = payload.uploadTotal ?? 0 + const downloadTotal = payload.downloadTotal ?? 0 + if (!previousActiveById?.size) { + if ( + activeConnections === previousActive && + uploadTotal === previous.uploadTotal && + downloadTotal === previous.downloadTotal + ) { + return previous + } return { - uploadTotal: payload.uploadTotal ?? 0, - downloadTotal: payload.downloadTotal ?? 0, + uploadTotal, + downloadTotal, activeConnections, closedConnections: previousClosed, } @@ -198,8 +222,8 @@ const mergeConnectionSnapshot = ( } return { - uploadTotal: payload.uploadTotal ?? 0, - downloadTotal: payload.downloadTotal ?? 0, + uploadTotal, + downloadTotal, activeConnections, closedConnections, } @@ -227,7 +251,9 @@ const flushPendingMessage = () => { lastFlushAt = Date.now() - connectionData = mergeConnectionSnapshot(payload, connectionData) + const nextData = mergeConnectionSnapshot(payload, connectionData) + if (nextData === connectionData) return + connectionData = nextData notifyConnectionListeners() } diff --git a/src/pages/rules.tsx b/src/pages/rules.tsx index 5fc812a78..64ac98e13 100644 --- a/src/pages/rules.tsx +++ b/src/pages/rules.tsx @@ -34,13 +34,11 @@ const RulesPage = () => { }, [refreshRules, refreshRuleProviders, pageVisible]) const filteredRules = useMemo(() => { - const rulesWithLineNo = rules.map((item, index) => ({ - ...item, - // UI-only derived data; keep app context/SWR data immutable - lineNo: index + 1, - })) - - return rulesWithLineNo.filter((item) => match(item.payload ?? '')) + const indices: number[] = [] + for (let i = 0; i < rules.length; i++) { + if (match(rules[i].payload ?? '')) indices.push(i) + } + return indices }, [rules, match]) const handleScroll = useCallback((e: Event) => { @@ -90,7 +88,12 @@ const RulesPage = () => { ref={virtuosoRef} count={filteredRules.length} estimateSize={40} - renderItem={(i) => } + renderItem={(i) => ( + + )} style={{ flex: 1 }} onScroll={handleScroll} />