fix: frontend memory leaks — Monaco dispose, TQ cache eviction, useEffect cleanup

- Dispose Monaco editor instances on dialog close to prevent cycle leak
- Replace gcTime: Infinity with finite TTLs and evict orphaned subscription queryKeys
- Add missing useEffect cleanup for timers, move setTimeout out of useMemo
This commit is contained in:
Tunglies 2026-04-05 22:45:26 +08:00
parent 2c766e1ada
commit b8fbabae04
No known key found for this signature in database
GPG Key ID: B9B01B389469B3E8
11 changed files with 102 additions and 25 deletions

View File

@ -444,6 +444,12 @@ export const CurrentProxyCard = () => {
[setState], [setState],
) )
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
}
}, [])
// 处理代理组变更 // 处理代理组变更
const handleGroupChange = useCallback( const handleGroupChange = useCallback(
(event: SelectChangeEvent<string>) => { (event: SelectChangeEvent<string>) => {

View File

@ -425,7 +425,7 @@ function useIPInfo() {
queryKey: [IP_INFO_CACHE_KEY], queryKey: [IP_INFO_CACHE_KEY],
queryFn: getIpInfo, queryFn: getIpInfo,
staleTime: Infinity, staleTime: Infinity,
gcTime: Infinity, gcTime: 60 * 60 * 1000,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
retry: 1, retry: 1,

View File

@ -168,6 +168,13 @@ export const EditorViewer = ({
} }
}, [open, syncMaximizedState]) }, [open, syncMaximizedState])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
return ( return (
<Dialog <Dialog
open={open} open={open}

View File

@ -34,11 +34,13 @@ import {
requestIdleCallback, requestIdleCallback,
} from 'foxact/request-idle-callback' } from 'foxact/request-idle-callback'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import { import {
startTransition, startTransition,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react' } from 'react'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
@ -149,6 +151,7 @@ export const GroupsEditorViewer = (props: Props) => {
[t], [t],
) )
const themeMode = useThemeMode() const themeMode = useThemeMode()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const [prevData, setPrevData] = useState('') const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true) const [visualization, setVisualization] = useState(true)
@ -481,6 +484,13 @@ export const GroupsEditorViewer = (props: Props) => {
getInterfaceNameList() getInterfaceNameList()
}, [fetchContent, fetchProfile, getInterfaceNameList, open]) }, [fetchContent, fetchProfile, getInterfaceNameList, open])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
const validateGroup = () => { const validateGroup = () => {
const group = formIns.getValues() const group = formIns.getValues()
if (group.name === '') { if (group.name === '') {
@ -1105,6 +1115,9 @@ export const GroupsEditorViewer = (props: Props) => {
language="yaml" language="yaml"
value={currData} value={currData}
theme={themeMode === 'light' ? 'light' : 'vs-dark'} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
onMount={(editorInstance) => {
editorRef.current = editorInstance
}}
options={{ options={{
tabSize: 2, // 根据语言类型设置缩进大小 tabSize: 2, // 根据语言类型设置缩进大小
minimap: { minimap: {

View File

@ -19,7 +19,7 @@ import {
import { open } from '@tauri-apps/plugin-shell' import { open } from '@tauri-apps/plugin-shell'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useCallback, useEffect, useReducer, useState } from 'react' import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ConfirmViewer } from '@/components/profile/confirm-viewer' import { ConfirmViewer } from '@/components/profile/confirm-viewer'
@ -96,7 +96,11 @@ export const ProfileItem = (props: Props) => {
// 新增状态:是否显示下次更新时间 // 新增状态:是否显示下次更新时间
const [showNextUpdate, setShowNextUpdate] = useState(false) const [showNextUpdate, setShowNextUpdate] = useState(false)
const showNextUpdateRef = useRef(false)
const [nextUpdateTime, setNextUpdateTime] = useState('') const [nextUpdateTime, setNextUpdateTime] = useState('')
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
)
const { uid, name = 'Profile', extra, updated = 0, option } = itemData const { uid, name = 'Profile', extra, updated = 0, option } = itemData
@ -178,6 +182,10 @@ export const ProfileItem = (props: Props) => {
setShowNextUpdate(!showNextUpdate) setShowNextUpdate(!showNextUpdate)
} }
useEffect(() => {
showNextUpdateRef.current = showNextUpdate
}, [showNextUpdate])
// 当组件加载或更新间隔变化时更新下次更新时间 // 当组件加载或更新间隔变化时更新下次更新时间
useEffect(() => { useEffect(() => {
if (showNextUpdate) { if (showNextUpdate) {
@ -192,19 +200,18 @@ export const ProfileItem = (props: Props) => {
// 订阅定时器更新事件 // 订阅定时器更新事件
useEffect(() => { useEffect(() => {
let refreshTimeout: number | undefined
// 处理定时器更新事件 - 这个事件专门用于通知定时器变更 // 处理定时器更新事件 - 这个事件专门用于通知定时器变更
const handleTimerUpdate = (event: Event) => { const handleTimerUpdate = (event: Event) => {
const source = event as CustomEvent<string> & { payload?: string } const source = event as CustomEvent<string> & { payload?: string }
const updatedUid = source.detail ?? source.payload const updatedUid = source.detail ?? source.payload
// 只有当更新的是当前配置时才刷新显示 // 只有当更新的是当前配置时才刷新显示
if (updatedUid === itemData.uid && showNextUpdate) { if (updatedUid === itemData.uid && showNextUpdateRef.current) {
debugLog(`收到定时器更新事件: uid=${updatedUid}`) debugLog(`收到定时器更新事件: uid=${updatedUid}`)
if (refreshTimeout !== undefined) { if (refreshTimeoutRef.current !== undefined) {
clearTimeout(refreshTimeout) clearTimeout(refreshTimeoutRef.current)
} }
refreshTimeout = window.setTimeout(() => { refreshTimeoutRef.current = window.setTimeout(() => {
fetchNextUpdateTime(true) fetchNextUpdateTime(true)
}, 1000) }, 1000)
} }
@ -214,13 +221,13 @@ export const ProfileItem = (props: Props) => {
window.addEventListener('verge://timer-updated', handleTimerUpdate) window.addEventListener('verge://timer-updated', handleTimerUpdate)
return () => { return () => {
if (refreshTimeout !== undefined) { if (refreshTimeoutRef.current !== undefined) {
clearTimeout(refreshTimeout) clearTimeout(refreshTimeoutRef.current)
} }
// 清理事件监听 // 清理事件监听
window.removeEventListener('verge://timer-updated', handleTimerUpdate) window.removeEventListener('verge://timer-updated', handleTimerUpdate)
} }
}, [fetchNextUpdateTime, itemData.uid, showNextUpdate]) }, [fetchNextUpdateTime, itemData.uid])
// local file mode // local file mode
// remote file mode // remote file mode

View File

@ -27,11 +27,13 @@ import {
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import { import {
startTransition, startTransition,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -56,6 +58,7 @@ export const ProxiesEditorViewer = (props: Props) => {
const { profileUid, property, open, onClose, onSave } = props const { profileUid, property, open, onClose, onSave } = props
const { t } = useTranslation() const { t } = useTranslation()
const themeMode = useThemeMode() const themeMode = useThemeMode()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const [prevData, setPrevData] = useState('') const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true) const [visualization, setVisualization] = useState(true)
@ -343,6 +346,13 @@ export const ProxiesEditorViewer = (props: Props) => {
fetchProfile() fetchProfile()
}, [fetchContent, fetchProfile, open]) }, [fetchContent, fetchProfile, open])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
const handleSave = useLockFn(async () => { const handleSave = useLockFn(async () => {
try { try {
await saveProfileFile(property, currData) await saveProfileFile(property, currData)
@ -469,6 +479,9 @@ export const ProxiesEditorViewer = (props: Props) => {
language="yaml" language="yaml"
value={currData} value={currData}
theme={themeMode === 'light' ? 'light' : 'vs-dark'} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
onMount={(editorInstance) => {
editorRef.current = editorInstance
}}
options={{ options={{
tabSize: 2, // 根据语言类型设置缩进大小 tabSize: 2, // 根据语言类型设置缩进大小
minimap: { minimap: {

View File

@ -29,11 +29,13 @@ import {
} from '@mui/material' } from '@mui/material'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import { import {
startTransition, startTransition,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -251,6 +253,8 @@ export const RulesEditorViewer = (props: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const themeMode = useThemeMode() const themeMode = useThemeMode()
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const [prevData, setPrevData] = useState('') const [prevData, setPrevData] = useState('')
const [currData, setCurrData] = useState('') const [currData, setCurrData] = useState('')
const [visualization, setVisualization] = useState(true) const [visualization, setVisualization] = useState(true)
@ -536,6 +540,13 @@ export const RulesEditorViewer = (props: Props) => {
fetchProfile() fetchProfile()
}, [fetchContent, fetchProfile, open]) }, [fetchContent, fetchProfile, open])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
const validateRule = () => { const validateRule = () => {
if ((ruleType.required ?? true) && !ruleContent) { if ((ruleType.required ?? true) && !ruleContent) {
throw new Error( throw new Error(
@ -770,6 +781,9 @@ export const RulesEditorViewer = (props: Props) => {
language="yaml" language="yaml"
value={currData} value={currData}
theme={themeMode === 'light' ? 'light' : 'vs-dark'} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
onMount={(editorInstance) => {
editorRef.current = editorInstance
}}
options={{ options={{
tabSize: 2, // 根据语言类型设置缩进大小 tabSize: 2, // 根据语言类型设置缩进大小
minimap: { minimap: {

View File

@ -16,6 +16,7 @@ import {
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { useLockFn } from 'ahooks' import { useLockFn } from 'ahooks'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import type { editor } from 'monaco-editor'
import type { Ref } from 'react' import type { Ref } from 'react'
import { import {
useCallback, useCallback,
@ -189,6 +190,7 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [visualization, setVisualization] = useState(true) const [visualization, setVisualization] = useState(true)
const skipYamlSyncRef = useRef(false) const skipYamlSyncRef = useRef(false)
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
const [values, setValues] = useState<{ const [values, setValues] = useState<{
enable: boolean enable: boolean
listen: string listen: string
@ -453,6 +455,13 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
} }
}, [visualization]) }, [visualization])
useEffect(() => {
return () => {
editorRef.current?.dispose()
editorRef.current = null
}
}, [])
const initDnsConfig = useCallback(async () => { const initDnsConfig = useCallback(async () => {
try { try {
const dnsConfigExists = await invoke<boolean>( const dnsConfigExists = await invoke<boolean>(
@ -1057,6 +1066,9 @@ export function DnsViewer({ ref }: { ref?: Ref<DialogRef> }) {
value={yamlContent} value={yamlContent}
theme={themeMode === 'light' ? 'light' : 'vs-dark'} theme={themeMode === 'light' ? 'light' : 'vs-dark'}
className="flex-grow" className="flex-grow"
onMount={(editorInstance) => {
editorRef.current = editorInstance
}}
options={{ options={{
tabSize: 2, tabSize: 2,
minimap: { minimap: {

View File

@ -42,7 +42,7 @@ export const useIconCache = ({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
staleTime: Infinity, staleTime: Infinity,
gcTime: Infinity, gcTime: 30 * 60 * 1000,
retry: 2, retry: 2,
}) })

View File

@ -93,7 +93,7 @@ export const useMihomoWsSubscription = <T>(
subscriptionCacheKey ?? '$sub$__disabled__', subscriptionCacheKey ?? '$sub$__disabled__',
]) ?? fallbackData, ]) ?? fallbackData,
staleTime: Infinity, staleTime: Infinity,
gcTime: Infinity, gcTime: 30_000,
enabled: subscriptionCacheKey !== null, enabled: subscriptionCacheKey !== null,
}) })
@ -243,8 +243,11 @@ export const useMihomoWsSubscription = <T>(
}, [subscriptionCacheKey]) }, [subscriptionCacheKey])
const refresh = useCallback(() => { const refresh = useCallback(() => {
if (subscriptionCacheKey) {
queryClient.removeQueries({ queryKey: [subscriptionCacheKey] })
}
setDate(Date.now()) setDate(Date.now())
}, [setDate]) }, [queryClient, subscriptionCacheKey, setDate])
return { response, refresh, subscriptionCacheKey, wsRef } return { response, refresh, subscriptionCacheKey, wsRef }
} }

View File

@ -309,20 +309,22 @@ export const useCustomTheme = () => {
styleElement.innerHTML = effectiveInjectedCss + globalStyles styleElement.innerHTML = effectiveInjectedCss + globalStyles
} }
const { palette } = muiTheme
setTimeout(() => {
const dom = document.querySelector('#Gradient2')
if (dom) {
dom.innerHTML = `
<stop offset="0%" stop-color="${palette.primary.main}" />
<stop offset="80%" stop-color="${palette.primary.dark}" />
<stop offset="100%" stop-color="${palette.primary.dark}" />
`
}
}, 0)
return muiTheme return muiTheme
}, [mode, theme_setting, userBackgroundImage, hasUserBackground]) }, [mode, theme_setting, userBackgroundImage, hasUserBackground])
useEffect(() => {
const id = setTimeout(() => {
const dom = document.querySelector('#Gradient2')
if (dom) {
dom.innerHTML = `
<stop offset="0%" stop-color="${theme.palette.primary.main}" />
<stop offset="80%" stop-color="${theme.palette.primary.dark}" />
<stop offset="100%" stop-color="${theme.palette.primary.dark}" />
`
}
}, 0)
return () => clearTimeout(id)
}, [theme.palette.primary.main, theme.palette.primary.dark])
return { theme } return { theme }
} }