11import { 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" ;
44import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings" ;
55import type * as Context from "effect/Context" ;
66import { 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
99import { 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";
1313import { DraftId , useComposerDraftStore } from "~/composerDraftStore" ;
1414import { __setClientSettingsForTests } from "~/hooks/useSettings" ;
1515import { getRouter } from "~/router" ;
16+ import { appAtomRegistry } from "~/rpc/atomRegistry" ;
17+ import { environmentThreadDetails } from "~/state/threads" ;
1618import {
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.
2530vi . 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+ } ) ;
0 commit comments