Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions src/Maui/Prism.Maui/Navigation/PageNavigationService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Web;
using Prism.Common;
Expand All @@ -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";
Expand Down Expand Up @@ -84,7 +85,7 @@ public virtual async Task<INavigationResult> GoBackAsync(INavigationParameters p
}
finally
{
_lastNavigate = DateTime.Now;
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
NavigationSource = PageNavigationSource.Device;
_semaphore.Release();
}
Expand Down Expand Up @@ -199,7 +200,7 @@ public virtual async Task<INavigationResult> GoBackToAsync(string viewName, INav
}
finally
{
_lastNavigate = DateTime.Now;
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
NavigationSource = PageNavigationSource.Device;
_semaphore.Release();
}
Expand Down Expand Up @@ -293,7 +294,7 @@ public virtual async Task<INavigationResult> GoBackToRootAsync(INavigationParame
}
finally
{
_lastNavigate = DateTime.Now;
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
NavigationSource = PageNavigationSource.Device;
_semaphore.Release();
}
Expand Down Expand Up @@ -337,7 +338,7 @@ public virtual async Task<INavigationResult> NavigateAsync(Uri uri, INavigationP
}
finally
{
_lastNavigate = DateTime.Now;
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
NavigationSource = PageNavigationSource.Device;
_semaphore.Release();
}
Expand Down Expand Up @@ -430,7 +431,7 @@ x is NavigationPage navPage
}
finally
{
_lastNavigate = DateTime.Now;
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
NavigationSource = PageNavigationSource.Device;
_semaphore.Release();
}
Expand All @@ -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);
}
}

/// <summary>
/// Exposes the minimum interval enforced between navigations. Used by tests.
/// </summary>
internal static TimeSpan MinTimeBetweenNavigations => _minTimeBetweenNavigations;

/// <summary>
/// Calculates how long the next navigation should wait so that at least
/// <see cref="_minTimeBetweenNavigations"/> elapses between navigations, giving the native
/// platform time to push/pop a page before the next request begins.
/// </summary>
/// <param name="lastNavigateTimestamp">The <see cref="Stopwatch.GetTimestamp"/> value captured after the previous navigation.</param>
/// <param name="currentTimestamp">The current <see cref="Stopwatch.GetTimestamp"/> value.</param>
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;
}

/// <summary>
/// Processes the Navigation for the Queued navigation segments
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System;
using System.Diagnostics;
using Prism.Navigation;
using Xunit;

namespace Prism.Maui.Tests.Fixtures.Navigation;

/// <summary>
/// Tests for the minimum-interval delay <see cref="PageNavigationService"/> enforces between
/// navigations. Reproduces issue #3405 where a backward device clock/timezone change caused the
/// delay to balloon and navigation to appear frozen.
/// </summary>
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);
}
}
Loading