Skip to content

Fix Picker selection when SelectedItem is removed from ItemsSource#34299

Open
jpd21122012 wants to merge 5 commits intodotnet:mainfrom
jpd21122012:fix/33307-picker-unsubscribe
Open

Fix Picker selection when SelectedItem is removed from ItemsSource#34299
jpd21122012 wants to merge 5 commits intodotnet:mainfrom
jpd21122012:fix/33307-picker-unsubscribe

Conversation

@jpd21122012
Copy link
Copy Markdown
Contributor

Description of Change

Fixes incorrect selection behavior in Picker when the selected item is removed from ItemsSource.

Previously, if the selected item was removed, Picker preserved the previous SelectedIndex, which could result in selecting a different item that now occupies the same index.

This caused index-based selection instead of identity-based selection.

Root Cause

GetSelectedIndex() returned the previous SelectedIndex when SelectedItem was no longer found in the collection.

This caused the control to keep an index that now pointed to a different item.

Fix

If the SelectedItem is no longer present in the collection, the control now returns -1, clearing the selection.

Verified Scenarios

  • Removing selected item clears selection.
  • Removing item before selected preserves the selected object.
  • Reordering items preserves the selected object.

Fixes #33307

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 2, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34299

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34299"

@dotnet-policy-service dotnet-policy-service bot added the community ✨ Community Contribution label Mar 2, 2026
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Hey there @@jpd21122012! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@jpd21122012
Copy link
Copy Markdown
Contributor Author

Hi, just checking if this PR could be reviewed for the upcoming 10.6 servicing release.

This fix ensures that Picker selection is updated correctly when the current SelectedItem is removed from the ItemsSource, preventing stale or inconsistent selection state.

The change is minimal and scoped to selection state handling, without affecting public APIs, so it should be safe for servicing.

Let me know if any additional scenarios should be validated 👍

@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented Mar 22, 2026

🤖 AI Summary

📊 Expand Full Reviewdb83806 · [Picker] Fix test expectation when removing selected item
🔍 Pre-Flight — Context & Validation

Issue: #33307 - The Picker is still binding to the property and reacts to data changes after the page is closed.
PR: #34299 - Fix Picker selection when SelectedItem is removed from ItemsSource
Platforms Affected: Windows, Android (identity-based selection fix is cross-platform logic)
Files Changed: 1 implementation (Picker.cs), 1 test (PickerTests.cs)

Key Findings

  • Root Cause: GetSelectedIndex() fell back to the previous SelectedIndex when SelectedItem was no longer found in the collection. This caused index-based rather than identity-based selection.
  • Fix Approach: A new GetSelectedIndexForCollectionMutation() method returns -1 (not stale index) when SelectedItem is not found. Used in RemoveItems(), AddItems(), and ResetItems() instead of the original GetSelectedIndex().
  • Test change: Updated TestItemsSourceCollectionChangedRemoveAtEndSelected to assert -1/null when selected item is removed (previously expected last remaining item — now considered incorrect behavior).
  • New tests added: 3 new unit tests covering: clear on removal, preserve on unrelated removal, preserve on insertion.
  • Inline review feedback: Copilot PR reviewer flagged duplicate code between GetSelectedIndex and GetSelectedIndexForCollectionMutation; PR author kept them separate intentionally for safety of the fix.
  • kubaflo concern: "I'm pretty sure this PR will regress some other aspects of the picker" — followed by running device tests and UI tests.
  • Test type: Unit tests (src/Controls/tests/Core.UnitTests/PickerTests.cs)
  • Gate result: ✅ PASSED (pre-confirmed)
  • Prior agent review: Yes — MauiBot summary present; labels: s/agent-reviewed, s/agent-approved, s/agent-fix-pr-picked

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #34299 Add GetSelectedIndexForCollectionMutation() returning -1 when item not found; use in mutation handlers ✅ PASSED (Gate) Picker.cs, PickerTests.cs Original PR

🔧 Fix — Analysis & Comparison

Fix Candidates

