mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 01:59:34 +02:00
Me stupid. Me forgot stage. Fix CmdPal list-page recovery after Back navigation
- Separate navigation suspension from permanent disposal. - Preserve fetch phase and selection intent across navigation. - Recover pending work without refetching unchanged pages.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
// Advance only after each milestone succeeds. An interrupted earlier phase
|
||||
// deliberately asks for more recovery, never for less than the page needs.
|
||||
internal enum ListPageFetchPhase
|
||||
{
|
||||
// Includes requests deferred while suspended, before a worker claims them.
|
||||
Fetching,
|
||||
|
||||
// Items owns the snapshot, but FilteredItems/ItemsUpdated have not caught up.
|
||||
Committed,
|
||||
|
||||
// Back needs only to restart unfinished initialization of retained rows.
|
||||
Published,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Atomically owns the current visit/fetch and its remaining work. Navigation
|
||||
/// invalidates the generation while retaining the phase and publication intent,
|
||||
/// so even a fetch still blocked in extension code can be recovered on Back.
|
||||
/// </summary>
|
||||
internal sealed record ListPageWorkState(
|
||||
int Generation,
|
||||
ListPageWorkStatus Status,
|
||||
ListPageFetchPhase Phase,
|
||||
bool KeepSelection = true,
|
||||
bool EnsureSelectionVisible = false);
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
internal enum ListPageWorkStatus
|
||||
{
|
||||
Active,
|
||||
Suspended,
|
||||
Stopped,
|
||||
}
|
||||
@@ -62,7 +62,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private InterlockedBoolean _isLoadingMore;
|
||||
private int _activeFetchCount;
|
||||
private int _latestFetchGeneration;
|
||||
private bool _deferredFetchRequested;
|
||||
private bool _deferredFetchKeepSelection = true;
|
||||
private bool _deferredFetchEnsureSelectionVisible;
|
||||
@@ -106,7 +105,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
private Task? _initializeItemsTask;
|
||||
private ListItemInitializationCoordinator? _itemInitializationCoordinator;
|
||||
|
||||
private int _initializationStopped;
|
||||
// Navigation may suspend and later restore this VM from the Frame back stack.
|
||||
// Dispose/SafeCleanup are terminal; resumption must never undo either of them.
|
||||
private ListPageWorkState _workState = new(0, ListPageWorkStatus.Active, ListPageFetchPhase.Published);
|
||||
|
||||
private bool IsWorkActive => Volatile.Read(ref _workState).Status == ListPageWorkStatus.Active;
|
||||
|
||||
// For cancelling the task to load the properties from the items in the list
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
@@ -242,6 +245,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private void RequestFetch(bool keepSelection, bool ensureSelectionVisible)
|
||||
{
|
||||
if (DeferFetchWhileInactive(keepSelection, ensureSelectionVisible))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep RPC GetItems work off the UI thread. If the provider raises
|
||||
// ItemsChanged while we're already on a background thread, stay on that
|
||||
// thread so same-thread reentrancy detection still works.
|
||||
@@ -291,9 +299,9 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static void QueueObservedBackgroundFetch(Action action, string logMessage)
|
||||
private static Task QueueObservedBackgroundFetch(Action action, string logMessage)
|
||||
{
|
||||
_ = Task.Run(
|
||||
return Task.Run(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
@@ -311,27 +319,40 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
}
|
||||
|
||||
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
|
||||
private void FetchItems(bool keepSelection, bool ensureSelectionVisible)
|
||||
private void FetchItems(bool keepSelection, bool ensureSelectionVisible, int? recoveryGeneration = null)
|
||||
{
|
||||
System.Diagnostics.Debug.Assert(!IsCurrentThreadUiThread(), "FetchItems should not run on the UI thread.");
|
||||
|
||||
// If this fetch should reset selection, remember that intent even if
|
||||
// a later incremental fetch cancels us.
|
||||
if (!keepSelection)
|
||||
{
|
||||
_forceFirstItemPending = true;
|
||||
}
|
||||
|
||||
CancellationToken cancellationToken;
|
||||
int fetchGeneration;
|
||||
lock (_fetchStateLock)
|
||||
{
|
||||
// Cancel any previous FetchItems operation
|
||||
CancelAndDisposeTokenSource(ref _fetchItemsCancellationTokenSource);
|
||||
_fetchItemsCancellationTokenSource = new CancellationTokenSource();
|
||||
if (!TryBeginFetch(keepSelection, ensureSelectionVisible, recoveryGeneration, out var work))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cancellationToken = _fetchItemsCancellationTokenSource.Token;
|
||||
fetchGeneration = Interlocked.Increment(ref _latestFetchGeneration);
|
||||
fetchGeneration = work.Generation;
|
||||
if (!work.KeepSelection)
|
||||
{
|
||||
_forceFirstItemPending = true;
|
||||
}
|
||||
|
||||
// Capture the token before publishing its owner: navigation can cancel
|
||||
// and dispose the source without acquiring this background fetch lock.
|
||||
var fetchCancellation = new CancellationTokenSource();
|
||||
cancellationToken = fetchCancellation.Token;
|
||||
var previousCancellation = Interlocked.Exchange(ref _fetchItemsCancellationTokenSource, fetchCancellation);
|
||||
CancelAndDisposeTokenSource(ref previousCancellation);
|
||||
if (!IsCurrentFetch(fetchGeneration))
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _fetchItemsCancellationTokenSource, null, fetchCancellation) == fetchCancellation)
|
||||
{
|
||||
fetchCancellation.Dispose();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Declared outside try so catch blocks can reference them
|
||||
@@ -441,6 +462,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
lock (_listLock)
|
||||
{
|
||||
ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken);
|
||||
|
||||
// Now that we have new ViewModels for everything from the
|
||||
// extension, smartly update our list of VMs
|
||||
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
|
||||
@@ -454,6 +477,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
}
|
||||
|
||||
itemsTransferredToList = true;
|
||||
AdvanceFetchPhase(fetchGeneration, ListPageFetchPhase.Committed);
|
||||
|
||||
// If we removed items, we need to clean them up, to remove our event handlers
|
||||
foreach (var removedItem in removedItems)
|
||||
@@ -502,60 +526,70 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
}
|
||||
|
||||
StartItemInitialization(fetchGeneration, cancellationToken);
|
||||
QueueItemsPublication(fetchGeneration);
|
||||
}
|
||||
|
||||
DoOnUiThread(
|
||||
() =>
|
||||
private void QueueItemsPublication(int fetchGeneration)
|
||||
{
|
||||
DoOnUiThread(() =>
|
||||
{
|
||||
lock (_fetchStateLock)
|
||||
{
|
||||
lock (_fetchStateLock)
|
||||
if (!IsCurrentFetch(fetchGeneration))
|
||||
{
|
||||
if (Volatile.Read(ref _initializationStopped) != 0 || !IsLatestFetchGeneration(fetchGeneration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_listLock)
|
||||
{
|
||||
if (!IsLatestFetchGeneration(fetchGeneration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Now that our Items contains everything we want, it's time for us to
|
||||
// re-evaluate our Filter on those items.
|
||||
if (!_isDynamic)
|
||||
{
|
||||
// A static list? Great! Just run the filter.
|
||||
RunFilteredItemsUpdate(ApplyFilterUnderLock);
|
||||
}
|
||||
else
|
||||
{
|
||||
// A dynamic list? Even better! Just stick everything into
|
||||
// FilteredItems. The extension already did any filtering it cared about.
|
||||
var snapshot = Items.Where(i => !i.IsInErrorState).ToList();
|
||||
RunFilteredItemsUpdate(() => ListHelpers.InPlaceUpdateList(FilteredItems, snapshot));
|
||||
}
|
||||
|
||||
UpdateEmptyContent();
|
||||
}
|
||||
|
||||
if (!IsLatestFetchGeneration(fetchGeneration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Consume the pending flag on the UI thread so a
|
||||
// forceFirstItem=true intent survives cancellation.
|
||||
var forceFirst = _forceFirstItemPending;
|
||||
_forceFirstItemPending = false;
|
||||
|
||||
ItemsUpdated?.Invoke(
|
||||
this,
|
||||
new ItemsUpdatedEventArgs(
|
||||
forceFirstItem: IsRootPage && forceFirst,
|
||||
ensureSelectionVisible: ensureSelectionVisible));
|
||||
_isLoadingMore.Clear();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
lock (_listLock)
|
||||
{
|
||||
// A deferred mutation is not a completed milestone. Keep its
|
||||
// notification and phase advancement inside the guarded action.
|
||||
RunFilteredItemsUpdate(() => PublishItemsUnderLock(fetchGeneration));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void PublishItemsUnderLock(int fetchGeneration)
|
||||
{
|
||||
// RunFilteredItemsUpdate's callers hold _listLock, including when a
|
||||
// reentrant publication is deferred until an earlier mutation finishes.
|
||||
var work = Volatile.Read(ref _workState);
|
||||
if (work.Status != ListPageWorkStatus.Active || work.Generation != fetchGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Reuse the same filtering/publication path after a fetch and when Back
|
||||
// recovers a committed snapshot whose callback was cancelled.
|
||||
if (!_isDynamic)
|
||||
{
|
||||
ApplyFilterUnderLock();
|
||||
}
|
||||
else
|
||||
{
|
||||
var snapshot = Items.Where(i => !i.IsInErrorState).ToList();
|
||||
ListHelpers.InPlaceUpdateList(FilteredItems, snapshot);
|
||||
}
|
||||
|
||||
UpdateEmptyContent();
|
||||
if (!IsCurrentFetch(fetchGeneration))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Consume selection intent only when the retained snapshot reaches the
|
||||
// UI, including a request originally received while suspended.
|
||||
var forceFirst = _forceFirstItemPending || !work.KeepSelection;
|
||||
_forceFirstItemPending = false;
|
||||
|
||||
ItemsUpdated?.Invoke(
|
||||
this,
|
||||
new ItemsUpdatedEventArgs(
|
||||
forceFirstItem: IsRootPage && forceFirst,
|
||||
ensureSelectionVisible: work.EnsureSelectionVisible));
|
||||
_isLoadingMore.Clear();
|
||||
AdvanceFetchPhase(fetchGeneration, ListPageFetchPhase.Published);
|
||||
}
|
||||
|
||||
private void StartItemInitialization(int fetchGeneration, CancellationToken fetchCancellationToken)
|
||||
@@ -564,8 +598,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
lock (_initializationCoordinatorLock)
|
||||
{
|
||||
if (Volatile.Read(ref _initializationStopped) != 0 || fetchCancellationToken.IsCancellationRequested ||
|
||||
!IsLatestFetchGeneration(fetchGeneration))
|
||||
if (!IsCurrentFetch(fetchGeneration) || fetchCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -591,9 +624,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
previousCoordinator?.Stop();
|
||||
CancelAndDisposeTokenSource(ref previousCancellation);
|
||||
|
||||
// Teardown never waits for the background-only lock. Close its race with
|
||||
// publication here; a Stop after this check is also safe before Run starts.
|
||||
if (Volatile.Read(ref _initializationStopped) != 0)
|
||||
// Navigation/teardown never wait for the background-only lock. Recheck
|
||||
// the generation too: a suspend/resume cycle must not revive this fetch.
|
||||
// A Stop after this check is also safe before Run starts.
|
||||
if (!IsCurrentFetch(fetchGeneration) || fetchCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
coordinator.Stop();
|
||||
Interlocked.CompareExchange(ref _itemInitializationCoordinator, null, coordinator);
|
||||
@@ -743,15 +777,16 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
private void ThrowIfFetchCanceledOrStale(int fetchGeneration, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (Volatile.Read(ref _latestFetchGeneration) != fetchGeneration)
|
||||
if (!IsCurrentFetch(fetchGeneration))
|
||||
{
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsLatestFetchGeneration(int fetchGeneration)
|
||||
private bool IsCurrentFetch(int fetchGeneration)
|
||||
{
|
||||
return Volatile.Read(ref _latestFetchGeneration) == fetchGeneration;
|
||||
var work = Volatile.Read(ref _workState);
|
||||
return work.Status == ListPageWorkStatus.Active && work.Generation == fetchGeneration;
|
||||
}
|
||||
|
||||
private void PublishVmCache(Dictionary<IListItem, ListItemViewModel> newCache)
|
||||
@@ -850,6 +885,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private void SetSelectedItem(ListItemViewModel item)
|
||||
{
|
||||
if (!IsWorkActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastSelectedItem = item;
|
||||
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;
|
||||
|
||||
@@ -1174,10 +1214,169 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
// The shell serializes navigation transitions on the UI thread. Terminal
|
||||
// cleanup may race them, but resumption can only transition Suspended -> Active.
|
||||
internal void SuspendForNavigation()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
Interlocked.Exchange(ref _initializationStopped, 1);
|
||||
if (TryChangeWorkStatus(ListPageWorkStatus.Active, ListPageWorkStatus.Suspended) is not null)
|
||||
{
|
||||
CancelPendingWork();
|
||||
}
|
||||
}
|
||||
|
||||
internal Task ResumeAfterNavigation()
|
||||
{
|
||||
var work = TryChangeWorkStatus(ListPageWorkStatus.Suspended, ListPageWorkStatus.Active);
|
||||
if (work is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Do not consume the recovery record when queueing: another navigation
|
||||
// can invalidate this visit before the worker or UI callback ever runs.
|
||||
return QueueObservedBackgroundFetch(
|
||||
() =>
|
||||
{
|
||||
if (!IsCurrentFetch(work.Generation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (work.Phase == ListPageFetchPhase.Fetching)
|
||||
{
|
||||
FetchItems(work.KeepSelection, work.EnsureSelectionVisible, work.Generation);
|
||||
}
|
||||
else
|
||||
{
|
||||
StartItemInitialization(work.Generation, CancellationToken.None);
|
||||
if (work.Phase == ListPageFetchPhase.Committed)
|
||||
{
|
||||
QueueItemsPublication(work.Generation);
|
||||
}
|
||||
}
|
||||
},
|
||||
"Failed to resume list page after navigation");
|
||||
}
|
||||
|
||||
private bool DeferFetchWhileInactive(bool keepSelection, bool ensureSelectionVisible)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var work = Volatile.Read(ref _workState);
|
||||
if (work.Status == ListPageWorkStatus.Active)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (work.Status == ListPageWorkStatus.Stopped)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var pendingKeepSelection = work.KeepSelection && keepSelection;
|
||||
var pendingEnsureSelectionVisible = work.EnsureSelectionVisible || ensureSelectionVisible;
|
||||
var pending = work.Phase == ListPageFetchPhase.Fetching &&
|
||||
work.KeepSelection == pendingKeepSelection &&
|
||||
work.EnsureSelectionVisible == pendingEnsureSelectionVisible
|
||||
? work
|
||||
: work with
|
||||
{
|
||||
Phase = ListPageFetchPhase.Fetching,
|
||||
KeepSelection = pendingKeepSelection,
|
||||
EnsureSelectionVisible = pendingEnsureSelectionVisible,
|
||||
};
|
||||
|
||||
// Even an already-covered request verifies ownership with a no-op CAS:
|
||||
// if resume won, this request must retry on the now-active page.
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _workState, pending, work), work))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Status and pending intent share one CAS. If resume won, retry sees
|
||||
// Active and the caller fetches normally; no late flag can be stranded.
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryBeginFetch(bool keepSelection, bool ensureSelectionVisible, int? recoveryGeneration, out ListPageWorkState work)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var previous = Volatile.Read(ref _workState);
|
||||
work = previous;
|
||||
if (recoveryGeneration.HasValue && previous.Generation != recoveryGeneration.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previous.Status != ListPageWorkStatus.Active)
|
||||
{
|
||||
if (DeferFetchWhileInactive(keepSelection, ensureSelectionVisible))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
work = new(
|
||||
unchecked(previous.Generation + 1),
|
||||
ListPageWorkStatus.Active,
|
||||
ListPageFetchPhase.Fetching,
|
||||
previous.KeepSelection && keepSelection,
|
||||
previous.EnsureSelectionVisible || ensureSelectionVisible);
|
||||
|
||||
// Use ReferenceEquals for all work-state CAS results: record == can
|
||||
// mistake a distinct-but-equal snapshot for a successful exchange.
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _workState, work, previous), previous))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AdvanceFetchPhase(int generation, ListPageFetchPhase phase)
|
||||
{
|
||||
var work = Volatile.Read(ref _workState);
|
||||
if (work.Status != ListPageWorkStatus.Active || work.Generation != generation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var completed = work with
|
||||
{
|
||||
Phase = phase,
|
||||
KeepSelection = phase == ListPageFetchPhase.Published || work.KeepSelection,
|
||||
EnsureSelectionVisible = phase != ListPageFetchPhase.Published && work.EnsureSelectionVisible,
|
||||
};
|
||||
|
||||
// A failed CAS means another fetch or navigation owns recovery now. Never
|
||||
// let a late unwind/commit/publication rewrite that owner's obligation.
|
||||
Interlocked.CompareExchange(ref _workState, completed, work);
|
||||
}
|
||||
|
||||
private ListPageWorkState? TryChangeWorkStatus(ListPageWorkStatus from, ListPageWorkStatus to)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var work = Volatile.Read(ref _workState);
|
||||
if (work.Status != from)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var next = work with { Status = to, Generation = unchecked(work.Generation + 1) };
|
||||
if (ReferenceEquals(Interlocked.CompareExchange(ref _workState, next, work), work))
|
||||
{
|
||||
return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelPendingWork()
|
||||
{
|
||||
// The status transition already invalidated callbacks and retained their
|
||||
// unfinished phase atomically. Never take worker-owned locks on navigation.
|
||||
CancelAndDisposeTokenSource(ref _selectedItemCts);
|
||||
CancelAndDisposeTokenSource(ref _cancellationTokenSource);
|
||||
Interlocked.Exchange(ref _itemInitializationCoordinator, null)?.Stop();
|
||||
@@ -1185,20 +1384,39 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
CancelAndDisposeTokenSource(ref _fetchItemsCancellationTokenSource);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
StopWork();
|
||||
}
|
||||
|
||||
private void StopWork()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var work = Volatile.Read(ref _workState);
|
||||
if (work.Status == ListPageWorkStatus.Stopped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryChangeWorkStatus(work.Status, ListPageWorkStatus.Stopped) is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
CancelPendingWork();
|
||||
}
|
||||
|
||||
protected override void UnsafeCleanup()
|
||||
{
|
||||
Interlocked.Exchange(ref _initializationStopped, 1);
|
||||
StopWork();
|
||||
base.UnsafeCleanup();
|
||||
|
||||
EmptyContent?.SafeCleanup();
|
||||
EmptyContent = new(new(null), PageContext, contextMenuFactory: null); // necessary?
|
||||
|
||||
CancelAndDisposeTokenSource(ref _selectedItemCts);
|
||||
CancelAndDisposeTokenSource(ref _cancellationTokenSource);
|
||||
Interlocked.Exchange(ref _itemInitializationCoordinator, null)?.Stop();
|
||||
CancelAndDisposeTokenSource(ref filterCancellationTokenSource);
|
||||
CancelAndDisposeTokenSource(ref _fetchItemsCancellationTokenSource);
|
||||
|
||||
lock (_listLock)
|
||||
{
|
||||
foreach (var item in Items)
|
||||
|
||||
@@ -67,16 +67,27 @@ public partial class ShellViewModel : ObservableObject,
|
||||
IsSearchBoxVisible = true;
|
||||
}
|
||||
|
||||
if (oldValue is IDisposable disposable)
|
||||
try
|
||||
{
|
||||
try
|
||||
if (oldValue is ListViewModel previousList)
|
||||
{
|
||||
// Frame keeps the VM in its navigation parameter for Back.
|
||||
// Cancel this visit's work without permanently disposing it.
|
||||
previousList.SuspendForNavigation();
|
||||
}
|
||||
else if (oldValue is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError(ex.ToString());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError(ex.ToString());
|
||||
}
|
||||
|
||||
if (value is ListViewModel currentList)
|
||||
{
|
||||
_ = currentList.ResumeAfterNavigation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,978 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public sealed partial class ListViewModelNavigationTests
|
||||
{
|
||||
private const string InitialGlyph = "\uE8D4";
|
||||
private const string SearchGlyph = "\uE8A5";
|
||||
|
||||
private sealed partial class TestHost : AppExtensionHost
|
||||
{
|
||||
public override string? GetExtensionDisplayName() => "Navigation test host";
|
||||
}
|
||||
|
||||
private sealed partial class SearchPage : DynamicListPage
|
||||
{
|
||||
private IListItem[] _items = [CreateItem("Initial", InitialGlyph)];
|
||||
private int _getItemsCount;
|
||||
|
||||
internal int GetItemsCount => Volatile.Read(ref _getItemsCount);
|
||||
|
||||
internal Action<int>? OnGetItems { get; set; }
|
||||
|
||||
public override IListItem[] GetItems()
|
||||
{
|
||||
var count = Interlocked.Increment(ref _getItemsCount);
|
||||
var items = Volatile.Read(ref _items);
|
||||
OnGetItems?.Invoke(count);
|
||||
return items;
|
||||
}
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch) =>
|
||||
ReplaceItems([CreateItem(newSearch, SearchGlyph)]);
|
||||
|
||||
internal void ReplaceItems(IListItem[] items, bool notify = true)
|
||||
{
|
||||
Volatile.Write(ref _items, items);
|
||||
if (notify)
|
||||
{
|
||||
RaiseItemsChanged(items.Length);
|
||||
}
|
||||
}
|
||||
|
||||
internal void Refresh() => RaiseItemsChanged();
|
||||
}
|
||||
|
||||
private sealed partial class StaticPage(IListItem[] items) : ListPage
|
||||
{
|
||||
private int _getItemsCount;
|
||||
|
||||
internal int GetItemsCount => Volatile.Read(ref _getItemsCount);
|
||||
|
||||
public override IListItem[] GetItems()
|
||||
{
|
||||
Interlocked.Increment(ref _getItemsCount);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed partial class TrackingItem(string title, ManualResetEventSlim? started = null, ManualResetEventSlim? release = null)
|
||||
: ListItem(new NoOpCommand { Name = title })
|
||||
{
|
||||
private int _initializationCount;
|
||||
|
||||
internal int InitializationCount => Volatile.Read(ref _initializationCount);
|
||||
|
||||
public override ITag[] Tags
|
||||
{
|
||||
get
|
||||
{
|
||||
Interlocked.Increment(ref _initializationCount);
|
||||
started?.Set();
|
||||
if (release is not null && !release.Wait(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
throw new TimeoutException("The blocked item was not released.");
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class QueuedTaskScheduler : TaskScheduler
|
||||
{
|
||||
private readonly ConcurrentQueue<Task> _tasks = new();
|
||||
|
||||
protected override IEnumerable<Task> GetScheduledTasks() => _tasks.ToArray();
|
||||
|
||||
protected override void QueueTask(Task task) => _tasks.Enqueue(task);
|
||||
|
||||
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false;
|
||||
|
||||
internal void Drain()
|
||||
{
|
||||
while (_tasks.TryDequeue(out var task))
|
||||
{
|
||||
TryExecuteTask(task);
|
||||
task.GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
internal void DrainUntil(Func<bool> condition)
|
||||
{
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
while (!condition())
|
||||
{
|
||||
Drain();
|
||||
Assert.IsTrue(elapsed.Elapsed < TimeSpan.FromSeconds(3), "UI publication did not finish.");
|
||||
Thread.Sleep(5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(true)]
|
||||
[DataRow(false)]
|
||||
[Timeout(15000)]
|
||||
public async Task ReturningToRetainedPagePublishesSearchResultsAndIcons(bool isRootPage)
|
||||
{
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page);
|
||||
viewModel.IsRootPage = isRootPage;
|
||||
using var shell = CreateShell();
|
||||
shell.CurrentPage = viewModel;
|
||||
|
||||
try
|
||||
{
|
||||
await ObserveItemsAsync(viewModel, "Initial", viewModel.InitializeProperties);
|
||||
Assert.AreEqual(InitialGlyph, viewModel.FilteredItems[0].Icon.Light.Icon);
|
||||
|
||||
foreach (var query in new[] { "Word", "Excel", "PowerPoint" })
|
||||
{
|
||||
await ObserveItemsAsync(viewModel, query, () =>
|
||||
{
|
||||
// Exercise the same shell setter as Frame forward/back navigation:
|
||||
// the navigation parameter retains and restores this exact VM.
|
||||
shell.CurrentPage = shell.NullPage;
|
||||
shell.CurrentPage = viewModel;
|
||||
viewModel.SearchTextBox = query;
|
||||
});
|
||||
|
||||
Assert.AreSame(viewModel, shell.CurrentPage);
|
||||
Assert.AreEqual(SearchGlyph, viewModel.FilteredItems[0].Icon.Light.Icon);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
WeakReferenceMessenger.Default.UnregisterAll(shell);
|
||||
viewModel.Dispose();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task ReturningToStaticPageRestartsPendingRealizedIconsWithoutItemsChanged()
|
||||
{
|
||||
using var started = new ManualResetEventSlim();
|
||||
using var release = new ManualResetEventSlim();
|
||||
var items = Enumerable.Range(0, 48).Select(index => (IListItem)CreateItem($"Item {index}")).ToArray();
|
||||
items[20] = new TrackingItem("Item 20", started, release) { Icon = new IconInfo(SearchGlyph) };
|
||||
var last = new TrackingItem("Item 47") { Icon = new IconInfo(SearchGlyph) };
|
||||
items[47] = last;
|
||||
var page = new StaticPage(items);
|
||||
var viewModel = CreateViewModel(page);
|
||||
using var shell = CreateShell();
|
||||
shell.CurrentPage = viewModel;
|
||||
ListItemRealizationRegistration registration = default;
|
||||
Task? oldWorker = null;
|
||||
|
||||
try
|
||||
{
|
||||
await ObserveItemsAsync(viewModel, vm => vm.FilteredItems.Count == 48, viewModel.InitializeProperties);
|
||||
await WaitForPublishedAsync(viewModel);
|
||||
Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2)));
|
||||
oldWorker = GetPrivateField<ListItemInitializationCoordinator>(viewModel, "_itemInitializationCoordinator").Completion;
|
||||
var pending = viewModel.FilteredItems.Single(item => ReferenceEquals(item.Model.Unsafe, last));
|
||||
Assert.AreEqual(0, last.InitializationCount);
|
||||
Assert.IsFalse(pending.Icon.HasIcon(light: true));
|
||||
|
||||
shell.CurrentPage = shell.NullPage;
|
||||
release.Set();
|
||||
await oldWorker.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
// The page has no ItemsChanged event to restart work for us. A new
|
||||
// visual tree can publish realization before the shell restores its VM.
|
||||
registration = pending.BeginRealization();
|
||||
Assert.IsTrue(registration.IsValid);
|
||||
shell.CurrentPage = viewModel;
|
||||
Assert.IsTrue(await pending.WaitForInitializationAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(3)));
|
||||
|
||||
Assert.AreEqual(1, last.InitializationCount);
|
||||
Assert.AreEqual(SearchGlyph, pending.Icon.Light.Icon);
|
||||
Assert.IsTrue(viewModel.FilteredItems.Contains(pending));
|
||||
Assert.AreEqual(1, page.GetItemsCount, "Restarting retained rows must not refetch an unchanged page.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
registration.Release();
|
||||
WeakReferenceMessenger.Default.UnregisterAll(shell);
|
||||
viewModel.Dispose();
|
||||
if (oldWorker is not null)
|
||||
{
|
||||
await oldWorker.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task FetchFromPreviousVisitCannotOverwriteResultsAfterReturn()
|
||||
{
|
||||
using var started = new ManualResetEventSlim();
|
||||
using var release = new ManualResetEventSlim();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page);
|
||||
using var shell = CreateShell();
|
||||
shell.CurrentPage = viewModel;
|
||||
Task? oldFetch = null;
|
||||
|
||||
try
|
||||
{
|
||||
await ObserveItemsAsync(viewModel, "Initial", viewModel.InitializeProperties);
|
||||
page.OnGetItems = count =>
|
||||
{
|
||||
if (count == 2)
|
||||
{
|
||||
started.Set();
|
||||
Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
};
|
||||
|
||||
oldFetch = Task.Run(() => page.ReplaceItems([CreateItem("Obsolete")]));
|
||||
Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2)));
|
||||
shell.CurrentPage = shell.NullPage;
|
||||
page.ReplaceItems([CreateItem("Current")]);
|
||||
Assert.AreEqual(2, page.GetItemsCount, "A hidden page must not restart fetching on ItemsChanged.");
|
||||
|
||||
await ObserveItemsAsync(viewModel, "Current", () => shell.CurrentPage = viewModel);
|
||||
release.Set();
|
||||
await oldFetch.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title);
|
||||
Assert.AreEqual(SearchGlyph, viewModel.FilteredItems.Single().Icon.Light.Icon);
|
||||
Assert.AreEqual(3, page.GetItemsCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
if (oldFetch is not null)
|
||||
{
|
||||
await oldFetch.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
WeakReferenceMessenger.Default.UnregisterAll(shell);
|
||||
viewModel.Dispose();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public void QueuedPublicationFromPreviousVisitStaysInvalidAfterResume()
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
using var shell = CreateShell();
|
||||
shell.CurrentPage = viewModel;
|
||||
var publications = 0;
|
||||
void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args) => publications++;
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.DrainUntil(() => viewModel.FilteredItems.Count == 1);
|
||||
scheduler.Drain();
|
||||
viewModel.ItemsUpdated += OnItemsUpdated;
|
||||
page.ReplaceItems([CreateItem("Obsolete")]);
|
||||
shell.CurrentPage = shell.NullPage;
|
||||
page.ReplaceItems([CreateItem("Current")]);
|
||||
|
||||
// Hold off the resumed background fetch before it can increment the
|
||||
// generation itself. Run queued UI work reentrantly on this test thread
|
||||
// to prove suspension invalidated the old callback, not just the next fetch.
|
||||
using (GetPrivateField<Lock>(viewModel, "_fetchStateLock").EnterScope())
|
||||
{
|
||||
shell.CurrentPage = viewModel;
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(0, publications, "Resumption must not make a previous visit's callback current again.");
|
||||
}
|
||||
|
||||
scheduler.DrainUntil(() => publications == 1);
|
||||
Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title);
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.ItemsUpdated -= OnItemsUpdated;
|
||||
WeakReferenceMessenger.Default.UnregisterAll(shell);
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(false)]
|
||||
[DataRow(true)]
|
||||
[Timeout(15000)]
|
||||
public void TerminalCleanupCannotBeReversedByBackNavigation(bool useSafeCleanup)
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
using var shell = CreateShell();
|
||||
shell.CurrentPage = viewModel;
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.DrainUntil(() => viewModel.FilteredItems.Count == 1);
|
||||
shell.CurrentPage = shell.NullPage;
|
||||
if (useSafeCleanup)
|
||||
{
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
else
|
||||
{
|
||||
viewModel.Dispose();
|
||||
}
|
||||
|
||||
shell.CurrentPage = viewModel;
|
||||
page.ReplaceItems([CreateItem("Must not load")]);
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(1, page.GetItemsCount);
|
||||
Assert.AreEqual(useSafeCleanup ? 0 : 1, viewModel.FilteredItems.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WeakReferenceMessenger.Default.UnregisterAll(shell);
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task NavigationHandoffDoesNotWaitForWorkerOwnedLocks()
|
||||
{
|
||||
using var held = new ManualResetEventSlim();
|
||||
using var release = new ManualResetEventSlim();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page);
|
||||
using var shell = CreateShell();
|
||||
shell.CurrentPage = viewModel;
|
||||
Task? lockHolder = null;
|
||||
Task? navigation = null;
|
||||
|
||||
try
|
||||
{
|
||||
await ObserveItemsAsync(viewModel, "Initial", viewModel.InitializeProperties);
|
||||
lockHolder = Task.Run(() =>
|
||||
{
|
||||
using (GetPrivateField<Lock>(viewModel, "_initializationCoordinatorLock").EnterScope())
|
||||
using (GetPrivateField<Lock>(viewModel, "_fetchStateLock").EnterScope())
|
||||
using (GetPrivateField<Lock>(viewModel, "_listLock").EnterScope())
|
||||
{
|
||||
held.Set();
|
||||
Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
});
|
||||
|
||||
Assert.IsTrue(held.Wait(TimeSpan.FromSeconds(2)));
|
||||
navigation = Task.Run(() =>
|
||||
{
|
||||
shell.CurrentPage = shell.NullPage;
|
||||
shell.CurrentPage = viewModel;
|
||||
});
|
||||
await navigation.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.IsFalse(lockHolder.IsCompleted, "Navigation must finish while the worker still owns the locks.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
if (lockHolder is not null)
|
||||
{
|
||||
await lockHolder.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
if (navigation is not null)
|
||||
{
|
||||
await navigation.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
WeakReferenceMessenger.Default.UnregisterAll(shell);
|
||||
viewModel.Dispose();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task ReturningToUnchangedPageDoesNotFetchOrRepublish()
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
var publications = 0;
|
||||
void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args) => publications++;
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
var retained = viewModel.FilteredItems.Single();
|
||||
viewModel.ItemsUpdated += OnItemsUpdated;
|
||||
|
||||
for (var visit = 0; visit < 3; visit++)
|
||||
{
|
||||
viewModel.SuspendForNavigation();
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
}
|
||||
|
||||
Assert.AreEqual(1, page.GetItemsCount);
|
||||
Assert.AreEqual(0, publications);
|
||||
Assert.AreSame(retained, viewModel.FilteredItems.Single());
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.ItemsUpdated -= OnItemsUpdated;
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task DirectInitialFetchDeferredBySuspensionIsRecovered()
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.SuspendForNavigation();
|
||||
viewModel.InitializeProperties(); // Calls FetchItems directly, not RequestFetch.
|
||||
Assert.AreEqual(0, page.GetItemsCount);
|
||||
Assert.AreEqual(ListPageFetchPhase.Fetching, GetWorkState(viewModel).Phase);
|
||||
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(1, page.GetItemsCount);
|
||||
Assert.AreEqual("Initial", viewModel.FilteredItems.Single().Title);
|
||||
Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(false)]
|
||||
[DataRow(true)]
|
||||
[Timeout(15000)]
|
||||
public async Task SupersededFetchDoesNotCreateRecoveryWhenItUnwinds(bool finishWhileSuspended)
|
||||
{
|
||||
using var started = new ManualResetEventSlim();
|
||||
using var release = new ManualResetEventSlim();
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
Task? oldFetch = null;
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
page.OnGetItems = count =>
|
||||
{
|
||||
if (count == 2)
|
||||
{
|
||||
started.Set();
|
||||
Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
};
|
||||
|
||||
oldFetch = Task.Run(() => page.ReplaceItems([CreateItem("Obsolete")]));
|
||||
Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2)));
|
||||
page.ReplaceItems([CreateItem("Current")]);
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase);
|
||||
|
||||
if (finishWhileSuspended)
|
||||
{
|
||||
viewModel.SuspendForNavigation();
|
||||
}
|
||||
|
||||
release.Set();
|
||||
await oldFetch.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
if (!finishWhileSuspended)
|
||||
{
|
||||
viewModel.SuspendForNavigation();
|
||||
}
|
||||
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(3, page.GetItemsCount, "A superseded fetch must not resurrect a satisfied request.");
|
||||
Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
if (oldFetch is not null)
|
||||
{
|
||||
await oldFetch.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task InterruptedFetchIsRecoveredBeforeTheOldGetItemsReturns()
|
||||
{
|
||||
using var started = new ManualResetEventSlim();
|
||||
using var release = new ManualResetEventSlim();
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
Task? oldFetch = null;
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
page.OnGetItems = count =>
|
||||
{
|
||||
if (count == 2)
|
||||
{
|
||||
started.Set();
|
||||
Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
};
|
||||
|
||||
oldFetch = Task.Run(() => page.ReplaceItems([CreateItem("Current")]));
|
||||
Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2)));
|
||||
viewModel.SuspendForNavigation();
|
||||
|
||||
// No ItemsChanged while suspended, and the cancelled call has not
|
||||
// unwound: recovery must already know that the snapshot is unfinished.
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
Assert.IsFalse(oldFetch.IsCompleted);
|
||||
Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title);
|
||||
Assert.AreEqual(3, page.GetItemsCount);
|
||||
|
||||
release.Set();
|
||||
await oldFetch.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
viewModel.SuspendForNavigation();
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(3, page.GetItemsCount, "The late unwind must not create another recovery fetch.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
if (oldFetch is not null)
|
||||
{
|
||||
await oldFetch.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task CommittedSnapshotIsRepublishedWithoutGetItemsAndPreservesSelectionIntent()
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
viewModel.IsRootPage = true;
|
||||
var publications = new List<ItemsUpdatedEventArgs>();
|
||||
void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args) => publications.Add(args);
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
viewModel.ItemsUpdated += OnItemsUpdated;
|
||||
page.ReplaceItems([CreateItem("Current")]);
|
||||
Assert.AreEqual(ListPageFetchPhase.Committed, GetWorkState(viewModel).Phase);
|
||||
Assert.AreEqual("Initial", viewModel.FilteredItems.Single().Title);
|
||||
|
||||
viewModel.SuspendForNavigation();
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
|
||||
Assert.AreEqual(2, page.GetItemsCount);
|
||||
Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title);
|
||||
Assert.AreEqual(1, publications.Count);
|
||||
Assert.IsTrue(publications[0].ForceFirstItem);
|
||||
Assert.IsTrue(publications[0].EnsureSelectionVisible);
|
||||
Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase);
|
||||
|
||||
viewModel.SuspendForNavigation();
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(1, publications.Count, "A recovered publication must not be repeated on the next Back.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.ItemsUpdated -= OnItemsUpdated;
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task SuspendedFetchRequestsSubsumeACommittedSnapshot()
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
page.ReplaceItems([CreateItem("Committed but superseded")]);
|
||||
viewModel.SuspendForNavigation();
|
||||
page.ReplaceItems([CreateItem("First suspended change")]);
|
||||
page.ReplaceItems([CreateItem("Latest suspended change")]);
|
||||
Assert.AreEqual(2, page.GetItemsCount);
|
||||
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(3, page.GetItemsCount, "Suspended requests should reconcile in one fetch.");
|
||||
Assert.AreEqual("Latest suspended change", viewModel.FilteredItems.Single().Title);
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task CancelledProviderFetchRemainsRecoverable()
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
page.OnGetItems = count =>
|
||||
{
|
||||
if (count == 2)
|
||||
{
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
};
|
||||
page.ReplaceItems([CreateItem("Current")]);
|
||||
Assert.AreEqual(ListPageFetchPhase.Fetching, GetWorkState(viewModel).Phase);
|
||||
|
||||
viewModel.SuspendForNavigation();
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title);
|
||||
Assert.AreEqual(3, page.GetItemsCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Fetching")]
|
||||
[DataRow("Committed")]
|
||||
[DataRow("Published")]
|
||||
[Timeout(15000)]
|
||||
public async Task RepeatedNavigationBeforeRecoveryRunsPreservesTheRequiredPhase(string phaseName)
|
||||
{
|
||||
using var held = new ManualResetEventSlim();
|
||||
using var release = new ManualResetEventSlim();
|
||||
var phase = Enum.Parse<ListPageFetchPhase>(phaseName);
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
Task? lockHolder = null;
|
||||
Task? firstResume = null;
|
||||
Task? secondResume = null;
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
if (phase == ListPageFetchPhase.Committed)
|
||||
{
|
||||
page.ReplaceItems([CreateItem("Current")]);
|
||||
}
|
||||
|
||||
viewModel.SuspendForNavigation();
|
||||
if (phase == ListPageFetchPhase.Fetching)
|
||||
{
|
||||
page.ReplaceItems([CreateItem("Current")]);
|
||||
}
|
||||
|
||||
lockHolder = Task.Run(() =>
|
||||
{
|
||||
using (GetPrivateField<Lock>(viewModel, "_initializationCoordinatorLock").EnterScope())
|
||||
using (GetPrivateField<Lock>(viewModel, "_fetchStateLock").EnterScope())
|
||||
{
|
||||
held.Set();
|
||||
Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
});
|
||||
Assert.IsTrue(held.Wait(TimeSpan.FromSeconds(2)));
|
||||
|
||||
firstResume = viewModel.ResumeAfterNavigation();
|
||||
viewModel.SuspendForNavigation();
|
||||
secondResume = viewModel.ResumeAfterNavigation();
|
||||
Assert.AreEqual(phase, GetWorkState(viewModel).Phase, "Queueing recovery must not consume it.");
|
||||
release.Set();
|
||||
await Task.WhenAll(lockHolder, firstResume, secondResume).WaitAsync(TimeSpan.FromSeconds(3));
|
||||
scheduler.Drain();
|
||||
|
||||
Assert.AreEqual(phase == ListPageFetchPhase.Published ? 1 : 2, page.GetItemsCount);
|
||||
Assert.AreEqual(phase == ListPageFetchPhase.Published ? "Initial" : "Current", viewModel.FilteredItems.Single().Title);
|
||||
Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.Set();
|
||||
foreach (var task in new[] { lockHolder, firstResume, secondResume })
|
||||
{
|
||||
if (task is not null)
|
||||
{
|
||||
await task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task ItemsChangedRacingResumeIsNotLost()
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
for (var visit = 1; visit <= 30; visit++)
|
||||
{
|
||||
using var begin = new ManualResetEventSlim();
|
||||
var title = $"Visit {visit}";
|
||||
viewModel.SuspendForNavigation();
|
||||
page.ReplaceItems([CreateItem(title)], notify: false);
|
||||
var request = Task.Run(() =>
|
||||
{
|
||||
Assert.IsTrue(begin.Wait(TimeSpan.FromSeconds(2)));
|
||||
page.Refresh();
|
||||
});
|
||||
var resume = Task.Run(async () =>
|
||||
{
|
||||
Assert.IsTrue(begin.Wait(TimeSpan.FromSeconds(2)));
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
});
|
||||
begin.Set();
|
||||
await Task.WhenAll(request, resume).WaitAsync(TimeSpan.FromSeconds(3));
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(title, viewModel.FilteredItems.Single().Title);
|
||||
Assert.AreEqual(visit + 1, page.GetItemsCount);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public void ReentrantPublicationDoesNotAdvanceThePhaseBeforeItsMutationRuns()
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
var reentered = false;
|
||||
var publications = 0;
|
||||
void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args) => publications++;
|
||||
void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs args)
|
||||
{
|
||||
if (reentered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
reentered = true;
|
||||
page.ReplaceItems([CreateItem("Inner")]);
|
||||
scheduler.Drain(); // Models WinUI pumping a queued callback during mutation.
|
||||
Assert.AreEqual(ListPageFetchPhase.Committed, GetWorkState(viewModel).Phase);
|
||||
Assert.AreEqual(0, publications, "A deferred mutation must not report successful publication.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
viewModel.ItemsUpdated += OnItemsUpdated;
|
||||
viewModel.FilteredItems.CollectionChanged += OnCollectionChanged;
|
||||
page.ReplaceItems([CreateItem("Outer")]);
|
||||
scheduler.Drain();
|
||||
|
||||
Assert.IsTrue(reentered);
|
||||
Assert.AreEqual("Inner", viewModel.FilteredItems.Single().Title);
|
||||
Assert.AreEqual(1, publications);
|
||||
Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.ItemsUpdated -= OnItemsUpdated;
|
||||
viewModel.FilteredItems.CollectionChanged -= OnCollectionChanged;
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[Timeout(15000)]
|
||||
public async Task DuplicateSuspendedRequestsReuseTheirPendingRecord()
|
||||
{
|
||||
var scheduler = new QueuedTaskScheduler();
|
||||
var page = new SearchPage();
|
||||
var viewModel = CreateViewModel(page, scheduler);
|
||||
|
||||
try
|
||||
{
|
||||
viewModel.InitializeProperties();
|
||||
scheduler.Drain();
|
||||
viewModel.SuspendForNavigation();
|
||||
page.ReplaceItems([CreateItem("Current")]);
|
||||
var pending = GetWorkState(viewModel);
|
||||
for (var request = 0; request < 30; request++)
|
||||
{
|
||||
page.Refresh();
|
||||
Assert.AreSame(pending, GetWorkState(viewModel));
|
||||
}
|
||||
|
||||
await viewModel.ResumeAfterNavigation();
|
||||
scheduler.Drain();
|
||||
Assert.AreEqual(2, page.GetItemsCount);
|
||||
Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title);
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.Dispose();
|
||||
scheduler.Drain();
|
||||
viewModel.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private static ListPageWorkState GetWorkState(ListViewModel viewModel) =>
|
||||
GetPrivateField<ListPageWorkState>(viewModel, "_workState");
|
||||
|
||||
private static async Task WaitForPublishedAsync(ListViewModel viewModel)
|
||||
{
|
||||
var elapsed = Stopwatch.StartNew();
|
||||
while (GetWorkState(viewModel).Phase != ListPageFetchPhase.Published)
|
||||
{
|
||||
Assert.IsTrue(elapsed.Elapsed < TimeSpan.FromSeconds(3), "The fetch did not finish publishing.");
|
||||
await Task.Delay(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static ListItem CreateItem(string title, string glyph = SearchGlyph) =>
|
||||
new(new NoOpCommand { Name = title }) { Icon = new IconInfo(glyph) };
|
||||
|
||||
private static ListViewModel CreateViewModel(IListPage page, TaskScheduler? scheduler = null) =>
|
||||
new(page, scheduler ?? TaskScheduler.Default, new TestHost(), CommandProviderContext.Empty, DefaultContextMenuFactory.Instance);
|
||||
|
||||
private static ShellViewModel CreateShell()
|
||||
{
|
||||
var hostService = new Mock<IAppHostService>();
|
||||
hostService.Setup(service => service.GetDefaultHost()).Returns(new TestHost());
|
||||
return new(TaskScheduler.Default, Mock.Of<IRootPageService>(), Mock.Of<IPageViewModelFactoryService>(), hostService.Object);
|
||||
}
|
||||
|
||||
private static T GetPrivateField<T>(ListViewModel viewModel, string name) =>
|
||||
(T)(typeof(ListViewModel).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(viewModel)
|
||||
?? throw new AssertFailedException($"Missing field {name}."));
|
||||
|
||||
private static Task ObserveItemsAsync(ListViewModel viewModel, string expectedTitle, Action action) =>
|
||||
ObserveItemsAsync(viewModel, vm => vm.FilteredItems.Count == 1 && vm.FilteredItems[0].Title == expectedTitle, action);
|
||||
|
||||
private static async Task ObserveItemsAsync(ListViewModel viewModel, Func<ListViewModel, bool> predicate, Action action)
|
||||
{
|
||||
var published = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args)
|
||||
{
|
||||
if (predicate(sender))
|
||||
{
|
||||
published.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.ItemsUpdated += OnItemsUpdated;
|
||||
try
|
||||
{
|
||||
action();
|
||||
await published.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.ItemsUpdated -= OnItemsUpdated;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user