Read the selected team once per reload, so its owner is listed once

Creating a team drew its owner twice. ReloadAsync rebuilds the team list and
then reselects, and the selection handler answers that assignment by starting
its own read of the members, invitations and vaults — while ReloadAsync is
awaiting a read of exactly the same thing. Both clear the collections up front
and both append when their round trip answers, so everything below the team list
was drawn twice. On a team nobody has been added to yet, whose only member is its
owner, that read as the owner being in the team twice.

The rows are records, so the reselect only raises a change when something about
the team has actually moved — which is every reload that follows a change:
creating a team, adding a member, renaming one. A plain refresh looked fine, and
the screen doubled precisely after the acts people come to this screen to
perform.

isReselecting suppresses the handler for the length of the assignment rather
than deduplicating rows afterwards, because only one of the two reads is
awaited. A command that reloads and then reads Members has to be looking at the
reload's own read and not at a fire-and-forget one that may not have answered.

The generation counter is the other half, and it is a different bug with the
same cause: selecting a second team before the first has answered leaves two
reads in flight against the same collections and nothing decides which wins, so
team A's members could land in the list under team B's name. A superseded read
now drops its answer instead of appending it.

Why nothing caught this. The fake server answers from memory, so every read
completes before the next begins and the appends can never interleave — the
duplicate needs a round trip to hold two reads open at once. FakeVaultServer
grows a MemberReadGate for that, and the new test holds a read open and counts
the reads in flight, which is the only moment a second one is distinguishable
from the first. It fails against the old behaviour with two. 237 tests pass in
the app suite and 83 in the layout suite.
This commit is contained in:
2026-08-03 16:26:52 +02:00
parent 38d8706784
commit a2c56de1f2
3 changed files with 130 additions and 12 deletions
@@ -527,6 +527,50 @@ public sealed class TeamSharingTests : IAsyncLifetime
teams.Status.ShouldContain("Withdrew the invitation");
}
/// <remarks>
/// <para>
/// A reload rebuilds the team list and reselects, so a reload that changed the selection — creating
/// the first team is exactly that — used to leave two reads of the same team in flight: the one the
/// reload awaits, and one the selection handler started on its own. Both clear the member list and
/// then both append to it, so every member was drawn twice. On a team nobody has been added to yet,
/// whose only member is its owner, that read as the owner being in the team twice.
/// </para>
/// <para>
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
/// memory each read finishes before the next begins, so the duplicate never appears and the bug
/// survives the test. Holding the read open is what makes this behave like a server.
/// </para>
/// </remarks>
[Fact]
public async Task CreatingATeam_ReadsItsMembersOnce()
{
await UnlockedAsync();
var teams = shell.Teams;
await teams.LoadAsync(Token);
teams.NewTeamCommand.Execute(null);
teams.NewTeamName = "Platform";
teams.NewTeamSlug = "platform";
var gate = new TaskCompletionSource();
server.MemberReadGate = gate;
var create = teams.CreateTeamCommand.ExecuteAsync(null);
// Asserted while the read is still in flight: that is the only moment at which a second read
// started by the selection handler is distinguishable from the reload's own.
server.MemberReads.ShouldBe(1, "a reload reads the selected team's members once");
gate.SetResult();
await create;
teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
}
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
{
await teams.LoadAsync(Token);