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:
Joseph Doherty
2026-08-11 06:03:06 -04:00
parent 9e243493fb
commit d14e0ee4b1
6 changed files with 238 additions and 45 deletions
@@ -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)