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.