From 6f2b8bce7e4c2a144dfe2bcf841a15660e2ce8f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 20:33:15 +0000 Subject: [PATCH 1/2] test(web): a mounted chat view keeps every thread structure it replaced Fails on main: ChatView's compiler-memoized focus callbacks chain old render scopes, so replaced thread structures survive a GC (#36383). Test taken from #31464. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ChatView.browser.test.tsx | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 46557548fec6..7b164e811542 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, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { ThreadId } from "@t3tools/contracts"; +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, userEvent } from "vite-plus/test/browser"; +import { cdp, page, userEvent } from "vite-plus/test/browser"; import { AppRoot } from "~/AppRoot"; // The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines) @@ -15,10 +15,14 @@ import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelSt import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter } from "~/router"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { environmentThreadDetails } from "~/state/threads"; import { ENVIRONMENT_ID, PROJECT_ID, + assistantItem, dispatchedCommands, + emitThreadEvent, installFakeEnvironment, rejectPendingCommand, threadWithHistory, @@ -303,3 +307,55 @@ it("opening a thread or returning to the window on a touch tablet does not focus vi.advanceTimersToNextFrame(); expect(document.activeElement).not.toBe(composer.element()); }); + +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([]); +}); From f88d1a113043f87271ca87a6db2714344868df8d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 20:37:56 +0000 Subject: [PATCH 2/2] fix(web): a mounted chat view no longer keeps every thread copy it replaced ChatView defined focusComposer and scheduleComposerFocus in its own render, so the React Compiler memoized them there and each one pinned that render's scope, which held the previous render's handlers. Old renders and their thread projections formed a chain freed only on unmount. Make both callbacks in a small useComposerFocus hook instead, and pass only the stable versions on, including to useComposerSelection. Fixes pingdotgg/t3code#36383 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.tsx | 19 +++++-------------- .../src/components/chat/useComposerFocus.ts | 18 ++++++++++++++++++ 2 files changed, 23 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 4a964ad24dbd..754edc3912db 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -19,6 +19,7 @@ import { import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; import { usePullRequestLinking } from "../hooks/usePullRequestLinking"; import { useComposerSelection } from "./chat/useComposerSelection"; +import { useComposerFocus } from "./chat/useComposerFocus"; import { useProjectCloneBanner } from "./chat/useProjectCloneBanner"; import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff"; import { @@ -195,7 +196,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"; @@ -1833,10 +1833,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 () => { @@ -1863,13 +1861,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; @@ -1884,7 +1875,7 @@ export default function ChatView(props: ChatViewProps) { }); return; } - scheduleComposerFocus(); + scheduleComposerFocusStable(); }; const insertArtifactTemplateStable = useStableCallback(insertArtifactTemplate); const sendAppMessage = async (text: string) => { @@ -2890,7 +2881,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..2dbf82308f45 --- /dev/null +++ b/apps/web/src/components/chat/useComposerFocus.ts @@ -0,0 +1,18 @@ +import type { ComposerHandleRef } from "../../composerHandleContext"; +import { useStableCallback } from "../../hooks/useStableCallback"; +import { isModelPickerOpen } from "../../modelPickerVisibility"; + +// Kept out of ChatView: a compiler-memoized closure there pins its render's scope, which pins the +// previous render's, so every thread copy ChatView showed stayed alive 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 }; +}