diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor
index aee2778e..eaebf206 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceModal.razor
@@ -108,13 +108,19 @@
private bool _enabled = true;
private string _deviceConfigJson = "{}";
- /// Parameters handed to the DynamicComponent-rendered device form. Rebuilt on every render —
- /// see DriverConfigModal for why this must not be cached.
- private Dictionary _deviceFormParameters => new()
- {
- ["DeviceConfigJson"] = _deviceConfigJson,
- ["DeviceConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _deviceConfigJson = v),
- };
+ /// Parameters handed to the DynamicComponent-rendered device form. Cached on the value it
+ /// carries and rebuilt when that changes (never build-once: see DriverConfigModal), with the callback
+ /// created once (R04 H2).
+ private Dictionary _deviceFormParameters => _deviceFormParameterCache.Get(
+ [_deviceConfigJson],
+ () => new()
+ {
+ ["DeviceConfigJson"] = _deviceConfigJson,
+ ["DeviceConfigJsonChanged"] = _deviceConfigJsonChanged ??=
+ EventCallback.Factory.Create(this, v => _deviceConfigJson = v),
+ });
+ private readonly ParameterDictionaryCache _deviceFormParameterCache = new();
+ private EventCallback? _deviceConfigJsonChanged;
private byte[] _rowVersion = [];
private string _parentDriverConfig = "{}";
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
index d15c8a8c..8c3e7047 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
@@ -111,16 +111,24 @@
private string _name = "";
private string _driverConfigJson = "{}";
- /// Parameters handed to the DynamicComponent-rendered driver form. Rebuilt on every render so
- /// the child receives the CURRENT json — a cached dictionary would pin the value captured at first
- /// render and silently strand every subsequent edit.
- private Dictionary _formParameters => new()
- {
- ["DriverConfigJson"] = _driverConfigJson,
- ["DriverConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _driverConfigJson = v),
- ["ResilienceConfig"] = _resilienceConfig!,
- ["ResilienceConfigChanged"] = EventCallback.Factory.Create(this, v => _resilienceConfig = v),
- };
+ /// Parameters handed to the DynamicComponent-rendered driver form. The child must receive the
+ /// CURRENT json — a build-once dictionary would pin the value captured at first render and silently
+ /// strand every subsequent edit — so the cache is keyed on the values it carries and rebuilt only when
+ /// one changes; the callbacks are created once and reused (R04 H2).
+ private Dictionary _formParameters => _formParameterCache.Get(
+ [_driverConfigJson, _resilienceConfig],
+ () => new()
+ {
+ ["DriverConfigJson"] = _driverConfigJson,
+ ["DriverConfigJsonChanged"] = _driverConfigJsonChanged ??=
+ EventCallback.Factory.Create(this, v => _driverConfigJson = v),
+ ["ResilienceConfig"] = _resilienceConfig!,
+ ["ResilienceConfigChanged"] = _resilienceConfigChanged ??=
+ EventCallback.Factory.Create(this, v => _resilienceConfig = v),
+ });
+ private readonly ParameterDictionaryCache _formParameterCache = new();
+ private EventCallback? _driverConfigJsonChanged;
+ private EventCallback? _resilienceConfigChanged;
private string? _resilienceConfig;
private byte[] _rowVersion = [];
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ParameterDictionaryCache.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ParameterDictionaryCache.cs
new file mode 100644
index 00000000..4a2e5f19
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ParameterDictionaryCache.cs
@@ -0,0 +1,50 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
+
+///
+/// Caches a DynamicComponent parameter dictionary (R04 H2) and rebuilds it only when one of the
+/// values it carries changed. The modals used to build a new dictionary, and new
+/// EventCallbacks, on every render. A plain build-once cache is wrong: it would pin the JSON
+/// captured at first render and silently strand every later edit — so the cache is keyed on the
+/// current values, and the callbacks it hands out are created once by the caller and reused.
+/// Note the limit: Blazor's change detection treats any EventCallback parameter as
+/// possibly changed, so a hosted form still sees SetParametersAsync on each parent render. The
+/// per-form _lastParsed guards remain load-bearing; this removes the per-render allocations and
+/// gives the child a stable dictionary and callback identity.
+///
+public sealed class ParameterDictionaryCache
+{
+ private object?[]? _keyValues;
+ private Dictionary? _cached;
+
+ /// Returns the cached dictionary when equal the previous call's,
+ /// otherwise builds, caches and returns a new one.
+ /// The values the dictionary carries (compared with ).
+ /// Builds the dictionary from the current state.
+ /// The parameter dictionary.
+ public Dictionary Get(object?[] keyValues, Func> build)
+ {
+ if (_cached is not null && _keyValues is not null && Same(_keyValues, keyValues)) return _cached;
+
+ _cached = build();
+ _keyValues = (object?[])keyValues.Clone();
+ return _cached;
+ }
+
+ /// Drops the cached dictionary (e.g. on modal close) so the next rebuilds.
+ public void Reset()
+ {
+ _cached = null;
+ _keyValues = null;
+ }
+
+ private static bool Same(object?[] a, object?[] b)
+ {
+ if (a.Length != b.Length) return false;
+ for (var i = 0; i < a.Length; i++)
+ {
+ if (!Equals(a[i], b[i])) return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor
index 474922f4..b9909392 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawTagModal.razor
@@ -217,15 +217,23 @@
}
}
- /// Parameters handed to the typed TagConfig editor. Internal so tests can assert them.
- internal IDictionary BuildEditorParameters() => new Dictionary
- {
- ["ConfigJson"] = _form.TagConfig,
- ["ConfigJsonChanged"] = EventCallback.Factory.Create(this, v => _form.TagConfig = v),
- ["DriverType"] = DriverType ?? "",
- // Live accessor, not a snapshot: the picker reads it when the operator clicks Browse.
- ["GetDriverConfigJson"] = (Func)(() => _mergedConfigJson ?? "{}"),
- };
+ /// Parameters handed to the typed TagConfig editor. Internal so tests can assert them. Cached
+ /// on the values it carries and rebuilt when one changes (never build-once, which would pin the first
+ /// render's TagConfig); the callback and accessor are created once (R04 H2).
+ internal IDictionary BuildEditorParameters() => _editorParameterCache.Get(
+ [_form.TagConfig, DriverType ?? ""],
+ () => new Dictionary
+ {
+ ["ConfigJson"] = _form.TagConfig,
+ ["ConfigJsonChanged"] = _configJsonChanged ??=
+ EventCallback.Factory.Create(this, v => _form.TagConfig = v),
+ ["DriverType"] = DriverType ?? "",
+ // Live accessor, not a snapshot: the picker reads it when the operator clicks Browse.
+ ["GetDriverConfigJson"] = _getDriverConfigJson ??= () => _mergedConfigJson ?? "{}",
+ });
+ private readonly ParameterDictionaryCache _editorParameterCache = new();
+ private EventCallback? _configJsonChanged;
+ private Func? _getDriverConfigJson;
private async Task SaveAsync()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/ParameterDictionaryCacheTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/ParameterDictionaryCacheTests.cs
new file mode 100644
index 00000000..7bbe3c0b
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/ParameterDictionaryCacheTests.cs
@@ -0,0 +1,87 @@
+using Microsoft.AspNetCore.Components;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
+
+///
+/// R04 H2: the modals' DynamicComponent parameter dictionaries are reused across renders while their
+/// values are unchanged, and rebuilt — carrying the current value — the moment one changes. The second
+/// half is the regression a naive build-once cache would introduce (stale JSON stranding every edit).
+///
+[Trait("Category", "Unit")]
+public sealed class ParameterDictionaryCacheTests
+{
+ [Fact]
+ public void Unchanged_values_return_the_same_dictionary()
+ {
+ var cache = new ParameterDictionaryCache();
+ var builds = 0;
+ var json = "{}";
+ Dictionary Build() { builds++; return new() { ["Json"] = json }; }
+
+ var first = cache.Get([json], Build);
+ cache.Get([json], Build).ShouldBeSameAs(first);
+ builds.ShouldBe(1);
+ }
+
+ [Fact]
+ public void A_changed_value_rebuilds_with_the_current_value()
+ {
+ var cache = new ParameterDictionaryCache();
+ var json = "{}";
+ Dictionary Build() => new() { ["Json"] = json };
+
+ cache.Get([json], Build)["Json"].ShouldBe("{}");
+ json = "{\"a\":1}";
+ cache.Get([json], Build)["Json"].ShouldBe("{\"a\":1}");
+ }
+
+ [Fact]
+ public void Null_values_compare_and_reset_forces_a_rebuild()
+ {
+ var cache = new ParameterDictionaryCache();
+ var builds = 0;
+ Dictionary Build() { builds++; return new(); }
+
+ cache.Get(["x", null], Build);
+ cache.Get(["x", null], Build);
+ builds.ShouldBe(1);
+
+ cache.Reset();
+ cache.Get(["x", null], Build);
+ builds.ShouldBe(2);
+ }
+
+ [Fact]
+ public void Modals_build_their_parameters_through_the_cache_with_reused_callbacks()
+ {
+ foreach (var (path, cacheField) in new[]
+ {
+ (new[] { "Components", "Shared", "Drivers", "DriverConfigModal.razor" }, "_formParameterCache.Get("),
+ (new[] { "Components", "Shared", "Drivers", "DeviceModal.razor" }, "_deviceFormParameterCache.Get("),
+ (new[] { "Components", "Shared", "Raw", "RawTagModal.razor" }, "_editorParameterCache.Get("),
+ })
+ {
+ var src = AdminUiSource.Read(path);
+ src.ShouldContain(cacheField, customMessage: path[^1]);
+ // Every EventCallback is created behind a ??= so it is made once per component.
+ var all = System.Text.RegularExpressions.Regex.Matches(src, @"EventCallback\.Factory\.Create").Count;
+ var once = System.Text.RegularExpressions.Regex.Matches(src, @"\?\?=\s*EventCallback\.Factory\.Create").Count;
+ all.ShouldBeGreaterThan(0, path[^1]);
+ once.ShouldBe(all, path[^1]);
+ }
+ }
+
+ [Fact]
+ public void RawTagModal_reuses_its_callback_across_renders()
+ {
+ var modal = new ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw.RawTagModal();
+ var first = modal.BuildEditorParameters();
+ var second = modal.BuildEditorParameters();
+
+ second.ShouldBeSameAs(first);
+ ((EventCallback)first["ConfigJsonChanged"]).HasDelegate.ShouldBeTrue();
+ }
+}