Skip to content

Commit e7cf2b9

Browse files
authored
Merge pull request #3406 from PrismLibrary/dev/ds/navigation-delay
Prevents navigation stalls from clock changes
2 parents 56f3d60 + ab59792 commit e7cf2b9

2 files changed

Lines changed: 104 additions & 9 deletions

File tree

src/Maui/Prism.Maui/Navigation/PageNavigationService.cs

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics;
12
using System.Text.RegularExpressions;
23
using System.Web;
34
using Prism.Common;
@@ -16,7 +17,7 @@ public class PageNavigationService : INavigationService, IRegistryAware
1617
{
1718
private static readonly SemaphoreSlim _semaphore = new (1, 1);
1819
private static readonly TimeSpan _minTimeBetweenNavigations = TimeSpan.FromMilliseconds(150);
19-
private static DateTime _lastNavigate;
20+
private static long _lastNavigateTimestamp;
2021
internal const string RemovePageRelativePath = "../";
2122
internal const string RemovePageInstruction = "__RemovePage/";
2223
internal const string RemovePageSegment = "__RemovePage";
@@ -84,7 +85,7 @@ public virtual async Task<INavigationResult> GoBackAsync(INavigationParameters p
8485
}
8586
finally
8687
{
87-
_lastNavigate = DateTime.Now;
88+
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
8889
NavigationSource = PageNavigationSource.Device;
8990
_semaphore.Release();
9091
}
@@ -199,7 +200,7 @@ public virtual async Task<INavigationResult> GoBackToAsync(string viewName, INav
199200
}
200201
finally
201202
{
202-
_lastNavigate = DateTime.Now;
203+
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
203204
NavigationSource = PageNavigationSource.Device;
204205
_semaphore.Release();
205206
}
@@ -293,7 +294,7 @@ public virtual async Task<INavigationResult> GoBackToRootAsync(INavigationParame
293294
}
294295
finally
295296
{
296-
_lastNavigate = DateTime.Now;
297+
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
297298
NavigationSource = PageNavigationSource.Device;
298299
_semaphore.Release();
299300
}
@@ -337,7 +338,7 @@ public virtual async Task<INavigationResult> NavigateAsync(Uri uri, INavigationP
337338
}
338339
finally
339340
{
340-
_lastNavigate = DateTime.Now;
341+
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
341342
NavigationSource = PageNavigationSource.Device;
342343
_semaphore.Release();
343344
}
@@ -430,7 +431,7 @@ x is NavigationPage navPage
430431
}
431432
finally
432433
{
433-
_lastNavigate = DateTime.Now;
434+
_lastNavigateTimestamp = Stopwatch.GetTimestamp();
434435
NavigationSource = PageNavigationSource.Device;
435436
_semaphore.Release();
436437
}
@@ -440,13 +441,42 @@ private static async Task WaitForPendingNavigationRequests()
440441
{
441442
await _semaphore.WaitAsync();
442443
// Ensure adequate time has passed since last navigation so that UI Refresh can Occur
443-
TimeSpan timeSinceLastNav = DateTime.Now - _lastNavigate;
444-
if (timeSinceLastNav < _minTimeBetweenNavigations)
444+
var delay = GetRemainingNavigationDelay(_lastNavigateTimestamp, Stopwatch.GetTimestamp());
445+
if (delay > TimeSpan.Zero)
445446
{
446-
await Task.Delay(_minTimeBetweenNavigations - timeSinceLastNav);
447+
await Task.Delay(delay);
447448
}
448449
}
449450