# Source Approach Test Result Files Changed Notes
1 claude-opus-4.6 Direct removed-item detection: e.OldItems.Contains(SelectedItem) in RemoveItems(); ItemsSource.IndexOf(SelectedItem) < 0 in ResetItems() ✅ PASS (49/49) Picker.cs Inline, no new method
2 claude-sonnet-4.6 Item-identity verification in ClampSelectedIndex(): after clamping, verify ItemsSource[newIndex] == SelectedItem, force -1 if stale ✅ PASS (49/49) Picker.cs Single change point, no new method
3 gpt-5.3-codex Notification-level invalidation: clear stale selection in CollectionChanged handler before dispatching to AddItems/RemoveItems/ResetItems ✅ PASS (50/50) Picker.cs, PickerTests.cs Added test for Replace action
4 gpt-5.4 (gemini unavailable) Fix GetSelectedIndex() directly: remove stale-index fallback, return -1 when SelectedItem not found; update mutation callers to always resync ✅ PASS (49/49) Picker.cs Fixes root cause at source method
5 cross-pollination (gpt-5.3-codex) Index-driven mutations: SelectedIndex as source of truth; no SelectedItem lookup during Add/Remove ❌ FAIL (hang) Picker.cs Re-entrant SelectedIndex change causing infinite loop
PR PR #34299 Add GetSelectedIndexForCollectionMutation() returning -1 when item not found; original GetSelectedIndex() unchanged ✅ PASSED (Gate) Picker.cs, PickerTests.cs Original PR — adds test coverage

Cross-Pollination

Model Round New Ideas? Details
claude-opus-4.6 2 No NO NEW IDEAS
gpt-5.3-codex 2 Yes Index-driven approach → tested as Attempt 5 → ❌ FAIL
claude-sonnet-4.6 2 No NO NEW IDEAS

Exhausted: Yes — all 4 models queried, no further new ideas

Selected Fix: PR's fix — Reason: The PR's GetSelectedIndexForCollectionMutation() approach is the safest for a servicing/bug-fix context. It minimally scopes the change to mutation contexts only, preserves the existing GetSelectedIndex() behavior for all other callers, and includes good test coverage. Attempts 1 and 4 are simpler alternatives, but the PR's approach makes the intent explicit and is safest as a conservative fix. Attempt 2 (ClampSelectedIndex) is elegant but has subtle risks if called outside ItemsSource context.


📋 Report — Final Recommendation

✅ Final Recommendation: APPROVE

Phase Status

Phase Status Notes
Pre-Flight ✅ COMPLETE Issue #33307, unit tests, 2 files changed
Gate ✅ PASSED Android — tests fail without fix, pass with fix
Try-Fix ✅ COMPLETE 5 attempts (4 passing, 1 failing); cross-pollination exhausted
Report ✅ COMPLETE

Summary

PR #34299 fixes a real, verified bug in Picker where removing the selected item from ItemsSource caused index-based (stale) selection instead of clearing selection. The fix is minimal, well-scoped, and backed by 3 new unit tests. Multi-model exploration confirmed the fix is sound — 4 independent approaches all passed, and the PR's implementation is among the safest choices for a servicing fix.

Selected Fix: PR's fix (GetSelectedIndexForCollectionMutation()) — conservative, well-scoped, doesn't break existing callers of GetSelectedIndex().

Root Cause

GetSelectedIndex() fell back to returning the previous SelectedIndex when SelectedItem was no longer found in the collection (return newIndex >= 0 ? newIndex : SelectedIndex). This caused collection mutations (remove, reset) to preserve a stale index pointing to whatever item now occupies that position, instead of clearing the selection to signal "no item selected."

Fix Quality

Strengths:

  • Scoped to mutation paths only (RemoveItems, AddItems, ResetItems) via new GetSelectedIndexForCollectionMutation() — preserves all existing GetSelectedIndex() behavior
  • Covers the right cases: clears selection when selected item removed, preserves when unrelated item removed, preserves when item inserted before/after selection
  • 3 new unit tests cover the new behavior and prevent regressions
  • Updated TestItemsSourceCollectionChangedRemoveAtEndSelected test expectations are correct (selected item removed → selection cleared, not "move to last item")

