fix(central-ui): resolve CentralUI-007..014 — nav authz, UTC date filters, disposal guards, N+1 fix, async script analysis

This commit is contained in:
Joseph Doherty
2026-05-16 20:58:03 -04:00
parent 738e67acc5
commit 71b90ba499
21 changed files with 976 additions and 81 deletions
@@ -150,6 +150,11 @@
public async ValueTask DisposeAsync()
{
// CentralUI-011: if the dialog is disposed while still open (the user
// navigated away), complete the pending task so the awaiting caller
// resumes deterministically instead of hanging forever.
_tcs?.TrySetResult(false);
if (_bodyLocked)
{
await TryUnlockBodyAsync();
@@ -30,6 +30,16 @@
private readonly List<ToastItem> _toasts = new();
private readonly object _lock = new();
// Cancels all pending auto-dismiss delays when the component is disposed
// (CentralUI-010) so their continuations never touch a disposed component.
private readonly CancellationTokenSource _disposalCts = new();
/// <summary>Number of toasts currently displayed.</summary>
public int ToastCount
{
get { lock (_lock) { return _toasts.Count; } }
}
public void ShowSuccess(string message, string title = "Success", int? autoDismissMs = null)
{
AddToast(title, message, ToastType.Success, autoDismissMs);
@@ -52,6 +62,9 @@
private void AddToast(string title, string message, ToastType type, int? autoDismissMs)
{
// If the component is already disposed, do not add or schedule anything.
if (_disposalCts.IsCancellationRequested) return;
var toast = new ToastItem { Title = title, Message = message, Type = type };
lock (_lock)
{
@@ -60,14 +73,41 @@
StateHasChanged();
var dismissMs = autoDismissMs ?? DefaultAutoDismissMs;
_ = Task.Delay(dismissMs).ContinueWith(_ =>
_ = AutoDismissAsync(toast, dismissMs, _disposalCts.Token);
}
/// <summary>
/// Removes a toast after its dismiss delay. The delay is bound to the
/// component's disposal token (CentralUI-010): if the host page is disposed
/// first, the delay is cancelled and the continuation never touches the
/// disposed component — no <see cref="ObjectDisposedException"/> escapes.
/// </summary>
private async Task AutoDismissAsync(ToastItem toast, int dismissMs, CancellationToken token)
{
try
{
lock (_lock)
{
_toasts.Remove(toast);
}
InvokeAsync(StateHasChanged);
});
await Task.Delay(dismissMs, token);
}
catch (OperationCanceledException)
{
return;
}
if (token.IsCancellationRequested) return;
lock (_lock)
{
_toasts.Remove(toast);
}
try
{
await InvokeAsync(StateHasChanged);
}
catch (ObjectDisposedException)
{
// Component disposed between the token check and the render — ignore.
}
}
private void Dismiss(ToastItem toast)
@@ -87,7 +127,11 @@
_ => "bg-secondary text-white"
};
public void Dispose() { }
public void Dispose()
{
_disposalCts.Cancel();
_disposalCts.Dispose();
}
private enum ToastType { Success, Error, Warning, Info }