Skip to content

Commit 7097207

Browse files
shanselmanCopilot
andcommitted
Prune stale FunctionalUI render cache paths
Prevent keyed chat timeline generations from leaving old renderer controls, components, and content flyouts cached after they disappear from the rendered tree. This keeps the #917 row-identity fix from trading stale visual reuse for unbounded detached UI cache growth. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 53a8cb1 commit 7097207

2 files changed

Lines changed: 66 additions & 1 deletion

File tree

src/OpenClawTray.FunctionalUI/FunctionalUI.cs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -913,10 +913,19 @@ internal sealed class UiRenderer(Action requestRender)
913913
private readonly Dictionary<string, Component> _components = new();
914914
private readonly Dictionary<string, Flyout> _contentFlyouts = new();
915915
private readonly HashSet<string> _mountedPaths = new();
916+
private readonly HashSet<string> _visitedControlPaths = new();
917+
private readonly HashSet<string> _visitedComponentKeys = new();
918+
private readonly HashSet<string> _visitedContentFlyoutPaths = new();
916919

917920
public UIElement Render(Element element, string path, List<Action> effects)
918921
{
919-
return RenderElement(element, path, effects);
922+
_visitedControlPaths.Clear();
923+
_visitedComponentKeys.Clear();
924+
_visitedContentFlyoutPaths.Clear();
925+
926+
var rendered = RenderElement(element, path, effects);
927+
PruneUnvisitedPaths();
928+
return rendered;
920929
}
921930

922931
public void Dispose()
@@ -993,6 +1002,8 @@ private UIElement RenderNavigationHost(INavigationHostElement element, string pa
9931002

9941003
private T GetOrCreate<T>(string path) where T : UIElement, new()
9951004
{
1005+
_visitedControlPaths.Add(path);
1006+
9961007
if (_controls.TryGetValue(path, out var existing) && existing is T typed)
9971008
return typed;
9981009

@@ -1020,6 +1031,8 @@ private UIElement RenderComponent(ComponentElement element, string path, List<Ac
10201031
{
10211032
var componentKey = GetComponentKey(element.ComponentType);
10221033
var key = path + ":" + componentKey;
1034+
_visitedComponentKeys.Add(key);
1035+
10231036
if (!_components.TryGetValue(key, out var component))
10241037
{
10251038
component = (Component)Activator.CreateInstance(element.ComponentType)!;
@@ -1378,6 +1391,8 @@ private FlyoutBase CreateFlyout(FlyoutElement element, string path, List<Action>
13781391

13791392
private Flyout CreateContentFlyout(ContentFlyoutElement element, string path, List<Action> effects)
13801393
{
1394+
_visitedContentFlyoutPaths.Add(path);
1395+
13811396
// Cache the Flyout instance per path so its identity is STABLE across
13821397
// re-renders. ConfigureButton reassigns control.Flyout on every render,
13831398
// and a full-root re-render fires on every state change (including the
@@ -1403,6 +1418,39 @@ private Flyout CreateContentFlyout(ContentFlyoutElement element, string path, Li
14031418
return flyout;
14041419
}
14051420

1421+
private void PruneUnvisitedPaths()
1422+
{
1423+
foreach (var (key, component) in _components.ToArray())
1424+
{
1425+
if (_visitedComponentKeys.Contains(key))
1426+
continue;
1427+
1428+
component.Context.RunEffectCleanups();
1429+
_components.Remove(key);
1430+
}
1431+
1432+
foreach (var (path, flyout) in _contentFlyouts.ToArray())
1433+
{
1434+
if (_visitedContentFlyoutPaths.Contains(path))
1435+
continue;
1436+
1437+
flyout.Hide();
1438+
flyout.Content = null;
1439+
_contentFlyouts.Remove(path);
1440+
}
1441+
1442+
foreach (var (path, control) in _controls.ToArray())
1443+
{
1444+
if (_visitedControlPaths.Contains(path))
1445+
continue;
1446+
1447+
_mountedPaths.Remove(path);
1448+
DetachChildren(control);
1449+
RemoveFromParent(control);
1450+
_controls.Remove(path);
1451+
}
1452+
}
1453+
14061454
private static MenuFlyout CreateMenuFlyout(MenuFlyoutContentElement element)
14071455
{
14081456
var flyout = new MenuFlyout { Placement = element.Placement };

tests/OpenClaw.Tray.Tests/FunctionalUiModifierResetContractTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ public void ApplyModifiers_ClearsStaleBorderTextAndControlValues()
3232
Assert.Contains("c.ClearValue(Control.BorderBrushProperty);", functionalUi);
3333
}
3434

35+
[Fact]
36+
public void UiRenderer_PrunesUnvisitedCachesAfterRender()
37+
{
38+
var functionalUi = Read("src", "OpenClawTray.FunctionalUI", "FunctionalUI.cs");
39+
40+
Assert.Contains("private readonly HashSet<string> _visitedControlPaths = new();", functionalUi);
41+
Assert.Contains("private readonly HashSet<string> _visitedComponentKeys = new();", functionalUi);
42+
Assert.Contains("private readonly HashSet<string> _visitedContentFlyoutPaths = new();", functionalUi);
43+
Assert.Contains("PruneUnvisitedPaths();", functionalUi);
44+
Assert.Contains("component.Context.RunEffectCleanups();", functionalUi);
45+
Assert.Contains("flyout.Hide();", functionalUi);
46+
Assert.Contains("flyout.Content = null;", functionalUi);
47+
Assert.Contains("_mountedPaths.Remove(path);", functionalUi);
48+
Assert.Contains("DetachChildren(control);", functionalUi);
49+
Assert.Contains("_controls.Remove(path);", functionalUi);
50+
}
51+
3552
private static string Read(params string[] parts)
3653
=> File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(parts).ToArray()));
3754
}

0 commit comments

Comments
 (0)