Minor concerns (non-blocking):

  • GetSelectedIndexForCollectionMutation() and GetSelectedIndex() are nearly identical with subtle fallback differences. The PR author acknowledged this and chose to keep them separate for safety. This is defensible for a bug fix. A future refactor could unify them with a parameter.
  • PR description's "Root Cause" section mentions modifying GetSelectedIndex() directly, but the actual implementation uses a new method — mismatch noted by Copilot PR reviewer, acknowledged but not yet updated. Recommend updating the description before merge.
  • kubaflo's device/UI test runs should be confirmed passing before merge.

@MauiBot MauiBot added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-win AI found a better alternative fix than the PR s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels Mar 22, 2026
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Mar 22, 2026

I'm pretty sure this PR will regress some other aspects of the picker

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Mar 22, 2026

/azp run maui-pr-uitests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Mar 22, 2026

/azp run maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

- Avoid modifying shared GetSelectedIndex behavior
- Update selection only during collection mutations (add/remove/reset)
- Ensure SelectedIndex is reset when SelectedItem is no longer present
- Prevent stale or incorrect selection after ItemsSource changes
Copilot AI review requested due to automatic review settings March 23, 2026 03:16
@jpd21122012
Copy link
Copy Markdown
Contributor Author

I'm pretty sure this PR will regress some other aspects of the picker

Thanks for the feedback!

I’ve reworked the fix to avoid modifying the shared GetSelectedIndex logic.

Instead, the selection is now updated only during collection mutations (add/remove/reset). This ensures that when the selected item is removed from the ItemsSource, the Picker does not fall back to a stale index, while keeping existing selection behavior intact for other scenarios.

I also verified this against multiple cases (item removal, insertions, reordering, and binding scenarios) to ensure no regressions.

Let me know if you’d like me to add test coverage for this

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes Picker selection consistency when ItemsSource mutates so selection is based on the selected object identity (and cleared when the selected object is removed), rather than accidentally “sticking” to an index that now points at a different item.

Changes:

  • Introduces GetSelectedIndexForCollectionMutation() to compute selection index from SelectedItem without falling back to the previous SelectedIndex when the item is missing.
  • Updates AddItems, RemoveItems, and ResetItems to use the new selection computation for collection-change scenarios.
  • Explicitly clears selection (ClampSelectedIndex(-1)) when a removal causes SelectedItem to no longer exist in the backing collection.

- Verify selection is cleared when selected item is removed
- Verify selection is preserved when non-selected items are removed
- Verify selection remains consistent during insert and reorder scenarios
@MauiBot MauiBot added s/agent-review-incomplete AI agent could not complete all phases (blocker, timeout, error) and removed s/agent-changes-requested AI agent recommends changes - found a better alternative or issues labels Mar 24, 2026
Copy link
Copy Markdown
Contributor

@kubaflo kubaflo left a comment

Choose a reason for hiding this comment

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

TestItemsSourceCollectionChangedRemoveAtEndSelected is failing

Selection should be cleared when the selected item is removed from the ItemsSource.
Updated test to expect SelectedIndex = -1 and SelectedItem = null.
@jpd21122012
Copy link
Copy Markdown
Contributor Author

TestItemsSourceCollectionChangedRemoveAtEndSelected is failing

I investigated the failure in TestItemsSourceCollectionChangedRemoveAtEndSelected and the issue was with the test expectation, not the Picker behavior.

The test was asserting that after removing the selected item from the ItemsSource, the selection should move to the last remaining item. That leads to an inconsistent state because the selected item no longer exists in the source.

I updated the test to reflect the correct behavior:

  • SelectedIndex should be -1
  • SelectedItem should be null

After correcting the expectation, the tests pass.

@jpd21122012 jpd21122012 requested a review from kubaflo April 1, 2026 17:18
@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented Apr 1, 2026

🚦 Gate - Test Before and After Fix

📊 Expand Full Gatedb83806 · [Picker] Fix test expectation when removing selected item

Gate Result: ✅ PASSED

Platform: ANDROID · Base: main · Merge base: 794a9fa6

