From abeb6b1b3664ac67c6a1c8b59a2f9ab268a2cdf9 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Sat, 7 Nov 2026 03:09:18 -0800 Subject: [PATCH] fix(studio): the workbench no longer renders again every two seconds for nothing: the terminal list poll skips the setter when it finds the list it already holds, since React runs the component once more for an update that hands back an equal value, which is what the poll test saw as a stray commit on the runner; the temporary probes are removed --- .../src/ui/useTerminalTabs.poll.test.tsx | 34 +++++++++++++++++++ .../frontend-next/src/ui/useTerminalTabs.ts | 10 +++++- .../src/ui/useWorkbenchFiles.poll.test.tsx | 4 +-- .../frontend-next/src/ui/useWorkbenchFiles.ts | 5 +-- 4 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 desktop/frontend-next/src/ui/useTerminalTabs.poll.test.tsx diff --git a/desktop/frontend-next/src/ui/useTerminalTabs.poll.test.tsx b/desktop/frontend-next/src/ui/useTerminalTabs.poll.test.tsx new file mode 100644 index 0000000000..46fde6cb87 --- /dev/null +++ b/desktop/frontend-next/src/ui/useTerminalTabs.poll.test.tsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { MockPort } from "../port/mock"; +import type { TerminalInfo } from "../port/terminal"; +import { useTerminalTabs } from "./useTerminalTabs"; +import "./testkit"; + +beforeEach(() => vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] })); +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +const info = (id: string): TerminalInfo => ({ id, name: "Git Bash", shell: "/usr/bin/bash", dir: "~", cols: 80, rows: 24, running: true, busy: false, created: "2026-11-07T00:00:00Z" }); + +// React runs the component again for a state update even when the value it is +// handed equals the one it holds, once the component has had a real update. A +// poll that finds nothing new must therefore not call the setter at all. +it("does not render again when a poll finds the same terminals", async () => { + const port = new MockPort(); + const listing = vi.spyOn(port, "terminals"); + listing.mockResolvedValueOnce([]).mockResolvedValue([info("t1")]); + let renders = 0; + renderHook(() => { + renders += 1; + return useTerminalTabs({ port, shown: true, pick: () => {}, setDismissed: () => {}, onExternal: () => {}, onError: () => {}, request: 0 }); + }); + await act(async () => {}); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + const afterChange = renders; + expect(listing).toHaveBeenCalledTimes(2); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(listing).toHaveBeenCalledTimes(4); + expect(renders).toBe(afterChange); +}); diff --git a/desktop/frontend-next/src/ui/useTerminalTabs.ts b/desktop/frontend-next/src/ui/useTerminalTabs.ts index 42513cb168..556a9c084d 100644 --- a/desktop/frontend-next/src/ui/useTerminalTabs.ts +++ b/desktop/frontend-next/src/ui/useTerminalTabs.ts @@ -31,13 +31,21 @@ export function useTerminalTabs({ port, shown, pick, setDismissed, onExternal, o const [shells, setShells] = useState([]); const [commands, setCommands] = useState>({}); const changed = useRef(0); + const held = useRef(list); + held.current = list; useEffect(() => { if (!shown) return; let live = true; const read = () => { const since = changed.current; - port.terminals().then((next) => { if (live && since === changed.current) setList((now) => (JSON.stringify(now) === JSON.stringify(next) ? now : next)); }, () => {}); + port.terminals().then((next) => { + if (!live || since !== changed.current) return; + // Not even an update that hands back the same value: it costs the + // component another render, and the poll runs for as long as the panel. + if (JSON.stringify(held.current) === JSON.stringify(next)) return; + setList(next); + }, () => {}); }; read(); const timer = window.setInterval(read, POLL_MS); diff --git a/desktop/frontend-next/src/ui/useWorkbenchFiles.poll.test.tsx b/desktop/frontend-next/src/ui/useWorkbenchFiles.poll.test.tsx index d0598a1af6..25eba35000 100644 --- a/desktop/frontend-next/src/ui/useWorkbenchFiles.poll.test.tsx +++ b/desktop/frontend-next/src/ui/useWorkbenchFiles.poll.test.tsx @@ -116,12 +116,10 @@ it("leaves the rendered Workbench untouched when Markdown polls are unchanged", commits.mockClear(); const base = performance.now(); const seen: string[] = []; - const notes: string[] = []; - (globalThis as { __diag?: (note: string) => void }).__diag = (note) => notes.push(note); for (const n of [1, 2]) { await tick(); seen.push(`after poll ${n} (${(performance.now() - base).toFixed(0)}ms): ${commits.mock.calls.length} commits [${commits.mock.calls.map((c) => `${c[1]}@${(c[5] - base).toFixed(0)}ms`).join(",")}], file reads ${read.mock.calls.length}, listings ${listed.mock.calls.length}`); } expect(read).toHaveBeenCalledTimes(3); - expect(commits.mock.calls.length, `${seen.join("; ")} | ${notes.join(" / ")}`).toBe(0); + expect(commits.mock.calls.length, seen.join("; ")).toBe(0); }); diff --git a/desktop/frontend-next/src/ui/useWorkbenchFiles.ts b/desktop/frontend-next/src/ui/useWorkbenchFiles.ts index b0b75969a0..eec296e072 100644 --- a/desktop/frontend-next/src/ui/useWorkbenchFiles.ts +++ b/desktop/frontend-next/src/ui/useWorkbenchFiles.ts @@ -15,8 +15,6 @@ type Entry = { const blank = (): Entry => ({ file: null, draft: "", diff: "", error: "", errorCode: "", missing: false, pending: "", issued: 0 }); const dirty = (entry: Entry) => !!entry.file && entry.draft !== entry.file.content; -const diag = (note: string) => (globalThis as { __diag?: (note: string) => void }).__diag?.(note); - export function useWorkbenchFiles({ port, workspaceRoot, repos = [], path, openPaths, shown, refresh, poll }: { port: AgentPort; workspaceRoot: string; repos?: GitRepoRow[]; path: string; openPaths: string[]; shown: boolean; refresh: string; poll: boolean; }) { @@ -36,7 +34,7 @@ export function useWorkbenchFiles({ port, workspaceRoot, repos = [], path, openP }, [entries]); useEffect(() => { for (const [path, entry] of entries) { - if (!openPaths.includes(path) && !dirty(entry) && entry.pending !== "save") { diag(`evict ${path} among ${JSON.stringify(openPaths)}`); entries.delete(path); } + if (!openPaths.includes(path) && !dirty(entry) && entry.pending !== "save") entries.delete(path); } }, [entries, openPaths, version]); @@ -47,7 +45,6 @@ export function useWorkbenchFiles({ port, workspaceRoot, repos = [], path, openP if (entry.pending === "save" || dirty(entry) || (entry.file && !shown) || (quiet && entry.pending === "read")) return; const ticket = ++entry.issued; let changed = !quiet || !entry.file; - if (quiet) diag(`quiet read, file ${entry.file ? "held" : "empty"}, pending ${entry.pending || "none"}`); void (async () => { const valid = () => live && current.current === entries && ticket === entry.issued; entry.pending = "read";