diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageFetchPhase.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageFetchPhase.cs
new file mode 100644
index 0000000000..c83e2b3457
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageFetchPhase.cs
@@ -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,
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkState.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkState.cs
new file mode 100644
index 0000000000..3688c064af
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkState.cs
@@ -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;
+
+///
+/// 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.
+///
+internal sealed record ListPageWorkState(
+ int Generation,
+ ListPageWorkStatus Status,
+ ListPageFetchPhase Phase,
+ bool KeepSelection = true,
+ bool EnsureSelectionVisible = false);
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkStatus.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkStatus.cs
new file mode 100644
index 0000000000..6e681b1e6d
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkStatus.cs
@@ -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,
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs
index 75f9752657..e6fd0ea5b2 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs
@@ -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 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)
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs
index ab47b069fe..ab85bf2e0f 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs
@@ -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();
}
}
}
diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ListViewModelNavigationTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ListViewModelNavigationTests.cs
new file mode 100644
index 0000000000..7d11178585
--- /dev/null
+++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ListViewModelNavigationTests.cs
@@ -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? 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 _tasks = new();
+
+ protected override IEnumerable 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 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(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(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(viewModel, "_initializationCoordinatorLock").EnterScope())
+ using (GetPrivateField(viewModel, "_fetchStateLock").EnterScope())
+ using (GetPrivateField(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();
+ 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(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(viewModel, "_initializationCoordinatorLock").EnterScope())
+ using (GetPrivateField(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(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();
+ hostService.Setup(service => service.GetDefaultHost()).Returns(new TestHost());
+ return new(TaskScheduler.Default, Mock.Of(), Mock.Of(), hostService.Object);
+ }
+
+ private static T GetPrivateField(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 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;
+ }
+ }
+}