Global error notification and loading states (task 98) #71
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/global-error-loading"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes task 98. Also fixes task 139 in passing (see below).
ErrorNotification(src/PlaceMark.WebUI/Notifications/) is the one dismissible surface for anApiException, replacing each page's own_generalErrorparagraph. Used byLogin,Register,GroupFormandOidcComplete.role="alert"+aria-live="assertive", deliberately: every consumer shows this after a submit the visitor cannot proceed past. The dismiss control is a real<button>— keyboard operability is the native element's own guarantee, no custom keydown wiring. Falls back to "Something went wrong." if ever given an empty message, though nothing today reaches that path sinceApiException.Message(ADR-0058) already builds a Detail-or-Title-or-status fallback.Task 139 fixed here, not left open — same component area, same defect.
#blazor-error-ui's dismiss<span>is now a<button>. Recommend closing 139.Task 157 (
FieldErrors' missingaria-describedby) is untouched — separate concern, needs its ownid-generation design.Loading states: submit buttons show their state in their label ("Signing in…" etc.),
Login's SSO button now disables during its own async gap,App.razorgains an<Authorizing>template (role="status", polite) for the previously-blank gap whileAuthorizeRouteViewawaits the token store.Reasoning and alternatives in ADR-0063.
Full CI sequence (build, test,
dotnet format --verify-no-changes) run locally against the pinned SDK.Verdict: mergeable
Build and full
PlaceMark.WebUI.Testssuite (168 tests) pass under the pinned 10.0.100 SDK;dotnet format --verify-no-changesclean.Accessibility checks all hold up:
ErrorNotification's dismiss control andindex.html's#blazor-error-ui .dismissare both genuine<button type="button">elements with accessible names (visually-hidden text /aria-label).ErrorNotificationTestsassertsTagName == "BUTTON"and that.Click()raisesOnDismiss, not a CSS class toggle — correct given bUnit's non-browser DOM can't itself prove the Enter/Space-to-click browser mapping, and the tests say so rather than overclaiming.BlazorErrorBannerTestsreadsindex.html/app.cssas text (nothing for bUnit to render) and states plainly what it can't prove: real click-handler wiring, which Blazor attaches lazily only on a genuine unhandled error. That gap is disclosed, not hidden.role="alert"+ explicitaria-live="assertive"is redundant (the role already implies it) but harmless — one live region, no double-announcement risk — and it's the right choice for a post-submit blocking error. Reasonable to state both since the ticket asks the choice be assertable directly rather than inferred.<Authorizing>'srole="status"is correctly polite, fires once per pending navigation, not per keystroke — nothing upstream ties it to input.outline, so keyboard focus visibility survives the chrome-stripping in bothErrorNotification.razor.cssandapp.css.Reuse confirmed:
Login,Register,GroupForm,OidcCompleteall renderErrorNotificationand none keeps a parallel_generalErrorparagraph.GroupForm's static.form-error("Only the group's Owner can rename it.") is a distinct, non-dismissible, parameter-derived courtesy notice — not an API-driven error — so it's correctly left outside the component, andGroupFormTestsnow separately proves the server-side 403 refusal (staleCallerIsOwner) routes throughErrorNotificationinstead.Empty-detail case verified:
ApiProblemException's constructor falls back toproblem.Detail ?? problem.Title ?? "The API answered {status} with no further detail."— never blank.ErrorNotification's own"Something went wrong."fallback is unreachable today but tested as defence-in-depth. No empty notification is possible.The
RenderTreeDiffBuilderNullReferenceException: reproduced it independently. However, my own isolation attempt shows the ADR's stated repro condition is imprecise. A bareCascadingAuthenticationState > Router > AuthorizeRouteViewwith noAuthorizingtemplate and noNotAuthorizedoverride, resolving a pending auth state after render, does not throw — it completes cleanly. AddingRedirectToLoginas theNotAuthorizedtemplate (unchanged by this PR, pre-existing since task 86) is what reproduces the NRE: the trigger is aNotAuthorized-transition rendering a component that callsNavigateTosynchronously mid-diff, not merely the absence of anAuthorizingtemplate. The conclusion — pre-existing framework/bUnit hazard, unrelated to this PR's own markup — still holds (RedirectToLoginpredates this PR and my repro used none of the new code), and the test's defensive strategy (don't resolve the pending task, prove only the loading state) is right regardless of the precise mechanism. But ADR-0063's phrase "a bareAuthorizeRouteViewwith noAuthorizingtemplate of any kind" overstates what's needed to trigger it and could mislead whoever next tries to close this gap. Worth a follow-up correction to the ADR text — not blocking.Task 139 is genuinely closed, not partially: real
<button>, accessible name, button chrome stripped, static shape covered by a new test — matches its acceptance criteria as far as anything without a browser can.What bUnit cannot show (task 156, no browser here): actual Enter/Space keyboard activation of either dismiss button; the framework's lazy click-handler wiring on
#blazor-error-uifiring from a genuine unhandled error; CSS hover/focus-visible rendering; and, as above, the<Authorizing>→resolved transition in a genuinely-awaitedAuthorizeRouteView. All four are disclosed in code comments rather than silently assumed.Verdict: changes needed
Adjudicating the counter-claim at
3c74064, as requested. I do not accept the correction as it now stands — my own independent reproduction contradicts it, not just my original narrower claim.What I ran. All four combinations of {
<Authorizing>present/absent} × {resolves authorized/not authorized}, against the realApp/MainLayout/Drawerstack for the "present" cases and a bareCascadingAuthenticationState > Router > AuthorizeRouteView(noAuthorizing, noNotAuthorizedoverride) for the "absent" cases, each dispatched two ways: directTaskCompletionSource.SetResultfrom the test thread, and through bUnit's ownIRenderedComponent<T>.InvokeAsync. After resolving, I didn't rely onWaitForState's race againstRenderer.UnhandledExceptionalone (that race can resolve either side first if both tasks are already complete when it's constructed) — I added a genuine 400ms wall-clock settle and then polledBunitContext.Renderer.UnhandledExceptiondirectly, the exact fieldBunitRenderer.HandleExceptionwrites into, decompiled to confirm. I also captured final markup to confirm each case actually reached the state it claimed to (not silently diverted).Result: only the original case throws.
<Authorizing>present, resolves not authorized,NotAuthorizedrendersRedirectToLogin→NullReferenceExceptioninRenderTreeDiffBuilder, every time. Every other combination — authorized reachingAccount's real content (confirmed via markup:<h1>Your account</h1>), and not-authorized with noRedirectToLogin(confirmed via markup: literalNot authorized, the framework's own default) — resolves cleanly, withRenderer.UnhandledExceptionnever completing, regardless of<Authorizing>'s presence or dispatch method. This holds for both new cases the correction commit says it tested (authorized outcome;InvokeAsyncdispatch).So on the specific, unhedged sentence — "All four combinations... were run and failed identically" — my reproduction disagrees, and it disagrees in the direction of my original narrower claim (
RedirectToLogin's reentrantNavigateToduring the diff), not the revised broader one. I can't identify what differs between my setup and the author's from here —bunitresolved to 2.8.6 (matching the pinned version), build and full suite are clean at3c74064. This is exactly the "one of you has a confounded experiment" case: two careful, controlled runs disagreeing on a testable claim, without either side's exact repro code in front of the other. I'd want to see the author's actual test code to diff against mine before anyone's account is treated as settled.Judging the ADR text as written:
AuthorizeRouteViewCorecompleting its authorization check as a second render batch... independent of which template is missing or what the second batch renders") is stated as something "checked directly rather than taken on trust" and "run... and failed identically" — i.e. presented as observed fact, in this project's own "run, not reasoned" idiom (ADR-0022's phrase). My reproduction says that specific fact is wrong, not merely unproven.Ask: reconcile before merging — either share the exact repro (test code, not just prose) so the discrepancy can be pinned down and the ADR corrected a third time with something both sides can verify, or weaken the claim to what's actually agreed: that the trigger reproduces in at least the
RedirectToLoginconfiguration, that a broader characterization was tried and is contested/unresolved, and that closing it needs source-level investigation rather than another plausible story from either side. Also worth fixing while this is open:AuthorizationTests.cs's own inline comment (aroundApp_AuthenticationStateStillResolving_ShowsALoadingState) still states the original, now-superseded "bareAuthorizeRouteViewwith noAuthorizingtemplate of any kind" claim — the correction commit only touched the ADR, so the test file and the ADR now disagree with each other too.The accessibility work from the first review (real
<button>s,role/aria-livechoices, no duplicate error surfaces, empty-detail fallback, task 139 closure) is unaffected by any of this and still stands.Verdict: mergeable
Re-reviewed
f8c2b80as a real pass, not a lap of honour — independently re-ran the claims rather than reading them and agreeing.AuthorizeRouteViewDiffingReproTests.csverified by execution, not inspection. I temporarily removed theSkipand ran all four:TemplateAbsent_ResolvesAuthorizedandTemplateAbsent_ResolvesAnonymouspass clean,TemplatePresent_ResolvesAuthorizedpasses clean,TemplatePresent_ResolvesAnonymousthrows the identicalNullReferenceExceptioninRenderTreeDiffBuilder. Matches the file's own claims exactly, on my toolchain independently of the author's.I then specifically checked the file's most load-bearing claim — that the earlier "authorized path throws" result was a missing-DI false positive — by deliberately removing the
PlaceMarkAuthenticationStateProviderregistration fromConfigureAuthorization()and re-runningTemplatePresent_ResolvesAuthorized: it throws the sameNullReferenceException, not a cleanInvalidOperationException, at the same point in the stack. That confirms the explanation itself, not just the corrected conclusion. Reverted cleanly afterward (git checkout --matches the committed file byte for byte).All four tests share
ConfigureAuthorization(), so the registration is present in all four, not just the one that used to be missing it — confirmed by reading and by the fact that none of the other three regressed to throwing for an unrelated DI reason.Full suite passes with the four repro tests genuinely skipped (168 passed, 4 skipped),
dotnet format --verify-no-changesclean.One documentation inaccuracy, non-blocking.
BareAuthorizeRouteViewWithoutAuthorizingTemplate's doc comment claims it's "the same shapeApp.razorhad before task 98 added an<Authorizing>template." Checked against the pre-PRApp.razor(merge-base127e787): that shape had a realRouterandFocusOnNavigatearound theAuthorizeRouteView; this harness has neither, using a hand-built staticRouteDatainstead. It's a further reduction, not the same shape — worth a wording fix (e.g. "a further-reduced version ofApp.razor's pre-task-98 shape — noRouter, noFocusOnNavigateeither") so a future reader doesn't treatTemplateAbsent_ResolvesAnonymousas isolating only the<Authorizing>variable when it also dropsRouter. Doesn't affect the class's actual conclusion, which is honest about testing "the full tree" as one bundled variable rather than claiming to have isolatedRouterspecifically.ADR-0063 correctly separates settled from open. "Which combination reproduces" is now stated as agreed across two independent reproductions (confirmed a third time by me, above) rather than asserted unilaterally; "the mechanism" is explicitly left open with a plausible confound named (
Router's own reaction to the reentrantNavigateTo, per the note above) rather than re-asserted as fact. This is the right shape — round three doesn't overclaim in the opposite direction, and it keeps the dispute trail rather than smoothing it away.AuthorizationTests.cs's comment now matches: scoped to the specificRedirectToLogin-rendering case, points to the ADR and the repro class, no longer restates the superseded "any outcome" claim.Accessibility work confirmed untouched: no diff in
Notifications/,wwwroot/index.html,wwwroot/css/,Pages/Auth/, orGroups/since the original1443737. Nothing from the first review needs re-checking.