Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .squad/agents/legolas/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
4 changes: 4 additions & 0 deletions src/Web/Features/BlogPosts/Edit/Edit.razor
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ else if (_model is not null)

protected override async Task OnParametersSetAsync()
{
_model = null;
_error = null;
_concurrencyError = false;
_isLoading = true;
try
{
var result = await Sender.Send(new GetBlogPostByIdQuery(Id));
Expand Down
105 changes: 105 additions & 0 deletions tests/Web.Tests.Bunit/Features/EditAclTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,111 @@ public void EditAllowsAdminToEditAnyPost()
cut.Markup.Should().Contain("Edit Post");
}

[Fact]
public void EditShowsNewPostContentAfterParameterChange()
{
// Arrange
var sender = Substitute.For<ISender>();
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<GetBlogPostByIdQuery>(q => q.Id == firstPostId), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Ok<BlogPostDto?>(firstPost)));
sender.Send(Arg.Is<GetBlogPostByIdQuery>(q => q.Id == secondPostId), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Ok<BlogPostDto?>(secondPost)));

Services.AddSingleton(sender);

// Act — first render
var cut = RenderWithUser<Edit>(
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...");
}

[Fact]
public void EditClearsStaleContentOnErrorAfterSuccessfulLoad()
{
// Arrange: first load succeeds, second load fails
var sender = Substitute.For<ISender>();
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<GetBlogPostByIdQuery>(q => q.Id == firstPostId), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Ok<BlogPostDto?>(firstPost)));
sender.Send(Arg.Is<GetBlogPostByIdQuery>(q => q.Id == secondPostId), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Fail<BlogPostDto?>("Post could not be loaded.")));

Services.AddSingleton(sender);

// Act — first render succeeds
var cut = RenderWithUser<Edit>(
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<ISender>();
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<GetBlogPostByIdQuery>(q => q.Id == firstPostId), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Ok<BlogPostDto?>(firstPost)));
sender.Send(Arg.Is<GetBlogPostByIdQuery>(q => q.Id == secondPostId), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Ok<BlogPostDto?>(null)));

Services.AddSingleton(sender);
var navigation = Services.GetRequiredService<NavigationManager>();

// Act — first render succeeds
var cut = RenderWithUser<Edit>(
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<TComponent> RenderWithUser<TComponent>(
ClaimsPrincipal principal,
Action<ComponentParameterCollectionBuilder<TComponent>>? configure = null)
Expand Down
Loading