diff --git a/src/Maui/Prism.Maui/Navigation/PageNavigationService.cs b/src/Maui/Prism.Maui/Navigation/PageNavigationService.cs index 5b5da8ee9..1c59914a0 100644 --- a/src/Maui/Prism.Maui/Navigation/PageNavigationService.cs +++ b/src/Maui/Prism.Maui/Navigation/PageNavigationService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.RegularExpressions; using System.Web; using Prism.Common; @@ -16,7 +17,7 @@ public class PageNavigationService : INavigationService, IRegistryAware { private static readonly SemaphoreSlim _semaphore = new (1, 1); private static readonly TimeSpan _minTimeBetweenNavigations = TimeSpan.FromMilliseconds(150); - private static DateTime _lastNavigate; + private static long _lastNavigateTimestamp; internal const string RemovePageRelativePath = "../"; internal const string RemovePageInstruction = "__RemovePage/"; internal const string RemovePageSegment = "__RemovePage"; @@ -84,7 +85,7 @@ public virtual async Task GoBackAsync(INavigationParameters p } finally { - _lastNavigate = DateTime.Now; + _lastNavigateTimestamp = Stopwatch.GetTimestamp(); NavigationSource = PageNavigationSource.Device; _semaphore.Release(); } @@ -199,7 +200,7 @@ public virtual async Task GoBackToAsync(string viewName, INav } finally { - _lastNavigate = DateTime.Now; + _lastNavigateTimestamp = Stopwatch.GetTimestamp(); NavigationSource = PageNavigationSource.Device; _semaphore.Release(); } @@ -293,7 +294,7 @@ public virtual async Task GoBackToRootAsync(INavigationParame } finally { - _lastNavigate = DateTime.Now; + _lastNavigateTimestamp = Stopwatch.GetTimestamp(); NavigationSource = PageNavigationSource.Device; _semaphore.Release(); } @@ -337,7 +338,7 @@ public virtual async Task NavigateAsync(Uri uri, INavigationP } finally { - _lastNavigate = DateTime.Now; + _lastNavigateTimestamp = Stopwatch.GetTimestamp(); NavigationSource = PageNavigationSource.Device; _semaphore.Release(); } @@ -430,7 +431,7 @@ x is NavigationPage navPage } finally { - _lastNavigate = DateTime.Now; + _lastNavigateTimestamp = Stopwatch.GetTimestamp(); NavigationSource = PageNavigationSource.Device; _semaphore.Release(); } @@ -440,13 +441,42 @@ private static async Task WaitForPendingNavigationRequests() { await _semaphore.WaitAsync(); // Ensure adequate time has passed since last navigation so that UI Refresh can Occur - TimeSpan timeSinceLastNav = DateTime.Now - _lastNavigate; - if (timeSinceLastNav < _minTimeBetweenNavigations) + var delay = GetRemainingNavigationDelay(_lastNavigateTimestamp, Stopwatch.GetTimestamp()); + if (delay > TimeSpan.Zero) { - await Task.Delay(_minTimeBetweenNavigations - timeSinceLastNav); + await Task.Delay(delay); } } + /// + /// Exposes the minimum interval enforced between navigations. Used by tests. + /// + internal static TimeSpan MinTimeBetweenNavigations => _minTimeBetweenNavigations; + + /// + /// Calculates how long the next navigation should wait so that at least + /// elapses between navigations, giving the native + /// platform time to push/pop a page before the next request begins. + /// + /// The value captured after the previous navigation. + /// The current value. + internal static TimeSpan GetRemainingNavigationDelay(long lastNavigateTimestamp, long currentTimestamp) + { + var elapsed = Stopwatch.GetElapsedTime(lastNavigateTimestamp, currentTimestamp); + + // A monotonic timestamp should always move forward, so elapsed is normally non-negative. + // If it ever regresses (e.g. a device clock/timezone change producing a backward reading), + // treat it as "no time elapsed" and wait the full minimum rather than the size of the + // backward jump. This keeps navigation responsive instead of freezing for the duration of + // the jump - e.g. an hour when travelling Eastern -> Central. See issue #3405. + if (elapsed < TimeSpan.Zero) + return _minTimeBetweenNavigations; + + return elapsed < _minTimeBetweenNavigations + ? _minTimeBetweenNavigations - elapsed + : TimeSpan.Zero; + } + /// /// Processes the Navigation for the Queued navigation segments /// diff --git a/tests/Maui/Prism.Maui.Tests/Fixtures/Navigation/PageNavigationDelayFixture.cs b/tests/Maui/Prism.Maui.Tests/Fixtures/Navigation/PageNavigationDelayFixture.cs new file mode 100644 index 000000000..47b0ce3b6 --- /dev/null +++ b/tests/Maui/Prism.Maui.Tests/Fixtures/Navigation/PageNavigationDelayFixture.cs @@ -0,0 +1,65 @@ +using System; +using System.Diagnostics; +using Prism.Navigation; +using Xunit; + +namespace Prism.Maui.Tests.Fixtures.Navigation; + +/// +/// Tests for the minimum-interval delay enforces between +/// navigations. Reproduces issue #3405 where a backward device clock/timezone change caused the +/// delay to balloon and navigation to appear frozen. +/// +public class PageNavigationDelayFixture +{ + // A fixed, arbitrary baseline timestamp so the tests are deterministic and independent of + // the machine's current Stopwatch value. + private const long Baseline = 1_000_000_000_000; + + // Converts a TimeSpan offset into Stopwatch ticks so the tests are independent of Stopwatch.Frequency. + private static long OffsetTimestamp(long baseline, TimeSpan offset) => + baseline + (long)(offset.TotalSeconds * Stopwatch.Frequency); + + [Fact] + public void GetRemainingNavigationDelay_WhenLessThanMinimumElapsed_ReturnsRemainingTime() + { + var min = PageNavigationService.MinTimeBetweenNavigations; + var elapsed = TimeSpan.FromMilliseconds(50); + var now = OffsetTimestamp(Baseline, elapsed); + + var delay = PageNavigationService.GetRemainingNavigationDelay(Baseline, now); + + // Some, but not all, of the minimum interval remains. + Assert.True(delay > TimeSpan.Zero); + Assert.InRange(delay, TimeSpan.Zero, min); + } + + [Fact] + public void GetRemainingNavigationDelay_WhenMinimumAlreadyElapsed_ReturnsZero() + { + var min = PageNavigationService.MinTimeBetweenNavigations; + var now = OffsetTimestamp(Baseline, min + TimeSpan.FromMilliseconds(50)); + + var delay = PageNavigationService.GetRemainingNavigationDelay(Baseline, now); + + Assert.Equal(TimeSpan.Zero, delay); + } + + // Issue #3405: when the device clock/timezone moves backward, the "current" timestamp is earlier + // than the timestamp recorded after the previous navigation. The delay must never exceed the + // configured minimum. Previously the delay grew by the full size of the backward jump (e.g. an + // hour), so the next navigation's Task.Delay never returned and the app appeared to freeze. + [Theory] + [InlineData(1)] // one minute earlier + [InlineData(60)] // one hour earlier (the exact scenario reported in the issue) + [InlineData(60 * 24)] // one day earlier + public void GetRemainingNavigationDelay_WhenClockMovesBackward_DoesNotExceedMinimum(int minutesBackward) + { + var min = PageNavigationService.MinTimeBetweenNavigations; + var now = OffsetTimestamp(Baseline, TimeSpan.FromMinutes(-minutesBackward)); + + var delay = PageNavigationService.GetRemainingNavigationDelay(Baseline, now); + + Assert.InRange(delay, TimeSpan.Zero, min); + } +}