Skip to content

Fix SelectionChanged not raised on collection Reset - #20942

Merged
MrJul merged 7 commits into
AvaloniaUI:masterfrom
NathanDrake2406:fix/listbox-selectionchanged-on-reset
May 27, 2026
Merged

Fix SelectionChanged not raised on collection Reset#20942
MrJul merged 7 commits into
AvaloniaUI:masterfrom
NathanDrake2406:fix/listbox-selectionchanged-on-reset

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Mar 19, 2026

Copy link
Copy Markdown
Contributor

What does the pull request do?

Fixes SelectingItemsControl.SelectionChanged not being raised when an items collection sends a Reset notification that clears the current selection. This covers controls such as ListBox bound to an ObservableCollection that is cleared.

What is the current behavior?

When a selected item is removed by a Reset, SelectionModel reports the loss through LostSelection. SelectingItemsControl only uses that callback to recover AlwaysSelected selection, so no public routed SelectionChanged event is raised for reset-to-empty cases.

What is the updated/expected behavior with this PR?

SelectionChanged is raised when a reset causes the control to lose its selected items. The event reports the previously selected items in RemovedItems and does not report removals when a reset preserves the selection.

Validated with:

  • dotnet run --project tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj -- --filter-class "Avalonia.Controls.UnitTests.Primitives.SelectingItemsControlTests"
  • dotnet run --project tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj -- --filter-class "Avalonia.Controls.UnitTests.Selection.InternalSelectionModelTests"
  • dotnet run --project tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj -- --filter-namespace "Avalonia.Controls.UnitTests.Selection"

How was the solution implemented (if it's not obvious)?

SelectingItemsControl now keeps a snapshot of the last selected items at the control boundary. When an items Reset starts, the control captures that existing snapshot. If SelectionModel.LostSelection is committed for that reset, the control raises the routed SelectionChanged event using the captured items as RemovedItems.

This keeps SelectionModel reset semantics intact and avoids diffing the reset collection contents.

Checklist

Breaking changes

None.

Obsoletions / Deprecations

None.

Fixed issues

Fixes #20897

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.0.999-cibuild0063669-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul added bug backport-candidate-11.3.x Consider this PR for backporting to 11.3 branch labels Mar 19, 2026
@NathanDrake2406
NathanDrake2406 marked this pull request as draft April 14, 2026 08:10
@MrJul

MrJul commented Apr 29, 2026

Copy link
Copy Markdown
Member

@NathanDrake2406 Is there something missing that you converted this PR to a draft?

@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review April 29, 2026 17:03
Copilot AI review requested due to automatic review settings April 29, 2026 17:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0065147-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@NathanDrake2406
NathanDrake2406 force-pushed the fix/listbox-selectionchanged-on-reset branch from ecab00d to f2e8388 Compare April 30, 2026 09:24
…deselected

When a collection bound to a SelectingItemsControl (e.g. ListBox) was
cleared via NotifyCollectionChangedAction.Reset, the SelectionChanged
event was not raised despite the selection being lost.

The root cause was in InternalSelectionModel.OnSourceReset: the base
SelectionModel.OnSourceReset() directly reset _selectedIndex to -1
before any Operation could capture the old selection state. The
subsequent SyncFromSelectedItems created an Operation that saw no
change (old and new both -1), so CommitOperation never fired
SelectionChanged.

The fix snapshots _writableSelectedItems before sync, diffs against
the post-sync state to find items that were actually lost (not merely
re-selected at a new index after reorder), and injects them as
DeselectedItems on the pending Operation — following the same pattern
used by OnSelectionRemoved for individual item removals.

Fixes AvaloniaUI#20897
@NathanDrake2406
NathanDrake2406 force-pushed the fix/listbox-selectionchanged-on-reset branch from 68eb1d7 to 7db50f8 Compare April 30, 2026 09:32
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0065164-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread src/Avalonia.Controls/Selection/InternalSelectionModel.cs Outdated
@MrJul

MrJul commented Apr 30, 2026

Copy link
Copy Markdown
Member

I had a deeper look at this.

From my understanding, the current behavior is expected from the SelectionModel side: the SelectionModel.LostSelection event is raised instead of SelectionModel.SelectionChanged. Imo, we shouldn't try to diff after a reset as this might be costly: after all, there's a reason a Reset was received.

However, the SelectionModel.LostSelection event doesn't trigger result in any event in the SelectingItemsControl, which is the original issue, #20897.

An alternative would be to add a flag or operation type to SelectionChangedEventArgs, informing the user that it's a complete reset.

cc @grokys for additional thoughts.

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review the implementation and what @MrJul said.

The Reset diff in InternalSelectionModel used a HashSet to detect
which previously-selected items were still present after sync. Selection
allows duplicates (same instance or equal items at multiple indices),
so set semantics collapsed duplicates into one entry and under-reported
deselections when only some occurrences were lost.

Track counts per item plus a null counter and decrement per match, so
RemovedItems reflects the actual number of lost selections.

Adds a duplicate-items Reset test covering the regression.
ListBox and other SelectingItemsControl callers did not receive SelectionChanged when a Reset cleared the selected items. The selection model reports this path through LostSelection, but the control only used that callback for AlwaysSelected recovery.

Track the last selected items at the control boundary, capture that snapshot for Reset notifications, and raise the routed SelectionChanged event when LostSelection commits during that reset. This avoids diffing reset contents while preserving the removed-items payload for clear/reset-to-empty cases.
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0065178-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@grokys

grokys commented May 12, 2026

Copy link
Copy Markdown
Member

I agree with @MrJul that we shouldn't be trying to diff or restore selection after a reset - we explicitly don't try to do that in the selection model because it involves copying an arbitrarily large collection.

We should be raising SelectionChanged on a reset however, I agree. It should simply reflect the new selection state at the time that it's raised IMO.

UpdateSelectedValueFromItem();
}

