fix(transport): replace Tauri listen() to avoid unlisten race crashing on WKWebView

Call plugin:event|listen/unlisten directly and guard the client-side listener
registry cleanup, so unsubscribe always reaches the backend even when the
registration eval has not yet populated window.__TAURI_EVENT_LISTENERS__.
Prevents the intermittent `listeners[eventId].handlerId` TypeError and the
resulting leaked listener.
This commit is contained in:
xintaofei
2026-04-22 11:25:38 +08:00
parent 3f3a325848
commit 5dd1deb986
2 changed files with 45 additions and 12 deletions

View File

@@ -2042,11 +2042,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
})
.then((fn) => {
if (cancelled) {
try {
fn()
} catch {
// Tauri listener may not be fully registered yet
}
fn()
} else {
unlisten = fn
listenerReadyRef.current = true
@@ -2066,11 +2062,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
clearTimeout(flushTimerRef.current)
flushTimerRef.current = null
}
try {
unlisten?.()
} catch {
// Tauri listener may not be fully registered yet
}
unlisten?.()
}
}, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters])

View File

@@ -1,17 +1,58 @@
import type { Transport, UnsubscribeFn } from "./types"
type TauriEventListenersWindow = {
__TAURI_EVENT_PLUGIN_INTERNALS__?: {
unregisterListener?: (event: string, eventId: number) => void
}
}
export class TauriTransport implements Transport {
async call<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const { invoke } = await import("@tauri-apps/api/core")
return invoke(command, args)
}
// Bypasses `@tauri-apps/api/event#listen` to sidestep an intermittent race:
// Tauri's Rust `listen_js` schedules a fire-and-forget eval that populates
// `window.__TAURI_EVENT_LISTENERS__[event][eventId]` and, separately, returns
// `eventId` via the invoke response. On WKWebView the two can arrive out of
// order. When cleanup fires before the eval lands, the built-in `_unlisten`
// throws synchronously on `listeners[eventId].handlerId` — and because that
// throw happens BEFORE `await invoke('plugin:event|unlisten')`, the backend
// listener is never removed (handler + payload buffering leak).
//
// We own the eventId here so we can always issue the backend unlisten, even
// when the client-side registry entry hasn't appeared yet.
async subscribe<T>(
event: string,
handler: (payload: T) => void
): Promise<UnsubscribeFn> {
const { listen } = await import("@tauri-apps/api/event")
return listen<T>(event, (e) => handler(e.payload))
const { invoke, transformCallback } = await import("@tauri-apps/api/core")
const handlerId = transformCallback((e: { payload: T }) => {
handler(e.payload)
})
const eventId = await invoke<number>("plugin:event|listen", {
event,
target: { kind: "Any" },
handler: handlerId,
})
let unlistened = false
return () => {
if (unlistened) return
unlistened = true
try {
const internals = (window as unknown as TauriEventListenersWindow)
.__TAURI_EVENT_PLUGIN_INTERNALS__
internals?.unregisterListener?.(event, eventId)
} catch {
// Registration eval has not landed yet; server-side unlisten below
// still clears the listener so events stop flowing.
}
// Fire-and-forget: callers expect a sync unsubscribe, and a failure here
// only means the backend already forgot this listener.
invoke("plugin:event|unlisten", { event, eventId }).catch(() => {})
}
}
isDesktop(): boolean {