diff --git a/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.cs b/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.cs index bc32d41119..8436ec1d26 100644 --- a/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.cs +++ b/src/Uno.Extensions.Reactive.Tests/Operators/Given_ListFeedSelection.cs @@ -805,4 +805,70 @@ public MyAggregateRoot(int key) public int? MyEntityKey { get; init; } } + + [TestMethod] + public async Task When_AddAsyncThenTrySelectAsync_Then_SelectionStateDoesNotReceiveIntermediateNone() + { + // This test verifies the fix for issue #2942 + // When AddAsync is called followed by TrySelectAsync, the selection state's + // ForEach callback should only fire with the selected value, not with null first + + var selection = State.Empty(this).Record(); + var list = ListState.Async(this, async _ => ImmutableList.Empty); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + // Add an item to the list and then select it + await list.AddAsync(42, CT); + await list.TrySelectAsync(42, CT); + + // The selection state should only get the selected value (42), not None/null first + await selection.Should().BeAsync(r => r + .Message(Data.None, Error.No, Progress.Final) // Initial empty state + .Message(42, Error.No, Progress.Final) // Selected value (should not have intermediate None) + ); + + await sut.Should().BeAsync(r => r + .Message(Data.None, Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Data) + .Current(Items.Some(42), Error.No, Progress.Final, Selection.Empty)) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Some(42), Error.No, Progress.Final, Selection.Items(42))) + ); + } + + [TestMethod] + public async Task When_AddAsyncThenTrySelectAsync_WithInitialData_Then_SelectionStateDoesNotReceiveIntermediateNone() + { + // Same as above but with an initial list that already has data + + var selection = State.Empty(this).Record(); + var list = ListState.Async(this, async _ => Enumerable.Range(0, 5).ToImmutableList()); + var sut = ListFeedSelection.Create(list, selection.Feed, "sut").Record(); + + await sut.WaitForMessages(1); + + // Add an item to the list and then select it + await list.AddAsync(42, CT); + await list.TrySelectAsync(42, CT); + + // The selection state should only get the selected value (42), not None/null first + await selection.Should().BeAsync(r => r + .Message(Data.None, Error.No, Progress.Final) // Initial empty state + .Message(42, Error.No, Progress.Final) // Selected value (should not have intermediate None) + ); + + await sut.Should().BeAsync(r => r + .Message(Items.Range(5), Error.No, Progress.Final, Selection.Empty) + .Message(m => m + .Changed(Changed.Data) + .Current(Items.Some(0, 1, 2, 3, 4, 42), Error.No, Progress.Final, Selection.Empty)) + .Message(m => m + .Changed(Changed.Selection & MessageAxes.SelectionSource) + .Current(Items.Some(0, 1, 2, 3, 4, 42), Error.No, Progress.Final, Selection.Items(42))) + ); + } } diff --git a/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.cs b/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.cs index efe7c89597..4b6ad6464d 100644 --- a/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.cs +++ b/src/Uno.Extensions.Reactive/Operators/ListFeedSelection.cs @@ -14,6 +14,9 @@ namespace Uno.Extensions.Reactive.Operators; internal sealed class ListFeedSelection : IListState, IStateImpl { internal static MessageAxis SelectionUpdateSource { get; } = new(MessageAxes.SelectionSource, _ => null) { IsTransient = true }; + + // Sentinel marker to indicate selection was cleared due to data changes (not an explicit user action) + private static readonly object InternalSelectionClear = new(); private readonly IListFeed _source; private readonly IState _selectionState; @@ -93,11 +96,13 @@ public async ValueTask UpdateMessageAsync(Action> Enable() Context .GetOrCreateSource(impl) - .Where(msg => msg.Changes.Contains(MessageAxis.Selection) && msg.Current.Get(SelectionUpdateSource) != _selectionState) + .Where(msg => msg.Changes.Contains(MessageAxis.Selection) + && msg.Current.Get(SelectionUpdateSource) != _selectionState + && msg.Current.Get(SelectionUpdateSource) != InternalSelectionClear) .ForEachAwaitWithCancellationAsync(SyncFromListToState, ConcurrencyMode.AbortPrevious, _ct.Token); return impl;