Test Without Fix (expect FAIL) With Fix (expect PASS)
🧪 PickerTests PickerTests ✅ FAIL — 106s ✅ PASS — 66s
🔴 Without fix — 🧪 PickerTests: FAIL ✅ · 106s
  Determining projects to restore...
  Restored /home/vsts/work/1/s/src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj (in 5.36 sec).
  Restored /home/vsts/work/1/s/src/Controls/src/Xaml/Controls.Xaml.csproj (in 366 ms).
  Restored /home/vsts/work/1/s/src/Controls/Maps/src/Controls.Maps.csproj (in 8.73 sec).
  Restored /home/vsts/work/1/s/src/TestUtils/src/TestUtils/TestUtils.csproj (in 5 ms).
  Restored /home/vsts/work/1/s/src/Graphics/src/Graphics/Graphics.csproj (in 11 ms).
  Restored /home/vsts/work/1/s/src/Controls/src/Core/Controls.Core.csproj (in 55 ms).
  Restored /home/vsts/work/1/s/src/Essentials/src/Essentials.csproj (in 32 ms).
  Restored /home/vsts/work/1/s/src/Core/maps/src/Maps.csproj (in 35 ms).
  Restored /home/vsts/work/1/s/src/Core/src/Core.csproj (in 66 ms).
  1 of 10 projects are up-to-date for restore.
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
  Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Maps -> /home/vsts/work/1/s/artifacts/bin/Maps/Debug/net10.0/Microsoft.Maui.Maps.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0/Microsoft.Maui.Controls.Maps.dll
  Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0/Microsoft.Maui.Controls.Xaml.dll
  TestUtils -> /home/vsts/work/1/s/artifacts/bin/TestUtils/Debug/netstandard2.0/Microsoft.Maui.TestUtils.dll
  Controls.Core.UnitTests -> /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll
