From 2a788ed8ac98d7560f3de2dadbaae1c769f4ced7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 17:33:45 -0400 Subject: [PATCH] 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 --- .../Components/Shared/Raw/RawTree.razor | 67 +++++---- .../ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs | 4 + .../Uns/RawNodeMenuCache.cs | 38 +++++ .../Uns/RawTreeRows.cs | 96 ++++++++++++ .../Raw/RawTreeRowsTests.cs | 140 ++++++++++++++++++ 5 files changed, 320 insertions(+), 25 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNodeMenuCache.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeRows.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTreeRowsTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor index 7b78a3f3..1f1d467b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTree.razor @@ -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. *@ + + @RenderRow(row) + @* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@ @@ -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: +
+ Loading… +
+ break; + case RawTreeRowKind.Error: +
+ @row.Node.Error +
+ break; + default: +
+ +
+ 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);
@if (hasExpander) @@ -194,26 +231,6 @@ @RenderRowBody(node, muted) }
- - @if (node.Expanded && node.Loading) - { -
- Loading… -
- } - else if (node.Expanded && node.Error is not null) - { -
- @node.Error -
- } - 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. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs index ad54d8c2..4d237296 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNode.cs @@ -98,4 +98,8 @@ public sealed class RawNode /// Last load error message, if any. public string? Error { get; set; } + + /// How many of the tree shows before a "Show more" row + /// ( per page). + public int VisibleChildLimit { get; set; } = RawTreeRows.DefaultPageSize; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNodeMenuCache.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNodeMenuCache.cs new file mode 100644 index 00000000..55a6ff2d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawNodeMenuCache.cs @@ -0,0 +1,38 @@ +using System.Runtime.CompilerServices; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// +/// Caches each 's context-menu item list (R04 P5). RawTree used to build a +/// fresh list (and fresh closures) per node on every render, and hand it to that row's +/// ContextMenu 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. +/// +public sealed class RawNodeMenuCache +{ + private readonly ConditionalWeakTable _entries = new(); + + /// Returns the cached menu for , building it on first use or after a dependency changed. + /// The node. + /// Whether the tree is in picker mode (picker menus differ entirely). + /// Builds the menu for the node. + /// The menu items. + public IReadOnlyList GetOrBuild( + RawNode node, bool pickerMode, Func> 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 Items); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeRows.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeRows.cs new file mode 100644 index 00000000..f1375b1e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeRows.cs @@ -0,0 +1,96 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns; + +/// What a flattened RawTree row renders. +public enum RawTreeRowKind +{ + /// A tree node row (expander + label + menu). + Node, + + /// The "Loading…" placeholder under an expanded container whose children are in flight. + Loading, + + /// The error line under an expanded container whose load failed. + Error, + + /// The "Show more" row under an expanded container with more children than its visible limit. + ShowMore, +} + +/// One row of the flattened, visible Raw tree. +/// What the row renders. +/// The node the row belongs to (for non- rows, the container). +/// Indent depth; a placeholder row sits one level below its container. +/// For , how many children are still hidden; otherwise 0. +public readonly record struct RawTreeRow(RawTreeRowKind Kind, RawNode Node, int Depth, int Remaining = 0) +{ + /// A key unique within the flattened list, stable across renders (for @key). + public string Key => Kind switch + { + RawTreeRowKind.Node => Node.Key, + RawTreeRowKind.Loading => "loading:" + Node.Key, + RawTreeRowKind.Error => "error:" + Node.Key, + _ => "more:" + Node.Key, + }; +} + +/// +/// Flattens the expanded part of the Raw tree into the rows the RawTree component renders +/// through one Virtualize (R04 P5). A recursive render of an expanded 5k-tag device rendered +/// every row (and a ContextMenu per row) on every state change; a flat list lets the browser +/// render only the rows in view. Each level also shows at most +/// children (default ), followed by a "Show more" row. +/// +public static class RawTreeRows +{ + /// The number of children an expanded level shows before a "Show more" row. + public const int DefaultPageSize = 500; + + /// Flattens the visible tree, depth-first, in display order. + /// The top-level nodes. + /// The visible rows. + public static List Flatten(IReadOnlyList roots) + { + var rows = new List(); + // An explicit stack instead of recursion: tag-group nesting depth is author-controlled. + var stack = new Stack(); + 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; + } + + /// Raises a container's visible-children limit by one page. + /// The container whose "Show more" row was clicked. + public static void ShowMore(RawNode node) => + node.VisibleChildLimit = Math.Max(node.VisibleChildLimit, 0) + DefaultPageSize; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTreeRowsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTreeRowsTests.cs new file mode 100644 index 00000000..b6e63c44 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTreeRowsTests.cs @@ -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; + +/// R04 P5: the Raw tree renders a flat, paged row list through Virtualize, with cached menus. +[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 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("