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";
2626import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts";
2727import { mergeEnvironmentThread } from "./threadDetail.ts";
2828import { createEnvironmentThreadShellAtoms } from "./threadShell.ts";
29import { applyShellStreamEvent } from "./shellReducer.ts";
2930
3031const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
3132const PROJECT_ID = ProjectId.make("project-1");
......@@ -149,7 +150,7 @@ function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState
149150 };
150151}
151152
152function makeHarness() {
153function makeHarness(environmentIds: ReadonlyArray<EnvironmentId> = [ENVIRONMENT_ID]) {
153154 const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) =>
154155 Atom.make(AsyncResult.success(shellState(SNAPSHOT))),
155156 );
......@@ -158,20 +159,20 @@ function makeHarness() {
158159 );
159160 const catalogValueAtom = Atom.make({
160161 isReady: true,
161 entries: new Map([
162 [
163 ENVIRONMENT_ID,
162 entries: new Map(
163 environmentIds.map((environmentId) => [
164 environmentId,
164165 {
165166 target: new PrimaryConnectionTarget({
166 environmentId: ENVIRONMENT_ID,
167 environmentId,
167168 label: "Environment",
168169 httpBaseUrl: "https://example.test",
169170 wsBaseUrl: "wss://example.test",
170171 }),
171172 profile: Option.none(),
172173 },
173 ],
174 ]),
174 ]),
175 ),
175176 });
176177 const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms);
177178 const projects = createEnvironmentProjectAtoms({
......@@ -188,7 +189,9 @@ function makeHarness() {
188189
189190 return {
190191 registry: AtomRegistry.make(),
192 catalogValueAtom,
191193 shellStateAtom: shellStateAtoms(ENVIRONMENT_ID),
194 shellStateAtomForEnvironment: shellStateAtoms,
192195 threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`),
193196 projects,
194197 threadShells,
......@@ -279,7 +282,8 @@ describe("environment entity projections", () => {
279282 const refsByProjectAtom =
280283 harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID);
281284 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);
283287 const threads = harness.registry.get(threadsAtom);
284288
285289 expect(threads).toHaveLength(1);
......@@ -299,9 +303,158 @@ describe("environment entity projections", () => {
299303 );
300304
301305 expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs);
306 expect(harness.registry.get(refsByProjectAtom)).toBe(membership);
302307 expect(harness.registry.get(threadsAtom)).toBe(threads);
303308 });
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
305458 it("updates only the requested thread detail and preserves untouched field identities", () => {
306459 const harness = makeHarness();
307460 const threadRef = {
packages/client-runtime/src/state/threadShell.ts+42-15
......@@ -35,6 +35,26 @@ export function createEnvironmentThreadShellAtoms(input: {
3535 environmentId: EnvironmentId,
3636 ) => Atom.Atom<OrchestrationShellSnapshot | null>;
3737}) {
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
3858 const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) =>
3959 Atom.make(
4060 (get): ReadonlyArray<OrchestrationThreadShell> =>
......@@ -95,6 +115,16 @@ export function createEnvironmentThreadShellAtoms(input: {
95115 previousRefs !== undefined && threadRefsEqual(previousRefs, refs) ? previousRefs : refs,
96116 );
97117 }
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 }
98128 previous = next;
99129 return previous;
100130 }).pipe(Atom.withLabel(`environment-thread-refs-by-project:${environmentId}`));
......@@ -102,16 +132,9 @@ export function createEnvironmentThreadShellAtoms(input: {
102132
103133 const threadShellAtomFamily = Atom.family((key: string) => {
104134 const ref = parseThreadKey(key);
105 let previousSource: OrchestrationThreadShell | null = null;
106 let previousValue: EnvironmentThreadShell | null = null;
107135 return Atom.make((get) => {
108136 const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null;
109 if (source === previousSource) {
110 return previousValue;
111 }
112 previousSource = source;
113 previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source);
114 return previousValue;
137 return source === null ? null : scopedThread(ref.environmentId, source);
115138 }).pipe(Atom.withLabel(`environment-thread-shell:${key}`));
116139 });
117140
......@@ -126,15 +149,17 @@ export function createEnvironmentThreadShellAtoms(input: {
126149 get(environmentThreadRefsByProjectAtom(projectRef.environmentId)).get(
127150 projectRef.projectId,
128151 ) ?? EMPTY_SCOPED_THREAD_REFS;
152 if (refs.length === 0) continue;
153 const threads = get(environmentThreadIndexAtom(projectRef.environmentId));
129154 for (const ref of refs) {
130155 const key = threadKey(ref);
131156 if (seen.has(key)) {
132157 continue;
133158 }
134159 seen.add(key);
135 const thread = get(threadShellAtomFamily(key));
136 if (thread !== null) {
137 next.push(thread);
160 const thread = threads.get(ref.threadId);
161 if (thread !== undefined) {
162 next.push(scopedThread(ref.environmentId, thread));
138163 }
139164 }
140165 }
......@@ -161,10 +186,12 @@ export function createEnvironmentThreadShellAtoms(input: {
161186
162187 let previousThreadShells: ReadonlyArray<EnvironmentThreadShell> = [];
163188 const threadShellsAtom = Atom.make((get) => {
164 const next = get(threadRefsAtom).flatMap((ref) => {
165 const thread = get(threadShellAtomFamily(threadKey(ref)));
166 return thread === null ? [] : [thread];
167 });
189 const next: EnvironmentThreadShell[] = [];
190 for (const environmentId of get(input.catalogValueAtom).entries.keys()) {
191 for (const thread of get(environmentThreadsAtom(environmentId))) {
192 next.push(scopedThread(environmentId, thread));
193 }
194 }
168195 if (arrayElementsEqual(previousThreadShells, next)) {
169196 return previousThreadShells;
170197 }