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
@@ -215,22 +215,44 @@
@* ── Row detail modal ──
The modal holds only the row's NotificationId and re-resolves the summary from
the page currently on screen on every render (DetailRow()). A refresh that
replaces the row list therefore never leaves this surface rendering a stale
record, and a row that has left the page closes the modal instead of stranding
it. *@
@if (DetailRow() is { } d)
the page currently on screen on every render (DetailRow()), so this surface
never renders a stale record after a refresh replaces the row list.
VISIBILITY IS GATED ON _detailNotificationId — user intent — NOT on whether
DetailRow() still resolves. That distinction is load-bearing: gating the
subtree on the resolve makes the modal's existence a function of list
contents, so any render where the row is momentarily absent unmounts the
whole subtree and disposes every event-handler id inside it, including
Close's. A click already in flight against a disposed handler throws
GetRequiredEventBindingEntry in the renderer. Keeping the mount tied to
intent means only the user closes this modal; an unresolvable row degrades to
the notice below while the frame — and its handlers — stay put. *@
@if (_detailNotificationId is { } detailId)
{
var d = DetailRow();
<div class="modal show d-block sb-modal-backdrop" tabindex="-1"
@onclick="CloseDetail">
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">Notification Detail — @ShortId(d.NotificationId)</h6>
<h6 class="modal-title">Notification Detail — @ShortId(detailId)</h6>
<button type="button" class="btn-close" aria-label="Close"
@onclick="CloseDetail"></button>
</div>
<div class="modal-body">
@if (d is null)
{
@* The row left the current page (refresh, filter or page change)
while the modal was open. The fetched detail below is keyed by
id and stays valid, so only the summary is unavailable. *@
<div class="alert alert-secondary py-2 mb-3" data-test="detail-row-gone">
This notification is no longer in the current page of results,
so its summary can't be shown. The message detail below is
still the one you opened.
</div>
}
else
{
<dl class="row mb-0">
<dt class="col-sm-3">Notification ID</dt>
<dd class="col-sm-9"><code>@d.NotificationId</code></dd>
@@ -280,6 +302,7 @@
<dd class="col-sm-9 text-danger">@d.LastError</dd>
}
</dl>
}
@* ── Recipients ── *@
<hr />
@@ -310,8 +333,9 @@
else
{
<div class="text-muted small">
Not yet resolved — recipients are resolved from list
"@d.ListName" at delivery time.
Not yet resolved — recipients are resolved from
@(d is null ? "the notification's list" : $"list \"{d.ListName}\"")
at delivery time.
</div>
}
}
@@ -340,15 +364,17 @@
}
</div>
<div class="modal-footer">
@if (d.Status == "Parked")
@* Actions need the resolved row to relay against, so they hide
when it is gone — but Close never does. *@
@if (d is { Status: "Parked" } parked)
{
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-success"
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
@onclick="() => RetryFromDetail(parked)" disabled="@_actionInProgress">
Retry
</button>
<button class="btn btn-outline-danger"
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
@onclick="() => DiscardFromDetail(parked)" disabled="@_actionInProgress">
Discard
</button>
</div>