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 8e6ad6e1..1337dc8e 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 @@ -170,6 +170,11 @@ } var dto = await Svc.LoadTagForEditAsync(TagId); + // Two opens can overlap on one modal instance (the tree's Edit command is synchronous and does + // not disable while this resolves), so every post-await write is discarded unless this open is + // still the displayed one. Last-completion-wins would point the picker at another device. + if (_loadedKey != key || !Visible) { return; } + // RawTagEditDto is a record struct → a missing tag comes back as default (all-zero), not null. if (dto is not { } d || string.IsNullOrEmpty(d.TagId)) { @@ -192,6 +197,8 @@ try { var merged = await Svc.LoadMergedProbeConfigAsync(d.DeviceId); + if (_loadedKey != key || !Visible) { return; } // superseded open → never install its device + if (merged is { } m) { _mergedConfigJson = m.MergedConfigJson; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/FakeRawTreeService.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/FakeRawTreeService.cs index 9c11df75..484c39ea 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/FakeRawTreeService.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/FakeRawTreeService.cs @@ -20,14 +20,26 @@ internal sealed class FakeRawTreeService : IRawTreeService public Func OnLoadMergedProbeConfig { get; set; } = _ => throw new NotImplementedException(); + /// + /// Optional asynchronous override of . The sync delegates above + /// complete inline, which cannot express two opens overlapping on one modal instance — set this to + /// gate a load on a and interleave the second open. + /// + public Func>? OnLoadTagForEditAsync { get; set; } + + /// Optional asynchronous override of ; see . + public Func>? OnLoadMergedProbeConfigAsync { get; set; } + /// public Task LoadTagForEditAsync(string tagId, CancellationToken ct = default) - => Task.FromResult(OnLoadTagForEdit(tagId)); + => OnLoadTagForEditAsync is { } async ? async(tagId) : Task.FromResult(OnLoadTagForEdit(tagId)); /// public Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync( string deviceId, CancellationToken ct = default) - => Task.FromResult(OnLoadMergedProbeConfig(deviceId)); + => OnLoadMergedProbeConfigAsync is { } async + ? async(deviceId) + : Task.FromResult(OnLoadMergedProbeConfig(deviceId)); /// public Task> LoadRootsAsync(CancellationToken ct = default) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTagModalMergedConfigTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTagModalMergedConfigTests.cs index 3ca5dd50..2fd06818 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTagModalMergedConfigTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Raw/RawTagModalMergedConfigTests.cs @@ -33,6 +33,9 @@ public sealed class RawTagModalMergedConfigTests { private const string MergedConfig = """{"endpoint":"10.0.0.1:44818","slot":0}"""; + /// A second device's config — the blob a superseded open must never install. + private const string StaleConfig = """{"endpoint":"10.0.0.9:44818","slot":3}"""; + [Fact] public async Task The_editor_receives_the_merged_device_config_not_a_stub() { @@ -98,6 +101,37 @@ public sealed class RawTagModalMergedConfigTests firstAccessor().ShouldBe(MergedConfig); } + [Fact] + public async Task A_stale_open_never_feeds_the_picker_another_devices_config() + { + // RawTree's OnEditTag is synchronous and does not disable while the modal resolves, so a rapid + // double-click across two tag rows overlaps two opens on ONE modal instance. Every write after an + // await must therefore be discarded if the open it belongs to is no longer the displayed one: + // last-completion-wins would silently point a live browse at the wrong controller. + var gate = new TaskCompletionSource<(string DriverType, string MergedConfigJson)?>(); + var svc = new FakeRawTreeService + { + OnLoadTagForEdit = tagId => TagOn(tagId, tagId == "tag-1" ? "dev-A" : "dev-B"), + OnLoadMergedProbeConfigAsync = deviceId => deviceId == "dev-A" + ? gate.Task + : Task.FromResult<(string DriverType, string MergedConfigJson)?>(("AbCip", MergedConfig)), + }; + + var modal = NewModal(svc, "tag-1"); + var firstOpen = InvokeParametersSetAsync(modal); + + // The second open supersedes the first while the first is still parked on dev-A's resolve. + modal.TagId = "tag-2"; + await InvokeParametersSetAsync(modal); + + gate.SetResult(("AbCip", StaleConfig)); + await firstOpen; + + DriverConfigJson(modal).ShouldBe( + MergedConfig, + "the superseded open's dev-A config overwrote the displayed tag's dev-B config"); + } + private static RawTagEditDto TagOn(string tagId, string deviceId) => new( tagId, deviceId,