From 9ceccc9bdd02ce88377ffcc74ac0f16b93c57dba Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:05:23 +0800 Subject: [PATCH] fix: subscribe to Tauri events through one helper that unlistens safely Switching pages left a trail of unhandled rejections ("undefined is not an object (evaluating 'listeners[eventId].handlerId')"), 11 and 64 in two runs of 60 page switches in the real app. `listen()` resolves as soon as Rust has registered the listener, but the script that installs it in the page is queued on the main thread while the IPC answer goes straight back from the async runtime, so the answer can win. Unlistening in that gap (cleanup that ran before `listen()` resolved: React StrictMode, a page that unmounts right away) makes Tauri's `_unlisten` read a listener that is not there yet. It throws before telling Rust, so besides the unhandled rejection the listener stays registered and the unmounted component keeps getting events. `subscribe()` / `useTauriEvent()` in `src/lib/tauriEvent.ts` replace every `listen()` call (27 call sites in 14 files, and `useAppEvent`): - the unsubscribe is synchronous, idempotent and safe before registration resolves (it then unlistens as soon as it does); - the handler stops the moment it is called, without waiting for Tauri; - a failed unlisten is caught and retried once a moment later, by when the listener is installed; `.ready` replaces awaiting the listen promise. `source.test.ts` now fails on a direct `listen`/`once` or window event helper outside the helper. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 16 +- src/Notices.tsx | 10 +- src/RequestDrawer.tsx | 6 +- src/UpdateWindow.tsx | 14 +- src/connection/useConnection.ts | 8 +- src/lib/tauriEvent.test.ts | 352 +++++++++++++++++++++++++++ src/lib/tauriEvent.ts | 92 +++++++ src/main.tsx | 4 +- src/overview/useLive.ts | 18 +- src/routing/useFlights.ts | 12 +- src/settings/AboutSection.tsx | 5 +- src/settings/GeneralSection.tsx | 9 +- src/settings/kit.tsx | 24 -- src/source.test.ts | 50 ++++ src/ui/README.md | 5 + src/upstreams/ChatgptLoginDialog.tsx | 6 +- src/upstreams/ZaiLoginDialog.tsx | 6 +- src/upstreams/data.ts | 14 +- src/useCoreEvent.ts | 10 +- src/useRequests.ts | 16 +- 20 files changed, 577 insertions(+), 100 deletions(-) create mode 100644 src/lib/tauriEvent.test.ts create mode 100644 src/lib/tauriEvent.ts diff --git a/src/App.tsx b/src/App.tsx index 4d74a319..8f9231fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { SearchIcon } from "lucide-react"; import { call } from "@/control"; import { useRequests } from "./useRequests"; @@ -396,9 +396,9 @@ function Shell({ first }: { first: boolean }) { void invoke("import_link_closed") .catch(() => {}) .then(take); - const un = listen("import-link", () => void take()); + const un = subscribe("import-link", () => void take()); return () => { - void un.then((f) => f()); + un(); }; }, []); const closeImport = useCallback(() => { @@ -435,7 +435,7 @@ function Shell({ first }: { first: boolean }) { void invoke("take_pending_view") .then(go) .catch(() => {}); - const un = listen("open-view", (e) => { + const un = subscribe("open-view", (e) => { go(e.payload); void invoke("take_pending_view").catch(() => {}); }); @@ -443,7 +443,7 @@ function Shell({ first }: { first: boolean }) { const local = (e: Event) => go((e as CustomEvent).detail); window.addEventListener("tw-open-view", local); return () => { - void un.then((f) => f()); + un(); window.removeEventListener("tw-open-view", local); }; }, []); @@ -526,7 +526,7 @@ function Shell({ first }: { first: boolean }) { */ useEffect(() => { let alive = true; - const un = listen("core-state", (e) => { + const un = subscribe("core-state", (e) => { if (!alive) return; setCore(e.payload); // 又起来了:之前读状态失败的次数和原因作废,这一回重新数 @@ -535,7 +535,7 @@ function Shell({ first }: { first: boolean }) { setLinkError(null); } }); - void un + void un.ready .then(() => invoke("core_state")) .then((c) => { if (alive) setCore(c); @@ -545,7 +545,7 @@ function Shell({ first }: { first: boolean }) { }); return () => { alive = false; - void un.then((f) => f()); + un(); }; }, []); diff --git a/src/Notices.tsx b/src/Notices.tsx index 7afa2e17..20de0ef6 100644 --- a/src/Notices.tsx +++ b/src/Notices.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BellIcon, XIcon } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { Button } from "@/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; import { when } from "@/format"; @@ -62,8 +62,8 @@ export function Notices({ useEffect(() => { load(); - const un = listen("notices-changed", (e) => setList(e.payload)); - return () => void un.then((f) => f()); + const un = subscribe("notices-changed", (e) => setList(e.payload)); + return un; }, [load]); useEffect(() => { @@ -74,8 +74,8 @@ export function Notices({ invoke("notice_mode") .then(setMode) .catch(() => setMode("system")); - const un = listen("notice-mode-changed", (e) => setMode(e.payload)); - return () => void un.then((f) => f()); + const un = subscribe("notice-mode-changed", (e) => setMode(e.payload)); + return un; }, []); function markRead(key: string) { diff --git a/src/RequestDrawer.tsx b/src/RequestDrawer.tsx index 130d4c12..b6729a86 100644 --- a/src/RequestDrawer.tsx +++ b/src/RequestDrawer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { call } from "@/control"; import { useText } from "@/i18n"; import { coreText } from "@/i18n/core.i18n"; @@ -154,7 +154,7 @@ function Detail({ id, onClose }: { id: number; onClose: () => void }) { useEffect(() => { if (!running) return; let alive = true; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; const mine = (ev.kind === "request_headers" || @@ -167,7 +167,7 @@ function Detail({ id, onClose }: { id: number; onClose: () => void }) { }); return () => { alive = false; - void un.then((f) => f()); + un(); }; }, [running, id, load]); diff --git a/src/UpdateWindow.tsx b/src/UpdateWindow.tsx index abcb2e92..4e7185a7 100644 --- a/src/UpdateWindow.tsx +++ b/src/UpdateWindow.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { getCurrentWindow } from "@tauri-apps/api/window"; import appIcon from "../src-tauri/icons/128x128.png"; import { Banner } from "@/ui/banner"; @@ -59,22 +59,22 @@ export default function UpdateWindow() { // 窗口开着的时候又查到了更新的一版 —— 换成新的那一版 useEffect(() => { - const un = listen("update-found", load); + const un = subscribe("update-found", load); return () => { - void un.then((f) => f()); + un(); }; }, [load]); useEffect(() => { - const a = listen("update-step", (e) => setStep(e.payload)); + const a = subscribe("update-step", (e) => setStep(e.payload)); // 事件报的是**这一块多大**,不是已下载总量,要自己累加 - const b = listen<[number, number | null]>("update-progress", (e) => { + const b = subscribe<[number, number | null]>("update-progress", (e) => { const [chunk, total] = e.payload; setProgress(([done, t]) => [done + chunk, total ?? t]); }); return () => { - void a.then((f) => f()); - void b.then((f) => f()); + a(); + b(); }; }, []); diff --git a/src/connection/useConnection.ts b/src/connection/useConnection.ts index 577ad5ae..b0a8c096 100644 --- a/src/connection/useConnection.ts +++ b/src/connection/useConnection.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { connApi, type ConnView } from "./api"; /** @@ -11,10 +11,10 @@ export function useConnection(): ConnView | null { const [view, setView] = useState(null); useEffect(() => { let alive = true; - const un = listen("connection", (e) => { + const un = subscribe("connection", (e) => { if (alive) setView(e.payload); }); - void un + void un.ready .then(() => connApi.view()) .then((v) => { if (alive) setView(v); @@ -24,7 +24,7 @@ export function useConnection(): ConnView | null { }); return () => { alive = false; - void un.then((f) => f()); + un(); }; }, []); return view; diff --git a/src/lib/tauriEvent.test.ts b/src/lib/tauriEvent.test.ts new file mode 100644 index 00000000..0dd802b2 --- /dev/null +++ b/src/lib/tauriEvent.test.ts @@ -0,0 +1,352 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import type { Event } from "@tauri-apps/api/event"; +import { RETRY_MS, subscribe, useTauriEvent } from "./tauriEvent"; + +/** + * 假的 `listen`:每订阅一次登记一条,注册什么时候完成、退订会不会失败都由测试来定。 + * `emit` 发给 Tauri 那边**还登记着**的监听 —— 退订失败的那一条照样收得到,和真应用 + * 里那条没退掉的监听一样。 + */ +interface Reg { + event: string; + options: unknown; + handler: (e: Event) => void; + unlisten: Mock<() => Promise>; + /** 注册完成:`listen` 兑现,交出退订函数 */ + register: () => void; + /** 注册失败(不在应用里) */ + fail: (e: unknown) => void; + /** Tauri 那边已经退掉了 */ + gone: boolean; +} + +const tauri = vi.hoisted(() => ({ regs: [] as Reg[] })); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn( + (event: string, handler: (e: Event) => void, options?: unknown) => + new Promise((resolve, reject) => { + const reg: Reg = { + event, + options, + handler, + unlisten: vi.fn(async () => { + reg.gone = true; + }), + register: () => resolve(() => reg.unlisten()), + fail: reject, + gone: false, + }; + tauri.regs.push(reg); + }), + ), +})); + +/** + * 只够 `useTauriEvent` 用的一个 React:`useRef`、`useEffect` 按调用顺序占格子,`mount`、 + * `rerender` 跑一遍组件再提交 effect。**StrictMode 的开发模式**照 React 自己的做法:第一次 + * 挂上时 effect 跑完、清理、再跑一遍。仓库里没有 DOM 测试环境,所以照着这个顺序重放。 + */ +const react = vi.hoisted(() => { + interface Slot { + ref?: { current: unknown }; + effect?: () => void | (() => void); + deps?: readonly unknown[]; + cleanup?: void | (() => void); + dirty?: boolean; + } + const slots: Slot[] = []; + let at = 0; + return { + slots, + start: () => { + at = 0; + }, + useRef: (init: unknown) => { + const s = (slots[at++] ??= {}); + s.ref ??= { current: init }; + return s.ref; + }, + useEffect: (effect: () => void | (() => void), deps?: readonly unknown[]) => { + const s = (slots[at++] ??= {}); + s.effect = effect; + if (!s.deps || !deps || deps.some((d, i) => !Object.is(d, s.deps?.[i]))) { + s.deps = deps; + s.dirty = true; + } + }, + }; +}); + +vi.mock("react", async (load) => ({ + ...(await load()), + useRef: react.useRef, + useEffect: react.useEffect, +})); + +function mount

