Open History to see the last commit
1// Render scenarios for scripts/render-metrics.ts. Each one mounts real components on
2// fixed fixtures, counts React commits and component renders while it drives them,
3// and writes the counts through the writeRenderReport browser command. They mount the
4// components ChatView composes, not ChatView, which needs a live environment connection.
5import { EnvironmentId, MessageId } from "@t3tools/contracts";
6import type { LegendListRef } from "@legendapp/list/react";
7import { createRef, useRef, useState, type ComponentProps, type ReactNode } from "react";
8import { expect, it, vi } from "vite-plus/test";
9import { commands, userEvent } from "vite-plus/test/browser";
10
11import "~/index.css";
12
13import { MessagesTimeline } from "~/components/chat/MessagesTimeline";
14import {
15 ComposerPromptEditorTiptap as ComposerPromptEditor,
16 type ComposerPromptEditorHandle,
17} from "~/components/ComposerPromptEditorTiptap";
18import { commitsSoFar, startCounting, stopCounting } from "./renderCounter";
19import { render } from "./render";
20
21declare module "vite-plus/test/browser" {
22 interface BrowserCommands {
23 /** Defined in vite.config.ts for the renders project. */
24 writeRenderReport: (scenario: string, json: string) => Promise<void>;
25 }
26}
27
28vi.mock(import("~/state/query"), async (original) => ({
29 ...(await original()),
30 useEnvironmentQuery: (() => ({
31 data: { tasks: [] },
32 dataUpdatedAt: 0,
33 error: null,
34 failure: null,
35 isPending: false,
36 isSuccess: true,
37 refresh: () => {},
38 })) as never,
39}));
40
41const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
42const CREATED_AT = "2026-03-17T19:12:28.000Z";
43const HISTORY_TURNS = 20;
44const STREAM_DELTAS = 30;
45const TYPED_TEXT = "fix the flaky tests!";
46
47type TimelineProps = ComponentProps<typeof MessagesTimeline>;
48type Entry = TimelineProps["timelineEntries"][number];
49
50function message(id: string, role: "user" | "assistant", text: string, streaming = false): Entry {
51 return {
52 id: `entry-${id}`,
53 kind: "message",
54 createdAt: CREATED_AT,
55 message: {
56 id: MessageId.make(id),
57 role,
58 text,
59 runId: null,
60 createdAt: CREATED_AT,
61 updatedAt: CREATED_AT,
62 streaming,
63 },
64 } as Entry;
65}
66
67// A thread with history: user prompts and markdown answers with lists and inline code.
68// No fenced code: Shiki highlights it asynchronously, and whether a block first renders
69// its fallback varies run to run.
70function history(thread: string): Entry[] {
71 return Array.from({ length: HISTORY_TURNS }, (_, turn) => [
72 message(`${thread}-user-${turn}`, "user", `${thread}: question ${turn} about the sidebar`),
73 message(
74 `${thread}-assistant-${turn}`,
75 "assistant",
76 `Answer ${turn} for ${thread}.\n\n- checked \`Sidebar.tsx\`\n- ran the tests\n\n` +
77 "The width is now `clamp(next, MIN_WIDTH, MAX_WIDTH)`, so the layout no longer jumps.",
78 ),
79 ]).flat();
80}
81
82// Props that some trees under comparison have dropped. A JSX spread is not checked for
83// excess properties, so this file type-checks on trees with and without them.
84const versionedProps = { onManualNavigation: () => {} };
85
86// One list handle per mounted timeline, as ChatView keeps one: the timeline compares the
87// handle across renders, and a new one per render would skip its scroll-to-end and settle work.
88// The callbacks and lists stay the same across renders too, as ChatView's do: new ones change
89// the rows' shared context, which re-renders every row and hides a row that re-renders alone.
90function timelineFor() {
91 const props: Omit<TimelineProps, "timelineEntries" | "routeThreadKey"> = {
92 isWorking: false,
93 listRef: createRef<LegendListRef | null>(),
94 latestRun: null,
95 turnDiffSummaries: [],
96 runs: [],
97 onOpenTurnDiff: () => {},
98 onOpenThread: () => {},
99 onForkFromRun: async () => {},
100 supportsConversationRollback: false,
101 onRevertToTurnCount: () => {},
102 isRevertingCheckpoint: false,
103 onImageExpand: () => {},
104 activeThreadEnvironmentId: ENVIRONMENT_ID,
105 markdownCwd: undefined,
106 resolvedTheme: "light",
107 timestampFormat: "locale",
108 workspaceRoot: undefined,
109 anchorMessageId: null,
110 onAnchorReady: () => {},
111 onAnchorSizeChanged: () => {},
112 contentInsetEndAdjustment: 0,
113 emptyPlaceholderInset: 0,
114 liveFollowEnabled: true,
115 onIsAtEndChange: () => {},
116 };
117 return (thread: string, entries: Entry[]) => (
118 <MessagesTimeline
119 {...props}
120 timelineEntries={entries}
121 routeThreadKey={`environment-local:${thread}`}
122 {...versionedProps}
123 />
124 );
125}
126
127function sizedContainer() {
128 const container = document.createElement("div");
129 container.style.cssText = "height: 900px; width: 900px; display: flex; flex-direction: column;";
130 return container;
131}
132
133// Legend List measures rows, and the minimap follows scroll, on later frames, and how
134// much of that lands inside a window varies run to run. Every window opens and closes
135// on a quiet tree: no commit across two consecutive checks.
136async function quiet() {
137 let seen = -1;
138 await vi.waitFor(
139 () => {
140 const now = commitsSoFar();
141 const settled = now === seen;
142 seen = now;
143 expect(settled).toBe(true);
144 },
145 { interval: 100, timeout: 10_000 },
146 );
147}
148
149// Off the guest page the connection runtime retries a missing environment on timers, and
150// its atom updates commit at varying times. On it, the runtime fails once and stays quiet.
151function onGuestPage(): Disposable {
152 const previous = `${window.location.pathname}${window.location.search}`;
153 window.history.replaceState(window.history.state, "", "/guest?share=render-scenarios");
154 return {
155 [Symbol.dispose]: () => window.history.replaceState(window.history.state, "", previous),
156 };
157}
158
159// A first mount loads lazy modules, web fonts and syntax highlighting at varying times.
160// Each scenario mounts its tree once uncounted, so the counted run starts warm.
161async function warmUp(ui: ReactNode, container?: HTMLElement) {
162 await using screen = await render(ui, container ? { container } : {});
163 await document.fonts.ready;
164 await quiet();
165 expect(screen.container.childElementCount).toBeGreaterThan(0);
166}
167
168async function report(scenario: string) {
169 await quiet();
170 const counts = stopCounting();
171 await commands.writeRenderReport(scenario, JSON.stringify(counts));
172}
173
174it("mount-thread", async () => {
175 using _guest = onGuestPage();
176 const timeline = timelineFor();
177 await warmUp(timelineFor()("thread-warm", history("thread-warm")), sizedContainer());
178 startCounting();
179 await using screen = await render(timeline("thread-a", history("thread-a")), {
180 container: sizedContainer(),
181 });
182 await expect.element(screen.getByText(`Answer ${HISTORY_TURNS - 1} for thread-a.`)).toBeVisible();
183 await report("mount-thread");
184});
185
186it("stream-assistant-turn", async () => {
187 using _guest = onGuestPage();
188 const timeline = timelineFor();
189 const base = history("thread-a");
190 const streamed = (text: string) => [
191 ...base,
192 message("live-user", "user", "Continue"),
193 message("live-assistant", "assistant", text, true),
194 ];
195 await warmUp(timelineFor()("thread-warm", streamed("warm")), sizedContainer());
196 await using screen = await render(timeline("thread-a", streamed("")), {
197 container: sizedContainer(),
198 });
199 await expect.element(screen.getByText("Continue")).toBeVisible();
200 await quiet();
201
202 startCounting();
203 let text = "";
204 for (let delta = 0; delta < STREAM_DELTAS; delta++) {
205 text += `word${delta} `;
206 await screen.rerender(timeline("thread-a", streamed(text)));
207 }
208 await expect
209 .element(screen.getByText(`word${STREAM_DELTAS - 1}`, { exact: false }))
210 .toBeVisible();
211 await report("stream-assistant-turn");
212});
213
214// Thread A to B and back, each switch counted until the tree is quiet again. Commits
215// are not reported: the minimap's follow-up renders sometimes split across two frames,
216// so the commit count varies by one while the renders stay identical.
217it("switch-thread", async () => {
218 using _guest = onGuestPage();
219 const timeline = timelineFor();
220 const a = history("thread-a");
221 const b = history("thread-b");
222 const lastAnswer = (thread: string) =>
223 screen.getByText(`Answer ${HISTORY_TURNS - 1} for ${thread}.`);
224 await warmUp(timelineFor()("thread-warm", a), sizedContainer());
225 await using screen = await render(timeline("thread-a", a), { container: sizedContainer() });
226 await expect.element(lastAnswer("thread-a")).toBeVisible();
227
228 await quiet();
229 startCounting();
230 await screen.rerender(timeline("thread-b", b));
231 await expect.element(lastAnswer("thread-b")).toBeVisible();
232 await quiet();
233 const toB = stopCounting();
234 startCounting();
235 await screen.rerender(timeline("thread-a", a));
236 await expect.element(lastAnswer("thread-a")).toBeVisible();
237 await quiet();
238 const toA = stopCounting();
239
240 const components = { ...toB.components };
241 for (const [name, count] of Object.entries(toA.components)) {
242 components[name] = (components[name] ?? 0) + count;
243 }
244 await commands.writeRenderReport(
245 "switch-thread",
246 JSON.stringify({
247 commits: null,
248 renders: toB.renders + toA.renders,
249 components,
250 }),
251 );
252});
253
254function Composer() {
255 const [prompt, setPrompt] = useState({ value: "", cursor: 0 });
256 const editorRef = useRef<ComposerPromptEditorHandle>(null);
257 return (
258 <ComposerPromptEditor
259 editorRef={editorRef}
260 value={prompt.value}
261 cursor={prompt.cursor}
262 richTextEnabled
263 contextRecords={new Map()}
264 skills={[]}
265 disabled={false}
266 placeholder="Ask anything"
267 onChange={(value, cursor) => setPrompt({ value, cursor })}
268 onPaste={() => {}}
269 />
270 );
271}
272
273it("type-in-composer", async () => {
274 using _guest = onGuestPage();
275 await warmUp(<Composer />);
276 await using screen = await render(<Composer />);
277 const editor = screen.getByRole("textbox");
278 await userEvent.click(editor);
279 await quiet();
280
281 startCounting();
282 await userEvent.type(editor, TYPED_TEXT);
283 await expect.element(editor).toHaveTextContent(TYPED_TEXT);
284 await report("type-in-composer");
285});