authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 09:42:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 09:42:42-07:00
logc66f15f39e61fda0a9a100578833ec12e7526859
tree336420f811e12a9d9cddf94812073e70819721ff
parent95103905f5f045523994213bf76bfb14acc31b27
signature Signed by PGP key B5690EEEBB952194

perf(client): reduce thread-list update work (#9716)


2 files changed, 203 insertions(+), 23 deletions(-)

packages/client-runtime/src/state/entities.test.ts+161-8
...@@ -26,6 +26,7 @@ import { createEnvironmentSnapshotAtom } from "./snapshots.ts";...@@ -26,6 +26,7 @@ import { createEnvironmentSnapshotAtom } from "./snapshots.ts";
26import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";26import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
27import { mergeEnvironmentThread } from "./threadDetail.ts";27import { mergeEnvironmentThread } from "./threadDetail.ts";
28import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";28import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";
29import { applyShellStreamEvent } from "./shellReducer.ts";
2930
30const ENVIRONMENT_ID = EnvironmentId.make("environment-1");31const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
31const PROJECT_ID = ProjectId.make("project-1");32const PROJECT_ID = ProjectId.make("project-1");
...@@ -149,7 +150,7 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState...@@ -149,7 +150,7 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState
149 };150 };
150}151}
151152
152function makeHarness() {153function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT_ID]) {
153 const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) =>154 const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) =>
154 Atom.make(AsyncResult.success(shellState(SNAPSHOT))),155 Atom.make(AsyncResult.success(shellState(SNAPSHOT))),
155 );156 );
...@@ -158,20 +159,20 @@ function makeHarness() {...@@ -158,20 +159,20 @@ function makeHarness() {
158 );159 );
159 const catalogValueAtom = Atom.make({160 const catalogValueAtom = Atom.make({
160 isReady: true,161 isReady: true,
161 entries: new Map([162 entries: new Map(
162 [163 environmentIds.map((environmentId) => [
163 ENVIRONMENT_ID,164 environmentId,
164 {165 {
165 target: new PrimaryConnectionTarget({166 target: new PrimaryConnectionTarget({
166 environmentId: ENVIRONMENT_ID,167 environmentId,
167 label: "Environment",168 label: "Environment",
168 httpBaseUrl: "https://example.test",169 httpBaseUrl: "https://example.test",
169 wsBaseUrl: "wss://example.test",170 wsBaseUrl: "wss://example.test",
170 }),171 }),
171 profile: Option.none(),172 profile: Option.none(),
172 },173 },
173 ],174 ]),
174 ]),175 ),
175 });176 });
176 const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms);177 const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms);
177 const projects = createEnvironmentProjectAtoms({178 const projects = createEnvironmentProjectAtoms({
...@@ -188,7 +189,9 @@ function makeHarness() {...@@ -188,7 +189,9 @@ function makeHarness() {
188189
189 return {190 return {
190 registry: AtomRegistry.make(),191 registry: AtomRegistry.make(),
192 catalogValueAtom,
191 shellStateAtom: shellStateAtoms(ENVIRONMENT_ID),193 shellStateAtom: shellStateAtoms(ENVIRONMENT_ID),
194 shellStateAtomForEnvironment: shellStateAtoms,
192 threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`),195 threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`),
193 projects,196 projects,
194 threadShells,197 threadShells,
...@@ -279,7 +282,8 @@ describe("environment entity projections", () => {...@@ -279,7 +282,8 @@ describe("environment entity projections", () => {
279 const refsByProjectAtom =282 const refsByProjectAtom =
280 harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID);283 harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID);
281 const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]);284 const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]);
282 const refs = harness.registry.get(refsByProjectAtom).get(PROJECT_ID);285 const membership = harness.registry.get(refsByProjectAtom);
286 const refs = membership.get(PROJECT_ID);
283 const threads = harness.registry.get(threadsAtom);287 const threads = harness.registry.get(threadsAtom);
284288
285 expect(threads).toHaveLength(1);289 expect(threads).toHaveLength(1);
...@@ -299,9 +303,158 @@ describe("environment entity projections", () => {...@@ -299,9 +303,158 @@ describe("environment entity projections", () => {
299 );303 );
300304
301 expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs);305 expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs);
306 expect(harness.registry.get(refsByProjectAtom)).toBe(membership);
302 expect(harness.registry.get(threadsAtom)).toBe(threads);307 expect(harness.registry.get(threadsAtom)).toBe(threads);
303 });308 });
304309
310 it("shares list values with point reads without retaining one atom per listed thread", () => {
311 const harness = makeHarness();
312 let snapshot: OrchestrationShellSnapshot = {
313 ...SNAPSHOT,
314 threads: Array.from({ length: 200 }, (_, index) => ({
315 ...THREAD_SHELL,
316 id: ThreadId.make(`listed-${index}`),
317 })),
318 };
319 harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot)));
320 const listAtom = harness.threadShells.threadShellsAtom;
321 const projectListAtom = harness.threadShells.threadShellsForProjectRefsAtom([
322 { environmentId: ENVIRONMENT_ID, projectId: PROJECT_ID },
323 ]);
324 const disposeList = harness.registry.mount(listAtom);
325 const disposeProjectList = harness.registry.mount(projectListAtom);
326 try {
327 const before = harness.registry.get(listAtom);
328 expect(before).toHaveLength(200);
329 expect(harness.registry.get(projectListAtom)).toEqual(before);
330 expect(harness.registry.getNodes().size).toBeLessThan(20);
331 const firstAtom = harness.threadShells.threadShellAtom({
332 environmentId: ENVIRONMENT_ID,
333 threadId: snapshot.threads[0]!.id,
334 });
335 expect(harness.registry.get(firstAtom)).toBe(before[0]);
336
337 snapshot = applyShellStreamEvent(snapshot, {
338 kind: "thread-upserted",
339 sequence: 2,
340 thread: { ...snapshot.threads.at(-1)!, title: "Updated last thread" },
341 });
342 harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot)));
343 const after = harness.registry.get(listAtom);
344 expect(after[0]).toBe(before[0]);
345 expect(after.at(-1)).not.toBe(before.at(-1));
346 expect(after.at(-1)?.title).toBe("Updated last thread");
347 expect(harness.registry.get(projectListAtom).at(-1)).toBe(after.at(-1));
348 expect(harness.registry.get(firstAtom)).toBe(after[0]);
349 expect(harness.registry.getNodes().size).toBeLessThan(20);
350 } finally {
351 disposeProjectList();
352 disposeList();
353 harness.registry.dispose();
354 }
355 });
356
357 it("keeps scoped identities and list order across project and environment changes", () => {
358 const remoteEnvironmentId = EnvironmentId.make("remote-environment");
359 const harness = makeHarness([ENVIRONMENT_ID, remoteEnvironmentId]);
360 const listAtom = harness.threadShells.threadShellsAtom;
361 const localRef = { environmentId: ENVIRONMENT_ID, threadId: THREAD_ID };
362 const remoteRef = { environmentId: remoteEnvironmentId, threadId: THREAD_ID };
363 const selectedAtom = harness.threadShells.threadShellsForProjectRefsAtom([
364 { environmentId: remoteEnvironmentId, projectId: PROJECT_ID },
365 { environmentId: ENVIRONMENT_ID, projectId: OTHER_PROJECT_ID },
366 { environmentId: remoteEnvironmentId, projectId: PROJECT_ID },
367 { environmentId: ENVIRONMENT_ID, projectId: PROJECT_ID },
368 ]);
369 const disposeList = harness.registry.mount(listAtom);
370 const disposeSelected = harness.registry.mount(selectedAtom);
371 try {
372 const original = harness.registry.get(listAtom);
373 const local = harness.registry.get(harness.threadShells.threadShellAtom(localRef));
374 const remote = harness.registry.get(harness.threadShells.threadShellAtom(remoteRef));
375 expect(original).toHaveLength(4);
376 expect(original[0]).toBe(local);
377 expect(original[2]).toBe(remote);
378 expect(local).not.toBe(remote);
379 expect(local?.environmentId).toBe(ENVIRONMENT_ID);
380 expect(remote?.environmentId).toBe(remoteEnvironmentId);
381 expect(harness.registry.get(selectedAtom)).toEqual([remote, original[1], local]);
382
383 harness.registry.set(
384 harness.shellStateAtomForEnvironment(remoteEnvironmentId),
385 AsyncResult.success(shellState({ ...SNAPSHOT, threads: SNAPSHOT.threads.toReversed() })),
386 );
387 expect([
388 ...harness.registry
389 .get(harness.threadShells.environmentThreadRefsByProjectAtom(remoteEnvironmentId))
390 .keys(),
391 ]).toEqual([OTHER_PROJECT_ID, PROJECT_ID]);
392 harness.registry.set(
393 harness.shellStateAtomForEnvironment(remoteEnvironmentId),
394 AsyncResult.success(shellState(SNAPSHOT)),
395 );
396
397 let snapshot = applyShellStreamEvent(SNAPSHOT, {
398 kind: "thread-upserted",
399 sequence: 2,
400 thread: { ...THREAD_SHELL, projectId: OTHER_PROJECT_ID, title: "Moved thread" },
401 });
402 harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot)));
403 const moved = harness.registry.get(harness.threadShells.threadShellAtom(localRef));
404 expect(harness.registry.get(selectedAtom)).toEqual([remote, moved, original[1]]);
405 expect(harness.registry.get(listAtom)[2]).toBe(remote);
406
407 snapshot = applyShellStreamEvent(snapshot, {
408 kind: "thread-removed",
409 sequence: 3,
410 threadId: OTHER_THREAD_ID,
411 });
412 harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot)));
413 expect(harness.registry.get(selectedAtom)).toEqual([remote, moved]);
414 expect(
415 harness.registry.get(
416 harness.threadShells.threadShellAtom({
417 environmentId: ENVIRONMENT_ID,
418 threadId: OTHER_THREAD_ID,
419 }),
420 ),
421 ).toBeNull();
422
423 const createdId = ThreadId.make("created-thread");
424 snapshot = applyShellStreamEvent(snapshot, {
425 kind: "thread-upserted",
426 sequence: 4,
427 thread: { ...THREAD_SHELL, id: createdId },
428 });
429 harness.registry.set(harness.shellStateAtom, AsyncResult.success(shellState(snapshot)));
430 const created = harness.registry.get(listAtom)[1];
431 expect(created?.id).toBe(createdId);
432 expect(harness.registry.get(selectedAtom)).toEqual([remote, moved, created]);
433
434 const catalog = harness.registry.get(harness.catalogValueAtom);
435 harness.registry.set(harness.catalogValueAtom, {
436 ...catalog,
437 entries: new Map([...catalog.entries].toReversed()),
438 });
439 expect(harness.registry.get(listAtom)).toEqual([remote, original[3], moved, created]);
440 harness.registry.set(harness.catalogValueAtom, {
441 ...catalog,
442 entries: new Map([[remoteEnvironmentId, catalog.entries.get(remoteEnvironmentId)!]]),
443 });
444 expect(harness.registry.get(listAtom)).toEqual([remote, original[3]]);
445 harness.registry.set(
446 harness.shellStateAtomForEnvironment(remoteEnvironmentId),
447 AsyncResult.success(shellState({ ...SNAPSHOT, threads: [] })),
448 );
449 expect(harness.registry.get(listAtom)).toEqual([]);
450 expect(harness.registry.get(harness.threadShells.threadShellAtom(remoteRef))).toBeNull();
451 } finally {
452 disposeSelected();
453 disposeList();
454 harness.registry.dispose();
455 }
456 });
457
305 it("updates only the requested thread detail and preserves untouched field identities", () => {458 it("updates only the requested thread detail and preserves untouched field identities", () => {
306 const harness = makeHarness();459 const harness = makeHarness();
307 const threadRef = {460 const threadRef = {
packages/client-runtime/src/state/threadShell.ts+42-15
...@@ -35,6 +35,26 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -35,6 +35,26 @@ export function createEnvironmentThreadShellAtoms(input: {
35 environmentId: EnvironmentId,35 environmentId: EnvironmentId,
36 ) => Atom.Atom<OrchestrationShellSnapshot | null>;36 ) => Atom.Atom<OrchestrationShellSnapshot | null>;
37}) {37}) {
38 // Point reads and aggregate lists share values without keeping an atom alive
39 // for every listed thread. Replaced source objects can be collected.
40 const scopedThreads = new WeakMap<
41 OrchestrationThreadShell,
42 Map<EnvironmentId, EnvironmentThreadShell>
43 >();
44 const scopedThread = (environmentId: EnvironmentId, thread: OrchestrationThreadShell) => {
45 let byEnvironment = scopedThreads.get(thread);
46 if (byEnvironment === undefined) {
47 byEnvironment = new Map();
48 scopedThreads.set(thread, byEnvironment);
49 }
50 let value = byEnvironment.get(environmentId);
51 if (value === undefined) {
52 value = scopeThreadShell(environmentId, thread);
53 byEnvironment.set(environmentId, value);
54 }
55 return value;
56 };
57
38 const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) =>58 const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) =>
39 Atom.make(59 Atom.make(
40 (get): ReadonlyArray<OrchestrationThreadShell> =>60 (get): ReadonlyArray<OrchestrationThreadShell> =>
...@@ -95,6 +115,16 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -95,6 +115,16 @@ export function createEnvironmentThreadShellAtoms(input: {
95 previousRefs !== undefined && threadRefsEqual(previousRefs, refs) ? previousRefs : refs,115 previousRefs !== undefined && threadRefsEqual(previousRefs, refs) ? previousRefs : refs,
96 );116 );
97 }117 }
118 const previousProjectIds = [...previous.keys()];
119 if (
120 next.size === previous.size &&
121 [...next].every(
122 ([projectId, refs], index) =>
123 previousProjectIds[index] === projectId && previous.get(projectId) === refs,
124 )
125 ) {
126 return previous;
127 }
98 previous = next;128 previous = next;
99 return previous;129 return previous;
100 }).pipe(Atom.withLabel(`environment-thread-refs-by-project:${environmentId}`));130 }).pipe(Atom.withLabel(`environment-thread-refs-by-project:${environmentId}`));
...@@ -102,16 +132,9 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -102,16 +132,9 @@ export function createEnvironmentThreadShellAtoms(input: {
102132
103 const threadShellAtomFamily = Atom.family((key: string) => {133 const threadShellAtomFamily = Atom.family((key: string) => {
104 const ref = parseThreadKey(key);134 const ref = parseThreadKey(key);
105 let previousSource: OrchestrationThreadShell | null = null;
106 let previousValue: EnvironmentThreadShell | null = null;
107 return Atom.make((get) => {135 return Atom.make((get) => {
108 const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null;136 const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null;
109 if (source === previousSource) {137 return source === null ? null : scopedThread(ref.environmentId, source);
110 return previousValue;
111 }
112 previousSource = source;
113 previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source);
114 return previousValue;
115 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));138 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));
116 });139 });
117140
...@@ -126,15 +149,17 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -126,15 +149,17 @@ export function createEnvironmentThreadShellAtoms(input: {
126 get(environmentThreadRefsByProjectAtom(projectRef.environmentId)).get(149 get(environmentThreadRefsByProjectAtom(projectRef.environmentId)).get(
127 projectRef.projectId,150 projectRef.projectId,
128 ) ?? EMPTY_SCOPED_THREAD_REFS;151 ) ?? EMPTY_SCOPED_THREAD_REFS;
152 if (refs.length === 0) continue;
153 const threads = get(environmentThreadIndexAtom(projectRef.environmentId));
129 for (const ref of refs) {154 for (const ref of refs) {
130 const key = threadKey(ref);155 const key = threadKey(ref);
131 if (seen.has(key)) {156 if (seen.has(key)) {
132 continue;157 continue;
133 }158 }
134 seen.add(key);159 seen.add(key);
135 const thread = get(threadShellAtomFamily(key));160 const thread = threads.get(ref.threadId);
136 if (thread !== null) {161 if (thread !== undefined) {
137 next.push(thread);162 next.push(scopedThread(ref.environmentId, thread));
138 }163 }
139 }164 }
140 }165 }
...@@ -161,10 +186,12 @@ export function createEnvironmentThreadShellAtoms(input: {...@@ -161,10 +186,12 @@ export function createEnvironmentThreadShellAtoms(input: {
161186
162 let previousThreadShells: ReadonlyArray<EnvironmentThreadShell> = [];187 let previousThreadShells: ReadonlyArray<EnvironmentThreadShell> = [];
163 const threadShellsAtom = Atom.make((get) => {188 const threadShellsAtom = Atom.make((get) => {
164 const next = get(threadRefsAtom).flatMap((ref) => {189 const next: EnvironmentThreadShell[] = [];
165 const thread = get(threadShellAtomFamily(threadKey(ref)));190 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
166 return thread === null ? [] : [thread];191 for (const thread of get(environmentThreadsAtom(environmentId))) {
167 });192 next.push(scopedThread(environmentId, thread));
193 }
194 }
168 if (arrayElementsEqual(previousThreadShells, next)) {195 if (arrayElementsEqual(previousThreadShells, next)) {
169 return previousThreadShells;196 return previousThreadShells;
170 }197 }