451+
/// <summary>
452+
/// Exposes the minimum interval enforced between navigations. Used by tests.
453+
/// </summary>
454+
internal static TimeSpan MinTimeBetweenNavigations => _minTimeBetweenNavigations;
455+
456+
/// <summary>
457+
/// Calculates how long the next navigation should wait so that at least
458+
/// <see cref="_minTimeBetweenNavigations"/> elapses between navigations, giving the native
459+
/// platform time to push/pop a page before the next request begins.
460+
/// </summary>
461+
/// <param name="lastNavigateTimestamp">The <see cref="Stopwatch.GetTimestamp"/> value captured after the previous navigation.</param>
462+
/// <param name="currentTimestamp">The current <see cref="Stopwatch.GetTimestamp"/> value.</param>
463+
internal static TimeSpan GetRemainingNavigationDelay(long lastNavigateTimestamp, long currentTimestamp)
464+
{
465+
var elapsed = Stopwatch.GetElapsedTime(lastNavigateTimestamp, currentTimestamp);
466+
467+
// A monotonic timestamp should always move forward, so elapsed is normally non-negative.
468+
// If it ever regresses (e.g. a device clock/timezone change producing a backward reading),
469+
// treat it as "no time elapsed" and wait the full minimum rather than the size of the
470+
// backward jump. This keeps navigation responsive instead of freezing for the duration of
471+
// the jump - e.g. an hour when travelling Eastern -> Central. See issue #3405.
472+
if (elapsed < TimeSpan.Zero)
473+
return _minTimeBetweenNavigations;
474+
475+
return elapsed < _minTimeBetweenNavigations
476+
? _minTimeBetweenNavigations - elapsed
477+
: TimeSpan.Zero;
478+
}
479+
450480
/// <summary>
451481
/// Processes the Navigation for the Queued navigation segments
452482
/// </summary>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using System;
2+
using System.Diagnostics;
3+
using Prism.Navigation;
4+
using Xunit;
5+
6+
namespace Prism.Maui.Tests.Fixtures.Navigation;
7+
8+
/// <summary>
9+
/// Tests for the minimum-interval delay <see cref="PageNavigationService"/> enforces between
10+
/// navigations. Reproduces issue #3405 where a backward device clock/timezone change caused the
11+
/// delay to balloon and navigation to appear frozen.
12+
/// </summary>
13+
public class PageNavigationDelayFixture
14+
{
15+
// A fixed, arbitrary baseline timestamp so the tests are deterministic and independent of
16+
// the machine's current Stopwatch value.
17+
private const long Baseline = 1_000_000_000_000;
18+
19+
// Converts a TimeSpan offset into Stopwatch ticks so the tests are independent of Stopwatch.Frequency.
20+
private static long OffsetTimestamp(long baseline, TimeSpan offset) =>
21+
baseline + (long)(offset.TotalSeconds * Stopwatch.Frequency);
22+
23+
[Fact]
24+
public void GetRemainingNavigationDelay_WhenLessThanMinimumElapsed_ReturnsRemainingTime()
25+
{
26+
var min = PageNavigationService.MinTimeBetweenNavigations;
27+
var elapsed = TimeSpan.FromMilliseconds(50);
28+
var now = OffsetTimestamp(Baseline, elapsed);
29+
30+
var delay = PageNavigationService.GetRemainingNavigationDelay(Baseline, now);
31+
32+
// Some, but not all, of the minimum interval remains.
33+
Assert.True(delay > TimeSpan.Zero);
34+
Assert.InRange(delay, TimeSpan.Zero, min);
35+
}
36+
37+
[Fact]
38+
public void GetRemainingNavigationDelay_WhenMinimumAlreadyElapsed_ReturnsZero()
39+
{
40+
var min = PageNavigationService.MinTimeBetweenNavigations;
41+
var now = OffsetTimestamp(Baseline, min + TimeSpan.FromMilliseconds(50));
42+
43+
var delay = PageNavigationService.GetRemainingNavigationDelay(Baseline, now);
44+
45+
Assert.Equal(TimeSpan.Zero, delay);
46+
}
47+
48+
// Issue #3405: when the device clock/timezone moves backward, the "current" timestamp is earlier
49+
// than the timestamp recorded after the previous navigation. The delay must never exceed the
50+
// configured minimum. Previously the delay grew by the full size of the backward jump (e.g. an
51+
// hour), so the next navigation's Task.Delay never returned and the app appeared to freeze.
52+
[Theory]
53+
[InlineData(1)] // one minute earlier
54+
[InlineData(60)] // one hour earlier (the exact scenario reported in the issue)
55+
[InlineData(60 * 24)] // one day earlier
56+
public void GetRemainingNavigationDelay_WhenClockMovesBackward_DoesNotExceedMinimum(int minutesBackward)
57+
{
58+
var min = PageNavigationService.MinTimeBetweenNavigations;
59+
var now = OffsetTimestamp(Baseline, TimeSpan.FromMinutes(-minutesBackward));
60+
61+
var delay = PageNavigationService.GetRemainingNavigationDelay(Baseline, now);
62+
63+
Assert.InRange(delay, TimeSpan.Zero, min);
64+
}
65+
}

0 commit comments

Comments
 (0)