fix: guard webview search against destroyed webviews (#10704)

* 🐛 fix: guard webview search against destroyed webviews

* delete code

* delete code
This commit is contained in:
beyondkmp 2025-10-14 14:04:57 +08:00 committed by kangfenmao
parent bb003c071c
commit 45195bb57d
2 changed files with 120 additions and 18 deletions

View File

@ -46,15 +46,58 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
setActiveIndex(0)
}, [])
const stopSearch = useCallback(() => {
const target = webviewRef.current ?? attachedWebviewRef.current
if (!target) return
try {
target.stopFindInPage('clearSelection')
} catch (error) {
logger.error('stopFindInPage failed', { error })
const ensureWebviewReady = useCallback(
(candidate: WebviewTag | null) => {
if (!candidate) return null
try {
const webContentsId = candidate.getWebContentsId?.()
if (!webContentsId) {
logger.debug('WebviewSearch: missing webContentsId before action', { appId })
return null
}
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed before action', { appId, error })
return null
}
return candidate
},
[appId]
)
const stopFindOnWebview = useCallback(
(webview: WebviewTag | null) => {
const usable = ensureWebviewReady(webview)
if (!usable) return false
try {
usable.stopFindInPage('clearSelection')
return true
} catch (error) {
logger.debug('stopFindInPage failed', { appId, error })
return false
}
},
[appId, ensureWebviewReady]
)
const getUsableWebview = useCallback(() => {
const candidates = [webviewRef.current, attachedWebviewRef.current]
for (const candidate of candidates) {
const usable = ensureWebviewReady(candidate)
if (usable) {
return usable
}
}
}, [webviewRef])
return null
}, [ensureWebviewReady, webviewRef])
const stopSearch = useCallback(() => {
const target = getUsableWebview()
if (!target) return
stopFindOnWebview(target)
}, [getUsableWebview, stopFindOnWebview])
const closeSearch = useCallback(() => {
setIsVisible(false)
@ -64,7 +107,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
const performSearch = useCallback(
(text: string, options?: Electron.FindInPageOptions) => {
const target = webviewRef.current ?? attachedWebviewRef.current
const target = getUsableWebview()
if (!target) {
logger.debug('Skip performSearch: webview not attached')
return
@ -81,7 +124,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
window.toast?.error(t('common.error'))
}
},
[resetSearchState, stopSearch, t, webviewRef]
[getUsableWebview, resetSearchState, stopSearch, t]
)
const handleFoundInPage = useCallback((event: Event & { result?: FoundInPageResult }) => {
@ -129,22 +172,26 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
return () => {
activeWebview.removeEventListener('found-in-page', handle)
if (attachedWebviewRef.current === activeWebview) {
try {
activeWebview.stopFindInPage('clearSelection')
} catch (error) {
logger.error('stopFindInPage failed', { error })
}
stopFindOnWebview(activeWebview)
attachedWebviewRef.current = null
}
}
}, [activeWebview, handleFoundInPage])
}, [activeWebview, handleFoundInPage, stopFindOnWebview])
useEffect(() => {
if (!activeWebview) return
if (!isWebviewReady) return
const onFindShortcut = window.api?.webview?.onFindShortcut
if (!onFindShortcut) return
const webContentsId = activeWebview.getWebContentsId?.()
let webContentsId: number | undefined
try {
webContentsId = activeWebview.getWebContentsId?.()
} catch (error) {
logger.debug('WebviewSearch: getWebContentsId failed', { appId, error })
return
}
if (!webContentsId) {
logger.warn('WebviewSearch: missing webContentsId', { appId })
return
@ -177,7 +224,7 @@ const WebviewSearch: FC<WebviewSearchProps> = ({ webviewRef, isWebviewReady, app
return () => {
unsubscribe?.()
}
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, openSearch])
}, [appId, activeWebview, closeSearch, goToNext, goToPrevious, isVisible, isWebviewReady, openSearch])
useEffect(() => {
if (!isVisible) return

View File

@ -164,6 +164,61 @@ describe('WebviewSearch', () => {
})
})
it('skips shortcut wiring when getWebContentsId throws', async () => {
const { webview } = createWebviewMock()
const error = new Error('not ready')
;(webview as any).getWebContentsId = vi.fn(() => {
throw error
})
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const { rerender } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
expect(onFindShortcutMock).not.toHaveBeenCalled()
;(webview as any).getWebContentsId = vi.fn(() => 1)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(onFindShortcutMock).toHaveBeenCalled()
})
})
it('does not call stopFindInPage when webview is not ready', async () => {
const { stopFindInPageMock, webview } = createWebviewMock()
const error = new Error('loading')
const getWebContentsIdMock = vi.fn(() => {
throw error
})
;(webview as any).getWebContentsId = getWebContentsIdMock
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>
const { rerender, unmount } = render(<WebviewSearch webviewRef={webviewRef} isWebviewReady appId="app-1" />)
await waitFor(() => {
expect(getWebContentsIdMock).toHaveBeenCalled()
})
stopFindInPageMock.mockImplementation(() => {
throw new Error('should not be called')
})
rerender(<WebviewSearch webviewRef={webviewRef} isWebviewReady={false} appId="app-1" />)
expect(stopFindInPageMock).not.toHaveBeenCalled()
unmount()
expect(stopFindInPageMock).not.toHaveBeenCalled()
})
it('closes the search overlay when escape is forwarded from the webview', async () => {
const { webview } = createWebviewMock()
const webviewRef = { current: webview } as React.RefObject<WebviewTag | null>