perf(adminui): RawTree renders a flat Virtualize list, pages 500 per level, caches menus (R04 P5)

The recursive RenderNode rendered every row of an expanded device, and built a
fresh context-menu list + closures per node per render. RawTreeRows flattens the
expanded tree (explicit stack) into rows rendered by one Virtualize; each level
shows 500 children, then a 'Show more' row. RawNodeMenuCache keys menus weakly
on the node instance and rebuilds on a picker-mode or Enabled change.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:33:45 -04:00
parent 10b83d69c5
commit 2a788ed8ac
5 changed files with 320 additions and 25 deletions
@@ -17,15 +17,19 @@
surface for delete-blockers and rename warnings. This
component is a single instance that recurses via the RenderNode RenderFragment, so it
owns one set of modals + one injected service for the whole tree. *@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@inject IRawTreeService Svc
@foreach (var root in Roots)
{
@RenderNode(root, 0)
}
@* One flat, virtualized row list (R04 P5): only the rows in view render, and each expanded level
shows RawTreeRows.DefaultPageSize children before a "Show more" row. Flatten runs per render; it is
O(visible rows) with no allocation per node beyond one row struct, so every mutation path below
(reload, toggle, load) is picked up without separate invalidation. *@
<Virtualize Items="@RawTreeRows.Flatten(Roots)" ItemSize="30" OverscanCount="20" Context="row">
@RenderRow(row)
</Virtualize>
@* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@
<DriverConfigModal @bind-Visible="_driverCfgVisible" DriverInstanceId="@_driverCfgId" OnSaved="DriverCfgSaved" />
@@ -163,12 +167,45 @@
// Row rendering
// ---------------------------------------------------------------------------
// Per-node menus are built once and reused across renders (R04 P5); see RawNodeMenuCache.
private readonly RawNodeMenuCache _menus = new();
private RenderFragment RenderRow(RawTreeRow row) => __builder =>
{
switch (row.Kind)
{
case RawTreeRowKind.Node:
@RenderNode(row.Node, row.Depth)
break;
case RawTreeRowKind.Loading:
<div @key="row.Key" class="small text-muted py-1" style="@($"padding-left:{row.Depth * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
break;
case RawTreeRowKind.Error:
<div @key="row.Key" class="small text-danger py-1" style="@($"padding-left:{row.Depth * 18}px")">
@row.Node.Error
</div>
break;
default:
<div @key="row.Key" class="small py-1" style="@($"padding-left:{row.Depth * 18}px")">
<button type="button" class="btn btn-sm btn-link p-0" @onclick="@(() => ShowMore(row.Node))">
Show @Math.Min(row.Remaining, RawTreeRows.DefaultPageSize) more
(@row.Remaining not shown)
</button>
</div>
break;
}
};
private void ShowMore(RawNode node) => RawTreeRows.ShowMore(node);
private RenderFragment RenderNode(RawNode node, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device;
var items = MenuFor(node);
var items = _menus.GetOrBuild(node, PickerMode, MenuFor);
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
@if (hasExpander)
@@ -194,26 +231,6 @@
@RenderRowBody(node, muted)
}
</div>
@if (node.Expanded && node.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (node.Expanded && node.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
@node.Error
</div>
}
else if (node.Expanded)
{
@foreach (var child in node.Children)
{
@RenderNode(child, depth + 1)
}
}
};
// The icon + label + badge shown for a node — the ContextMenu right-click surface.
@@ -98,4 +98,8 @@ public sealed class RawNode
/// <summary>Last load error message, if any.</summary>
public string? Error { get; set; }
/// <summary>How many of <see cref="Children"/> the tree shows before a "Show more" row
/// (<see cref="RawTreeRows.DefaultPageSize"/> per page).</summary>
public int VisibleChildLimit { get; set; } = RawTreeRows.DefaultPageSize;
}
@@ -0,0 +1,38 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Caches each <see cref="RawNode"/>'s context-menu item list (R04 P5). <c>RawTree</c> used to build a
/// fresh list (and fresh closures) per node on every render, and hand it to that row's
/// <c>ContextMenu</c> as a new reference each time. The cache is keyed weakly on the node instance, so
/// a reload (which replaces the child nodes) drops the stale entries with the nodes, and it rebuilds
/// when anything the menu depends on (picker mode, the node's enabled state) differs.
/// </summary>
public sealed class RawNodeMenuCache
{
private readonly ConditionalWeakTable<RawNode, Entry> _entries = new();
/// <summary>Returns the cached menu for <paramref name="node"/>, building it on first use or after a dependency changed.</summary>
/// <param name="node">The node.</param>
/// <param name="pickerMode">Whether the tree is in picker mode (picker menus differ entirely).</param>
/// <param name="build">Builds the menu for the node.</param>
/// <returns>The menu items.</returns>
public IReadOnlyList<ContextMenuItem> GetOrBuild(
RawNode node, bool pickerMode, Func<RawNode, IReadOnlyList<ContextMenuItem>> build)
{
if (_entries.TryGetValue(node, out var entry)
&& entry.PickerMode == pickerMode
&& entry.Enabled == node.Enabled)
{
return entry.Items;
}
var items = build(node);
_entries.AddOrUpdate(node, new Entry(pickerMode, node.Enabled, items));
return items;
}
private sealed record Entry(bool PickerMode, bool Enabled, IReadOnlyList<ContextMenuItem> Items);
}
@@ -0,0 +1,96 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>What a flattened <c>RawTree</c> row renders.</summary>
public enum RawTreeRowKind
{
/// <summary>A tree node row (expander + label + menu).</summary>
Node,
/// <summary>The "Loading…" placeholder under an expanded container whose children are in flight.</summary>
Loading,
/// <summary>The error line under an expanded container whose load failed.</summary>
Error,
/// <summary>The "Show more" row under an expanded container with more children than its visible limit.</summary>
ShowMore,
}
/// <summary>One row of the flattened, visible Raw tree.</summary>
/// <param name="Kind">What the row renders.</param>
/// <param name="Node">The node the row belongs to (for non-<see cref="RawTreeRowKind.Node"/> rows, the container).</param>
/// <param name="Depth">Indent depth; a placeholder row sits one level below its container.</param>
/// <param name="Remaining">For <see cref="RawTreeRowKind.ShowMore"/>, how many children are still hidden; otherwise 0.</param>
public readonly record struct RawTreeRow(RawTreeRowKind Kind, RawNode Node, int Depth, int Remaining = 0)
{
/// <summary>A key unique within the flattened list, stable across renders (for <c>@key</c>).</summary>
public string Key => Kind switch
{
RawTreeRowKind.Node => Node.Key,
RawTreeRowKind.Loading => "loading:" + Node.Key,
RawTreeRowKind.Error => "error:" + Node.Key,
_ => "more:" + Node.Key,
};
}
/// <summary>
/// Flattens the expanded part of the Raw tree into the rows the <c>RawTree</c> component renders
/// through one <c>Virtualize</c> (R04 P5). A recursive render of an expanded 5k-tag device rendered
/// every row (and a <c>ContextMenu</c> per row) on every state change; a flat list lets the browser
/// render only the rows in view. Each level also shows at most <see cref="RawNode.VisibleChildLimit"/>
/// children (default <see cref="DefaultPageSize"/>), followed by a "Show more" row.
/// </summary>
public static class RawTreeRows
{
/// <summary>The number of children an expanded level shows before a "Show more" row.</summary>
public const int DefaultPageSize = 500;
/// <summary>Flattens the visible tree, depth-first, in display order.</summary>
/// <param name="roots">The top-level nodes.</param>
/// <returns>The visible rows.</returns>
public static List<RawTreeRow> Flatten(IReadOnlyList<RawNode> roots)
{
var rows = new List<RawTreeRow>();
// An explicit stack instead of recursion: tag-group nesting depth is author-controlled.
var stack = new Stack<RawTreeRow>();
for (var i = roots.Count - 1; i >= 0; i--) stack.Push(new RawTreeRow(RawTreeRowKind.Node, roots[i], 0));
while (stack.Count > 0)
{
var row = stack.Pop();
rows.Add(row);
if (row.Kind != RawTreeRowKind.Node) continue;
var n = row.Node;
if (!n.Expanded) continue;
var childDepth = row.Depth + 1;
if (n.Loading)
{
rows.Add(new RawTreeRow(RawTreeRowKind.Loading, n, childDepth));
continue;
}
if (n.Error is not null)
{
rows.Add(new RawTreeRow(RawTreeRowKind.Error, n, childDepth));
continue;
}
var shown = Math.Min(Math.Max(n.VisibleChildLimit, 0), n.Children.Count);
var hidden = n.Children.Count - shown;
// Pushed in reverse so they pop in display order; the ShowMore row is pushed first so it pops
// after the last shown child's whole (possibly expanded) subtree.
if (hidden > 0) stack.Push(new RawTreeRow(RawTreeRowKind.ShowMore, n, childDepth, hidden));
for (var i = shown - 1; i >= 0; i--) stack.Push(new RawTreeRow(RawTreeRowKind.Node, n.Children[i], childDepth));
}
return rows;
}
/// <summary>Raises a container's visible-children limit by one page.</summary>
/// <param name="node">The container whose "Show more" row was clicked.</param>
public static void ShowMore(RawNode node) =>
node.VisibleChildLimit = Math.Max(node.VisibleChildLimit, 0) + DefaultPageSize;
}
@@ -0,0 +1,140 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
/// <summary>R04 P5: the Raw tree renders a flat, paged row list through Virtualize, with cached menus.</summary>
[Trait("Category", "Unit")]
public sealed class RawTreeRowsTests
{
private static RawNode Node(string key, RawNodeKind kind = RawNodeKind.Tag, bool lazy = false, bool enabled = true) =>
new() { Kind = kind, Key = key, DisplayName = key, HasLazyChildren = lazy, Enabled = enabled };
private static RawNode Device(string key, int tags)
{
var d = Node(key, RawNodeKind.Device, lazy: true);
for (var i = 0; i < tags; i++) d.Children.Add(Node($"{key}/t{i}"));
d.Loaded = true;
d.Expanded = true;
return d;
}
[Fact]
public void Collapsed_nodes_show_no_children()
{
var dev = Device("dev", 3);
dev.Expanded = false;
RawTreeRows.Flatten([dev]).Select(r => r.Key).ShouldBe(["dev"]);
}
[Fact]
public void Expanded_nodes_flatten_depth_first_in_display_order_with_depth()
{
var root = Node("cluster", RawNodeKind.Cluster, lazy: true);
root.Expanded = true;
var drv = Node("drv", RawNodeKind.Driver, lazy: true);
drv.Expanded = true;
drv.Children.Add(Device("devA", 2));
root.Children.Add(drv);
root.Children.Add(Node("folderB", RawNodeKind.Folder, lazy: true));
var rows = RawTreeRows.Flatten([root]);
rows.Select(r => (r.Key, r.Depth)).ShouldBe(
[
("cluster", 0), ("drv", 1), ("devA", 2), ("devA/t0", 3), ("devA/t1", 3), ("folderB", 1),
]);
}
[Fact]
public void A_5k_tag_device_shows_one_page_then_a_show_more_row()
{
var dev = Device("dev", 5000);
var rows = RawTreeRows.Flatten([dev]);
rows.Count.ShouldBe(1 + RawTreeRows.DefaultPageSize + 1);
var more = rows[^1];
more.Kind.ShouldBe(RawTreeRowKind.ShowMore);
more.Remaining.ShouldBe(5000 - RawTreeRows.DefaultPageSize);
more.Depth.ShouldBe(1);
RawTreeRows.ShowMore(dev);
var after = RawTreeRows.Flatten([dev]);
after.Count(r => r.Kind == RawTreeRowKind.Node).ShouldBe(1 + 2 * RawTreeRows.DefaultPageSize);
after[^1].Remaining.ShouldBe(5000 - 2 * RawTreeRows.DefaultPageSize);
}
[Fact]
public void A_level_at_or_under_the_page_size_has_no_show_more_row()
{
var rows = RawTreeRows.Flatten([Device("dev", RawTreeRows.DefaultPageSize)]);
rows.ShouldNotContain(r => r.Kind == RawTreeRowKind.ShowMore);
// Positive control: one more child does produce one.
RawTreeRows.Flatten([Device("dev", RawTreeRows.DefaultPageSize + 1)])
.Count(r => r.Kind == RawTreeRowKind.ShowMore).ShouldBe(1);
}
[Fact]
public void Show_more_row_follows_the_whole_subtree_of_the_last_shown_child()
{
var dev = Node("dev", RawNodeKind.Device, lazy: true);
dev.Expanded = true;
dev.VisibleChildLimit = 1;
var grp = Node("grp", RawNodeKind.TagGroup, lazy: true);
grp.Expanded = true;
grp.Children.Add(Node("grp/t"));
dev.Children.Add(grp);
dev.Children.Add(Node("dev/hidden"));
RawTreeRows.Flatten([dev]).Select(r => r.Key).ShouldBe(["dev", "grp", "grp/t", "more:dev"]);
}
[Fact]
public void Loading_and_error_rows_replace_children()
{
var loading = Device("a", 2);
loading.Loading = true;
var failed = Device("b", 2);
failed.Error = "boom";
RawTreeRows.Flatten([loading, failed]).Select(r => r.Kind).ShouldBe(
[
RawTreeRowKind.Node, RawTreeRowKind.Loading, RawTreeRowKind.Node, RawTreeRowKind.Error,
]);
}
[Fact]
public void Menu_cache_reuses_the_list_and_rebuilds_when_a_dependency_changes()
{
var cache = new RawNodeMenuCache();
var builds = 0;
IReadOnlyList<ContextMenuItem> Build(RawNode _) { builds++; return [new ContextMenuItem { Label = "x" }]; }
var node = Node("drv", RawNodeKind.Driver);
var first = cache.GetOrBuild(node, pickerMode: false, Build);
cache.GetOrBuild(node, pickerMode: false, Build).ShouldBeSameAs(first);
builds.ShouldBe(1);
// Picker mode is a different menu entirely.
cache.GetOrBuild(node, pickerMode: true, Build).ShouldNotBeSameAs(first);
builds.ShouldBe(2);
// A reload replaces the node instance (Enabled is init-only), so the new node gets its own menu.
var reloaded = Node("drv", RawNodeKind.Driver, enabled: false);
cache.GetOrBuild(reloaded, pickerMode: true, Build);
builds.ShouldBe(3);
}
[Fact]
public void RawTree_renders_through_Virtualize_and_the_menu_cache()
{
var src = AdminUiSource.Read("Components", "Shared", "Raw", "RawTree.razor");
src.ShouldContain("<Virtualize Items=\"@RawTreeRows.Flatten(Roots)\"");
src.ShouldContain("_menus.GetOrBuild(node, PickerMode, MenuFor)");
src.ShouldNotContain("@RenderNode(child, depth + 1)");
}
}