fix(ui): gate detail modals on user intent, not on the row resolving
The sweep's modal re-key (holding the row's id and re-resolving it, rather than holding the record) also used that resolve as the modal's visibility gate. That makes the modal's existence a function of list contents: any render where the row is momentarily unresolvable unmounts the whole subtree and disposes every event-handler id inside it, Close's included. A click already in flight against a disposed handler makes the renderer throw GetRequiredEventBindingEntry during DispatchEventAsync — which is how this surfaced, as an intermittent failure of CloseButton_DismissesModal (989/990 on one run, green on re-run). The record-held form made that structurally impossible: the modal existed because the user opened it, and no list mutation could retract that. This restores the property while keeping the re-key's actual benefit. Visibility now gates on the held id; the resolve drives only content. An unresolvable row degrades to an explicit notice and hides the row-scoped actions, while the frame and Close stay mounted. Detail fetched by id still renders, so the user does not lose the body they opened. Applied to all four surfaces that shared the construction: NotificationReport, ConfigurationAuditLog, ParkedMessages (offcanvas drawer) and SiteCallsReport. Modal_StaysOpen_WhenItsRowLeavesThePage drops the opened row from the next query and asserts the modal survives, keeps its fetched body, hides Retry/Discard, and that Close still works. It was run against a deliberately restored defective gate and failed there before passing here — a regression test that passes both ways would be worthless against a race. 20 consecutive runs of the previously flaky class: no failures. CentralUI.Tests 991/991, solution build 0/0. The plan doc gains a section recording that the sweep was reported as behaviour-preserving when it was not, and why the merge review missed it.
This commit is contained in:
+53
@@ -256,6 +256,59 @@ public class NotificationReportDetailModalTests : BunitContext
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The modal must stay mounted when the row it was opened for leaves the
|
||||
/// current page — it is gated on user intent (the held id), never on the
|
||||
/// row still resolving.
|
||||
///
|
||||
/// This is a race regression guard, not a cosmetic one. When the subtree was
|
||||
/// gated on the resolve, a refresh that dropped the row unmounted the whole
|
||||
/// modal and disposed every event-handler id inside it, Close's included. A
|
||||
/// click already in flight against a disposed handler makes the renderer
|
||||
/// throw GetRequiredEventBindingEntry — which is exactly how this surfaced,
|
||||
/// as an intermittent failure in CloseButton_DismissesModal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Modal_StaysOpen_WhenItsRowLeavesThePage()
|
||||
{
|
||||
var cut = Render<NotificationReportPage>();
|
||||
cut.WaitForState(() => cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
var row = cut.FindAll("tbody tr")
|
||||
.First(r => r.TextContent.Contains("Pump fault at Plant-A"));
|
||||
row.DoubleClick();
|
||||
cut.WaitForState(() => cut.FindAll(".modal.show").Count > 0);
|
||||
|
||||
// The next query drops the opened row, as a refresh, filter change or
|
||||
// page change legitimately can while the modal is open.
|
||||
_queryReply = _queryReply with
|
||||
{
|
||||
Notifications = _queryReply.Notifications!
|
||||
.Where(n => n.NotificationId != "notif-aaaaaaaa-1111-full-id")
|
||||
.ToList(),
|
||||
TotalCount = 1,
|
||||
};
|
||||
|
||||
cut.FindAll("button").First(b => b.TextContent.Contains("Query")).Click();
|
||||
cut.WaitForState(() => !cut.Markup.Contains("Pump fault at Plant-A"));
|
||||
|
||||
// Still mounted, and it says why the summary is missing rather than
|
||||
// vanishing out from under the user.
|
||||
var modal = cut.Find(".modal.show");
|
||||
Assert.NotNull(modal.QuerySelector("[data-test='detail-row-gone']"));
|
||||
|
||||
// The detail that was already fetched is keyed by id, so it survives.
|
||||
Assert.Contains(
|
||||
"Pump-001 tripped on overcurrent at 14:32. Investigate immediately.",
|
||||
modal.TextContent);
|
||||
|
||||
// Row-scoped actions are gone (nothing to relay against) but Close is not,
|
||||
// and it still works — the handler was never disposed.
|
||||
Assert.DoesNotContain("Retry", modal.QuerySelector(".modal-footer")!.TextContent);
|
||||
cut.Find(".modal.show .modal-footer button").Click();
|
||||
cut.WaitForAssertion(() => Assert.Empty(cut.FindAll(".modal.show")));
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
|
||||
Reference in New Issue
Block a user