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 9ca4b51e..aee2778e 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
@@ -123,20 +123,27 @@
private string? _saveError;
private bool _openHandled;
+ // Bumped on every open and close; LoadAsync re-checks it after each await so a superseded open can't
+ // install one target's fields under another target's displayed modal (RawBrowseModal idiom).
+ private int _openGeneration;
+
+ private bool IsStale(int generation) => generation != _openGeneration || !Visible;
+
protected override async Task OnParametersSetAsync()
{
if (Visible && !_openHandled)
{
_openHandled = true;
- await LoadAsync();
+ await LoadAsync(++_openGeneration);
}
else if (!Visible)
{
_openHandled = false;
+ _openGeneration++;
}
}
- private async Task LoadAsync()
+ private async Task LoadAsync(int generation)
{
_busy = false; _loadError = null; _saveError = null;
_isNew = string.IsNullOrEmpty(DeviceId);
@@ -153,6 +160,7 @@
else
{
var dto = await Svc.LoadDeviceForEditAsync(DeviceId!);
+ if (IsStale(generation)) { return; }
if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; }
_resolvedDriverInstanceId = dto.DriverInstanceId;
_driverType = dto.DriverType;
@@ -166,6 +174,7 @@
if (!string.IsNullOrEmpty(_resolvedDriverInstanceId))
{
var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId);
+ if (IsStale(generation)) { return; }
_parentDriverConfig = drv?.DriverConfig ?? "{}";
}
}
@@ -217,6 +226,7 @@
{
Visible = value;
_openHandled = value;
+ _openGeneration++;
await VisibleChanged.InvokeAsync(value);
}
}
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 7a93ef0a..d15c8a8c 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
@@ -129,20 +129,27 @@
private string? _saveError;
private bool _openHandled;
+ // Bumped on every open and close; LoadAsync re-checks it after each await so a superseded open can't
+ // install one target's fields under another target's displayed modal (RawBrowseModal idiom).
+ private int _openGeneration;
+
+ private bool IsStale(int generation) => generation != _openGeneration || !Visible;
+
protected override async Task OnParametersSetAsync()
{
if (Visible && !_openHandled)
{
_openHandled = true;
- await LoadAsync();
+ await LoadAsync(++_openGeneration);
}
else if (!Visible)
{
_openHandled = false;
+ _openGeneration++;
}
}
- private async Task LoadAsync()
+ private async Task LoadAsync(int generation)
{
_busy = false; _loadError = null; _saveError = null;
_isNew = string.IsNullOrEmpty(DriverInstanceId);
@@ -158,6 +165,7 @@
else
{
var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!);
+ if (IsStale(generation)) { return; }
if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; }
_driverType = dto.DriverType;
_name = dto.Name;
@@ -206,6 +214,7 @@
{
Visible = value;
_openHandled = value;
+ _openGeneration++;
await VisibleChanged.InvokeAsync(value);
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor
index a7af44d6..99ecdad4 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvExportModal.razor
@@ -85,20 +85,26 @@
private int _rowCount;
private string? _groupFilter;
+ // Bumped on every open and close so a superseded build can't install its CSV under another device's modal.
+ private int _openGeneration;
+
protected override async Task OnParametersSetAsync()
{
if (Visible && !_built)
{
_built = true;
- await BuildAsync();
+ await BuildAsync(++_openGeneration);
}
else if (!Visible)
{
_built = false;
+ _openGeneration++;
}
}
- private async Task BuildAsync()
+ private bool IsStale(int generation) => generation != _openGeneration || !Visible;
+
+ private async Task BuildAsync(int generation)
{
_loading = true;
_error = null;
@@ -110,6 +116,7 @@
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
+ if (IsStale(generation)) { return; }
if (ctx is { } c)
{
_groupFilter = c.GroupPath;
@@ -127,6 +134,7 @@
}
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
+ if (IsStale(generation)) { return; }
// When exporting a single group, keep only that group's subtree (its path + descendants).
if (!string.IsNullOrEmpty(_groupFilter))
@@ -147,11 +155,11 @@
}
catch (Exception ex)
{
- _error = ex.Message;
+ if (!IsStale(generation)) { _error = ex.Message; }
}
finally
{
- _loading = false;
+ if (!IsStale(generation)) { _loading = false; }
}
}
@@ -164,6 +172,7 @@
private async Task Close()
{
_built = false;
+ _openGeneration++;
_csv = "";
_dataUri = "";
Visible = false;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor
index b755ed7c..cf530f97 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawCsvImportModal.razor
@@ -191,6 +191,11 @@
private bool _committing;
private bool _resolved;
+ // Bumped on every open and every close; an async continuation compares its captured value after each
+ // await so a superseded open can never write its target under a different device's displayed modal
+ // (same idiom as RawBrowseModal / RawManualTagEntryModal).
+ private int _openGeneration;
+
private string _effectiveDeviceId = "";
private string? _groupPrefix;
@@ -209,17 +214,21 @@
{
if (Visible && !_resolved)
{
- await ResolveTargetAsync();
_resolved = true;
+ var generation = ++_openGeneration;
+ await ResolveTargetAsync(generation);
}
else if (!Visible)
{
_resolved = false;
+ _openGeneration++;
}
}
+ private bool IsStale(int generation) => generation != _openGeneration || !Visible;
+
// Resolve the effective device id + the group-path prefix once per open.
- private async Task ResolveTargetAsync()
+ private async Task ResolveTargetAsync(int generation)
{
_effectiveDeviceId = DeviceId;
_groupPrefix = null;
@@ -227,6 +236,7 @@
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
+ if (IsStale(generation)) { return; }
if (ctx is { } c)
{
_groupPrefix = c.GroupPath;
@@ -244,12 +254,14 @@
_commitErrors = new();
_removed.Clear();
_parsing = true;
+ var generation = _openGeneration;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024);
using var reader = new StreamReader(stream);
var text = await reader.ReadToEndAsync();
+ if (IsStale(generation)) { return; }
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
_fatal = _result.FatalError;
}
@@ -283,6 +295,8 @@
_committing = true;
_commitErrors = new();
+ var generation = _openGeneration;
+ var targetDeviceId = _effectiveDeviceId;
try
{
var rows = _result.Rows
@@ -290,7 +304,8 @@
.Select(r => r.ImportRow!)
.ToList();
- var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
+ var outcome = await Svc.ImportTagsAsync(targetDeviceId, rows);
+ if (IsStale(generation)) { return; }
if (outcome.Errors.Count > 0)
{
_commitErrors = outcome.Errors.ToList();
@@ -315,6 +330,7 @@
Reset();
Visible = false;
_resolved = false;
+ _openGeneration++;
await VisibleChanged.InvokeAsync(false);
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor
index bb2709b0..e35536f6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/AddReferenceModal.razor
@@ -80,11 +80,20 @@
private string? _error;
private bool _wasVisible;
+ // Bumped on every open and close so a superseded picker-root load can't install its tree under a
+ // different equipment's (or a re-opened) modal (RawBrowseModal idiom).
+ private int _openGeneration;
+
+ private bool IsStale(int generation) => generation != _openGeneration || !Visible;
+
protected override async Task OnParametersSetAsync()
{
// Reset + reload only on the false→true transition so re-renders while open don't wipe state.
+ if (!Visible && _wasVisible) { _openGeneration++; }
if (Visible && !_wasVisible)
{
+ _wasVisible = true;
+ var generation = ++_openGeneration;
_selected.Clear();
_error = null;
_roots = Array.Empty
();
@@ -94,6 +103,7 @@
var root = string.IsNullOrEmpty(EquipmentId)
? null
: await Svc.LoadReferencePickerRootAsync(EquipmentId);
+ if (IsStale(generation)) { return; }
_roots = root is null ? Array.Empty() : new[] { root };
_loading = false;
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/AdminUiFormSweepGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/AdminUiFormSweepGuardTests.cs
new file mode 100644
index 00000000..163a60cf
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/AdminUiFormSweepGuardTests.cs
@@ -0,0 +1,60 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Raw;
+
+///
+/// Issue #604 source-scan guards (Razor has no bUnit): an EditForm with a validator but nothing
+/// to show its messages is a silent no-op Save, the layout must keep its error boundary, and the
+/// API-key Delete must not fire straight from the row button.
+///
+public sealed class AdminUiFormSweepGuardTests
+{
+ /// EditForm pages whose validation messages are rendered by a child/section component.
+ private static readonly HashSet AllowList = new(StringComparer.Ordinal);
+
+ [Fact]
+ public void Every_razor_with_an_EditForm_and_a_DataAnnotationsValidator_renders_validation_messages()
+ {
+ var offenders = new List();
+ foreach (var file in Directory.EnumerateFiles(ComponentsDir(), "*.razor", SearchOption.AllDirectories))
+ {
+ var text = File.ReadAllText(file);
+ if (!text.Contains("
+/// Issue #604: the _openGeneration stale-open guard (already on RawBrowseModal /
+/// RawManualTagEntryModal) on the five modals that lacked it. Each test opens the modal onto
+/// target A with the async resolve held open, closes, reopens onto target B, then lets A's resolve
+/// finish: A's continuation must not write A's state under B's displayed modal.
+///
+///
+/// AdminUI has no bUnit, so components are driven as plain classes (reflection for the injected
+/// properties, [Parameter]s and private state — same harness as
+/// ). Every test releases the stale resolve LAST and asserts
+/// the live open's value survived; reverting the guard makes the stale value win.
+///
+#pragma warning disable BL0006
+public sealed class ModalStaleOpenGuardTests : IDisposable
+{
+ private readonly List _renderers = new();
+
+ [Fact]
+ public async Task CsvImport_stale_group_resolve_never_retargets_the_live_open()
+ {
+ var dbName = SeedGroup("TG-A", "dev-A", "GroupA");
+ var gate = new TaskCompletionSource();
+ var modal = Make(
+ ("ExportReader", new RawTagCsvExportReader(new GatedFactory(dbName, gate.Task))));
+
+ SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", "TG-A"));
+ var stale = Open(modal);
+
+ SetParams(modal, ("Visible", false));
+ await Open(modal);
+ SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", null));
+ await Open(modal);
+
+ gate.SetResult();
+ await stale;
+
+ Field(modal, "_groupPrefix").ShouldBeNull();
+ Field(modal, "_effectiveDeviceId").ShouldBe("");
+ }
+
+ [Fact]
+ public async Task CsvExport_stale_group_resolve_never_retargets_the_live_open()
+ {
+ var dbName = SeedGroup("TG-A", "dev-A", "GroupA");
+ var gate = new TaskCompletionSource();
+ var modal = Make(
+ ("ExportReader", new RawTagCsvExportReader(new GatedFactory(dbName, gate.Task))));
+
+ SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", "TG-A"));
+ var stale = Open(modal);
+
+ SetParams(modal, ("Visible", false));
+ await Open(modal);
+ SetParams(modal, ("Visible", true), ("DeviceId", ""), ("TagGroupId", null));
+ await Open(modal);
+
+ gate.SetResult();
+ await stale;
+
+ Field(modal, "_groupFilter").ShouldBeNull();
+ Field(modal, "_rowCount").ShouldBe(0);
+ Field(modal, "_dataUri").ShouldBe("");
+ }
+
+ [Fact]
+ public async Task DeviceModal_stale_load_never_installs_the_old_devices_fields()
+ {
+ var gate = new TaskCompletionSource();
+ var svc = Stub(async (method, args) =>
+ {
+ if (method.Name != nameof(IRawTreeService.LoadDeviceForEditAsync)) { return null; }
+ if ((string)args[0]! == "dev-A")
+ {
+ await gate.Task;
+ return new RawDeviceEditDto("dev-A", "drv", "NameA", "{}", true, "Modbus", []);
+ }
+ return new RawDeviceEditDto("dev-B", "", "NameB", "{}", true, "Modbus", []);
+ });
+ var modal = Make(("Svc", svc));
+
+ SetParams(modal, ("Visible", true), ("DeviceId", "dev-A"));
+ var stale = Open(modal);
+
+ SetParams(modal, ("Visible", false));
+ await Open(modal);
+ SetParams(modal, ("Visible", true), ("DeviceId", "dev-B"));
+ await Open(modal);
+
+ gate.SetResult();
+ await stale;
+
+ Field(modal, "_name").ShouldBe("NameB");
+ }
+
+ [Fact]
+ public async Task DriverConfigModal_stale_load_never_installs_the_old_drivers_fields()
+ {
+ var gate = new TaskCompletionSource();
+ var svc = Stub(async (method, args) =>
+ {
+ if (method.Name != nameof(IRawTreeService.LoadDriverForEditAsync)) { return null; }
+ if ((string)args[0]! == "drv-A")
+ {
+ await gate.Task;
+ return new RawDriverEditDto("drv-A", "NameA", "Modbus", """{"a":1}""", null, []);
+ }
+ return new RawDriverEditDto("drv-B", "NameB", "Modbus", """{"b":2}""", null, []);
+ });
+ var modal = Make(("Svc", svc));
+
+ SetParams(modal, ("Visible", true), ("DriverInstanceId", "drv-A"));
+ var stale = Open(modal);
+
+ SetParams(modal, ("Visible", false));
+ await Open(modal);
+ SetParams(modal, ("Visible", true), ("DriverInstanceId", "drv-B"));
+ await Open(modal);
+
+ gate.SetResult();
+ await stale;
+
+ Field(modal, "_name").ShouldBe("NameB");
+ Field(modal, "_driverConfigJson").ShouldBe("""{"b":2}""");
+ }
+
+ [Fact]
+ public async Task AddReferenceModal_stale_root_load_never_replaces_the_live_opens_tree()
+ {
+ var gate = new TaskCompletionSource();
+ var svc = Stub(async (method, args) =>
+ {
+ if (method.Name != nameof(IUnsTreeService.LoadReferencePickerRootAsync)) { return null; }
+ if ((string)args[0]! == "eq-A")
+ {
+ await gate.Task;
+ return Root("root-A");
+ }
+ return Root("root-B");
+ });
+ var modal = Make(("Svc", svc));
+
+ SetParams(modal, ("Visible", true), ("EquipmentId", "eq-A"));
+ var stale = Open(modal);
+
+ SetParams(modal, ("Visible", false));
+ await Open(modal);
+ SetParams(modal, ("Visible", true), ("EquipmentId", "eq-B"));
+ await Open(modal);
+
+ gate.SetResult();
+ await stale;
+
+ Field>(modal, "_roots").Single().Key.ShouldBe("root-B");
+ }
+
+ // ---- harness ----
+
+ private static RawNode Root(string key)
+ => new() { Kind = RawNodeKind.Cluster, Key = key, DisplayName = key };
+
+ private static string SeedGroup(string tagGroupId, string deviceId, string name)
+ {
+ var dbName = $"modalstale-{Guid.NewGuid():N}";
+ using var db = RawTreeTestDb.CreateNamed(dbName);
+ db.TagGroups.Add(new TagGroup { TagGroupId = tagGroupId, DeviceId = deviceId, Name = name });
+ db.SaveChanges();
+ return dbName;
+ }
+
+ private T Make(params (string Name, object Value)[] injected) where T : class, IComponent, new()
+ {
+ var component = new T();
+ foreach (var (name, value) in injected)
+ {
+ var property = typeof(T).GetProperty(name, BindingFlags.NonPublic | BindingFlags.Instance);
+ property.ShouldNotBeNull($"the injected {name} property was renamed or removed.");
+ property!.SetValue(component, value);
+ }
+ var renderer = new InertRenderer();
+ _renderers.Add(renderer);
+ renderer.Attach(component);
+ return component;
+ }
+
+ private static void SetParams(IComponent modal, params (string Name, object? Value)[] values)
+ {
+ foreach (var (name, value) in values)
+ {
+ var property = modal.GetType().GetProperty(name);
+ property.ShouldNotBeNull($"the [Parameter] {name} was renamed or removed.");
+ property!.SetValue(modal, value);
+ }
+ }
+
+ private static async Task Open(IComponent modal)
+ {
+ var method = modal.GetType().GetMethod(
+ "OnParametersSetAsync", BindingFlags.NonPublic | BindingFlags.Instance);
+ method.ShouldNotBeNull("the open path moved off OnParametersSetAsync.");
+ await (Task)method!.Invoke(modal, null)!;
+ }
+
+ private static T Field(object modal, string name)
+ {
+ var field = modal.GetType().GetField(name, BindingFlags.NonPublic | BindingFlags.Instance);
+ field.ShouldNotBeNull($"the modal's {name} field was renamed or removed.");
+ return (T)field!.GetValue(modal)!;
+ }
+
+ ///
+ /// A throwaway implementation of a service interface: every call goes to ,
+ /// whose result is wrapped as the method's Task<T>. A null result is a null payload.
+ ///
+ private static TService Stub(Func> handler)
+ where TService : class
+ {
+ var proxy = DispatchProxy.Create();
+ ((StubProxy)(object)proxy).Handler = handler;
+ return proxy;
+ }
+
+ ///
+ public void Dispose()
+ {
+ foreach (var renderer in _renderers) { renderer.Dispose(); }
+ }
+
+ /// DispatchProxy body for .
+ public class StubProxy : DispatchProxy
+ {
+ /// Receives every call; set right after construction.
+ public Func> Handler { get; set; } = default!;
+
+ ///
+ protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
+ {
+ var inner = targetMethod!.ReturnType.GetGenericArguments()[0];
+ var task = Handler(targetMethod, args ?? []);
+ var tcs = typeof(TaskCompletionSource<>).MakeGenericType(inner);
+ var source = Activator.CreateInstance(tcs)!;
+ task.ContinueWith(t =>
+ {
+ if (t.IsFaulted) { tcs.GetMethod("SetException", [typeof(Exception)])!.Invoke(source, [t.Exception!.GetBaseException()]); }
+ else { tcs.GetMethod("SetResult")!.Invoke(source, [t.Result]); }
+ });
+ return tcs.GetProperty("Task")!.GetValue(source);
+ }
+ }
+
+ private sealed class GatedFactory(string dbName, Task gate) : IDbContextFactory
+ {
+ ///
+ public OtOpcUaConfigDbContext CreateDbContext() => RawTreeTestDb.CreateNamed(dbName);
+
+ ///
+ public async Task CreateDbContextAsync(CancellationToken ct = default)
+ {
+ await gate;
+ return CreateDbContext();
+ }
+ }
+
+ private sealed class InertRenderer()
+ : Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
+ {
+ ///
+ public override Dispatcher Dispatcher { get; } = new InlineDispatcher();
+
+ /// Assigns the component a render handle, as a root component would receive.
+ public void Attach(IComponent component) => AssignRootComponentId(component);
+
+ ///
+ protected override void HandleException(Exception exception)
+ => ExceptionDispatchInfo.Capture(exception).Throw();
+
+ ///
+ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
+ }
+
+ private sealed class InlineDispatcher : Dispatcher
+ {
+ ///
+ public override bool CheckAccess() => true;
+
+ ///
+ public override Task InvokeAsync(Action workItem)
+ {
+ workItem();
+ return Task.CompletedTask;
+ }
+
+ ///
+ public override Task InvokeAsync(Func workItem) => workItem();
+
+ ///
+ public override Task InvokeAsync(Func workItem)
+ => Task.FromResult(workItem());
+
+ ///
+ public override Task InvokeAsync(Func> workItem) => workItem();
+ }
+}