authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 11:49:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 11:49:11-07:00
logfec606f9ae524277ef1f6886e614f32d9e15e36e
tree7cf83521250c0fb16339721af6c33ebc844bbac6
parentd6e29dc9dee943b34d6b0d11441fa944c7bff7c9
signature Signed by PGP key B5690EEEBB952194

perf(web): avoid repeated terminal metadata scans (#9747)

Build one ordered terminal metadata index per immutable snapshot. Reuse unchanged session wrappers and thread groups across consumers, with separate environment targets. Keep numeric ordering, picker ties, attach state, and subscription lifetimes unchanged. Let retired snapshots and groups be collected. Created with GPT-6 Astra (preview) in Codex.

2 files changed, 292 insertions(+), 27 deletions(-)

apps/web/src/state/terminalSessions.test.ts created+171
...@@ -0,0 +1,171 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts";
3import { selectRunningSubprocessTerminalIds } from "@t3tools/client-runtime/state/terminal";
4
5import { selectKnownTerminalSessions } from "./terminalSessions";
6
7vi.mock("./terminal", () => ({ terminalEnvironment: {} }));
8
9const environmentA = EnvironmentId.make("environment-a");
10const environmentB = EnvironmentId.make("environment-b");
11const threadA = ThreadId.make("thread-a");
12const threadB = ThreadId.make("thread-b");
13
14function summary(
15 threadId: string,
16 terminalId: string,
17 changes: Partial<TerminalSummary> = {},
18): TerminalSummary {
19 return {
20 threadId,
21 terminalId,
22 cwd: "/repo",
23 worktreePath: null,
24 status: "running",
25 pid: 123,
26 exitCode: null,
27 exitSignal: null,
28 hasRunningSubprocess: true,
29 label: "Terminal",
30 updatedAt: "2026-09-04T00:00:00.000Z",
31 ...changes,
32 };
33}
34
35describe("selectKnownTerminalSessions", () => {
36 it("preserves numeric terminal order and stable picker ties across threads", () => {
37 const metadata = [
38 summary(threadB, "terminal-2"),
39 summary(threadA, "terminal-10"),
40 summary(threadB, "terminal-1"),
41 summary(threadA, "terminal-1"),
42 summary(threadA, "terminal-01"),
43 ];
44 const all = selectKnownTerminalSessions(metadata, environmentA, null);
45 const selected = selectKnownTerminalSessions(metadata, environmentA, threadB);
46 expect(all.map((session) => `${session.target.threadId}/${session.target.terminalId}`)).toEqual(
47 [
48 "thread-b/terminal-1",
49 "thread-a/terminal-1",
50 "thread-a/terminal-01",
51 "thread-b/terminal-2",
52 "thread-a/terminal-10",
53 ],
54 );
55 expect(selected.map((session) => session.target.terminalId)).toEqual([
56 "terminal-1",
57 "terminal-2",
58 ]);
59 const repeated = [...metadata];
60 expect(selectKnownTerminalSessions(repeated, environmentA, null)).toBe(all);
61 expect(selectKnownTerminalSessions(repeated, environmentA, threadB)).toBe(selected);
62
63 const reordered = metadata.toReversed();
64 expect(selectKnownTerminalSessions(reordered, environmentA, threadB)).toBe(selected);
65 expect(
66 selectKnownTerminalSessions(reordered, environmentA, null).map(
67 (session) => `${session.target.threadId}/${session.target.terminalId}`,
68 ),
69 ).toEqual([
70 "thread-a/terminal-01",
71 "thread-a/terminal-1",
72 "thread-b/terminal-1",
73 "thread-b/terminal-2",
74 "thread-a/terminal-10",
75 ]);
76 });
77
78 it("changes only the affected group and preserves earlier snapshots", () => {
79 const first = summary(threadA, "terminal-1");
80 const second = summary(threadA, "terminal-2");
81 const other = summary(threadB, "terminal-1");
82 const metadata = [first, second, other];
83 const beforeA = selectKnownTerminalSessions(metadata, environmentA, threadA);
84 const beforeB = selectKnownTerminalSessions(metadata, environmentA, threadB);
85 Object.freeze(beforeA);
86 Object.freeze(beforeB);
87 const exited = {
88 ...second,
89 status: "exited" as const,
90 pid: null,
91 exitCode: 0,
92 hasRunningSubprocess: false,
93 label: "Finished",
94 updatedAt: "2026-09-04T00:00:01.000Z",
95 };
96 const next = [first, other, exited];
97 const afterA = selectKnownTerminalSessions(next, environmentA, threadA);
98 expect(selectKnownTerminalSessions(next, environmentA, threadB)).toBe(beforeB);
99 expect(afterA).not.toBe(beforeA);
100 expect(afterA[0]).toBe(beforeA[0]);
101 expect(afterA[1]?.state).toMatchObject({
102 status: "exited",
103 hasRunningSubprocess: false,
104 summary: { label: "Finished", pid: null, exitCode: 0 },
105 });
106 expect(selectRunningSubprocessTerminalIds(afterA)).toEqual(["terminal-1"]);
107 expect(beforeA[1]?.state).toMatchObject({
108 status: "running",
109 hasRunningSubprocess: true,
110 summary: { label: "Terminal", pid: 123 },
111 });
112 expect(selectKnownTerminalSessions(metadata, environmentA, threadA)).toBe(beforeA);
113
114 const renamed = [first, exited, { ...other, label: "Renamed" }];
115 expect(selectKnownTerminalSessions(renamed, environmentA, threadA)).toBe(afterA);
116 expect(
117 selectKnownTerminalSessions(renamed, environmentA, threadB)[0]?.state.summary?.label,
118 ).toBe("Renamed");
119 expect(selectKnownTerminalSessions([other], environmentA, threadA)).toEqual([]);
120 expect(selectKnownTerminalSessions([other], environmentA, threadB)).toBe(beforeB);
121 });
122
123 it("keeps environment targets separate when snapshots share the same objects", () => {
124 const metadata = [summary(threadA, "terminal-1")];
125 const first = selectKnownTerminalSessions(metadata, environmentA, threadA);
126 const second = selectKnownTerminalSessions(metadata, environmentB, threadA);
127 expect(first[0]?.target.environmentId).toBe(environmentA);
128 expect(second[0]?.target.environmentId).toBe(environmentB);
129 expect(first).not.toBe(second);
130 expect(first[0]).not.toBe(second[0]);
131 expect(selectKnownTerminalSessions([...metadata], environmentA, threadA)).toBe(first);
132 expect(selectKnownTerminalSessions([...metadata], environmentB, threadA)).toBe(second);
133 });
134
135 it("returns stable empty results without a connected environment or matching sessions", () => {
136 const metadata = [summary(threadA, "terminal-1")];
137 const empty = selectKnownTerminalSessions(null, environmentA, threadA);
138 expect(selectKnownTerminalSessions(metadata, null, threadA)).toBe(empty);
139 expect(selectKnownTerminalSessions([], environmentA, null)).toBe(empty);
140 expect(selectKnownTerminalSessions(metadata, environmentA, threadB)).toBe(empty);
141 });
142
143 it("does not materialize unrelated raw thread identifiers", () => {
144 const metadata = [summary(" untrimmed-thread ", "terminal-1"), summary(threadA, "terminal-2")];
145 expect(
146 selectKnownTerminalSessions(metadata, environmentA, threadA).map(
147 (session) => session.target.threadId,
148 ),
149 ).toEqual([threadA]);
150 });
151
152 it("reads the metadata array once for all thread selectors", () => {
153 const source = Array.from({ length: 40 }, (_, index) =>
154 summary(`thread-${index}`, "terminal-1"),
155 );
156 let reads = 0;
157 const metadata = new Proxy(source, {
158 get(target, property, receiver) {
159 if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) reads += 1;
160 return Reflect.get(target, property, receiver);
161 },
162 });
163 for (const item of source) {
164 expect(
165 selectKnownTerminalSessions(metadata, environmentA, ThreadId.make(item.threadId)),
166 ).toHaveLength(1);
167 }
168 expect(selectKnownTerminalSessions(metadata, environmentA, null)).toHaveLength(source.length);
169 expect(reads).toBe(source.length);
170 });
171});
apps/web/src/state/terminalSessions.ts+121-27
...@@ -6,12 +6,121 @@ import {...@@ -6,12 +6,121 @@ import {
6 type KnownTerminalSession,6 type KnownTerminalSession,
7 type TerminalSessionState,7 type TerminalSessionState,
8} from "@t3tools/client-runtime/state/terminal";8} from "@t3tools/client-runtime/state/terminal";
9import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts";9import {
10 ThreadId,
11 type EnvironmentId,
12 type TerminalAttachInput,
13 type TerminalSummary,
14} from "@t3tools/contracts";
10import { useMemo } from "react";15import { useMemo } from "react";
1116
12import { useEnvironmentQuery } from "./query";17import { useEnvironmentQuery } from "./query";
13import { terminalEnvironment } from "./terminal";18import { terminalEnvironment } from "./terminal";
1419
20const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<ReadonlyArray<KnownTerminalSession>>([]);
21
22interface TerminalMetadataIndex {
23 readonly all: ReadonlyArray<TerminalSummary>;
24 readonly byThreadId: ReadonlyMap<string, ReadonlyArray<TerminalSummary>>;
25 readonly views: Map<EnvironmentId, Map<ThreadId | null, ReadonlyArray<KnownTerminalSession>>>;
26}
27
28const metadataIndexes = new WeakMap<ReadonlyArray<TerminalSummary>, TerminalMetadataIndex>();
29const sessionsBySummary = new WeakMap<TerminalSummary, Map<EnvironmentId, KnownTerminalSession>>();
30// Reuse groups a consumer still holds without keeping old group members alive
31// merely because their first summary remains in a newer metadata snapshot.
32const groupsByAnchor = new WeakMap<
33 TerminalSummary,
34 Map<
35 EnvironmentId,
36 {
37 readonly all?: WeakRef<ReadonlyArray<KnownTerminalSession>>;
38 readonly thread?: WeakRef<ReadonlyArray<KnownTerminalSession>>;
39 }
40 >
41>();
42
43function knownSession(
44 summary: TerminalSummary,
45 environmentId: EnvironmentId,
46): KnownTerminalSession {
47 let byEnvironment = sessionsBySummary.get(summary);
48 const previous = byEnvironment?.get(environmentId);
49 if (previous) return previous;
50 const session = {
51 target: {
52 environmentId,
53 threadId: ThreadId.make(summary.threadId),
54 terminalId: summary.terminalId,
55 },
56 state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE),
57 };
58 if (!byEnvironment) {
59 byEnvironment = new Map();
60 sessionsBySummary.set(summary, byEnvironment);
61 }
62 byEnvironment.set(environmentId, session);
63 return session;
64}
65
66function terminalMetadataIndex(metadata: ReadonlyArray<TerminalSummary>): TerminalMetadataIndex {
67 let index = metadataIndexes.get(metadata);
68 if (!index) {
69 const compare = new Intl.Collator(undefined, { numeric: true }).compare;
70 const all = metadata.toSorted((left, right) => compare(left.terminalId, right.terminalId));
71 const byThreadId = new Map<string, TerminalSummary[]>();
72 for (const summary of all) {
73 const group = byThreadId.get(summary.threadId);
74 if (group) group.push(summary);
75 else byThreadId.set(summary.threadId, [summary]);
76 }
77 index = { all, byThreadId, views: new Map() };
78 metadataIndexes.set(metadata, index);
79 }
80 return index;
81}
82
83/** Share one ordered index per immutable snapshot without changing metadata subscriptions. */
84export function selectKnownTerminalSessions(
85 metadata: ReadonlyArray<TerminalSummary> | null,
86 environmentId: EnvironmentId | null,
87 threadId: ThreadId | null,
88): ReadonlyArray<KnownTerminalSession> {
89 if (environmentId === null || metadata === null || metadata.length === 0) {
90 return EMPTY_KNOWN_TERMINAL_SESSIONS;
91 }
92 const index = terminalMetadataIndex(metadata);
93 let views = index.views.get(environmentId);
94 const cached = views?.get(threadId);
95 if (cached) return cached;
96 const summaries = threadId === null ? index.all : index.byThreadId.get(threadId);
97 if (!summaries || summaries.length === 0) return EMPTY_KNOWN_TERMINAL_SESSIONS;
98
99 const anchor = summaries[0]!;
100 const kind = threadId === null ? "all" : "thread";
101 let groups = groupsByAnchor.get(anchor);
102 const previousGroups = groups?.get(environmentId);
103 const previous = previousGroups?.[kind]?.deref();
104 const sessions =
105 previous?.length === summaries.length &&
106 previous.every((session, index) => session.state.summary === summaries[index])
107 ? previous
108 : summaries.map((summary) => knownSession(summary, environmentId));
109 if (!groups) {
110 groups = new Map();
111 groupsByAnchor.set(anchor, groups);
112 }
113 if (sessions !== previous) {
114 groups.set(environmentId, { ...previousGroups, [kind]: new WeakRef(sessions) });
115 }
116 if (!views) {
117 views = new Map();
118 index.views.set(environmentId, views);
119 }
120 views.set(threadId, sessions);
121 return sessions;
122}
123
15export function useAttachedTerminalSession(input: {124export function useAttachedTerminalSession(input: {
16 readonly environmentId: EnvironmentId | null;125 readonly environmentId: EnvironmentId | null;
17 readonly terminal: TerminalAttachInput | null;126 readonly terminal: TerminalAttachInput | null;
...@@ -38,11 +147,11 @@ export function useAttachedTerminalSession(input: {...@@ -38,11 +147,11 @@ export function useAttachedTerminalSession(input: {
38 return EMPTY_TERMINAL_SESSION_STATE;147 return EMPTY_TERMINAL_SESSION_STATE;
39 }148 }
40 const summary =149 const summary =
41 metadata.data?.find(150 (metadata.data === null
42 (terminal) =>151 ? null
43 terminal.threadId === input.terminal?.threadId &&152 : terminalMetadataIndex(metadata.data)
44 terminal.terminalId === input.terminal?.terminalId,153 .byThreadId.get(input.terminal.threadId)
45 ) ?? null;154 ?.find((terminal) => terminal.terminalId === input.terminal?.terminalId)) ?? null;
46 const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE);155 const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE);
47 return attach.error === null ? state : { ...state, error: attach.error, status: "error" };156 return attach.error === null ? state : { ...state, error: attach.error, status: "error" };
48 }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]);157 }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]);
...@@ -60,31 +169,16 @@ export function useKnownTerminalSessions(input: {...@@ -60,31 +169,16 @@ export function useKnownTerminalSessions(input: {
60 input: null,169 input: null,
61 }),170 }),
62 );171 );
63 return useMemo(() => {172 return useMemo(
64 if (input.environmentId === null) {173 () => selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId),
65 return [];174 [input.environmentId, input.threadId, metadata.data],
66 }175 );
67 return (metadata.data ?? [])
68 .filter((summary) => input.threadId === null || summary.threadId === input.threadId)
69 .map((summary) => ({
70 target: {
71 environmentId: input.environmentId!,
72 threadId: ThreadId.make(summary.threadId),
73 terminalId: summary.terminalId,
74 },
75 state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE),
76 }))
77 .sort((left, right) =>
78 left.target.terminalId.localeCompare(right.target.terminalId, undefined, {
79 numeric: true,
80 }),
81 );
82 }, [input.environmentId, input.threadId, metadata.data]);
83}176}
84177
85export function useThreadRunningTerminalIds(input: {178export function useThreadRunningTerminalIds(input: {
86 readonly environmentId: EnvironmentId | null;179 readonly environmentId: EnvironmentId | null;
87 readonly threadId: ThreadId | null;180 readonly threadId: ThreadId | null;
88}): ReadonlyArray<string> {181}): ReadonlyArray<string> {
89 return selectRunningSubprocessTerminalIds(useKnownTerminalSessions(input));182 const sessions = useKnownTerminalSessions(input);
183 return useMemo(() => selectRunningSubprocessTerminalIds(sessions), [sessions]);
90}184}