11import { act , fireEvent , render , screen } from "@testing-library/react" ;
22import { beforeEach , describe , expect , it , vi } from "vitest" ;
33import { useAppStore } from "@/renderer/state/appStore" ;
4- import { ChatPaneActionsContext , useChatPaneActions } from "../chatPaneActionsContext" ;
4+ import {
5+ ChatPaneActionsContext ,
6+ useChatPaneActions ,
7+ type ChatPaneActions ,
8+ } from "../chatPaneActionsContext" ;
59import { MessageList } from "./MessageList" ;
610
711type MockVirtualRow = {
@@ -15,6 +19,8 @@ type MockVirtualizer = {
1519 getTotalSize : ( ) => number ;
1620 measure : ( ) => void ;
1721 measureElement : ( element : HTMLDivElement | null ) => void ;
22+ resizeItem : ( index : number , size : number ) => void ;
23+ options : { measureElement : ( element : Element , entry : undefined , instance : unknown ) => number } ;
1824 scrollToIndex : ( index : number , options ?: { align ?: "start" | "center" | "end" | "auto" } ) => void ;
1925 shouldAdjustScrollPositionOnItemSizeChange ?: (
2026 item : { start : number ; size : number } ,
@@ -34,13 +40,18 @@ const {
3440 useVirtualizerMock,
3541 measureMock,
3642 measureElementMock,
43+ resizeItemMock,
44+ optionsMeasureElementMock,
3745 scrollToIndexMock,
3846 getVirtualItemsMock,
3947 getTotalSizeMock,
4048} = vi . hoisted ( ( ) => ( {
4149 useVirtualizerMock : vi . fn < ( options : MockVirtualizerOptions ) => MockVirtualizer > ( ) ,
4250 measureMock : vi . fn < ( ) => void > ( ) ,
4351 measureElementMock : vi . fn < ( element : HTMLDivElement | null ) => void > ( ) ,
52+ resizeItemMock : vi . fn < ( index : number , size : number ) => void > ( ) ,
53+ optionsMeasureElementMock :
54+ vi . fn < ( element : Element , entry : undefined , instance : unknown ) => number > ( ) ,
4455 scrollToIndexMock :
4556 vi . fn < ( index : number , options ?: { align ?: "start" | "center" | "end" | "auto" } ) => void > ( ) ,
4657 getVirtualItemsMock : vi . fn < ( ) => MockVirtualRow [ ] > ( ) ,
@@ -77,11 +88,14 @@ describe("MessageList", () => {
7788 { key : "row-3" , index : 2 , start : 192 } ,
7889 ] ) ;
7990 getTotalSizeMock . mockReturnValue ( 384 ) ;
91+ optionsMeasureElementMock . mockReturnValue ( 96 ) ;
8092 useVirtualizerMock . mockReturnValue ( {
8193 getVirtualItems : getVirtualItemsMock ,
8294 getTotalSize : getTotalSizeMock ,
8395 measure : measureMock ,
8496 measureElement : measureElementMock ,
97+ resizeItem : resizeItemMock ,
98+ options : { measureElement : optionsMeasureElementMock } ,
8599 scrollToIndex : scrollToIndexMock ,
86100 } ) ;
87101 } ) ;
@@ -140,78 +154,40 @@ describe("MessageList", () => {
140154 expect ( document . querySelector ( "[data-item-id='item-2']" ) ) . not . toHaveAttribute ( "style" ) ;
141155 } ) ;
142156
143- it ( "only adjusts scroll for measured rows fully above the viewport" , ( ) => {
144- const scrollElement = document . createElement ( "div" ) ;
145- scrollElement . scrollTop = 160 ;
146-
147- render (
148- < MessageList
149- threadId = "thread-1"
150- entries = { makeEntries ( [ "item-1" , "item-2" , "item-3" , "item-4" ] ) }
151- scrollElement = { scrollElement }
152- /> ,
153- ) ;
157+ it ( "never lets TanStack adjust scroll itself and compensates rows fully above the viewport on the next commit" , ( ) => {
158+ const { scrollElement, shouldAdjust, commit } = renderCompensationList ( ) ;
154159
155- const virtualizer = useVirtualizerMock . mock . results [ 0 ] ! . value ;
156- const shouldAdjust = virtualizer . shouldAdjustScrollPositionOnItemSizeChange ! ;
160+ expect ( shouldAdjust ( { start : 0 , size : 80 } , 40 , idleVirtualizer ) ) . toBe ( false ) ;
157161
158- const idleVirtualizer = { isScrolling : false , scrollDirection : null } as const ;
159- expect ( shouldAdjust ( { start : 0 , size : 80 } , 40 , idleVirtualizer ) ) . toBe ( true ) ;
160- expect ( shouldAdjust ( { start : 96 , size : 100 } , 40 , idleVirtualizer ) ) . toBe ( false ) ;
162+ commit ( ) ;
163+ expect ( scrollElement . scrollTop ) . toBe ( 200 ) ;
161164 } ) ;
162165
163- it ( "does not adjust scroll for rows above the viewport during active upward scroll" , ( ) => {
164- const scrollElement = document . createElement ( "div" ) ;
165- scrollElement . scrollTop = 160 ;
166+ it ( "does not compensate rows that overlap or sit below the viewport" , ( ) => {
167+ const { scrollElement, shouldAdjust, commit } = renderCompensationList ( ) ;
166168
167- render (
168- < MessageList
169- threadId = "thread-1"
170- entries = { makeEntries ( [ "item-1" , "item-2" , "item-3" , "item-4" ] ) }
171- scrollElement = { scrollElement }
172- /> ,
173- ) ;
174-
175- const virtualizer = useVirtualizerMock . mock . results [ 0 ] ! . value ;
176- const shouldAdjust = virtualizer . shouldAdjustScrollPositionOnItemSizeChange ! ;
169+ expect ( shouldAdjust ( { start : 96 , size : 100 } , 40 , idleVirtualizer ) ) . toBe ( false ) ;
177170
178- expect (
179- shouldAdjust ( { start : 0 , size : 80 } , - 40 , {
180- isScrolling : true ,
181- scrollDirection : "backward" ,
182- } ) ,
183- ) . toBe ( false ) ;
171+ commit ( ) ;
172+ expect ( scrollElement . scrollTop ) . toBe ( 160 ) ;
184173 } ) ;
185174
186- it ( "does not adjust scroll for delayed row measurements after scrolling upward" , ( ) => {
187- const scrollElement = document . createElement ( "div" ) ;
188- scrollElement . scrollTop = 160 ;
189-
190- render (
191- < MessageList
192- threadId = "thread-1"
193- entries = { makeEntries ( [ "item-1" , "item-2" , "item-3" , "item-4" ] ) }
194- scrollElement = { scrollElement }
195- /> ,
196- ) ;
197-
198- const virtualizer = useVirtualizerMock . mock . results [ 0 ] ! . value ;
199- const shouldAdjust = virtualizer . shouldAdjustScrollPositionOnItemSizeChange ! ;
175+ it ( "compensates rows above the viewport during active upward scroll" , ( ) => {
176+ const { scrollElement, shouldAdjust, commit } = renderCompensationList ( ) ;
200177
201178 scrollElement . scrollTop = 120 ;
202- fireEvent . scroll ( scrollElement ) ;
203-
204179 expect (
205180 shouldAdjust ( { start : 0 , size : 80 } , - 40 , {
206- isScrolling : false ,
207- scrollDirection : null ,
181+ isScrolling : true ,
182+ scrollDirection : "backward" ,
208183 } ) ,
209184 ) . toBe ( false ) ;
185+
186+ commit ( ) ;
187+ expect ( scrollElement . scrollTop ) . toBe ( 80 ) ;
210188 } ) ;
211189
212- it ( "adjusts streaming row height changes when bottom-sticky" , ( ) => {
213- const scrollElement = document . createElement ( "div" ) ;
214- scrollElement . scrollTop = 160 ;
190+ it ( "compensates streaming row height changes when bottom-sticky" , ( ) => {
215191 const actions = {
216192 openProjectRelativePath : vi . fn < ( path : string , lineNumber ?: number ) => void > ( ) ,
217193 revealProjectFolderInTree : vi . fn < ( path : string ) => void > ( ) ,
@@ -221,26 +197,28 @@ describe("MessageList", () => {
221197 projectLocation : { kind : "windows" as const , path : "C:\\repo" } ,
222198 projectRootNames : new Set < string > ( ) ,
223199 } ;
200+ const { scrollElement, shouldAdjust, commit } = renderCompensationList ( actions ) ;
201+
202+ expect ( shouldAdjust ( { start : 96 , size : 100 } , 24 , idleVirtualizer ) ) . toBe ( false ) ;
203+
204+ commit ( ) ;
205+ expect ( scrollElement . scrollTop ) . toBe ( 184 ) ;
206+ } ) ;
207+
208+ it ( "measures newly mounted rows synchronously so the size correction lands in the mount commit" , ( ) => {
209+ const scrollElement = document . createElement ( "div" ) ;
210+ optionsMeasureElementMock . mockReturnValue ( 132 ) ;
224211
225212 render (
226- < ChatPaneActionsContext . Provider value = { actions } >
227- < MessageList
228- threadId = "thread-1"
229- entries = { makeEntries ( [ "item-1" , "item-2" , "item-3" , "item-4" ] ) }
230- scrollElement = { scrollElement }
231- />
232- </ ChatPaneActionsContext . Provider > ,
213+ < MessageList
214+ threadId = "thread-1"
215+ entries = { makeEntries ( [ "item-1" , "item-2" , "item-3" , "item-4" ] ) }
216+ scrollElement = { scrollElement }
217+ /> ,
233218 ) ;
234219
235- const virtualizer = useVirtualizerMock . mock . results [ 0 ] ! . value ;
236- const shouldAdjust = virtualizer . shouldAdjustScrollPositionOnItemSizeChange ! ;
237-
238- expect (
239- shouldAdjust ( { start : 96 , size : 100 } , 24 , {
240- isScrolling : false ,
241- scrollDirection : null ,
242- } ) ,
243- ) . toBe ( true ) ;
220+ expect ( resizeItemMock ) . toHaveBeenCalledWith ( 1 , 132 ) ;
221+ expect ( resizeItemMock ) . toHaveBeenCalledWith ( 2 , 132 ) ;
244222 } ) ;
245223
246224 it ( "registers TanStack scrollToIndex as the bottom scroll handler" , ( ) => {
@@ -479,3 +457,35 @@ describe("MessageList", () => {
479457function makeEntries ( itemIds : readonly string [ ] ) {
480458 return itemIds . map ( ( id ) => ( { kind : "item" as const , id } ) ) ;
481459}
460+
461+ const idleVirtualizer = { isScrolling : false , scrollDirection : null } as const ;
462+
463+ /**
464+ * Renders a MessageList wired for the scroll-compensation tests and returns
465+ * the intercepted size-change predicate plus a `commit` that re-renders so the
466+ * pending compensation layout effect applies.
467+ */
468+ function renderCompensationList ( actions ?: ChatPaneActions ) {
469+ const scrollElement = document . createElement ( "div" ) ;
470+ scrollElement . scrollTop = 160 ;
471+ const entries = makeEntries ( [ "item-1" , "item-2" , "item-3" , "item-4" ] ) ;
472+ // A fresh element per render: re-passing the identical element would let
473+ // React bail out and skip the commit the compensation effect runs in.
474+ const makeUi = ( ) => {
475+ const list = (
476+ < MessageList threadId = "thread-1" entries = { entries } scrollElement = { scrollElement } />
477+ ) ;
478+ return actions ? (
479+ < ChatPaneActionsContext . Provider value = { actions } > { list } </ ChatPaneActionsContext . Provider >
480+ ) : (
481+ list
482+ ) ;
483+ } ;
484+ const { rerender } = render ( makeUi ( ) ) ;
485+ const virtualizer = useVirtualizerMock . mock . results [ 0 ] ! . value ;
486+ return {
487+ scrollElement,
488+ shouldAdjust : virtualizer . shouldAdjustScrollPositionOnItemSizeChange ! ,
489+ commit : ( ) => rerender ( makeUi ( ) ) ,
490+ } ;
491+ }
0 commit comments