fix: prevent EventEmitter memory leak in useApiServer hook (#11385)

Implement single instance IPC subscription pattern to resolve MaxListenersExceededWarning. Previously, each component using useApiServer would register a separate 'api-server:ready' listener, and React strict mode double rendering would quickly exceed the 10 listener limit.

Changes:
- Add module-level subscription manager with onReadyCallbacks Set
- Ensure only one IPC listener is registered regardless of component count
- Use useRef to maintain stable callback references
- Properly cleanup subscriptions when all components unmount

This maintains existing behavior while keeping listener count constant at 1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
fullex 2025-11-21 21:42:34 +08:00 committed by GitHub
parent c48f222cdb
commit 62309ae1bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -1,11 +1,31 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setApiServerEnabled as setApiServerEnabledAction } from '@renderer/store/settings' import { setApiServerEnabled as setApiServerEnabledAction } from '@renderer/store/settings'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('useApiServer') const logger = loggerService.withContext('useApiServer')
// Module-level single instance subscription to prevent EventEmitter memory leak
// Only one IPC listener will be registered regardless of how many components use this hook
const onReadyCallbacks = new Set<() => void>()
let removeIpcListener: (() => void) | null = null
const ensureIpcSubscribed = () => {
if (!removeIpcListener) {
removeIpcListener = window.api.apiServer.onReady(() => {
onReadyCallbacks.forEach((cb) => cb())
})
}
}
const cleanupIpcIfEmpty = () => {
if (onReadyCallbacks.size === 0 && removeIpcListener) {
removeIpcListener()
removeIpcListener = null
}
}
export const useApiServer = () => { export const useApiServer = () => {
const { t } = useTranslation() const { t } = useTranslation()
// FIXME: We currently store two copies of the config data in both the renderer and the main processes, // FIXME: We currently store two copies of the config data in both the renderer and the main processes,
@ -102,15 +122,28 @@ export const useApiServer = () => {
checkApiServerStatus() checkApiServerStatus()
}, [checkApiServerStatus]) }, [checkApiServerStatus])
// Listen for API server ready event // Use ref to keep the latest checkApiServerStatus without causing re-subscription
const checkStatusRef = useRef(checkApiServerStatus)
useEffect(() => { useEffect(() => {
const cleanup = window.api.apiServer.onReady(() => { checkStatusRef.current = checkApiServerStatus
logger.info('API server ready event received, checking status') })
checkApiServerStatus()
})
return cleanup // Create stable callback for the single instance subscription
}, [checkApiServerStatus]) const handleReady = useCallback(() => {
logger.info('API server ready event received, checking status')
checkStatusRef.current()
}, [])
// Listen for API server ready event using single instance subscription
useEffect(() => {
ensureIpcSubscribed()
onReadyCallbacks.add(handleReady)
return () => {
onReadyCallbacks.delete(handleReady)
cleanupIpcIfEmpty()
}
}, [handleReady])
return { return {
apiServerConfig, apiServerConfig,