From 37f68a28bcabc49a78012ef0590f8c44d789a52b Mon Sep 17 00:00:00 2001 From: Boromir Date: Mon, 11 May 2026 17:24:40 -0700 Subject: [PATCH 1/3] fix(ui): refresh edit page state on route changes - reset loading state at parameter changes so route updates fetch and render correctly - add bUnit coverage for post ID parameter switch regression Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Web/Features/BlogPosts/Edit/Edit.razor | 1 + .../Web.Tests.Bunit/Features/EditAclTests.cs | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/Web/Features/BlogPosts/Edit/Edit.razor b/src/Web/Features/BlogPosts/Edit/Edit.razor index e13db35e..eb2f780d 100644 --- a/src/Web/Features/BlogPosts/Edit/Edit.razor +++ b/src/Web/Features/BlogPosts/Edit/Edit.razor @@ -66,6 +66,7 @@ else if (_model is not null) protected override async Task OnParametersSetAsync() { + _isLoading = true; try { var result = await Sender.Send(new GetBlogPostByIdQuery(Id)); diff --git a/tests/Web.Tests.Bunit/Features/EditAclTests.cs b/tests/Web.Tests.Bunit/Features/EditAclTests.cs index ad9021b8..ec5cf427 100644 --- a/tests/Web.Tests.Bunit/Features/EditAclTests.cs +++ b/tests/Web.Tests.Bunit/Features/EditAclTests.cs @@ -166,6 +166,42 @@ public void EditAllowsAdminToEditAnyPost() cut.Markup.Should().Contain("Edit Post"); } + [Fact] + public void EditShowsNewPostContentAfterParameterChange() + { + // Arrange + var sender = Substitute.For(); + var firstPostId = Guid.NewGuid(); + var secondPostId = Guid.NewGuid(); + const string OwnerSub = "auth0|owner-user"; + + var firstPost = new BlogPostDto(firstPostId, "First Post Title", "First Content", OwnerSub, "Owner", string.Empty, [], DateTime.UtcNow, null, false); + var secondPost = new BlogPostDto(secondPostId, "Second Post Title", "Second Content", OwnerSub, "Owner", string.Empty, [], DateTime.UtcNow, null, false); + + sender.Send(Arg.Is(q => q.Id == firstPostId), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(firstPost))); + sender.Send(Arg.Is(q => q.Id == secondPostId), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(secondPost))); + + Services.AddSingleton(sender); + + // Act — first render + var cut = RenderWithUser( + CreatePrincipalWithSub(OwnerSub, ["Author"]), + parameters => parameters.Add(p => p.Id, firstPostId)); + + cut.Markup.Should().Contain("First Post Title"); + cut.Markup.Should().NotContain("Loading..."); + + // Act — change parameters to a different post + cut.Render(parameters => parameters.Add(p => p.Id, secondPostId)); + + // Assert — second post content shown, loading indicator gone, no stale first-post content + cut.Markup.Should().Contain("Second Post Title"); + cut.Markup.Should().NotContain("First Post Title"); + cut.Markup.Should().NotContain("Loading..."); + } + private IRenderedComponent RenderWithUser( ClaimsPrincipal principal, Action>? configure = null) From 68c6e7bcd001f32fac437bc17c8e6dec9c1b318f Mon Sep 17 00:00:00 2001 From: Boromir Date: Mon, 11 May 2026 17:44:21 -0700 Subject: [PATCH 2/3] fix(ui): reset stale state (_model/_error/_concurrencyError) on parameter changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this fix, OnParametersSetAsync only reset _isLoading. If a first load succeeded (populating _model), then a second route-parameter change triggered a fetch that failed or returned null, the old _model data remained visible alongside the new error message. Changes: - Edit.razor: add _model = null, _error = null, _concurrencyError = false at the top of OnParametersSetAsync so all display state is clean before every fetch cycle - EditAclTests: add two regression tests covering the stale-content paths: • EditClearsStaleContentOnErrorAfterSuccessfulLoad • EditClearsStaleContentOnNullAfterSuccessfulLoad All 92 bUnit tests pass (was 90 before). Gate 4 fully green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Web/Features/BlogPosts/Edit/Edit.razor | 3 + .../Web.Tests.Bunit/Features/EditAclTests.cs | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/Web/Features/BlogPosts/Edit/Edit.razor b/src/Web/Features/BlogPosts/Edit/Edit.razor index eb2f780d..a5a6ae5b 100644 --- a/src/Web/Features/BlogPosts/Edit/Edit.razor +++ b/src/Web/Features/BlogPosts/Edit/Edit.razor @@ -66,6 +66,9 @@ else if (_model is not null) protected override async Task OnParametersSetAsync() { + _model = null; + _error = null; + _concurrencyError = false; _isLoading = true; try { diff --git a/tests/Web.Tests.Bunit/Features/EditAclTests.cs b/tests/Web.Tests.Bunit/Features/EditAclTests.cs index ec5cf427..6f4761bd 100644 --- a/tests/Web.Tests.Bunit/Features/EditAclTests.cs +++ b/tests/Web.Tests.Bunit/Features/EditAclTests.cs @@ -202,6 +202,75 @@ public void EditShowsNewPostContentAfterParameterChange() cut.Markup.Should().NotContain("Loading..."); } + [Fact] + public void EditClearsStaleContentOnErrorAfterSuccessfulLoad() + { + // Arrange: first load succeeds, second load fails + var sender = Substitute.For(); + var firstPostId = Guid.NewGuid(); + var secondPostId = Guid.NewGuid(); + const string OwnerSub = "auth0|owner-user"; + + var firstPost = new BlogPostDto(firstPostId, "First Post Title", "First Content", OwnerSub, "Owner", string.Empty, [], DateTime.UtcNow, null, false); + + sender.Send(Arg.Is(q => q.Id == firstPostId), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(firstPost))); + sender.Send(Arg.Is(q => q.Id == secondPostId), Arg.Any()) + .Returns(Task.FromResult(Result.Fail("Post could not be loaded."))); + + Services.AddSingleton(sender); + + // Act — first render succeeds + var cut = RenderWithUser( + CreatePrincipalWithSub(OwnerSub, ["Author"]), + parameters => parameters.Add(p => p.Id, firstPostId)); + + cut.Markup.Should().Contain("First Post Title"); + + // Act — second render returns error + cut.Render(parameters => parameters.Add(p => p.Id, secondPostId)); + + // Assert — stale form content gone, error shown + cut.Markup.Should().NotContain("First Post Title"); + cut.Markup.Should().NotContain("Loading..."); + cut.Markup.Should().Contain("Post could not be loaded."); + } + + [Fact] + public void EditClearsStaleContentOnNullAfterSuccessfulLoad() + { + // Arrange: first load succeeds, second returns null (post not found) + var sender = Substitute.For(); + var firstPostId = Guid.NewGuid(); + var secondPostId = Guid.NewGuid(); + const string OwnerSub = "auth0|owner-user"; + + var firstPost = new BlogPostDto(firstPostId, "First Post Title", "First Content", OwnerSub, "Owner", string.Empty, [], DateTime.UtcNow, null, false); + + sender.Send(Arg.Is(q => q.Id == firstPostId), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(firstPost))); + sender.Send(Arg.Is(q => q.Id == secondPostId), Arg.Any()) + .Returns(Task.FromResult(Result.Ok(null))); + + Services.AddSingleton(sender); + var navigation = Services.GetRequiredService(); + + // Act — first render succeeds + var cut = RenderWithUser( + CreatePrincipalWithSub(OwnerSub, ["Author"]), + parameters => parameters.Add(p => p.Id, firstPostId)); + + cut.Markup.Should().Contain("First Post Title"); + + // Act — second render: post not found → should redirect + cut.Render(parameters => parameters.Add(p => p.Id, secondPostId)); + + // Assert — redirected and no stale form content visible + navigation.Uri.Should().EndWith("/blog"); + cut.Markup.Should().NotContain("First Post Title"); + cut.Markup.Should().NotContain("Loading..."); + } + private IRenderedComponent RenderWithUser( ClaimsPrincipal principal, Action>? configure = null) From 1c9ebfaca335896b53cfe85956775cf0f0af186f Mon Sep 17 00:00:00 2001 From: Boromir Date: Mon, 11 May 2026 17:46:23 -0700 Subject: [PATCH 3/3] docs(squad): append Legolas history for PR #310 stale-state fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/legolas/history.md | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/.squad/agents/legolas/history.md b/.squad/agents/legolas/history.md index 8aedd847..a954c590 100644 --- a/.squad/agents/legolas/history.md +++ b/.squad/agents/legolas/history.md @@ -881,3 +881,54 @@ Then each variant only declares its colour-specific overrides. This is idiomatic - `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` + +--- + +## 2026-05-11 — PR #310 Unblock: Stale-State Fix on Route Parameter Changes + +### Task + +Resolve reviewer CHANGES_REQUESTED blockers on PR #310 (branch `squad/307-fix-edit-null-post-redirect`): + +1. Merge conflict with dev (caused by prior rebase/squash of PR #309) +2. Stale-state bug: `_model`, `_error`, `_concurrencyError` not reset before each fetch cycle +3. Missing regression tests for the stale-content paths + +### What Was Done + +**Merge conflict resolution:** rebased branch onto `origin/dev` using `git rebase`. Commits `8c7b15f` and `8076fc6` were already upstream (merged as `5e176dc` via PR #309); git skipped them cleanly. Only the new commit `e18b851` was replayed as `37f68a2`. + +**Stale-state fix in `Edit.razor`:** added three reset lines at the top of `OnParametersSetAsync` before `_isLoading = true`: + +```csharp +_model = null; +_error = null; +_concurrencyError = false; +``` + +This guarantees all display state is clean before every fetch cycle, not just `_isLoading`. + +**New bUnit regression tests (EditAclTests.cs):** + +- `EditClearsStaleContentOnErrorAfterSuccessfulLoad` — first fetch succeeds (post A loaded), second fetch fails with error → old form content gone, error shown +- `EditClearsStaleContentOnNullAfterSuccessfulLoad` — first fetch succeeds, second returns null (not found) → redirect to /blog, no stale form content + +### Test Counts + +- Before: 90 bUnit tests +- After: 92 bUnit tests (all pass) +- Full Gate 4 suite: Architecture (16), Domain (42), Web (154), bUnit (92) — all green + +### Learnings + +**Stale-state pattern in Blazor route pages:** + +- Resetting ONLY `_isLoading` is insufficient — ALL display state (`_model`, `_error`, `_concurrencyError`) must be cleared at the top of `OnParametersSetAsync` before the async operation +- If only `_isLoading` is reset, previous successful load data remains and the UI renders stale content alongside new error messages during route parameter changes + +**Rebase vs merge for de-duplicating squash-merged commits:** + +- When upstream squash-merges commits from a branch, a later rebase will skip those commits automatically (`dropping ... patch contents already upstream`) — no manual conflict resolution needed for the already-merged commits +- Use `git rebase --skip` when the first conflict is a commit already upstream + +**Filed:** `.squad/decisions/inbox/legolas-pr310-unblock.md`