(component: (props: P) => void, props: P, { strict = false } = {}) { + const cleanup = () => { + for (const s of react.slots) if (typeof s.cleanup === "function") s.cleanup(); + }; + const commit = () => { + for (const s of react.slots) { + if (!s.dirty) continue; + if (typeof s.cleanup === "function") s.cleanup(); + s.cleanup = s.effect?.(); + s.dirty = false; + } + }; + react.start(); + component(props); + commit(); + if (strict) { + // StrictMode:挂上、卸掉、再挂上,三步之间没有任何异步 + cleanup(); + for (const s of react.slots) s.cleanup = s.effect?.(); + } + return { + rerender(next: P) { + react.start(); + component(next); + commit(); + }, + unmount: cleanup, + }; +} + +/** 发一个事件给 Tauri 那边还登记着的监听 */ +function emit(event: string, payload: unknown) { + for (const r of tauri.regs) if (r.event === event && !r.gone) r.handler({ event, id: 0, payload }); +} + +/** + * 跑完所有排着的微任务,再过一轮事件循环。Node 在这之间检查没人接的 rejection, + * `rejections` 收得到的话就是在这之前漏的 + */ +const settle = () => new Promise((r) => setImmediate(r)); + +const rejections: unknown[] = []; +const onRejection = (e: unknown) => rejections.push(e); + +beforeEach(() => { + tauri.regs.length = 0; + react.slots.length = 0; + rejections.length = 0; + process.on("unhandledRejection", onRejection); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); +}); + +afterEach(async () => { + await settle(); + process.off("unhandledRejection", onRejection); + vi.useRealTimers(); + // 每一条都不许漏出没人接的 rejection + expect(rejections).toEqual([]); +}); + +/** Tauri 的 `_unlisten` 在登记脚本还没到页面时抛的那个错 */ +const notYet = () => new TypeError("undefined is not an object (evaluating 'listeners[eventId].handlerId')"); + +describe("subscribe", () => { + it("注册完成后退订:退一次,之后的事件不再进回调", async () => { + const seen: unknown[] = []; + const off = subscribe("core-state", (e) => seen.push(e.payload)); + const [reg] = tauri.regs; + reg!.register(); + await settle(); + emit("core-state", 1); + off(); + await settle(); + emit("core-state", 2); + expect(seen).toEqual([1]); + expect(reg!.unlisten).toHaveBeenCalledTimes(1); + }); + + it("注册完成前就退订:完成的那一刻退,只退一次", async () => { + const seen: unknown[] = []; + const off = subscribe("core-event", (e) => seen.push(e.payload)); + off(); + const [reg] = tauri.regs; + expect(reg!.unlisten).not.toHaveBeenCalled(); + reg!.register(); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(1); + emit("core-event", "late"); + expect(seen).toEqual([]); + }); + + it("退订两次只退一次,注册前后都一样", async () => { + const early = subscribe("a", () => {}); + early(); + early(); + tauri.regs[0]!.register(); + await settle(); + early(); + + const late = subscribe("b", () => {}); + tauri.regs[1]!.register(); + await settle(); + late(); + late(); + await settle(); + late(); + + expect(tauri.regs[0]!.unlisten).toHaveBeenCalledTimes(1); + expect(tauri.regs[1]!.unlisten).toHaveBeenCalledTimes(1); + }); + + /** + * 真应用里的那个空档:注册完成时登记脚本还没到页面,退订抛错(Tauri 的退订函数是 + * async 的,抛错就是一个 rejection)。**不往外漏**,隔一会儿再退一次就退掉了; + * 中间 Tauri 照旧发来的事件不进回调 + */ + it("退订失败不往外抛,隔一会儿再退一次", async () => { + const seen: unknown[] = []; + const off = subscribe("core-event", (e) => seen.push(e.payload)); + off(); + const [reg] = tauri.regs; + reg!.unlisten.mockRejectedValueOnce(notYet()); + reg!.register(); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(1); + emit("core-event", "still registered"); + expect(seen).toEqual([]); + + vi.advanceTimersByTime(RETRY_MS); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(2); + expect(reg!.gone).toBe(true); + vi.advanceTimersByTime(RETRY_MS * 10); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(2); + }); + + it("退订函数同步抛错也一样接住", async () => { + const off = subscribe("core-event", () => {}); + const [reg] = tauri.regs; + reg!.unlisten.mockImplementationOnce(() => { + throw notYet(); + }); + reg!.register(); + await settle(); + off(); + await settle(); + vi.advanceTimersByTime(RETRY_MS); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(2); + expect(reg!.gone).toBe(true); + }); + + it("两次都退不掉就算了:不再重试,也不往外抛", async () => { + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + const seen: unknown[] = []; + const off = subscribe("core-event", (e) => seen.push(e.payload)); + const [reg] = tauri.regs; + reg!.unlisten.mockRejectedValue(notYet()); + reg!.register(); + await settle(); + off(); + await settle(); + vi.advanceTimersByTime(RETRY_MS * 10); + await settle(); + expect(reg!.unlisten).toHaveBeenCalledTimes(2); + // 监听还登记着,回调照样失效 + emit("core-event", "after"); + expect(seen).toEqual([]); + expect(debug).toHaveBeenCalledTimes(1); + debug.mockRestore(); + }); + + /** 「先挂上监听再读一次现状」等的是它 */ + it("ready 在注册完成时兑现,退订过也一样", async () => { + const off = subscribe("connection", () => {}); + let ready = false; + void off.ready.then(() => { + ready = true; + }); + off(); + await settle(); + expect(ready).toBe(false); + tauri.regs[0]!.register(); + await settle(); + expect(ready).toBe(true); + }); + + /** 不在应用里(浏览器里看 `pnpm dev`、截图页没 mock 的事件):没人等也不漏 rejection */ + it("注册失败时 ready 拒绝;没人等它也不漏,退订什么都不做", async () => { + const quiet = subscribe("language-changed", () => {}); + tauri.regs[0]!.fail(new Error("not in Tauri")); + await settle(); + quiet(); + + const waited = subscribe("core-state", () => {}); + tauri.regs[1]!.fail(new Error("not in Tauri")); + await expect(waited.ready).rejects.toThrow("not in Tauri"); + waited(); + await settle(); + }); + + /** 窗口范围的事件(`getCurrentWindow().listen` 那一种):`target` 原样交给 `listen` */ + it("options 原样交给 listen", () => { + const target = { kind: "Window", label: "main" } as const; + subscribe("tauri://focus", () => {}, { target }); + expect(tauri.regs[0]!.options).toEqual({ target }); + }); +}); + +describe("useTauriEvent", () => { + function Probe({ event, onEvent }: { event: string; onEvent: (p: unknown) => void }) { + useTauriEvent(event, (e) => onEvent(e.payload)); + } + + /** + * 开发模式下 StrictMode 把挂上的 effect 跑两遍,中间清理一次,**三步都在第一个 + * `listen` 兑现之前**。以前这正是成串报错的地方:第一份在兑现的那一刻被退订 + */ + it("StrictMode 双跑:第一份兑现时退一次,只剩一份在听", async () => { + const seen: unknown[] = []; + mount(Probe, { event: "notices-changed", onEvent: (p) => seen.push(p) }, { strict: true }); + expect(tauri.regs).toHaveLength(2); + const [first, second] = tauri.regs; + first!.unlisten.mockRejectedValueOnce(notYet()); + first!.register(); + second!.register(); + await settle(); + emit("notices-changed", "x"); + expect(seen).toEqual(["x"]); + vi.advanceTimersByTime(RETRY_MS); + await settle(); + expect(first!.unlisten).toHaveBeenCalledTimes(2); + expect(first!.gone).toBe(true); + expect(second!.unlisten).not.toHaveBeenCalled(); + }); + + it("回调换了不重新订阅,事件交给最新的回调", async () => { + const a: unknown[] = []; + const b: unknown[] = []; + const view = mount(Probe, { event: "update-step", onEvent: (p) => a.push(p) }); + tauri.regs[0]!.register(); + await settle(); + view.rerender({ event: "update-step", onEvent: (p) => b.push(p) }); + emit("update-step", 1); + expect(tauri.regs).toHaveLength(1); + expect(a).toEqual([]); + expect(b).toEqual([1]); + }); + + it("事件名换了:退掉旧的、订新的;卸掉时退掉", async () => { + const view = mount(Probe, { event: "a", onEvent: () => {} }); + tauri.regs[0]!.register(); + await settle(); + view.rerender({ event: "b", onEvent: () => {} }); + tauri.regs[1]!.register(); + await settle(); + expect(tauri.regs.map((r) => [r.event, r.unlisten.mock.calls.length])).toEqual([ + ["a", 1], + ["b", 0], + ]); + view.unmount(); + await settle(); + expect(tauri.regs[1]!.unlisten).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/tauriEvent.ts b/src/lib/tauriEvent.ts new file mode 100644 index 00000000..3228700b --- /dev/null +++ b/src/lib/tauriEvent.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef } from "react"; +import { listen, type EventCallback, type EventName, type Options, type UnlistenFn } from "@tauri-apps/api/event"; + +/** + * 听 Rust 那边发来的事件。**界面里的事件订阅一律走这里**,不直接调 `listen`、`once` + * 或窗口上的 `listen`/`onXxx`(`source.test.ts` 盯着)。窗口范围的事件用 `options.target`。 + * + * **直接用 `listen` 退订会抛错,还可能根本没退掉。**`listen` 兑现时,Tauri 只是把「在 + * 页面里登记这个监听」的那段脚本排进了应用主线程的队列,应答却是从异步运行时直接发回 + * 来的 —— 两者谁先到页面说不准。真应用里换页时实测:一千来次订阅里有几十次应答先到, + * 脚本多半晚几毫秒到几十毫秒才跟上。这个空档里退订,Tauri 的 `_unlisten` 去读 + * `listeners[eventId].handlerId`,那一项还没有: + * + * · 退订函数是 async 的,抛出来的错误成了一个没人接的 rejection,换页时成串地出; + * · 它抛在通知 Rust 之前,Rust 那边的登记原样留着。脚本随后到达、监听照常装上,之后 + * 每来一个事件,都去调那个已经卸掉的组件的回调。 + * + * 退订偏偏最容易撞进这个空档:effect 的清理先于 `listen` 兑现时(StrictMode 的双跑、 + * 刚挂上就卸掉的页面),`un.then((f) => f())` 正好在兑现的那一刻退。 + * + * 所以这里: + * + * · 退订函数是同步的,什么时候调都行,调几次都只退一次。注册还没完成就记下,完成的 + * 那一刻退; + * · 一退订,回调当场失效,不等 Tauri 那边真的退掉; + * · 退订失败不往外抛,隔一会儿再退一次,那时脚本早到了;再不行就算了 —— 回调反正已经 + * 失效。再退一次不会退掉别的:上面那种失败抛在动任何东西之前,别的失败(通知 Rust 的 + * 那一下没送到)重来一遍也只是按同一个号再删一次。 + */ +export function subscribe(event: EventName, handler: EventCallback, options?: Options): Unsubscribe { + let live = true; + let unlisten: UnlistenFn | null = null; + const ready = listen( + event, + (e) => { + if (live) handler(e); + }, + options, + ).then((f) => { + unlisten = f; + if (!live) release(f); + }); + // 只退订、没人等 `ready` 的地方,「不在应用里」的那次拒绝不该成为没人接的 rejection + ready.catch(() => {}); + const off = () => { + if (!live) return; + live = false; + if (unlisten) release(unlisten); + }; + return Object.assign(off, { ready }); +} + +/** + * 退订。**同步、随时可调、调几次都只退一次**,可以直接当 effect 的清理函数返回。 + */ +export type Unsubscribe = (() => void) & { + /** + * 注册完成时兑现,注册失败(不在应用里)时拒绝,跟着 `listen` 返回的那个 promise 落定。 + * 「先挂上监听再读一次现状」的地方等它:它兑现时 Rust 那边已经记下了这个监听,之后发出 + * 的事件都会发给它。 + */ + readonly ready: Promise; +}; + +/** + * 组件挂着的时候听一个事件。回调总是用最新的那一个(页面里通常是个每次渲染都新的闭包), + * 订阅只跟着事件名重建 —— 重建一次就可能漏掉那一瞬间的事件。 + */ +export function useTauriEvent(event: EventName, handler: EventCallback): void { + const latest = useRef(handler); + latest.current = handler; + useEffect(() => subscribe(event, (e) => latest.current(e)), [event]); +} + +/** 第一次没退掉之后隔多久再退。登记脚本多半只晚几毫秒到几十毫秒,这里留足余量 */ +export const RETRY_MS = 250; + +function release(f: UnlistenFn, again = true): void { + attempt(f).catch((e: unknown) => { + if (again) setTimeout(() => release(f, false), RETRY_MS); + else if (import.meta.env.DEV) console.debug("[tauri-event] unlisten failed", e); + }); +} + +/** 调一次退订函数,同步抛出和异步拒绝一样收成一个 promise */ +function attempt(f: UnlistenFn): Promise { + try { + return Promise.resolve(f()); + } catch (e) { + return Promise.reject(e); + } +} diff --git a/src/main.tsx b/src/main.tsx index 9d6235fd..7940ef16 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,12 +1,12 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "./lib/tauriEvent"; import { setLang, type Lang } from "./i18n"; import "./index.css"; // 设置里换了语言:每个开着的窗口当场换。不在应用里时没有事件可听 -void listen("language-changed", (e) => setLang(e.payload)).catch(() => {}); +subscribe("language-changed", (e) => setLang(e.payload)); // 更新窗口和主窗口是同一份页面,按窗口的名字决定画哪一个。给一个小窗 // 单独起一个入口,就要多维护一份构建配置和一份样式入口。 diff --git a/src/overview/useLive.ts b/src/overview/useLive.ts index fb4490b9..579ee1ac 100644 --- a/src/overview/useLive.ts +++ b/src/overview/useLive.ts @@ -1,6 +1,6 @@ import { useEffect, useReducer, useRef } from "react"; import { call } from "@/control"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { textOf } from "@/i18n"; import type { CoreEvent, HistoryRow } from "@/types"; import { liveText } from "./useLive.i18n"; @@ -207,7 +207,7 @@ export function useInFlight(): number { flying.current.delete(id); since?.ended.add(id); }; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind === "request_started") start(ev.id); else if ( @@ -229,7 +229,7 @@ export function useInFlight(): number { try { // **等订阅真的挂上再问。**`listen` 是异步注册的:先问的话,快照和 // 订阅之间结束的请求,它的结局谁都没收到,就一直挂在「进行中」 - await un; + await un.ready; const open = await call("InFlight", null); // 等的这会儿 core 停了、或者又开始了一次对账:这份作废 if (!alive || since !== mark) return; @@ -249,7 +249,7 @@ export function useInFlight(): number { **core 不在跑的时候,谁都不在「进行中」。**它一停,正在跑的请求就断 了,而它们再也不会有结局;回来的时候(`running:`)重新对一遍。 */ - const unState = listen("core-state", (e) => { + const unState = subscribe("core-state", (e) => { if (e.payload.startsWith("running")) { void resync(); } else { @@ -260,8 +260,8 @@ export function useInFlight(): number { }); return () => { alive = false; - void un.then((f) => f()); - void unState.then((f) => f()); + un(); + unState(); }; }, [soon]); @@ -324,7 +324,7 @@ export function useLiveWindow(active: boolean, windowMs: number) { }); arrived.current += 1; }; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind === "request_finished" || ev.kind === "request_cancelled") { // 取消的也画进曲线:**上游已经为它计了费**,那些 token 真实发生过 @@ -362,7 +362,7 @@ export function useLiveWindow(active: boolean, windowMs: number) { const seed = async () => { try { // 挂上了再补 —— `listen` 是异步注册的 - await un; + await un.ready; /* **库里的时刻是 core 的时钟,图的横轴是这边的时钟。**连的是另一台机器上 的 core 时,两边不一定对得上:差出一分钟,补回来的这一段就整段错开一分钟, @@ -407,7 +407,7 @@ export function useLiveWindow(active: boolean, windowMs: number) { }, LIVE_FRAME_MS); return () => { alive = false; - void un.then((f) => f()); + un(); clearInterval(h); }; }, [active, windowMs, frame, soon]); diff --git a/src/routing/useFlights.ts b/src/routing/useFlights.ts index 30cafc1f..02e55c21 100644 --- a/src/routing/useFlights.ts +++ b/src/routing/useFlights.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { call } from "@/control"; import type { CoreEvent } from "@/types"; import { applyFlightEvent, type Flight } from "./flights"; @@ -43,7 +43,7 @@ export function useFlights(): ReadonlyMap { timers.add(h); }; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind === "events_dropped") { // 结局可能正在丢掉的那几条里:清掉,按快照重来 @@ -62,7 +62,7 @@ export function useFlights(): ReadonlyMap { seen = mark; try { // 等订阅真的挂上再问:`listen` 是异步注册的 - await un; + await un.ready; const open = await call("InFlight", null); if (!alive || seen !== mark) return; // 每个请求到目前为止的事件按原来的顺序重放:已经路由了的,一打开就画到上游 @@ -78,7 +78,7 @@ export function useFlights(): ReadonlyMap { } } void resync(); - const unState = listen("core-state", (e) => { + const unState = subscribe("core-state", (e) => { if (e.payload.startsWith("running")) { void resync(); return; @@ -90,8 +90,8 @@ export function useFlights(): ReadonlyMap { return () => { alive = false; - void un.then((f) => f()); - void unState.then((f) => f()); + un(); + unState(); if (batch) clearTimeout(batch); for (const h of timers) clearTimeout(h); }; diff --git a/src/settings/AboutSection.tsx b/src/settings/AboutSection.tsx index 2f28b8da..c939c371 100644 --- a/src/settings/AboutSection.tsx +++ b/src/settings/AboutSection.tsx @@ -10,6 +10,7 @@ import { StatusLabel } from "@/ui/status-dot"; import { Switch } from "@/ui/switch"; import { Tip } from "@/ui/tip"; import { useResource } from "@/lib/resource"; +import { useTauriEvent } from "@/lib/tauriEvent"; import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; import { errorText } from "@/i18n/core.i18n"; @@ -17,7 +18,7 @@ import { remoteText } from "@/connection/remote.i18n"; import type { RemoteCore } from "@/connection/api"; import type { Found, UpdateView } from "@/updateFlow"; import { APP_KEYS, settingsApi } from "./api"; -import { FLASH, Loaded, RowError, SettingsCard, SettingsGroup, SettingsRow, anchorId, useAppEvent, useWrite } from "./kit"; +import { FLASH, Loaded, RowError, SettingsCard, SettingsGroup, SettingsRow, anchorId, useWrite } from "./kit"; import { settingsText } from "./SettingsPage.i18n"; /** 「检查更新」按下去之后的结果。`idle`:这次打开设置页之后还没查过 */ @@ -55,7 +56,7 @@ export function AboutSection({ remote, linked }: { remote: RemoteCore | null; li }; // 后台那轮自动检查查到了:这里也跟着显示 - useAppEvent("update-found", withOffer); + useTauriEvent("update-found", (e) => withOffer(e.payload)); async function look() { setCheck({ kind: "checking" }); diff --git a/src/settings/GeneralSection.tsx b/src/settings/GeneralSection.tsx index 441a3abe..3b272118 100644 --- a/src/settings/GeneralSection.tsx +++ b/src/settings/GeneralSection.tsx @@ -8,6 +8,7 @@ import { notify } from "@/ui/notify"; import { guideText } from "@/guide/guide.i18n"; import { resetHints, useAnyDismissed } from "@/guide/hints"; import { useResource } from "@/lib/resource"; +import { useTauriEvent } from "@/lib/tauriEvent"; import { LANG_NAMES, setLang, useText, type Lang } from "@/i18n"; import { isMac } from "@/platform"; import { @@ -20,7 +21,7 @@ import { type ThemeView, } from "./api"; import { generalText } from "./GeneralSection.i18n"; -import { Loaded, SettingsCard, SettingsGroup, SettingsRow, useAppEvent, useWrite } from "./kit"; +import { Loaded, SettingsCard, SettingsGroup, SettingsRow, useWrite } from "./kit"; /** * 设置 → 通用。**改的是这个应用自己,点一下就换**,不走配置文件的「保存」:这几项 @@ -62,7 +63,7 @@ function LanguageRow() { return v; }); // 别的窗口(或者系统语言)换了:跟着重读 - useAppEvent("language-changed", () => void r.reload()); + useTauriEvent("language-changed", () => void r.reload()); return ( ("menubar-style-changed", (s) => void mutate(s)); + useTauriEvent("menubar-style-changed", (e) => void mutate(e.payload)); return ( ("notice-mode-changed", (m) => void mutate(m)); + useTauriEvent("notice-mode-changed", (e) => void mutate(e.payload)); return ( (r: Resource, write: (next: T) => Promise): [bo ); return [pending, set]; } - -/** - * 听一个 Rust 那边发的应用事件(`language-changed`、`notice-mode-changed`…)。它们 - * 不是 core 的事件,`useResource` 的 `events` 管不到。不在应用里时什么都不做。 - */ -export function useAppEvent(name: string, cb: (payload: T) => void) { - const f = useRef(cb); - f.current = cb; - useEffect(() => { - let off: (() => void) | null = null; - let dead = false; - listen(name, (e) => f.current(e.payload)) - .then((un) => { - if (dead) un(); - else off = un; - }) - .catch(() => {}); - return () => { - dead = true; - off?.(); - }; - }, [name]); -} diff --git a/src/source.test.ts b/src/source.test.ts index 3d0bc805..536df7a4 100644 --- a/src/source.test.ts +++ b/src/source.test.ts @@ -21,6 +21,25 @@ function hasControlChar(text: string): boolean { return false; } +/** 唯一可以直接订阅 Tauri 事件的文件 */ +const TAURI_EVENT = join("src", "lib", "tauriEvent.ts"); + +/** + * 一段源码里绕过 `@/lib/tauriEvent` 订阅 Tauri 事件的地方:从 `@tauri-apps/api/event` + * 拿值(只拿类型的 `import type` 不算)、窗口或 webview 上的 `.listen(`/`.once(`, + * 以及窗口上那几个 `onXxx(` 事件接口 —— 它们返回的是同一种退订函数 + */ +function tauriEventMisuse(code: string): string[] { + const out: string[] = []; + const values = code.replace(/import\s+type\s[^;]*?from\s*["']@tauri-apps\/api\/event["']/g, ""); + if (/["']@tauri-apps\/api\/event["']/.test(values)) out.push("@tauri-apps/api/event"); + if (/\.(listen|once)\s*(<[^>()]*>)?\s*\(/.test(code)) out.push(".listen( / .once("); + if (/\bon(Resized|Moved|CloseRequested|FocusChanged|ScaleChanged|ThemeChanged|DragDropEvent)\s*\(/.test(code)) { + out.push("window onXxx("); + } + return out; +} + describe("源码文件", () => { /** * **下拉框里的占位值曾经是一个字面的 NUL 字节。**编译和运行都没问题, @@ -31,4 +50,35 @@ describe("源码文件", () => { const bad = sources("src").filter((f) => hasControlChar(readFileSync(f, "utf8"))); expect(bad).toEqual([]); }); + + /** + * **Tauri 的事件一律经 `@/lib/tauriEvent` 订阅。**直接用 `listen` 退订,会撞上 + * Tauri 还没在页面里登记好监听的那个空档:抛出没人接的 rejection(换页时一次 + * 几十个),监听也没退掉,卸掉的组件照样收事件。为什么、怎么避开见那个文件。 + * 测试文件不管:它们要 mock 这个模块。 + */ + it("Tauri 事件只经 @/lib/tauriEvent 订阅", () => { + const bad = sources("src") + .filter((f) => /[.]tsx?$/.test(f) && !/[.]test[.]tsx?$/.test(f) && f !== TAURI_EVENT) + .flatMap((f) => tauriEventMisuse(readFileSync(f, "utf8")).map((why) => `${f}: ${why}`)); + expect(bad).toEqual([]); + }); + + /** 上面那条靠的几个正则真的认得出来,不是因为认不出才通过 */ + it("认得出绕过 @/lib/tauriEvent 的写法", () => { + expect(tauriEventMisuse('import { listen } from "@tauri-apps/api/event";')).toEqual(["@tauri-apps/api/event"]); + expect(tauriEventMisuse("import { once as o } from '@tauri-apps/api/event'")).toEqual(["@tauri-apps/api/event"]); + expect(tauriEventMisuse('const { listen } = await import("@tauri-apps/api/event");')).toEqual([ + "@tauri-apps/api/event", + ]); + expect(tauriEventMisuse('getCurrentWindow().listen("tauri://focus", f)')).toEqual([".listen( / .once("]); + expect(tauriEventMisuse("await win.once('x', f)")).toEqual([".listen( / .once("]); + expect(tauriEventMisuse("void getCurrentWindow().onFocusChanged(({ payload }) => f(payload))")).toEqual([ + "window onXxx(", + ]); + // 只拿类型、经 helper 订阅、配置里的 `listen.port` 都不算 + expect(tauriEventMisuse('import type { Event } from "@tauri-apps/api/event";')).toEqual([]); + expect(tauriEventMisuse('const un = subscribe("core-state", (e) => f(e.payload));')).toEqual([]); + expect(tauriEventMisuse("const port = ov.listen.port;")).toEqual([]); + }); }); diff --git a/src/ui/README.md b/src/ui/README.md index db447571..8f0575eb 100644 --- a/src/ui/README.md +++ b/src/ui/README.md @@ -241,6 +241,11 @@ r.mutate(next | (prev) => next, { revalidate? }) // optimistic update; returns the last change is never answered with data from before it. - `events`: core / local event kinds that make the data stale (throttled 2.5s, like `useCoreEvent`). Prefer this over polling — never add `setInterval`. +- Any other event pushed from Rust (`language-changed`, `update-found`, …): + `useTauriEvent(name, handler)`, or `subscribe()` inside an effect that needs the + unsubscribe or `.ready` — both from `@/lib/tauriEvent`. Never `listen` from + `@tauri-apps/api/event` directly: unlistening right after it resolves throws and can + leave the listener registered (`src/source.test.ts` fails on it). - `deps`: values that change the answer (config version, a filter). The old data stays on screen while the new one loads. - `invalidate("keys")` / `invalidate("upstream:")` (prefix) from anywhere after a diff --git a/src/upstreams/ChatgptLoginDialog.tsx b/src/upstreams/ChatgptLoginDialog.tsx index f09d2911..9a059875 100644 --- a/src/upstreams/ChatgptLoginDialog.tsx +++ b/src/upstreams/ChatgptLoginDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { CopyIcon, ExternalLinkIcon, SmartphoneIcon } from "lucide-react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; import { Checkbox } from "@/ui/checkbox"; @@ -114,7 +114,7 @@ export function ChatgptLoginDialog({ // 结果由 core 发事件,不必一直问;问一遍是为了事件漏掉时也能收尾 useEffect(() => { if (!waiting) return; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind !== "login_finished" || ev.login !== waiting) return; // 事件只报结果,不带登上的是哪个账号:那一项只在这次登录的状态里。立刻问一次, @@ -137,7 +137,7 @@ export function ChatgptLoginDialog({ }); }, POLL_MS); return () => { - void un.then((f) => f()); + un(); clearInterval(timer); }; // settle 每次渲染都是新的,但订阅只该跟着这次登录重建 diff --git a/src/upstreams/ZaiLoginDialog.tsx b/src/upstreams/ZaiLoginDialog.tsx index 4f723329..1f9a4d10 100644 --- a/src/upstreams/ZaiLoginDialog.tsx +++ b/src/upstreams/ZaiLoginDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useSystemProxyLabel } from "@/connection/Remote"; import { ExternalLinkIcon } from "lucide-react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { Banner } from "@/ui/banner"; import { Button } from "@/ui/button"; import { Checkbox } from "@/ui/checkbox"; @@ -95,7 +95,7 @@ export function ZaiLoginDialog({ // 结果由 core 发事件,不必一直问;问一遍是为了事件漏掉时也能收尾 useEffect(() => { if (!waiting) return; - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind !== "login_finished" || ev.login !== waiting) return; // 事件只报结果,不带登上的是哪个账号:那一项只在这次登录的状态里。立刻问一次, @@ -118,7 +118,7 @@ export function ZaiLoginDialog({ }); }, POLL_MS); return () => { - void un.then((f) => f()); + un(); clearInterval(timer); }; // settle 每次渲染都是新的,但订阅只该跟着这次登录重建 diff --git a/src/upstreams/data.ts b/src/upstreams/data.ts index d1430e19..23de8ed7 100644 --- a/src/upstreams/data.ts +++ b/src/upstreams/data.ts @@ -5,7 +5,7 @@ * 跟着事件流走的一份现状,不缓存。 */ import { useEffect, useMemo, useRef, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { bucketStart } from "@/format"; import { useResource, type Resource } from "@/lib/resource"; import type { CoreEvent, CostBucketGroup, ProviderView } from "@/types"; @@ -45,7 +45,7 @@ export function useUpstreamStats(): { stats: Resource; since: num const { mutate, reload } = stats; useEffect(() => { - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind !== "quota_seen") return; mutate( @@ -59,7 +59,7 @@ export function useUpstreamStats(): { stats: Resource; since: num })), ); }); - return () => void un.then((f) => f()); + return un; }, [mutate]); useEffect(() => { @@ -217,7 +217,7 @@ export function useInFlight(): ReadonlyMap { if (oldest !== undefined) ended.current.delete(oldest); } }; - const unEvents = listen("core-event", (e) => { + const unEvents = subscribe("core-event", (e) => { const ev = e.payload; switch (ev.kind) { case "request_started": @@ -236,7 +236,7 @@ export function useInFlight(): ReadonlyMap { } bump(); }); - const unState = listen("core-state", (e) => { + const unState = subscribe("core-state", (e) => { live.current.clear(); bump(); if (e.payload.startsWith("running:")) seed(); @@ -244,8 +244,8 @@ export function useInFlight(): ReadonlyMap { seed(); return () => { alive = false; - void unEvents.then((f) => f()); - void unState.then((f) => f()); + unEvents(); + unState(); }; }, []); diff --git a/src/useCoreEvent.ts b/src/useCoreEvent.ts index 54d10054..799b177e 100644 --- a/src/useCoreEvent.ts +++ b/src/useCoreEvent.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { LOCAL_KINDS, type CoreEvent, type LocalEvent } from "./types"; /** @@ -46,14 +46,14 @@ export function useCoreEvent( }, throttleMs); }; const local = new Set(LOCAL_KINDS); - const unCore = listen("core-event", (e) => { + const unCore = subscribe("core-event", (e) => { // 这台机器上的事只认 `local-event` 那一路:旧版 core 自己也发同名的两种 if (!local.has(e.payload.kind)) soon(e.payload.kind); }); - const unLocal = listen("local-event", (e) => soon(e.payload.kind)); + const unLocal = subscribe("local-event", (e) => soon(e.payload.kind)); return () => { - void unCore.then((f) => f()); - void unLocal.then((f) => f()); + unCore(); + unLocal(); if (timer) clearTimeout(timer); }; }, [want, throttleMs]); diff --git a/src/useRequests.ts b/src/useRequests.ts index c1fc0374..efb6c04f 100644 --- a/src/useRequests.ts +++ b/src/useRequests.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { listen } from "@tauri-apps/api/event"; +import { subscribe } from "@/lib/tauriEvent"; import { call } from "@/control"; import { applyEvent, @@ -459,7 +459,7 @@ export function useRequests(ready: boolean) { 那一刻记,不等下一帧落地。 */ resetCoreClock(); - const un = listen("core-event", (e) => { + const un = subscribe("core-event", (e) => { const ev = e.payload; if (ev.kind === "request_started") noteCoreTime(ev.at_ms, Date.now()); if (since) { @@ -489,7 +489,7 @@ export function useRequests(ready: boolean) { try { // **等订阅真的挂上再问**:`listen` 是异步注册的,先问的话,快照和 // 订阅之间结束的请求,结局谁都没收到 - await un; + await un.ready; const sentAt = Date.now(); const open = await call("InFlight", null); const gotAt = Date.now(); @@ -509,7 +509,7 @@ export function useRequests(ready: boolean) { 再也等不到结局(见 `interruptInFlight`)。回来的时候(`running:`) 再对一次账,补上重连之前就开始了的。 */ - const unState = listen("core-state", (e) => { + const unState = subscribe("core-state", (e) => { if (e.payload.startsWith("running")) { void resync(); return; @@ -526,7 +526,7 @@ export function useRequests(ready: boolean) { 客户端配置里新出现的可疑内容。**这台机器上的文件监视说的,不是 core**: 连着哪个 core 都一样。MCP 页有新发现时直接落在「发现」上要用它。 */ - const unLocal = listen("local-event", (e) => { + const unLocal = subscribe("local-event", (e) => { const ev = e.payload; if (ev.kind === "scan_alert") setAlerts((prev) => [...ev.alerts, ...prev].slice(0, 50)); }); @@ -535,9 +535,9 @@ export function useRequests(ready: boolean) { // 但显式断掉能省下事件堆积。 return () => { alive = false; - un.then((f) => f()); - void unState.then((f) => f()); - void unLocal.then((f) => f()); + un(); + unState(); + unLocal(); if (frame.current !== null) cancelAnimationFrame(frame.current); if (settle.current) clearTimeout(settle.current); };