_selectedItemsSnapshot = Selection.SelectedItems.ToArray();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory: keep a single List for the snapshot instead of creating a new array each time the selection changes.

@NathanDrake2406
NathanDrake2406 requested a review from Copilot May 27, 2026 07:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment on lines +148 to +149
private object?[] _selectedItemsSnapshot = Array.Empty<object>();
private object?[]? _selectedItemsBeforeReset;
Comment on lines +1028 to +1029
_selectedItemsSnapshot = Selection.SelectedItems.ToArray();
_selectedItemsBeforeReset = null;
Comment on lines +1051 to +1058
if (_selectedItemsBeforeReset?.Length > 0)
{
RaiseEvent(new SelectionChangedEventArgs(
SelectionChangedEvent,
_selectedItemsBeforeReset,
Array.Empty<object>()));
_selectedItemsBeforeReset = null;
}
- Replace per-change ToArray() snapshot with persistent List<object?> to
  avoid allocations on every selection change (review: MrJul).
- Read snapshot in PreCollectionChanged instead of Selection.SelectedItems
  because the source is already empty by the time Reset fires.
- Align LostSelection event-raising with SelectionChanged path: use
  BuildEventRoute + HasHandlers guard to avoid allocating args when
  no handlers are attached (review: copilot).
- Harden existing Reset tests to Assert.Single to catch double-fire.
@NathanDrake2406
NathanDrake2406 requested a review from Copilot May 27, 2026 07:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +1059 to +1062
var ev = new SelectionChangedEventArgs(
SelectionChangedEvent,
_selectedItemsBeforeReset,
Array.Empty<object?>());
Comment on lines +473 to +479
private void OnItemsViewPreCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Reset && _selectedItemsSnapshot.Count > 0)
{
_selectedItemsBeforeReset = _selectedItemsSnapshot.ToArray();
}
}
…apshot

- Extract RaiseSelectionChanged helper so both the normal
  (SelectionChanged) and reset (LostSelection) paths share the same
  BuildEventRoute/HasHandlers guard and SelectionChangedEventArgs
  construction (review: copilot).
- Move _selectedItemsBeforeReset clear outside the conditional in
  OnSelectionModelLostSelection so the field is always nulled after
  LostSelection, preventing accidental reuse (review: copilot).
- Add comment documenting the snapshot lifecycle in
  OnItemsViewPreCollectionChanged.
@NathanDrake2406
NathanDrake2406 requested a review from Copilot May 27, 2026 08:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +1031 to +1035
_selectedItemsSnapshot.Clear();
_selectedItemsSnapshot.AddRange(Selection.SelectedItems);
_selectedItemsBeforeReset = null;

if (route.HasHandlers)
{
var ev = new SelectionChangedEventArgs(
SelectionChangedEvent,
e.DeselectedItems.ToArray(),
e.SelectedItems.ToArray());
RaiseEvent(ev);
}
RaiseSelectionChanged(e.DeselectedItems.ToArray(), e.SelectedItems.ToArray());
Comment on lines +149 to +150
private readonly List<object?> _selectedItemsSnapshot = new();
private object?[]? _selectedItemsBeforeReset;
…confirmed

SelectionChangedEventArgs materialized arrays via ToArray() at the call site
before checking whether the routed event had any registered handlers. This
allocated needlessly in the common case of no external subscribers.

