fix(central-ui): pending-updates indicator starts polling once the async auth resolve completes

OnParametersSetAsync awaits the principal. On a real cookie session that await
suspends, so the FIRST render ran with _visible still false and
OnAfterRenderAsync(firstRender: true) skipped EnsurePollingStarted(); the
render that followed the resolve was firstRender: false, and nothing else
started the poll unless AuthenticationStateChanged happened to fire. The block
therefore rendered and sat on 'Checking for updates…' for the life of the
circuit. OnParametersSetAsync now starts polling itself when visible (the
start is idempotent and the OnAfterRender call is kept).

Also corrects the comments that called the non-forced
GetAuthenticationStateAsync cheap: on CookieAuthenticationStateProvider both
entry points revalidate against the database and differ only in failClosed, so
the saving is not asking the provider on a tick and reusing the cascaded state
the router already resolved. The provider fallback is a real refresh, is only
taken when no cascade is present (AddCascadingAuthenticationState is
registered, so it is dead in production), and is now resolved at most once per
auth state rather than on every parameter set.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-14 14:29:36 -04:00
parent 623c6d0fe8
commit 3a21cfc2f5
3 changed files with 101 additions and 24 deletions
@@ -369,4 +369,47 @@ public class PendingUpdatesIndicatorTests : BunitContext
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
[Fact]
public void Polling_StartsAfterAnAsynchronousAuthResolveCompletes()
{
// Regression: OnParametersSetAsync awaits the principal. On a real cookie
// session that await suspends, so the FIRST render happens with _visible
// still false and OnAfterRenderAsync(firstRender: true) skips starting the
// poll; the render that follows the resolve is firstRender: false. Without a
// start from OnParametersSetAsync the block sits on "Checking for updates…"
// for the life of the circuit.
var gate = new TaskCompletionSource<AuthenticationState>(
TaskCreationOptions.RunContinuationsAsynchronously);
var cache = CacheReturning(Snapshot(SiteRow(1, staleInstances: 4)));
Services.AddSingleton<AuthenticationStateProvider>(new DeferredAuthStateProvider(gate.Task));
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
var cut = Render<PendingUpdatesIndicator>();
// Nothing resolved yet: no principal, so nothing rendered and nothing polled.
Assert.True(string.IsNullOrWhiteSpace(cut.Markup));
gate.SetResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Deployer)], "TestAuth"))));
cut.WaitForAssertion(() =>
{
_ = cache.Received().GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
Assert.Contains("4 instances", cut.Markup);
});
}
/// <summary>An auth provider whose state resolves only when the supplied task completes.</summary>
private sealed class DeferredAuthStateProvider : AuthenticationStateProvider
{
private readonly Task<AuthenticationState> _state;
public DeferredAuthStateProvider(Task<AuthenticationState> state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync() => _state;
}
}