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

This commit is contained in:
yhh committed 2026-11-07 03:09:18 -08:00
1 parent 1317859a0c
commit abeb6b1b36
4 files changed
+45 -8

No files matched your search

@@ -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);
});
@@ -31,13 +31,21 @@ export function useTerminalTabs({ port, shown, pick, setDismissed, onExternal, o
const [shells, setShells] = useState<ShellChoice[]>([]);
const [commands, setCommands] = useState<Record<string, string>>({});
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);
@@ -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);
});
@@ -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";