perf(connections): reduce snapshot and table allocation churn

Reuse unchanged connection lists, sort traffic columns without display snapshots, and suspend the relative-time clock while hidden. Keep rule rows indexed and release resize listeners on unmount.
This commit is contained in:
Tunglies
2026-11-10 07:52:02 +08:00
parent 9d0bee4af9
commit f84be8df72
9 changed files with 406 additions and 28 deletions
+1
View File
@@ -48,6 +48,7 @@
- 优化连接、规则和日志筛选:切换页面后保留过滤条件
- 优化全局模式代理列表:仅显示 `GLOBAL` 节点,选中其中的策略组时才显示该组
- 优化连接和规则页面的资源占用,减少连接列表更新及排序时的卡顿
**🍎 macOS**
@@ -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)
})
@@ -8,7 +8,7 @@ let timerId: number | null = null
const listeners = new Set<RelativeTimeListener>()
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)
}
}
}
@@ -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<typeof import('react')>()),
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<typeof import('./connection-row-view')>()
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')
})
@@ -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<SortingState | null>(null)
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const resizeCleanupRef = useRef<(() => void) | null>(null)
useEffect(() => () => resizeCleanupRef.current?.(), [])
const rowSnapshotCacheRef = useRef(new Map<string, TableRowSnapshot>())
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 })
+4 -3
View File
@@ -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 (
<Item sx={{ borderBottom: '1px solid var(--divider-color)' }}>
@@ -40,7 +41,7 @@ const RuleItem = (props: Props) => {
variant="body2"
sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: 'center' }}
>
{value.lineNo}
{lineNo}
</Typography>
<Box sx={{ userSelect: 'text' }}>
+154
View File
@@ -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<typeof readConnectionData>['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()
})
+40 -14
View File
@@ -150,25 +150,49 @@ const mergeConnectionSnapshot = (
const nextConnections = payload.connections ?? []
const previousActive = previous.activeConnections ?? []
const previousClosed = previous.closedConnections ?? []
const previousActiveById = new Map<string, IConnectionsItem>()
const sameOrder =
nextConnections.length === previousActive.length &&
nextConnections.every(
(connection, i) => connection.id === previousActive[i].id,
)
let previousActiveById: Map<string, IConnectionsItem> | 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()
}
+11 -8
View File
@@ -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) => <RuleItem value={filteredRules[i]} />}
renderItem={(i) => (
<RuleItem
value={rules[filteredRules[i]]}
lineNo={filteredRules[i] + 1}
/>
)}
style={{ flex: 1 }}
onScroll={handleScroll}
/>