The event items (IReadOnlyList<object?>) already implement IList via
ReadOnlySelectionListBase. RaiseSelectionChanged now accepts
IReadOnlyList<object?> and casts to IList inside the HasHandlers gate,
falling back to ToArray() only for non-IList enumerables.
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.1.999-cibuild0065810-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul added backport-candidate-12.0.x Consider this PR for backporting to 12.0 branch and removed backport-candidate-11.3.x Consider this PR for backporting to 11.3 branch labels May 27, 2026

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@MrJul
MrJul added this pull request to the merge queue May 27, 2026
Merged via the queue into AvaloniaUI:master with commit b0877fe May 27, 2026
12 checks passed
MrJul pushed a commit to MrJul/Avalonia that referenced this pull request May 28, 2026
* fix: raise SelectionChanged event on collection Reset when items are deselected

When a collection bound to a SelectingItemsControl (e.g. ListBox) was
cleared via NotifyCollectionChangedAction.Reset, the SelectionChanged
event was not raised despite the selection being lost.

The root cause was in InternalSelectionModel.OnSourceReset: the base
SelectionModel.OnSourceReset() directly reset _selectedIndex to -1
before any Operation could capture the old selection state. The
subsequent SyncFromSelectedItems created an Operation that saw no
change (old and new both -1), so CommitOperation never fired
SelectionChanged.

The fix snapshots _writableSelectedItems before sync, diffs against
the post-sync state to find items that were actually lost (not merely
re-selected at a new index after reorder), and injects them as
DeselectedItems on the pending Operation — following the same pattern
used by OnSelectionRemoved for individual item removals.

Fixes AvaloniaUI#20897

* fix: use multiset diff to report lost duplicate selections on Reset

The Reset diff in InternalSelectionModel used a HashSet to detect
which previously-selected items were still present after sync. Selection
allows duplicates (same instance or equal items at multiple indices),
so set semantics collapsed duplicates into one entry and under-reported
deselections when only some occurrences were lost.

Track counts per item plus a null counter and decrement per match, so
RemovedItems reflects the actual number of lost selections.

Adds a duplicate-items Reset test covering the regression.

* fix: raise SelectionChanged for reset-lost selection

ListBox and other SelectingItemsControl callers did not receive SelectionChanged when a Reset cleared the selected items. The selection model reports this path through LostSelection, but the control only used that callback for AlwaysSelected recovery.

Track the last selected items at the control boundary, capture that snapshot for Reset notifications, and raise the routed SelectionChanged event when LostSelection commits during that reset. This avoids diffing reset contents while preserving the removed-items payload for clear/reset-to-empty cases.

* fix: address review feedback on SelectionChanged Reset snapshot

- Replace per-change ToArray() snapshot with persistent List<object?> to
  avoid allocations on every selection change (review: MrJul).
- Read snapshot in PreCollectionChanged instead of Selection.SelectedItems
  because the source is already empty by the time Reset fires.
- Align LostSelection event-raising with SelectionChanged path: use
  BuildEventRoute + HasHandlers guard to avoid allocating args when
  no handlers are attached (review: copilot).
- Harden existing Reset tests to Assert.Single to catch double-fire.

* fix: consolidate SelectionChanged raising and defend against stale snapshot

- Extract RaiseSelectionChanged helper so both the normal
  (SelectionChanged) and reset (LostSelection) paths share the same
  BuildEventRoute/HasHandlers guard and SelectionChangedEventArgs
  construction (review: copilot).
- Move _selectedItemsBeforeReset clear outside the conditional in
  OnSelectionModelLostSelection so the field is always nulled after
  LostSelection, preventing accidental reuse (review: copilot).
- Add comment documenting the snapshot lifecycle in
  OnItemsViewPreCollectionChanged.

* perf: defer SelectionChangedEventArgs allocations until handlers are confirmed

SelectionChangedEventArgs materialized arrays via ToArray() at the call site
before checking whether the routed event had any registered handlers. This
allocated needlessly in the common case of no external subscribers.

The event items (IReadOnlyList<object?>) already implement IList via
ReadOnlySelectionListBase. RaiseSelectionChanged now accepts
IReadOnlyList<object?> and casts to IList inside the HasHandlers gate,
falling back to ToArray() only for non-IList enumerables.
@MrJul MrJul added backported-12.0.x and removed backport-candidate-12.0.x Consider this PR for backporting to 12.0 branch labels May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ListBox does not raise SelectionChanged event when clearing items with NotifyCollectionChangedAction.Reset

5 participants