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);
- }
- 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("