Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>.Empty(this).Record();
var list = ListState.Async(this, async _ => ImmutableList<int>.Empty);
var sut = ListFeedSelection<int>.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<int>.Empty(this).Record();
var list = ListState.Async(this, async _ => Enumerable.Range(0, 5).ToImmutableList());
var sut = ListFeedSelection<int>.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)))
);
}
}
13 changes: 10 additions & 3 deletions src/Uno.Extensions.Reactive/Operators/ListFeedSelection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ namespace Uno.Extensions.Reactive.Operators;
internal sealed class ListFeedSelection<TSource, TOther> : IListState<TSource>, IStateImpl
{
internal static MessageAxis<object?> 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<TSource> _source;
private readonly IState<TOther> _selectionState;
Expand Down Expand Up @@ -93,11 +96,13 @@ public async ValueTask UpdateMessageAsync(Action<MessageBuilder<IImmutableList<T
// TODO: This is only to ensure reliability, we should detect changes on the collection and update the SelectionInfo accordingly!
u.Set(MessageAxis.Selection, MessageAxisValue.Unset, null);

// Mark this as an internal clear due to data changes, not an explicit selection change
u.Set(SelectionUpdateSource, InternalSelectionClear);
selectionHasChanged = true;
}

if (selectionHasChanged)
else if (selectionHasChanged)
{
// This is an explicit selection change from the caller
u.Set(SelectionUpdateSource, this);
}
},
Expand Down Expand Up @@ -130,7 +135,9 @@ private IState<IImmutableList<TSource>> 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;
Expand Down
Loading