77using OpenClaw . Chat ;
88using OpenClawTray . Chat ;
99using OpenClawTray . FunctionalUI ;
10+ using OpenClawTray . FunctionalUI . Core ;
1011using OpenClawTray . FunctionalUI . Hosting ;
1112using Windows . Graphics ;
1213using static OpenClawTray . FunctionalUI . Factories ;
@@ -30,6 +31,7 @@ public async Task LargeNativeChatTimeline_VirtualizesRowsAndFollowsNewMessages()
3031
3132 var props = BuildProps ( InitialRows , scrollToBottomToken : 0 ) ;
3233 FunctionalHostControl ? host = null ;
34+ var initialCachedVirtualControls = 0 ;
3335
3436 await _ui . RunOnUIAsync ( ( ) =>
3537 {
@@ -57,24 +59,44 @@ await _ui.RunOnUIAsync(() =>
5759 Assert . Equal ( Orientation . Vertical , layout . Orientation ) ;
5860 Assert . Equal ( 2 , layout . Spacing ) ;
5961 Assert . Equal ( InitialRows , CountItems ( repeater . ItemsSource ) ) ;
62+ var realizedRows = Enumerable . Range ( 0 , InitialRows )
63+ . Count ( index => repeater . TryGetElement ( index ) is not null ) ;
64+ Assert . InRange ( realizedRows , 1 , InitialRows - 1 ) ;
65+ initialCachedVirtualControls = host ! . CachedVirtualStackControlCount ;
66+ Assert . True ( initialCachedVirtualControls > 0 ) ;
6067
6168 var scrollViewer = FindLogical < ScrollViewer > ( host ! ) . Single ( ) ;
6269 _ui . Container . UpdateLayout ( ) ;
6370 Assert . True ( scrollViewer . ScrollableHeight > 0 , "large chat timeline should overflow and become scrollable" ) ;
64-
65- scrollViewer . ChangeView ( null , scrollViewer . ScrollableHeight , null , disableAnimation : true ) ;
66- _ui . Container . UpdateLayout ( ) ;
67- Assert . True ( scrollViewer . VerticalOffset > 0 , "large chat timeline should scroll away from the top" ) ;
6871 } ) ;
6972
70- await DrainRenderQueueAsync ( ) ;
73+ foreach ( var fraction in new [ ] { 0.25 , 0.5 , 0.75 , 1.0 } )
74+ {
75+ await _ui . RunOnUIAsync ( ( ) =>
76+ {
77+ var scrollViewer = FindLogical < ScrollViewer > ( host ! ) . Single ( ) ;
78+ scrollViewer . ChangeView (
79+ null ,
80+ scrollViewer . ScrollableHeight * fraction ,
81+ null ,
82+ disableAnimation : true ) ;
83+ _ui . Container . UpdateLayout ( ) ;
84+ } ) ;
85+ await DrainRenderQueueAsync ( ) ;
86+ }
7187
7288 object ? stableItemsSource = null ;
7389 object ? stableItemTemplate = null ;
7490 props = BuildProps ( InitialRows , scrollToBottomToken : 0 , textRevision : 1 ) ;
7591 await _ui . RunOnUIAsync ( ( ) =>
7692 {
7793 var repeater = FindLogical < ItemsRepeater > ( host ! ) . Single ( ) ;
94+ Assert . Null ( repeater . TryGetElement ( 0 ) ) ;
95+ Assert . NotNull ( repeater . TryGetElement ( InitialRows - 1 ) ) ;
96+ Assert . InRange (
97+ host ! . CachedVirtualStackControlCount ,
98+ 1 ,
99+ initialCachedVirtualControls * 2 ) ;
78100 stableItemsSource = repeater . ItemsSource ;
79101 stableItemTemplate = repeater . ItemTemplate ;
80102
@@ -140,6 +162,57 @@ await _ui.RunOnUIAsync(() =>
140162 await _ui . RunOnUIAsync ( ( ) => host ! . Dispose ( ) ) ;
141163 }
142164
165+ [ Fact ]
166+ public async Task RealizedComponentRow_ReplacesRootAndPrunesRemovedEffects ( )
167+ {
168+ await _ui . ResetContainerAsync ( ) ;
169+ DisposableVirtualRow . CleanupCount = 0 ;
170+
171+ FunctionalHostControl ? host = null ;
172+ ItemsRepeater ? repeater = null ;
173+ UIElement ? stableContainer = null ;
174+ var expandedCacheCount = 0 ;
175+ var cleanupCountBeforeCollapse = 0 ;
176+
177+ await _ui . RunOnUIAsync ( ( ) =>
178+ {
179+ host = new FunctionalHostControl { Width = 600 , Height = 400 , SuppressAutoDispose = true } ;
180+ _ui . Container . Children . Add ( host ) ;
181+ host . Mount ( _ => VirtualVStack (
182+ 0 ,
183+ Component < SwappingVirtualRow , SwappingVirtualRowProps > ( new SwappingVirtualRowProps ( true ) ) ) ) ;
184+ } ) ;
185+ await DrainRenderQueueAsync ( ) ;
186+
187+ await _ui . RunOnUIAsync ( ( ) =>
188+ {
189+ repeater = FindLogical < ItemsRepeater > ( host ! ) . Single ( ) ;
190+ stableContainer = repeater . TryGetElement ( 0 ) ;
191+ Assert . IsType < Border > ( stableContainer ) ;
192+ Assert . Contains ( FindDescendants < TextBlock > ( host ! ) , text => text . Text == "expanded row" ) ;
193+ Assert . Contains ( FindDescendants < TextBlock > ( host ! ) , text => text . Text == "disposable child" ) ;
194+ expandedCacheCount = host ! . CachedVirtualStackControlCount ;
195+ Assert . True ( expandedCacheCount > 1 ) ;
196+ cleanupCountBeforeCollapse = DisposableVirtualRow . CleanupCount ;
197+
198+ host . Mount ( _ => VirtualVStack (
199+ 0 ,
200+ Component < SwappingVirtualRow , SwappingVirtualRowProps > ( new SwappingVirtualRowProps ( false ) ) ) ) ;
201+ } ) ;
202+ await DrainRenderQueueAsync ( ) ;
203+
204+ await _ui . RunOnUIAsync ( ( ) =>
205+ {
206+ Assert . Same ( stableContainer , repeater ! . TryGetElement ( 0 ) ) ;
207+ Assert . Contains ( FindDescendants < TextBlock > ( host ! ) , text => text . Text == "collapsed row" ) ;
208+ Assert . DoesNotContain ( FindDescendants < TextBlock > ( host ! ) , text => text . Text == "disposable child" ) ;
209+ Assert . Equal ( cleanupCountBeforeCollapse + 1 , DisposableVirtualRow . CleanupCount ) ;
210+ Assert . InRange ( host ! . CachedVirtualStackControlCount , 1 , expandedCacheCount - 1 ) ;
211+ } ) ;
212+
213+ await _ui . RunOnUIAsync ( ( ) => host ! . Dispose ( ) ) ;
214+ }
215+
143216 private async Task DrainRenderQueueAsync ( )
144217 {
145218 await _ui . RunOnUIAsync ( ( ) => { } ) ;
@@ -191,4 +264,27 @@ private static int CountItems(object? itemsSource) =>
191264 itemsSource is System . Collections . IEnumerable enumerable
192265 ? enumerable . Cast < object > ( ) . Count ( )
193266 : 0 ;
267+
268+ private sealed record SwappingVirtualRowProps ( bool Expanded ) ;
269+
270+ private sealed class SwappingVirtualRow : Component < SwappingVirtualRowProps >
271+ {
272+ public override Element Render ( ) => Props . Expanded
273+ ? VStack (
274+ 0 ,
275+ TextBlock ( "expanded row" ) ,
276+ Component < DisposableVirtualRow > ( ) )
277+ : TextBlock ( "collapsed row" ) ;
278+ }
279+
280+ private sealed class DisposableVirtualRow : Component
281+ {
282+ internal static int CleanupCount { get ; set ; }
283+
284+ public override Element Render ( )
285+ {
286+ UseEffect ( ( Func < Action > ) ( ( ) => ( ) => CleanupCount ++ ) , Array . Empty < object > ( ) ) ;
287+ return TextBlock ( "disposable child" ) ;
288+ }
289+ }
194290}
0 commit comments