Skip to content

Commit 5fac75c

Browse files
committed
test(web): a mounted ChatView lets go of the thread structures it replaced
The first test holds a WeakRef to each thread structure ChatView has shown and forces a GC: on main the four oldest of six are still alive, kept by the chain of render scopes behind the composer focus callbacks. The second file pins useComposerFocus: callers keep one identity, focus reaches the composer's current handle, and an open model picker keeps focus.
1 parent 25918c9 commit 5fac75c

2 files changed

Lines changed: 126 additions & 4 deletions

File tree

‎apps/web/src/components/ChatView.browser.test.tsx‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { createMemoryHistory } from "@tanstack/react-router";
2-
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
3-
import { ThreadId } from "@t3tools/contracts";
2+
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
3+
import { RunId, ThreadId } from "@t3tools/contracts";
44
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
55
import type * as Context from "effect/Context";
66
import { expect, it, vi } from "vite-plus/test";
7-
import { page } from "vite-plus/test/browser";
7+
import { cdp, page } from "vite-plus/test/browser";
88

99
import { AppRoot } from "~/AppRoot";
1010
// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines)
@@ -13,13 +13,18 @@ import "~/components/ChatView";
1313
import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
1414
import { __setClientSettingsForTests } from "~/hooks/useSettings";
1515
import { getRouter } from "~/router";
16+
import { appAtomRegistry } from "~/rpc/atomRegistry";
17+
import { environmentThreadDetails } from "~/state/threads";
1618
import {
1719
ENVIRONMENT_ID,
1820
PROJECT_ID,
21+
assistantItem,
22+
emitThreadEvent,
1923
installFakeEnvironment,
24+
threadWithHistory,
2025
type FakeDriver,
2126
} from "../../test/fakeEnvironment";
22-
import { render } from "../../test/render";
27+
import { act, render } from "../../test/render";
2328

