fix(ui): EventListShell — reload on Filter swap + harden refresh-timer (HIGH+MED)

* Filter changes from the parent (page-state singleton swap) now trigger
  LoadAsync via OnParametersSetAsync once the component has rendered.
  Previously the reload happened only on first render, so navigating back
  to a list page with a different sport/date filter showed the prior data.
* Refresh-timer Elapsed handler is hoisted to a named async-void method
  with try/catch around InvokeAsync. An unhandled exception on the timer
  thread used to crash WebView2; now it's logged and swallowed.
* Overlapping ticks are skipped via a _loading short-circuit so a slow
  Loader doesn't stack up cancelled-superseded loads.
This commit is contained in:
2026-05-09 15:31:48 +03:00
parent 857d456b95
commit d1e6ce7ce2
@@ -317,6 +317,11 @@
[Parameter] public IReadOnlyList<string>? AvailableCountries { get; set; } [Parameter] public IReadOnlyList<string>? AvailableCountries { get; set; }
[Parameter] public bool LiveMode { get; set; } [Parameter] public bool LiveMode { get; set; }
[Parameter] public int AutoRefreshSeconds { get; set; } = 30; [Parameter] public int AutoRefreshSeconds { get; set; } = 30;
/// <summary>
/// Legacy hint from parent pages that the source data is unseeded;
/// currently advisory only — the empty-state copy is unconditional.
/// </summary>
[Parameter] public bool Stale { get; set; } [Parameter] public bool Stale { get; set; }
private EventBrowsingState.PageFilter _filter = default!; private EventBrowsingState.PageFilter _filter = default!;
@@ -326,15 +331,24 @@
private List<EventListItem> _rows = new(); private List<EventListItem> _rows = new();
private DateTimeOffset? _lastLoadedAt; private DateTimeOffset? _lastLoadedAt;
private bool _loading; private bool _loading;
private bool _firstRendered;
private System.Timers.Timer? _refreshTimer; private System.Timers.Timer? _refreshTimer;
private readonly Dictionary<string, RatesSnap> _previousRates = new(StringComparer.Ordinal); private readonly Dictionary<string, RatesSnap> _previousRates = new(StringComparer.Ordinal);
protected override void OnParametersSet() protected override async Task OnParametersSetAsync()
{ {
// Reload when the parent swaps the Filter parameter for a different
// instance — page state singletons hand us a new record on every
// change, so reference inequality is the right trigger. The first
// render still kicks off via OnAfterRenderAsync; this branch covers
// subsequent parent-driven filter swaps (e.g. nav between pre-match
// and live, or restoring from EventBrowsingState).
if (!ReferenceEquals(_filter, Filter)) if (!ReferenceEquals(_filter, Filter))
{ {
_filter = Filter; _filter = Filter;
_searchInput = Filter.SearchTerm; _searchInput = Filter.SearchTerm;
if (_firstRendered)
await LoadAsync();
} }
} }
@@ -342,6 +356,7 @@
{ {
if (firstRender) if (firstRender)
{ {
_firstRendered = true;
await LoadAsync(); await LoadAsync();
if (LiveMode) StartTimer(); if (LiveMode) StartTimer();
} }
@@ -352,11 +367,27 @@
_refreshTimer?.Dispose(); _refreshTimer?.Dispose();
var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0; var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0;
_refreshTimer = new System.Timers.Timer(interval) { AutoReset = true }; _refreshTimer = new System.Timers.Timer(interval) { AutoReset = true };
_refreshTimer.Elapsed += async (_, _) => _refreshTimer.Elapsed += OnRefreshTimerElapsed;
_refreshTimer.Start();
}
private async void OnRefreshTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
// async void here is unavoidable — System.Timers.Timer.Elapsed is an
// event with a void-returning signature. We MUST catch every exception
// ourselves; an unhandled throw on the threadpool would tear down the
// WebView2 process. Skip overlapping ticks so a slow Loader doesn't
// stack up cancelled-superseded loads on the UI thread.
if (_loading) return;
try
{ {
await InvokeAsync(LoadAsync); await InvokeAsync(LoadAsync);
}; }
_refreshTimer.Start(); catch
{
// Swallowed — LoadAsync already handles its own errors; this catch
// is the last line of defense for InvokeAsync itself.
}
} }
private async Task LoadAsync() private async Task LoadAsync()