diff --git a/UndoRedo.Test/UndoRedoStackTests.cs b/UndoRedo.Test/UndoRedoStackTests.cs index fd42f6e..bd31f90 100644 --- a/UndoRedo.Test/UndoRedoStackTests.cs +++ b/UndoRedo.Test/UndoRedoStackTests.cs @@ -1179,6 +1179,35 @@ public override ICommand MergeWith(ICommand other) } } + public static IEnumerable NavigationTimeoutsOutsideCancelAfterRange => + [ + [TimeSpan.MaxValue], + [TimeSpan.FromDays(60)], + [TimeSpan.FromSeconds(-5)], + [TimeSpan.MinValue], + ]; + + [TestMethod] + [DynamicData(nameof(NavigationTimeoutsOutsideCancelAfterRange))] + public async Task UndoRedoAsync_NavigationTimeoutOutsideCancelAfterRange_AppliesAndNavigatesWithoutThrowing(TimeSpan timeout) + { + // Arrange + UndoRedoService stack = new(new StackManager(), new SaveBoundaryManager(), new CommandMerger(), + new UndoRedoOptions(DefaultNavigationTimeout: timeout)); + MockNavigationProvider navigationProvider = new(); + stack.SetNavigationProvider(navigationProvider); + int value = 0; + stack.Execute(new DelegateCommand("Increment", () => value++, () => value--, navigationContext: "editor")); + + // Act & Assert: CancelAfter rejects these values, which surfaced after the change was applied + Assert.IsTrue(await stack.UndoAsync().ConfigureAwait(false)); + Assert.AreEqual(0, value); + Assert.AreEqual("editor", navigationProvider.LastNavigatedContext, "Navigation should run with no timeout"); + + Assert.IsTrue(await stack.RedoAsync().ConfigureAwait(false)); + Assert.AreEqual(1, value); + } + private sealed class ThrowingNavigationProvider : INavigationProvider { public Task NavigateToAsync(string context, CancellationToken cancellationToken = default) => diff --git a/UndoRedo/Services/UndoRedoService.cs b/UndoRedo/Services/UndoRedoService.cs index 660ff30..7607f6d 100644 --- a/UndoRedo/Services/UndoRedoService.cs +++ b/UndoRedo/Services/UndoRedoService.cs @@ -278,6 +278,11 @@ public async Task UndoToSaveBoundaryAsync(SaveBoundary saveBoundary, bool private SaveBoundary? FindLiveSaveBoundary(SaveBoundary saveBoundary) => _saveBoundaryManager.SaveBoundaries.FirstOrDefault(boundary => boundary.IsSameSavePointAs(saveBoundary)); + /// + /// The longest navigation timeout accepts on every target framework. + /// + private static readonly TimeSpan MaxNavigationTimeout = TimeSpan.FromMilliseconds(int.MaxValue); + /// /// Navigates to where a change was made, after the undo or redo has already been applied. /// Navigation is best effort: any failure is swallowed, because an exception here would tell the @@ -290,11 +295,18 @@ private async Task NavigateSafelyAsync(string? navigationContext, CancellationTo return; } - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(_options.EffectiveNavigationTimeout); - try { + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // CancelAfter throws for negative delays and for delays past its limit, which is + // int.MaxValue ms on older targets. Treat those as "no timeout" rather than failing here. + TimeSpan timeout = _options.EffectiveNavigationTimeout; + if (timeout > TimeSpan.Zero && timeout <= MaxNavigationTimeout) + { + cts.CancelAfter(timeout); + } + await _navigationProvider.NavigateToAsync(navigationContext!, cts.Token).ConfigureAwait(false); } #pragma warning disable CA1031 // Do not catch general exception types