2429
// The socket is the only fake layer; see test/appScenarios.renders.tsx.
2530
vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
@@ -65,3 +70,55 @@ it("a new draft does not load its reserved thread before the server lists it", a
6570
// A 404 here marks the thread deleted, and the view never loads it once the send creates it.
6671
expect(threadRequests).toEqual([]);
6772
});
73+
74+
it("a mounted thread view lets go of the thread structures it has replaced", async () => {
75+
// Items are shared with the fixture, so a reopen's snapshot also carries the earlier updates.
76+
const history = threadWithHistory("updated", 3);
77+
const items = [...history.items];
78+
const updated = { ...history, items };
79+
const other = threadWithHistory("other", 3);
80+
using _environment = installFakeEnvironment([updated, other]);
81+
__setClientSettingsForTests({
82+
...DEFAULT_CLIENT_SETTINGS,
83+
onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
84+
});
85+
const router = getRouter(createMemoryHistory({ initialEntries: ["/"] }));
86+
await router.load();
87+
await using _app = await render(<AppRoot router={router} />, { router: false });
88+
const open = async (threadId: ThreadId, text: string) => {
89+
await act(() =>
90+
router.navigate({
91+
to: "/$environmentId/$threadId",
92+
params: { environmentId: ENVIRONMENT_ID, threadId },
93+
}),
94+
);
95+
await expect.element(page.getByText(text).first()).toBeVisible();
96+
};
97+
const structureAtom = environmentThreadDetails.quietThreadAtom(
98+
scopeThreadRef(ENVIRONMENT_ID, updated.id),
99+
);
100+
101+
// Each round, a new answer arrives while the thread is shown, then the user switches away.
102+
const shown: Array<WeakRef<object>> = [];
103+
await open(other.id, "Answer 2 for other.");
104+
for (let round = 0; round < 6; round++) {
105+
await open(updated.id, round === 0 ? "Answer 2 for updated." : `Update ${round - 1}.`);
106+
const update = assistantItem(
107+
updated.id,
108+
RunId.make("updated-run-2"),
109+
`updated-update-${round}`,
110+
6 + round,
111+
`Update ${round}.`,
112+
);
113+
items.push(update);
114+
await act(() => emitThreadEvent(updated.id, { type: "turn-item.updated", payload: update }));
115+
await expect.element(page.getByText(`Update ${round}.`)).toBeVisible();
116+
shown.push(new WeakRef(appAtomRegistry.get(structureAtom)!.projection));
117+
await open(other.id, "Answer 2 for other.");
118+
}
119+
await cdp().send("HeapProfiler.collectGarbage");
120+
121+
// The thread keeps its latest structure, and the timeline may remember the one before it.
122+
const kept = shown.slice(0, -2).flatMap((structure, round) => (structure.deref() ? [round] : []));
123+
expect(kept).toEqual([]);
124+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { RefObject } from "react";
2+
import { expect, it, vi } from "vite-plus/test";
3+
4+
import type { ChatComposerHandle } from "./ChatComposer";
5+
import { registerModelPickerContent } from "../../modelPickerVisibility";
6+
import { render } from "../../../test/render";
7+
import { useComposerFocus } from "./useComposerFocus";
8+
9+
type Focus = ReturnType<typeof useComposerFocus>;
10+
11+
function Probe(props: {
12+
readonly composerRef: RefObject<ChatComposerHandle | null>;
13+
readonly onRender: (focus: Focus) => void;
14+
}) {
15+
props.onRender(useComposerFocus(props.composerRef));
16+
return null;
17+
}
18+
19+
const handle = (focusAtEnd: () => void) => ({ focusAtEnd }) as unknown as ChatComposerHandle;
20+
21+
it("focuses the composer's current handle without changing identity", async () => {
22+
const composerRef: RefObject<ChatComposerHandle | null> = { current: null };
23+
const renders: Focus[] = [];
24+
const onRender = (focus: Focus) => renders.push(focus);
25+
await using screen = await render(<Probe composerRef={composerRef} onRender={onRender} />);
26+
27+
const replaced = vi.fn();
28+
const current = vi.fn();
29+
composerRef.current = handle(replaced);
30+
// The composer replaces its handle as the thread changes, with no render here in between.
31+
composerRef.current = handle(current);
32+
renders.at(-1)!.focusComposer();
33+
expect(current).toHaveBeenCalledTimes(1);
34+
35+
await screen.rerender(<Probe composerRef={composerRef} onRender={onRender} />);
36+
expect(renders.at(-1)!.focusComposer).toBe(renders[0]!.focusComposer);
37+
expect(renders.at(-1)!.scheduleComposerFocus).toBe(renders[0]!.scheduleComposerFocus);
38+
expect(replaced).not.toHaveBeenCalled();
39+
});
40+
41+
it("schedules focus for the next frame unless the model picker is open by then", async () => {
42+
const focusAtEnd = vi.fn();
43+
const composerRef: RefObject<ChatComposerHandle | null> = { current: handle(focusAtEnd) };
44+
const renders: Focus[] = [];
45+
await using _screen = await render(
46+
<Probe composerRef={composerRef} onRender={(focus) => renders.push(focus)} />,
47+
);
48+
const { scheduleComposerFocus } = renders.at(-1)!;
49+
const frames: FrameRequestCallback[] = [];
50+
using _frames = vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => {
51+
frames.push(frame);
52+
return frames.length;
53+
});
54+
55+
scheduleComposerFocus();
56+
expect(focusAtEnd).not.toHaveBeenCalled();
57+
frames.shift()!(0);
58+
expect(focusAtEnd).toHaveBeenCalledTimes(1);
59+
60+
scheduleComposerFocus();
61+
const closeModelPicker = registerModelPickerContent();
62+
frames.shift()!(0);
63+
closeModelPicker();
64+
expect(focusAtEnd).toHaveBeenCalledTimes(1);
65+
});

0 commit comments

Comments
 (0)