From 8c7b15fefe97f032e738ca14b73813498c548d82 Mon Sep 17 00:00:00 2001 From: Boromir Date: Mon, 11 May 2026 13:53:07 -0700 Subject: [PATCH 1/3] fix(ui): redirect to /blog when post not found in Edit page, add bUnit test - Edit.razor: null-value branch now navigates to /blog instead of leaving _model null (which caused infinite 'Loading...' state) - EditAclTests: added EditRedirectsToBlogWhenPostNotFound covering the Result.Ok(null) path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Web/Features/BlogPosts/Edit/Edit.razor | 3 ++- .../Web.Tests.Bunit/Features/EditAclTests.cs | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Web/Features/BlogPosts/Edit/Edit.razor b/src/Web/Features/BlogPosts/Edit/Edit.razor index a51f58e9..c3d08849 100644 --- a/src/Web/Features/BlogPosts/Edit/Edit.razor +++ b/src/Web/Features/BlogPosts/Edit/Edit.razor @@ -86,7 +86,8 @@ else if (_model is not null) } else { - _model = null; + Navigation.NavigateTo("/blog"); + return; } } else diff --git a/tests/Web.Tests.Bunit/Features/EditAclTests.cs b/tests/Web.Tests.Bunit/Features/EditAclTests.cs index 3e90c815..ae5c5a31 100644 --- a/tests/Web.Tests.Bunit/Features/EditAclTests.cs +++ b/tests/Web.Tests.Bunit/Features/EditAclTests.cs @@ -31,6 +31,29 @@ public EditAclTests() Services.AddSingleton(_authProvider); } + [Fact] + public void EditRedirectsToBlogWhenPostNotFound() + { + // Arrange + var sender = Substitute.For(); + var postId = Guid.NewGuid(); + + sender.Send(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(null))); + + Services.AddSingleton(sender); + + var navigation = Services.GetRequiredService(); + + // Act + RenderWithUser( + CreatePrincipalWithSub("auth0|some-user", ["Author"]), + parameters => parameters.Add(p => p.Id, postId)); + + // Assert + navigation.Uri.Should().EndWith("/blog"); + } + [Fact] public void EditRedirectsToBlogWhenAuthorIsNotPostOwner() { From 8076fc633ecdf01812700d9b0d2611f8dbea1eb7 Mon Sep 17 00:00:00 2001 From: Boromir Date: Mon, 11 May 2026 16:19:50 -0700 Subject: [PATCH 2/3] fix(ui): clear loading state when Edit page gets null post result - Add _isLoading flag (init true) replacing the _model is null && _error is null derived condition, so the spinner clears on every OnParametersSetAsync exit - Wrap OnParametersSetAsync body in try/finally to guarantee _isLoading = false even when NavigateTo+return early-exits (null post or unauthorized author paths) - Add role='status' ARIA attribute to the loading paragraph for accessibility - Update EditRedirectsToBlogWhenPostNotFound test to capture cut and assert Loading... is not shown after a null-post result Closes #307 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Web/Features/BlogPosts/Edit/Edit.razor | 42 +++++++++++-------- .../Web.Tests.Bunit/Features/EditAclTests.cs | 3 +- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/Web/Features/BlogPosts/Edit/Edit.razor b/src/Web/Features/BlogPosts/Edit/Edit.razor index c3d08849..e13db35e 100644 --- a/src/Web/Features/BlogPosts/Edit/Edit.razor +++ b/src/Web/Features/BlogPosts/Edit/Edit.razor @@ -26,9 +26,9 @@ } -@if (_model is null && _error is null) +@if (_isLoading) { -

Loading...

+

Loading...

} else if (_model is not null) { @@ -60,39 +60,47 @@ else if (_model is not null) private PostFormModel? _model; private string? _error; private bool _concurrencyError; + private bool _isLoading = true; private string _callerUserId = string.Empty; private bool _callerIsAdmin; protected override async Task OnParametersSetAsync() { - var result = await Sender.Send(new GetBlogPostByIdQuery(Id)); - if (result.Success) + try { - if (result.Value is not null) + var result = await Sender.Send(new GetBlogPostByIdQuery(Id)); + if (result.Success) { - var authState = await AuthStateProvider.GetAuthenticationStateAsync(); - var user = authState.User; - _callerUserId = user.FindFirst("sub")?.Value ?? string.Empty; - _callerIsAdmin = user.IsInRole("Admin"); - var isAuthor = result.Value.AuthorId == _callerUserId; + if (result.Value is not null) + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + _callerUserId = user.FindFirst("sub")?.Value ?? string.Empty; + _callerIsAdmin = user.IsInRole("Admin"); + var isAuthor = result.Value.AuthorId == _callerUserId; + + if (!_callerIsAdmin && !isAuthor) + { + Navigation.NavigateTo("/blog"); + return; + } - if (!_callerIsAdmin && !isAuthor) + _model = new PostFormModel { Title = result.Value.Title, Content = result.Value.Content }; + } + else { Navigation.NavigateTo("/blog"); return; } - - _model = new PostFormModel { Title = result.Value.Title, Content = result.Value.Content }; } else { - Navigation.NavigateTo("/blog"); - return; + _error = result.Error; } } - else + finally { - _error = result.Error; + _isLoading = false; } } diff --git a/tests/Web.Tests.Bunit/Features/EditAclTests.cs b/tests/Web.Tests.Bunit/Features/EditAclTests.cs index ae5c5a31..ad9021b8 100644 --- a/tests/Web.Tests.Bunit/Features/EditAclTests.cs +++ b/tests/Web.Tests.Bunit/Features/EditAclTests.cs @@ -46,12 +46,13 @@ public void EditRedirectsToBlogWhenPostNotFound() var navigation = Services.GetRequiredService(); // Act - RenderWithUser( + var cut = RenderWithUser( CreatePrincipalWithSub("auth0|some-user", ["Author"]), parameters => parameters.Add(p => p.Id, postId)); // Assert navigation.Uri.Should().EndWith("/blog"); + cut.Markup.Should().NotContain("Loading..."); } [Fact] From 065f99e591a4feb41a424b64eefdc772e5cabf8d Mon Sep 17 00:00:00 2001 From: Boromir Date: Mon, 11 May 2026 16:23:53 -0700 Subject: [PATCH 3/3] docs(squad): legolas history for issue #307 loading state fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/legolas/history.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.squad/agents/legolas/history.md b/.squad/agents/legolas/history.md index 7c364bb2..8aedd847 100644 --- a/.squad/agents/legolas/history.md +++ b/.squad/agents/legolas/history.md @@ -851,3 +851,33 @@ Then each variant only declares its colour-specific overrides. This is idiomatic - `cut.WaitForAssertion(...)` is required after `button[type='submit'].Click()` to await the async handler **Branch:** `squad/300-restrict-blog-post-edit-to-author-or-admin` + +--- + +## 2025-07-19 — Issue #307: Edit Page Loading State Fix + +### Problem + +`Edit.razor` used `_model is null && _error is null` as the "Loading..." condition. When a post is not found, `OnParametersSetAsync` calls `NavigateTo("/blog")` and `return`s early — never setting `_model` or `_error`. In bUnit (and briefly in real Blazor before circuit navigation completes), the page was permanently stuck on "Loading...". + +### Fix Applied + +- Added `private bool _isLoading = true;` field +- Changed template condition from `_model is null && _error is null` to `_isLoading` +- Added `role="status"` ARIA attribute to the loading paragraph +- Wrapped `OnParametersSetAsync` body in `try/finally { _isLoading = false; }` to guarantee clearing on ALL exit paths (including early `return` via `NavigateTo`) +- Updated `EditRedirectsToBlogWhenPostNotFound` bUnit test to assert `cut.Markup.Should().NotContain("Loading...")` after null-post result + +### Learnings + +**Pattern: `_isLoading` flag for async-loaded Blazor pages** + +- Never derive "is loading" from `_model is null && _error is null` — it fails on early-return paths +- Always use a dedicated `_isLoading` flag with `try/finally` in `OnParametersSetAsync` / `OnInitializedAsync` +- This pattern must be applied to all async-loaded pages in the codebase (List, Create, ManageRoles, Profile) + +**bUnit behaviour** + +- `NavigationManager.NavigateTo` in bUnit changes `.Uri` but does NOT unmount the component — so loading states must be explicitly cleared, not relied on navigation to resolve them + +**Filed:** `.squad/decisions/inbox/legolas-issue307-ui.md`