Skip to content
Source
Sign inSign up

useThreadListV2.test.tsx

244 lines9.4 KB

1import { act, useLayoutEffect } from "react";
2import { createRoot } from "react-dom/client";
3import { RegistryContext } from "@effect/atom-react";
4import type { OlderSettledShelfEnvironment } from "@t3tools/client-runtime/state/threads";
5import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
6import * as DateTime from "effect/DateTime";
7import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
8import { Atom, AtomRegistry } from "effect/reactivity";
9import { expect, it, vi } from "vite-plus/test";
10​
11import { olderSettledThreads } from "../../state/threads";
12import { makeRawThreadShell } from "../../test-fixtures";
13import { useThreadListV2 } from "./useThreadListV2";
14​
15vi.mock("../../state/threads", () => ({
16 olderSettledThreads: { shelfAtom: vi.fn() },
17 threadFamilies: { mountRows: () => () => {} },
18}));
19vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: undefined }));
20​
21it("reuses a large thread list across tab renders and updates when its inputs change", async () => {
22 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
23 using _globals = { [Symbol.dispose]: () => vi.unstubAllGlobals() };
24 const shelfAtom = Atom.make<ReadonlyMap<EnvironmentId, OlderSettledShelfEnvironment>>(new Map());
25 vi.mocked(olderSettledThreads.shelfAtom).mockReturnValue(shelfAtom);
26 const registry = AtomRegistry.make();
27 using _registry = { [Symbol.dispose]: () => registry.dispose() };
28 const environmentId = EnvironmentId.make("environment-1");
29 const otherEnvironmentId = EnvironmentId.make("environment-2");
30 const threads = Array.from({ length: 1200 }, (_, index) =>
31 presentThreadShell(
32 index % 2 === 0 ? environmentId : otherEnvironmentId,
33 makeRawThreadShell({
34 id: ThreadId.make(`thread-${index}`),
35 title: `Thread ${index}`,
36 settledOverride: index < 150 ? "active" : "settled",
37 settledAt: index < 150 ? null : DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"),
38 }),
39 ),
40 );
41 const environmentIds = new Set([environmentId, otherEnvironmentId]);
42 const input: Parameters<typeof useThreadListV2>[0] = {
43 threads,
44 pendingTasks: [],
45 environmentId: null,
46 projectRefs: null,
47 searchQuery: "",
48 searchMatches: new Map(),
49 settlementEnvironmentIds: environmentIds,
50 snoozeEnvironmentIds: environmentIds,
51 pinReorderEnvironmentIds: environmentIds,
52 activeReorderEnvironmentIds: environmentIds,
53 queuedThreadKeys: new Set(),
54 settledLimit: 10,
55 now: "2026-01-02T00:00:00.000Z",
56 pendingOrder: null,
57 shelfPreferencesLoaded: true,
58 workingShelfEnabled: true,
59 workingShelfExpanded: false,
60 snoozedShelfExpanded: false,
61 settledShelfExpanded: true,
62 selectedThreadKey: null,
63 };
64 const seen: Array<ReturnType<typeof useThreadListV2>> = [];
65 function Probe(props: { input: typeof input; tab: string }) {
66 return (
67 <RegistryContext value={registry}>
68 <ListProbe {...props} />
69 </RegistryContext>
70 );
71 }
72 function ListProbe(props: { input: typeof input; tab: string }) {
73 const result = useThreadListV2(props.input);
74 useLayoutEffect(() => {
75 seen.push(result);
76 });
77 return <div>{props.tab}</div>;
78 }
79​
80 const root = createRoot(document.createElement("div"));
81 using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
82 await act(async () => root.render(<Probe input={{ ...input }} tab="chats" />));
83 const initial = seen.at(-1)!;
84 expect(initial.layout.items).toHaveLength(160);
85 expect(initial.layout.settledCount).toBe(1050);
86​
87 for (const tab of ["pullRequests", "chats", "settings", "chats"]) {
88 await act(async () => root.render(<Probe input={{ ...input }} tab={tab} />));
89 expect(seen.at(-1)!.olderSettledShelf.isListed).toBe(initial.olderSettledShelf.isListed);
90 expect(seen.at(-1)!.olderSettledShelf.unloadedCount).toBe(
91 initial.olderSettledShelf.unloadedCount,
92 );
93 expect(seen.at(-1)!.layout).toBe(initial.layout);
94 expect(seen.at(-1)!.items).toBe(initial.items);
95 }
96​
97 const nextMinute = { ...input, now: "2026-01-02T00:01:00.000Z" };
98 await act(async () => root.render(<Probe input={nextMinute} tab="chats" />));
99 expect(seen.at(-1)!.layout).toBe(initial.layout);
100 expect(seen.at(-1)!.items).not.toBe(initial.items);
101 expect(seen.at(-1)!.items.find((item) => item.type === "v2-thread")).toMatchObject({
102 snoozePresetMinute: nextMinute.now,
103 });
104​
105 await act(async () => root.render(<Probe input={{ ...input, environmentId }} tab="chats" />));
106 expect(seen.at(-1)!.layout.items).toHaveLength(85);
107 expect(seen.at(-1)!.layout.settledCount).toBe(525);
108 expect(
109 seen.at(-1)!.layout.items.every((item) => item.thread.environmentId === environmentId),
110 ).toBe(true);
111​
112 const queuedThreadKeys = new Set([`${environmentId}:thread-150`]);
113 await act(async () => root.render(<Probe input={{ ...input, queuedThreadKeys }} tab="chats" />));
114 const queued = seen.at(-1)!;
115 expect(queued.layout.items).toHaveLength(161);
116 expect(queued.layout.settledCount).toBe(1049);
117 expect(
118 queued.items.find((item) => item.type === "v2-thread" && item.item.thread.id === "thread-150"),
119 ).toMatchObject({ hasQueuedMessages: true, item: { variant: "card" } });
120​
121 await act(async () => root.render(<Probe input={{ ...input, settledLimit: 35 }} tab="chats" />));
122 expect(seen.at(-1)!.layout.items).toHaveLength(185);
123​
124 const updatedThreads = threads.map((thread) =>
125 thread.id === "thread-0" ? { ...thread, title: "Updated title" } : thread,
126 );
127 await act(async () =>
128 root.render(<Probe input={{ ...input, threads: updatedThreads }} tab="chats" />),
129 );
130 expect(
131 seen.at(-1)!.layout.items.find((item) => item.thread.id === "thread-0")?.thread.title,
132 ).toBe("Updated title");
133​
134 await act(async () =>
135 root.render(
136 <Probe
137 input={{ ...input, threads: updatedThreads, searchQuery: "Updated title" }}
138 tab="chats"
139 />,
140 ),
141 );
142 expect(seen.at(-1)!.layout.items.map((item) => item.thread.title)).toEqual(["Updated title"]);
143 const search = seen.at(-1)!;
144 await act(async () =>
145 root.render(
146 <Probe
147 input={{ ...nextMinute, threads: updatedThreads, searchQuery: "Updated title" }}
148 tab="chats"
149 />,
150 ),
151 );
152 expect(seen.at(-1)!.layout).not.toBe(search.layout);
153 expect(seen.at(-1)!.layout.items.map((item) => item.thread.title)).toEqual(["Updated title"]);
154​
155 const clockThreads = threads.map((thread) =>
156 thread.id === "thread-0" ? { ...thread, snoozedUntil: "2026-01-02T00:00:30.000Z" } : thread,
157 );
158 const clockInput = { ...input, threads: clockThreads, snoozedShelfExpanded: true };
159 await act(async () => root.render(<Probe input={clockInput} tab="chats" />));
160 const beforeClock = seen.at(-1)!;
161 expect(beforeClock.layout.snoozedCount).toBe(1);
162 expect(beforeClock.layout.items.find((item) => item.thread.id === "thread-0")?.snoozed).toBe(
163 true,
164 );
165 await act(async () => root.render(<Probe input={clockInput} tab="settings" />));
166 expect(seen.at(-1)!.items).toBe(beforeClock.items);
167​
168 await act(async () =>
169 root.render(<Probe input={{ ...clockInput, now: "2026-01-02T00:00:10.000Z" }} tab="chats" />),
170 );
171 expect(seen.at(-1)!.layout).toBe(beforeClock.layout);
172 expect(seen.at(-1)!.layout.snoozedCount).toBe(1);
173​
174 await act(async () =>
175 root.render(<Probe input={{ ...clockInput, now: "2026-01-02T00:01:00.000Z" }} tab="chats" />),
176 );
177 const afterClock = seen.at(-1)!;
178 expect(afterClock.items).not.toBe(beforeClock.items);
179 expect(afterClock.layout.snoozedCount).toBe(0);
180 expect(afterClock.layout.items.find((item) => item.thread.id === "thread-0")?.snoozed).toBe(
181 false,
182 );
183 await act(async () =>
184 root.render(<Probe input={{ ...clockInput, now: "2026-01-02T00:02:00.000Z" }} tab="chats" />),
185 );
186 expect(seen.at(-1)!.layout).toBe(afterClock.layout);
187 expect(seen.at(-1)!.items).not.toBe(afterClock.items);
188​
189 await act(async () =>
190 root.render(<Probe input={{ ...clockInput, now: "2026-01-02T00:00:20.000Z" }} tab="chats" />),
191 );
192 expect(seen.at(-1)!.layout.snoozedCount).toBe(1);
193 expect(seen.at(-1)!.layout.items.find((item) => item.thread.id === "thread-0")?.snoozed).toBe(
194 true,
195 );
196 await act(async () => root.render(<Probe input={input} tab="chats" />));
197 const before = "2026-01-01T12:00:00.000Z";
198 const older = {
199 before,
200 beforeMs: Date.parse(before),
201 counts: new Map([[ProjectId.make("project-test"), 575]]),
202 };
203 await act(async () => {
204 registry.set(
205 shelfAtom,
206 new Map([
207 [
208 environmentId,
209 {
210 older,
211 pages: { reads: [], frontier: { settledAt: before }, status: "pending" },
212 },
213 ],
214 ]),
215 );
216 });
217 const loading = seen.at(-1)!;
218 expect(loading.layout.settledCount).toBe(1100);
219 expect(loading.layout.listedSettledThreads).toHaveLength(0);
220 expect(loading.layout.items).toHaveLength(150);
221 expect(loading.olderSettledShelf.isListed(threads[150]!)).toBe(false);
222 await act(async () => root.render(<Probe input={input} tab="settings" />));
223 expect(seen.at(-1)!.items).toBe(loading.items);
224​
225 await act(async () => {
226 registry.set(
227 shelfAtom,
228 new Map([
229 [
230 environmentId,
231 {
232 older,
233 pages: { reads: [], frontier: null, status: "ready" },
234 },
235 ],
236 ]),
237 );
238 });
239 const loaded = seen.at(-1)!;
240 expect(loaded.layout.settledCount).toBe(1100);
241 expect(loaded.layout.items).toHaveLength(160);
242 expect(loaded.olderSettledShelf.isListed(threads[150]!)).toBe(true);
243 expect(loaded.items).not.toBe(loading.items);
244});