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
@@ -251,23 +251,39 @@
}
</div>
@* Re-resolved from the CURRENT page every render (see DrawerMessage). A row that
has been retried, discarded or paged away simply resolves to null and the drawer
renders nothing — it can never keep showing a row that no longer exists. *@
@* Re-resolved from the CURRENT page every render (see DrawerMessage), so the
drawer can never keep showing a row that no longer exists.
The drawer is MOUNTED on _drawerMessageId — user intent — not on the resolve
succeeding. Gating the subtree on DrawerMessage would make the drawer's
existence a function of list contents, so a render where the row is
momentarily absent unmounts it and disposes every event-handler id inside,
Close included; a click already in flight against a disposed handler throws
GetRequiredEventBindingEntry in the renderer. A row that was retried,
discarded or paged away degrades to the notice below instead. *@
@{ var drawer = DrawerMessage; }
@if (drawer != null)
@if (_drawerMessageId is not null)
{
<div class="offcanvas-backdrop fade show" @onclick="CloseDrawer"></div>
<div class="offcanvas offcanvas-end show parked-drawer" tabindex="-1" style="visibility: visible;">
<div class="offcanvas-header border-bottom">
<div>
<div class="text-muted small text-uppercase">Parked message</div>
<h5 class="offcanvas-title mb-0">@drawer.TargetSystem</h5>
<div class="small text-muted">@drawer.MethodName</div>
<h5 class="offcanvas-title mb-0">@(drawer?.TargetSystem ?? "No longer listed")</h5>
<div class="small text-muted">@drawer?.MethodName</div>
</div>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseDrawer"></button>
</div>
<div class="offcanvas-body small">
@if (drawer is null)
{
<div class="alert alert-secondary py-2 mb-0" data-test="drawer-row-gone">
This message is no longer on the current page — it was retried,
discarded, or the page changed while the drawer was open.
</div>
}
else
{
<dl class="row mb-3">
<dt class="col-4 text-muted fw-normal">Message ID</dt>
<dd class="col-8 d-flex align-items-center gap-2">
@@ -304,19 +320,25 @@
<div class="text-muted text-uppercase small fw-semibold mb-1">Error</div>
<pre class="bg-body-secondary border rounded p-2 small mb-0 parked-error-pre">@drawer.ErrorMessage</pre>
}
</div>
<div class="border-top p-3 d-flex gap-2">
<button class="btn btn-outline-success btn-sm flex-grow-1"
@onclick="RetryFromDrawer" disabled="@_actionInProgress">
@if (_actionInProgress && _activeAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry
</button>
<button class="btn btn-outline-danger btn-sm flex-grow-1"
@onclick="DiscardFromDrawer" disabled="@_actionInProgress">
@if (_actionInProgress && _activeAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard
</button>
</div>
@* Actions need the resolved row to act on, so they hide when it is gone.
Close stays mounted regardless — see the note above. *@
@if (drawer is not null)
{
<div class="border-top p-3 d-flex gap-2">
<button class="btn btn-outline-success btn-sm flex-grow-1"
@onclick="RetryFromDrawer" disabled="@_actionInProgress">
@if (_actionInProgress && _activeAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Retry
</button>
<button class="btn btn-outline-danger btn-sm flex-grow-1"
@onclick="DiscardFromDrawer" disabled="@_actionInProgress">
@if (_actionInProgress && _activeAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
Discard
</button>
</div>
}
</div>
}