From d1e6ce7ce2ee00f7ab6301a14b622f76650e1dac Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 9 May 2026 15:31:48 +0300 Subject: [PATCH] =?UTF-8?q?fix(ui):=20EventListShell=20=E2=80=94=20reload?= =?UTF-8?q?=20on=20Filter=20swap=20+=20harden=20refresh-timer=20(HIGH+MED)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../Pages/Shared/EventListShell.razor | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/Marathon.UI/Pages/Shared/EventListShell.razor b/src/Marathon.UI/Pages/Shared/EventListShell.razor index 3454a66..7713e5c 100644 --- a/src/Marathon.UI/Pages/Shared/EventListShell.razor +++ b/src/Marathon.UI/Pages/Shared/EventListShell.razor @@ -317,6 +317,11 @@ [Parameter] public IReadOnlyList? AvailableCountries { get; set; } [Parameter] public bool LiveMode { get; set; } [Parameter] public int AutoRefreshSeconds { get; set; } = 30; + + /// + /// Legacy hint from parent pages that the source data is unseeded; + /// currently advisory only — the empty-state copy is unconditional. + /// [Parameter] public bool Stale { get; set; } private EventBrowsingState.PageFilter _filter = default!; @@ -326,15 +331,24 @@ private List _rows = new(); private DateTimeOffset? _lastLoadedAt; private bool _loading; + private bool _firstRendered; private System.Timers.Timer? _refreshTimer; private readonly Dictionary _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)) { _filter = Filter; _searchInput = Filter.SearchTerm; + if (_firstRendered) + await LoadAsync(); } } @@ -342,6 +356,7 @@ { if (firstRender) { + _firstRendered = true; await LoadAsync(); if (LiveMode) StartTimer(); } @@ -352,11 +367,27 @@ _refreshTimer?.Dispose(); var interval = Math.Max(5, AutoRefreshSeconds) * 1000.0; _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); - }; - _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()