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
@@ -290,3 +290,70 @@ equivalent re-key on NotificationReport was exercised through the confirm path a
**Rig note:** `docker/docker-compose.yml` carries an uncommitted `DisableLogin: "true"` from the earlier
EWS live-gate session (SEC-36). It predates this sweep and was left untouched.
## 5. Post-sweep correction: the modal re-key was not behaviour-preserving
This sweep was carried out and reported as "wrappers, classes and prose only —
behaviour preserved byte-for-byte." **That claim was false for four files**, and the
error was found only after the fact, while re-reading the same files during the
Theme 0.4.1 bump. Recorded here rather than quietly fixed, because the failure was
in the reporting as much as in the code.
### What actually changed
Four detail surfaces were re-keyed from holding the selected **record** to holding
its **id** and re-resolving from the current page on every render:
| Surface | Field before | After |
|---|---|---|
| `Pages/Notifications/NotificationReport.razor` | `_detailNotification` | `_detailNotificationId` + `DetailRow()` |
| `Pages/Audit/ConfigurationAuditLog.razor` | `_modalEntry` | `_modalEntryId` + `ModalEntry()` |
| `Pages/Monitoring/ParkedMessages.razor` | (record) | `_drawerMessageId` + `DrawerMessage` |
| `Pages/SiteCalls/SiteCallsReport.razor(.cs)` | `_detailSiteCall` | `_detailSiteCallId` + `DetailRow()` |
The re-key itself is sound — it fixes a real stale-record class of bug. The defect
was in what it was wired to.
### The defect
Each surface also used the resolve as the **visibility gate**
(`@if (DetailRow() is { } d)`). That makes the modal's *existence* a function of
list contents, so any render where the row is momentarily unresolvable unmounts the
entire subtree and disposes every event-handler id inside it — including Close's. A
click already in flight against a disposed handler makes the renderer throw
`GetRequiredEventBindingEntry` during `DispatchEventAsync`.
The previous field-held record made this structurally impossible: the modal existed
because the user opened it, and no list mutation could retract that.
**Symptom:** an intermittent `NotificationReportDetailModalTests.CloseButton_DismissesModal`
failure — 989/990 on one full-suite run, passing on re-run and 5/5 in isolation.
That signature is a race, not evidence against one.
### The fix
Visibility is now gated on the held **id** (user intent); 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
(`_detail`) is unaffected and still renders.
### Verification
- `Modal_StaysOpen_WhenItsRowLeavesThePage` — opens the modal, drops the row from
the next query, asserts the modal is still mounted, shows the notice, keeps the
fetched body, hides Retry/Discard, and that Close still works.
- **Negative control:** the test was run against a deliberately restored defective
gate and **failed**, then passed on the fix. A regression test that passes both
ways proves nothing; this one discriminates.
- 20 consecutive runs of the previously-flaky class: 0 failures.
- CentralUI.Tests 991/991; full solution build 0 warnings / 0 errors.
### Process note
The sweep ran as parallel batch agents, several of which correctly refused
instructions that were wrong (§2 Refusals). This defect went the other way: an agent
did *more* than the brief, the extra work looked like an improvement, and the
merge review checked that the diff was *plausible* rather than that it was *in
scope*. A scope claim covering N files needs to be checked against N diffs — for
this sweep, `git diff` filtered to non-comment, non-class changes takes about two
minutes and would have caught it before the commit, not after.