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:
@@ -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…
|
||||
</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…
|
||||
</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)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user