From 25918c937ba13dae196f4bb82c67c0f834923417 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:17:08 +0100 Subject: [PATCH 1/2] perf(web): ChatView no longer keeps every thread copy it has shown ChatView's composer focus callbacks were made in its render. The React Compiler memoizes them, and a closure keeps its render's whole scope, the thread projection included. That scope held the memoized callbacks of an earlier render, which kept that render's scope, and so on. So while ChatView stayed mounted, every copy of a thread it had received (each reopen that loads the thread again delivers a new one) stayed in memory. The callbacks now come from a small hook whose scope holds only the composer ref, which breaks the chain. Browser harness (whole app, fake environment, 20 threads x 600 items, forced GC), 10 switches between two threads that each deliver a new copy, median of 5 alternating runs per arm: heap growth 60.1 -> 6.3 MB; heap after the switches 432.5 -> 378.5 MB. --- apps/web/src/components/ChatView.tsx | 19 +++++------------ .../src/components/chat/useComposerFocus.ts | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/components/chat/useComposerFocus.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d1225065aeec..14d43e3cea58 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -183,7 +183,6 @@ import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { shortcutLabelForCommand } from "../keybindings"; import { cn } from "~/lib/utils"; -import { isModelPickerOpen } from "../modelPickerVisibility"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; @@ -345,6 +344,7 @@ import { } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; +import { useComposerFocus } from "./chat/useComposerFocus"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { assetEnvironment } from "../state/assets"; @@ -1801,10 +1801,8 @@ export default function ChatView(props: ChatViewProps) { sendInFlightRef, }); - const focusComposer = () => { - composerRef.current?.focusAtEnd(); - }; - const focusComposerStable = useStableCallback(focusComposer); + const { focusComposer: focusComposerStable, scheduleComposerFocus: scheduleComposerFocusStable } = + useComposerFocus(composerRef); const canInterruptRunningThread = canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime); const onInterrupt = async () => { @@ -1831,13 +1829,6 @@ export default function ChatView(props: ChatViewProps) { () => (embedded ? undefined : subscribeSnapShotComposerFocus(focusComposerStable)), [embedded, focusComposerStable], ); - const scheduleComposerFocus = () => { - window.requestAnimationFrame(() => { - // The model picker stays open through its own changes, so it keeps focus. - if (!isModelPickerOpen()) focusComposer(); - }); - }; - const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus); const insertArtifactTemplate = (template: CodexArtifactTemplate) => { const composer = composerRef.current; if (!composer) return; @@ -1852,7 +1843,7 @@ export default function ChatView(props: ChatViewProps) { }); return; } - scheduleComposerFocus(); + scheduleComposerFocusStable(); }; const insertArtifactTemplateStable = useStableCallback(insertArtifactTemplate); const sendAppMessage = async (text: string) => { @@ -2846,7 +2837,7 @@ export default function ChatView(props: ChatViewProps) { canOverrideServerThreadEnvMode, setPendingServerThreadEnvModeEntry, setPendingServerThreadStartFromOriginByThreadId, - scheduleComposerFocus, + scheduleComposerFocus: scheduleComposerFocusStable, }); const { onResume, isResuming } = useComposerResume({ environmentId, diff --git a/apps/web/src/components/chat/useComposerFocus.ts b/apps/web/src/components/chat/useComposerFocus.ts new file mode 100644 index 000000000000..38158e26033a --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocus.ts @@ -0,0 +1,21 @@ +import type { ComposerHandleRef } from "../../composerHandleContext"; +import { useStableCallback } from "../../hooks/useStableCallback"; +import { isModelPickerOpen } from "../../modelPickerVisibility"; + +/** + * Made outside ChatView's render: a memoized closure there keeps that render's scope, thread + * projection included, and the scope keeps the previous render's, so every thread copy + * ChatView showed stayed in memory until it unmounted. + */ +export function useComposerFocus(composerRef: ComposerHandleRef) { + const focusComposer = useStableCallback(() => { + composerRef.current?.focusAtEnd(); + }); + const scheduleComposerFocus = useStableCallback(() => { + window.requestAnimationFrame(() => { + // The model picker stays open through its own changes, so it keeps focus. + if (!isModelPickerOpen()) focusComposer(); + }); + }); + return { focusComposer, scheduleComposerFocus }; +} From 5fac75c784dc5324dcfe8f5995626df1b375dca7 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:19:51 +0100 Subject: [PATCH 2/2] 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. --- .../src/components/ChatView.browser.test.tsx | 65 +++++++++++++++++-- .../chat/useComposerFocus.browser.test.tsx | 65 +++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/chat/useComposerFocus.browser.test.tsx diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 96eb837db4d6..6b3668099b02 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -1,10 +1,10 @@ import { createMemoryHistory } from "@tanstack/react-router"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { ThreadId } from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { RunId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; import { expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { cdp, page } from "vite-plus/test/browser"; import { AppRoot } from "~/AppRoot"; // The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines) @@ -13,13 +13,18 @@ import "~/components/ChatView"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { getRouter } from "~/router"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { environmentThreadDetails } from "~/state/threads"; import { ENVIRONMENT_ID, PROJECT_ID, + assistantItem, + emitThreadEvent, installFakeEnvironment, + threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; -import { render } from "../../test/render"; +import { act, render } from "../../test/render"; // The socket is the only fake layer; see test/appScenarios.renders.tsx. 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 // A 404 here marks the thread deleted, and the view never loads it once the send creates it. expect(threadRequests).toEqual([]); }); + +it("a mounted thread view lets go of the thread structures it has replaced", async () => { + // Items are shared with the fixture, so a reopen's snapshot also carries the earlier updates. + const history = threadWithHistory("updated", 3); + const items = [...history.items]; + const updated = { ...history, items }; + const other = threadWithHistory("other", 3); + using _environment = installFakeEnvironment([updated, other]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const router = getRouter(createMemoryHistory({ initialEntries: ["/"] })); + await router.load(); + await using _app = await render(, { router: false }); + const open = async (threadId: ThreadId, text: string) => { + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId }, + }), + ); + await expect.element(page.getByText(text).first()).toBeVisible(); + }; + const structureAtom = environmentThreadDetails.quietThreadAtom( + scopeThreadRef(ENVIRONMENT_ID, updated.id), + ); + + // Each round, a new answer arrives while the thread is shown, then the user switches away. + const shown: Array> = []; + await open(other.id, "Answer 2 for other."); + for (let round = 0; round < 6; round++) { + await open(updated.id, round === 0 ? "Answer 2 for updated." : `Update ${round - 1}.`); + const update = assistantItem( + updated.id, + RunId.make("updated-run-2"), + `updated-update-${round}`, + 6 + round, + `Update ${round}.`, + ); + items.push(update); + await act(() => emitThreadEvent(updated.id, { type: "turn-item.updated", payload: update })); + await expect.element(page.getByText(`Update ${round}.`)).toBeVisible(); + shown.push(new WeakRef(appAtomRegistry.get(structureAtom)!.projection)); + await open(other.id, "Answer 2 for other."); + } + await cdp().send("HeapProfiler.collectGarbage"); + + // The thread keeps its latest structure, and the timeline may remember the one before it. + const kept = shown.slice(0, -2).flatMap((structure, round) => (structure.deref() ? [round] : [])); + expect(kept).toEqual([]); +}); diff --git a/apps/web/src/components/chat/useComposerFocus.browser.test.tsx b/apps/web/src/components/chat/useComposerFocus.browser.test.tsx new file mode 100644 index 000000000000..e153c2669170 --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocus.browser.test.tsx @@ -0,0 +1,65 @@ +import type { RefObject } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import type { ChatComposerHandle } from "./ChatComposer"; +import { registerModelPickerContent } from "../../modelPickerVisibility"; +import { render } from "../../../test/render"; +import { useComposerFocus } from "./useComposerFocus"; + +type Focus = ReturnType; + +function Probe(props: { + readonly composerRef: RefObject; + readonly onRender: (focus: Focus) => void; +}) { + props.onRender(useComposerFocus(props.composerRef)); + return null; +} + +const handle = (focusAtEnd: () => void) => ({ focusAtEnd }) as unknown as ChatComposerHandle; + +it("focuses the composer's current handle without changing identity", async () => { + const composerRef: RefObject = { current: null }; + const renders: Focus[] = []; + const onRender = (focus: Focus) => renders.push(focus); + await using screen = await render(); + + const replaced = vi.fn(); + const current = vi.fn(); + composerRef.current = handle(replaced); + // The composer replaces its handle as the thread changes, with no render here in between. + composerRef.current = handle(current); + renders.at(-1)!.focusComposer(); + expect(current).toHaveBeenCalledTimes(1); + + await screen.rerender(); + expect(renders.at(-1)!.focusComposer).toBe(renders[0]!.focusComposer); + expect(renders.at(-1)!.scheduleComposerFocus).toBe(renders[0]!.scheduleComposerFocus); + expect(replaced).not.toHaveBeenCalled(); +}); + +it("schedules focus for the next frame unless the model picker is open by then", async () => { + const focusAtEnd = vi.fn(); + const composerRef: RefObject = { current: handle(focusAtEnd) }; + const renders: Focus[] = []; + await using _screen = await render( + renders.push(focus)} />, + ); + const { scheduleComposerFocus } = renders.at(-1)!; + const frames: FrameRequestCallback[] = []; + using _frames = vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => { + frames.push(frame); + return frames.length; + }); + + scheduleComposerFocus(); + expect(focusAtEnd).not.toHaveBeenCalled(); + frames.shift()!(0); + expect(focusAtEnd).toHaveBeenCalledTimes(1); + + scheduleComposerFocus(); + const closeModelPicker = registerModelPickerContent(); + frames.shift()!(0); + closeModelPicker(); + expect(focusAtEnd).toHaveBeenCalledTimes(1); +});