Test run for /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (x64)

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.16]   Discovering: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:01.25]   Discovered:  Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:01.26]   Starting:    Microsoft.Maui.Controls.Core.UnitTests
  Passed PickerRetainsSelectionWhenUnselectedItemIsRemovedFromItemsSource [14 ms]
  Passed NullItemReturnsEmptyStringFromInterface [1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 0, insertNames: ["George", "Pete"]) [9 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 2, insertNames: ["George"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 1, insertNames: ["George"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 1, insertNames: ["George", "Pete"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 2, insertNames: ["George", "Pete"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 3, insertNames: ["George"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 0, insertNames: ["George"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 3, insertNames: ["George", "Pete"]) [< 1 ms]
  Passed TestSelectedIndexAssignedItemsSourceCollectionChangedInsert [2 ms]
  Passed TestItemsSourceCollectionChangedReAssign [6 ms]
  Passed TestItemsSourceCollectionOfStrings [< 1 ms]
  Passed PickerRetainsSelectionAfterInsertReorderingItemsSource [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 1, removeCount: 2, selectedItemPreserved: False) [4 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 2, removeCount: 1, selectedItemPreserved: True) [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 0, removeCount: 2, selectedItemPreserved: False) [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 1, removeCount: 1, selectedItemPreserved: False) [< 1 ms]
[xUnit.net 00:00:01.39]       Assert.Equal() Failure: Values differ
[xUnit.net 00:00:01.39]       Expected: -1
[xUnit.net 00:00:01.39]       Actual:   1
[xUnit.net 00:00:01.39]       Stack Trace:
[xUnit.net 00:00:01.39]         /_/src/Controls/tests/Core.UnitTests/PickerTests.cs(876,0): at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerClearsSelectionWhenSelectedItemIsRemovedFromItemsSource()
[xUnit.net 00:00:01.39]            at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
[xUnit.net 00:00:01.39]            at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 0, removeCount: 1, selectedItemPreserved: True) [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 2, removeCount: 2, selectedItemPreserved: True) [< 1 ms]
  Passed TestSelectedIndexChangedOnCollectionShrink [1 ms]
  Passed TestSelectedIndexOutOfRangeUpdatesSelectedItem [< 1 ms]
  Passed TestDisplayConverter [< 1 ms]
  Passed PickerPreservesSelectedItemAfterRemovingItemBeforeSelection [< 1 ms]
  Passed TestSelectedIndexInRange [< 1 ms]
  Failed PickerClearsSelectionWhenSelectedItemIsRemovedFromItemsSource [2 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: -1
Actual:   1
  Stack Trace:
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.PickerClearsSelectionWhenSelectedItemIsRemovedFromItemsSource() in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 876
   at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
   at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
  Passed TestSelectedItemSet [3 ms]
[xUnit.net 00:00:01.39]       Assert.Equal() Failure: Values differ
[xUnit.net 00:00:01.39]       Expected: -1
[xUnit.net 00:00:01.39]       Actual:   1
[xUnit.net 00:00:01.39]       Stack Trace:
[xUnit.net 00:00:01.39]         /_/src/Controls/tests/Core.UnitTests/PickerTests.cs(475,0): at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.TestItemsSourceCollectionChangedRemoveAtEndSelected(Int32 removeCount)
[xUnit.net 00:00:01.39]            at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
[xUnit.net 00:00:01.39]            at System.Reflection.MethodBaseInvoker.InvokeDirectByRefWithFewArgs(Object obj, Span`1 copyOfArgs, BindingFlags invokeAttr)
[xUnit.net 00:00:01.39]       Assert.Equal() Failure: Values differ
[xUnit.net 00:00:01.39]       Expected: -1
[xUnit.net 00:00:01.39]       Actual:   2
[xUnit.net 00:00:01.39]       Stack Trace:
[xUnit.net 00:00:01.39]         /_/src/Controls/tests/Core.UnitTests/PickerTests.cs(475,0): at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.TestItemsSourceCollectionChangedRemoveAtEndSelected(Int32 removeCount)
[xUnit.net 00:00:01.39]            at InvokeStub_PickerTests.TestItemsSourceCollectionChangedRemoveAtEndSelected(Object, Span`1)
[xUnit.net 00:00:01.39]            at System.Reflection.MethodBaseInvoker.InvokeWithOneArg(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
[xUnit.net 00:00:01.39]     PickerClearsSelectionWhenSelectedItemIsRemovedFromItemsSource [FAIL]
[xUnit.net 00:00:01.39]     TestItemsSourceCollectionChangedRemoveAtEndSelected(removeCount: 2) [FAIL]
[xUnit.net 00:00:01.39]     TestItemsSourceCollectionChangedRemoveAtEndSelected(removeCount: 1) [FAIL]
  Passed TestSelectedIndexAssignedItemsSourceCollectionChangedReAssign [< 1 ms]
  Passed TestSelectedIndexAssignedItemsSourceCollectionChangedClear [1 ms]
  Passed TestItemsSourceCollectionChangedClear [< 1 ms]
  Passed TestSelectedIndexInRangeDefaultSelectedIndex [< 1 ms]
  Failed TestItemsSourceCollectionChangedRemoveAtEndSelected(removeCount: 2) [< 1 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: -1
Actual:   1
  Stack Trace:
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.TestItemsSourceCollectionChangedRemoveAtEndSelected(Int32 removeCount) in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 475
   at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
   at System.Reflection.MethodBaseInvoker.InvokeDirectByRefWithFewArgs(Object obj, Span`1 copyOfArgs, BindingFlags invokeAttr)
  Failed TestItemsSourceCollectionChangedRemoveAtEndSelected(removeCount: 1) [< 1 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: -1
Actual:   2
  Stack Trace:
     at Microsoft.Maui.Controls.Core.UnitTests.PickerTests.TestItemsSourceCollectionChangedRemoveAtEndSelected(Int32 removeCount) in /_/src/Controls/tests/Core.UnitTests/PickerTests.cs:line 475
   at InvokeStub_PickerTests.TestItemsSourceCollectionChangedRemoveAtEndSelected(Object, Span`1)
   at System.Reflection.MethodBaseInvoker.InvokeWithOneArg(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
  Passed TestSelectedItemChangeSelectedIndex [< 1 ms]
  Passed TestNestedDisplayMemberPathExpression [< 1 ms]
  Passed TestSetItemsSourceProperty [1 ms]
  Passed PickerPreservesSelectedItemAfterInsertingItemBeforeSelection [< 1 ms]
  Passed TestItemsSourceCollectionChangedAppend [< 1 ms]
  Passed SettingSelectedIndexUpdatesSelectedItem [2 ms]
  Passed TestSelectedItemAssignedItemsSourceCollectionChangedRemove [< 1 ms]
  Passed ThrowsWhenModifyingItemsIfItemsSourceIsSet [< 1 ms]
  Passed TestItemsSourceEnums [3 ms]
  Passed TestEmptyCollectionResetItems [< 1 ms]
  Passed TestSetSelectedIndexOnNullRows [< 1 ms]
  Passed TestSelectedIndexAssignedItemsSourceCollectionChangedAppend [< 1 ms]
[xUnit.net 00:00:01.40]   Finished:    Microsoft.Maui.Controls.Core.UnitTests
  Passed TestSelectedItemDefault [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsert [< 1 ms]
  Passed TestUnsubscribeINotifyCollectionChanged [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemove [< 1 ms]

Test Run Failed.
Total tests: 49
     Passed: 46
     Failed: 3
 Total time: 1.8646 Seconds

🟢 With fix — 🧪 PickerTests: PASS ✅ · 66s
  Determining projects to restore...
  All projects are up-to-date for restore.
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Maps -> /home/vsts/work/1/s/artifacts/bin/Maps/Debug/net10.0/Microsoft.Maui.Maps.dll
  Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  ##vso[build.updatebuildnumber]10.0.60-ci+azdo.13757456
  Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0/Microsoft.Maui.Controls.Xaml.dll
  Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0/Microsoft.Maui.Controls.Maps.dll
  TestUtils -> /home/vsts/work/1/s/artifacts/bin/TestUtils/Debug/netstandard2.0/Microsoft.Maui.TestUtils.dll
  Controls.Core.UnitTests -> /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll
Test run for /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (x64)

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.37]   Discovering: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:02.96]   Discovered:  Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:02.97]   Starting:    Microsoft.Maui.Controls.Core.UnitTests
  Passed PickerRetainsSelectionWhenUnselectedItemIsRemovedFromItemsSource [22 ms]
  Passed NullItemReturnsEmptyStringFromInterface [1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 0, insertNames: ["George", "Pete"]) [15 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 2, insertNames: ["George"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 1, insertNames: ["George"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 1, insertNames: ["George", "Pete"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 2, insertNames: ["George", "Pete"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 3, insertNames: ["George"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 0, insertNames: ["George"]) [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsertBeforeSelected(insertionIndex: 3, insertNames: ["George", "Pete"]) [< 1 ms]
  Passed TestSelectedIndexAssignedItemsSourceCollectionChangedInsert [2 ms]
  Passed TestItemsSourceCollectionChangedReAssign [6 ms]
  Passed TestItemsSourceCollectionOfStrings [< 1 ms]
  Passed PickerRetainsSelectionAfterInsertReorderingItemsSource [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 1, removeCount: 2, selectedItemPreserved: False) [2 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 2, removeCount: 1, selectedItemPreserved: True) [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 0, removeCount: 2, selectedItemPreserved: False) [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 1, removeCount: 1, selectedItemPreserved: False) [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 0, removeCount: 1, selectedItemPreserved: True) [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveBeforeSelected(removeIndex: 2, removeCount: 2, selectedItemPreserved: True) [< 1 ms]
  Passed TestSelectedIndexChangedOnCollectionShrink [1 ms]
  Passed TestSelectedIndexOutOfRangeUpdatesSelectedItem [1 ms]
  Passed TestDisplayConverter [1 ms]
  Passed PickerPreservesSelectedItemAfterRemovingItemBeforeSelection [< 1 ms]
  Passed TestSelectedIndexInRange [< 1 ms]
  Passed PickerClearsSelectionWhenSelectedItemIsRemovedFromItemsSource [< 1 ms]
  Passed TestSelectedItemSet [3 ms]
  Passed TestSelectedIndexAssignedItemsSourceCollectionChangedReAssign [< 1 ms]
  Passed TestSelectedIndexAssignedItemsSourceCollectionChangedClear [1 ms]
  Passed TestItemsSourceCollectionChangedClear [< 1 ms]
  Passed TestSelectedIndexInRangeDefaultSelectedIndex [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveAtEndSelected(removeCount: 2) [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemoveAtEndSelected(removeCount: 1) [< 1 ms]
  Passed TestSelectedItemChangeSelectedIndex [< 1 ms]
  Passed TestNestedDisplayMemberPathExpression [< 1 ms]
  Passed TestSetItemsSourceProperty [1 ms]
  Passed PickerPreservesSelectedItemAfterInsertingItemBeforeSelection [< 1 ms]
  Passed TestItemsSourceCollectionChangedAppend [< 1 ms]
  Passed SettingSelectedIndexUpdatesSelectedItem [3 ms]
  Passed TestSelectedItemAssignedItemsSourceCollectionChangedRemove [< 1 ms]
  Passed ThrowsWhenModifyingItemsIfItemsSourceIsSet [< 1 ms]
  Passed TestItemsSourceEnums [2 ms]
  Passed TestEmptyCollectionResetItems [< 1 ms]
  Passed TestSetSelectedIndexOnNullRows [< 1 ms]
  Passed TestSelectedIndexAssignedItemsSourceCollectionChangedAppend [< 1 ms]
[xUnit.net 00:00:03.11]   Finished:    Microsoft.Maui.Controls.Core.UnitTests
  Passed TestSelectedItemDefault [< 1 ms]
  Passed TestItemsSourceCollectionChangedInsert [< 1 ms]
  Passed TestUnsubscribeINotifyCollectionChanged [< 1 ms]
  Passed TestItemsSourceCollectionChangedRemove [< 1 ms]

Test Run Successful.
Total tests: 49
     Passed: 49
 Total time: 4.2071 Seconds

📁 Fix files reverted (2 files)
  • eng/pipelines/ci-copilot.yml
  • src/Controls/src/Core/Picker/Picker.cs

@MauiBot MauiBot removed the s/agent-review-incomplete AI agent could not complete all phases (blocker, timeout, error) label Apr 1, 2026
@MauiBot MauiBot added s/agent-approved AI agent recommends approval - PR fix is correct and optimal s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates and removed s/agent-fix-win AI found a better alternative fix than the PR labels Apr 1, 2026
@jpd21122012
Copy link
Copy Markdown
Contributor Author

Hi, just following up on this PR.

I reworked the fix to scope the behavior specifically to collection mutations, avoiding changes to the shared GetSelectedIndex logic.

I also added regression tests and validated the following scenarios:

  • Removing the selected item → selection is cleared
  • Removing a non-selected item → selection is preserved
  • Inserting items before/after the selected item → selection remains correct
  • Reordering items → selection remains consistent based on SelectedItem identity

The goal is to ensure the fix addresses the issue without impacting existing selection behavior.

Let me know if there are any additional scenarios you’d like me to cover

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Apr 7, 2026

/azp run maui-pr-uitests

@azure-pipelines
Copy link
Copy Markdown

No pipelines are associated with this pull request.

2 similar comments
@azure-pipelines
Copy link
Copy Markdown

No pipelines are associated with this pull request.

@azure-pipelines
Copy link
Copy Markdown

No pipelines are associated with this pull request.

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Apr 7, 2026

/azp run maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Apr 7, 2026

🟢 .NET MAUI Review - Approved

Expand Full Review - db83806 - Fix Picker selection when SelectedItem is removed from ItemsSource

Code Review — PR #34299

Independent Assessment

What this changes: Introduces identity-based selection tracking for the Picker control during ItemsSource collection mutations. A new method GetSelectedIndexForCollectionMutation() resolves the selected item's index without falling back to the previous SelectedIndex when the item is no longer in the collection. RemoveItems now detects when the selected item has been removed (index == -1 && SelectedItem != null) and clears the selection. ResetItems also uses the new method so a full collection reset properly clears selection when the selected item is gone.

Inferred motivation: The old GetSelectedIndex() fell back to SelectedIndex when SelectedItem was not found in the collection, causing the Picker to silently select a different item occupying the old index position after a removal. This is confusing UX — users expect either the same item to remain selected, or selection to be cleared.

Reconciliation with PR Narrative

Author claims: Fixes #33307 — when a selected item is removed from ItemsSource, the Picker should clear selection instead of selecting whatever item now occupies the old index.

Agreement: The code matches this description exactly. The root cause analysis (fallback to stale SelectedIndex in GetSelectedIndex()) is correct. The fix is targeted and preserves the original GetSelectedIndex() for the OnItemsCollectionChanged path (direct Items manipulation), which is appropriate.

Findings

💡 Suggestion — Add a test for the ResetItems path

The PR changes ResetItems() from ClampSelectedIndex(SelectedIndex) to ClampSelectedIndex(GetSelectedIndexForCollectionMutation()). This is an important behavioral change (a full collection reset now clears selection if the selected item is absent), but there is no test that exercises this path. Consider adding a test like:

[Fact]
public void PickerClearsSelectionWhenItemsSourceIsReplacedWithoutSelectedItem()
{
    var items = new ObservableCollection<string> { "A", "B", "C" };
    var picker = new Picker { ItemsSource = items, SelectedItem = "B" };
    
    // Replace ItemsSource entirely — "B" is not in the new list
    picker.ItemsSource = new ObservableCollection<string> { "X", "Y", "Z" };
    
    Assert.Equal(-1, picker.SelectedIndex);
    Assert.Null(picker.SelectedItem);
}

💡 Suggestion — Consider a test for Replace/Move actions routing through ResetItems

The CollectionChanged handler routes Move, Replace, and Reset actions through ResetItems(). A test that replaces a selected item in-place (e.g., items[1] = "NewItem") would verify this path too.

Devil's Advocate

  • Could clearing selection on removal break existing apps? Theoretically, apps that relied on "select the item at the same index after removal" would see different behavior. However, that old behavior was essentially a bug — silently selecting a different item is far worse than clearing selection. The new behavior is consistent with standard UI framework semantics (WPF, WinUI ComboBox all clear selection when the selected item is removed).
  • Is the two-method split (GetSelectedIndex / GetSelectedIndexForCollectionMutation) the right design? The alternative would be adding a parameter to GetSelectedIndex. But the split is clearer — GetSelectedIndex retains its existing semantics for the OnItemsCollectionChanged path, and the new method has a descriptive (if verbose) name that communicates its purpose. This is acceptable.
  • CI status: The previous maui-pr run shows a macOS Debug build failure, but all Helix unit tests (Debug and Release) passed, confirming the test changes compile and run correctly. A new CI run is in progress. The macOS Debug failure appears infrastructure-related (not caused by this PR's changes, since the code is entirely cross-platform shared code that built successfully on Windows in both Debug and Release).

Verdict: LGTM

Confidence: high
Summary: The fix correctly transitions from index-based to identity-based selection tracking during collection mutations. The new RemoveItems logic properly detects and clears selection when the selected item is removed. Tests are well-structured, covering the key scenarios (removal of selected item, removal of unselected item, and insert reordering). Two minor suggestions for additional test coverage of the ResetItems and Replace paths.

Copy link
Copy Markdown
Contributor

@kubaflo kubaflo left a comment

Choose a reason for hiding this comment

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

This test is failing PickerShouldNotCrashWhenSelectedIndexExceedsItemsSourceCount

Refine logic to clear SelectedItem only if it was actually removed from the collection, using LINQ's Contains for accuracy. Adds System.Linq import to support this check.
@jpd21122012
Copy link
Copy Markdown
Contributor Author

This test is failing PickerShouldNotCrashWhenSelectedIndexExceedsItemsSourceCount

Thanks for the feedback!

I updated the implementation to only clear the selection when the SelectedItem is explicitly part of the removed items, instead of relying on index resolution.

I ran the full PickerTests suite (49 tests), including the scenarios around collection mutations (insert/remove/reorder), and all tests are passing with no regressions.

This ensures the fix addresses the issue while preserving existing selection behavior

@jpd21122012 jpd21122012 requested a review from kubaflo April 12, 2026 21:38
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented Apr 12, 2026

/azp run maui-pr-uitests , maui-pr-devicetests

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 2 pipeline(s).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community ✨ Community Contribution s/agent-approved AI agent recommends approval - PR fix is correct and optimal s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The Picker is still binding to the property and reacts to data changes after the page is closed.

4 participants