Fix #29459: Switching bindings now triggers propertyChanged correctly#32914
Fix #29459: Switching bindings now triggers propertyChanged correctly#32914StephaneDelcroix wants to merge 10 commits intonet11.0from
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request fixes issue #29459 where dynamically switching TwoWay bindings on a bindable property failed to trigger propertyChanged callbacks when the new binding's value matched a stale ManualValueSetter entry.
Key Changes
- Core fix: Modified
BindableObject.SetBinding()to explicitly clearManualValueSetterentries when setting a new binding, ensuring clean state and proper value comparison - Comprehensive test coverage: Added both XAML-based and Core unit tests that reproduce the exact scenario from the bug report
- Test validation: All existing tests pass (5422 Controls.Core.UnitTests, 1770 Controls.Xaml.UnitTests)
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Controls/src/Core/BindableObject.cs | Added defensive code to remove stale ManualValueSetter entries when switching bindings, preventing incorrect value comparison |
| src/Controls/tests/Xaml.UnitTests/Issues/Maui29459.xaml | Added minimal XAML page with custom control for test infrastructure |
| src/Controls/tests/Xaml.UnitTests/Issues/Maui29459.xaml.cs | Added comprehensive XAML unit tests reproducing the issue with internal ViewModel synchronization pattern |
| src/Controls/tests/Core.UnitTests/BindingUnitTests.cs | Added Core unit tests mirroring the XAML tests to ensure fix works at the framework level |
| // Note: PropertyChanged may or may not fire when values are equal - this is implementation-dependent | ||
| // The key assertion is that the Value is correct |
There was a problem hiding this comment.
[nitpick] The comment on line 240 states "PropertyChanged may or may not fire when values are equal", but then the test only asserts that the value is correct without checking the PropertyChangedCount behavior. This makes the test's assertion incomplete for documenting the actual behavior. Consider either:
- Removing the comment and keeping just the value assertion, OR
- Adding an explicit assertion about PropertyChangedCount behavior (e.g.,
// PropertyChangedCount behavior is implementation-defined when values are equal)
| // Note: PropertyChanged may or may not fire when values are equal - this is implementation-dependent | |
| // The key assertion is that the Value is correct | |
| // PropertyChangedCount behavior is implementation-defined when values are equal; no assertion is made. |
| page.MyControl.SetBinding(Maui29459CustomControl.ValueProperty, nameof(Maui29459ViewModel.B)); | ||
|
|
||
| Assert.That(page.MyControl.Value, Is.EqualTo(50), "Value should remain 50"); | ||
| // Note: PropertyChanged may or may not fire when values are equal - this is implementation-dependent |
There was a problem hiding this comment.
The test name is "..DoesNotTriggerPropertyChanged" yet this comment says "may or may not fire". Which is the correct one?
// FROM:
public void SwitchingBindingToSameValueDoesNotTriggerPropertyChanged([Values] XamlInflator inflator)
// TO:
public void SwitchingBindingToSameValueMaintainsCorrectValue([Values] XamlInflator inflator)// FROM:
// PropertyChanged should NOT fire (optimization)
// TO:
// The value should be correct regardless of PropertyChanged firing behavior71395b3 to
4ce7e91
Compare
4ce7e91 to
b995037
Compare
e3590a6 to
6fae381
Compare
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 32914Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 32914" |
21866c7 to
de6f3c7
Compare
|
|
When dynamically switching bindings on a TwoWay bindable property, the propertyChanged callback was not being fired if the new binding's value matched a previously manually-set value that was still cached. Root cause: In SetBinding(), when moving the current value to FromBinding specificity, stale ManualValueSetter entries were not cleared. This caused SetValueActual to compare against the wrong baseline value. Fix: Clear ManualValueSetter entries when switching bindings to ensure proper value comparison. Added unit tests in both Xaml.UnitTests and Core.UnitTests.
- Replace NUnit attributes with xUnit equivalents - Use IDisposable pattern for setup/teardown - Rename test to 'MaintainsCorrectValue' and update comment to clarify that PropertyChanged behavior is implementation-defined for same values
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
SetterSpecificityList does not support indexer syntax. Use SetValue method which is the correct API on net11.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add PropertyChangedCount assertion to Issue29459_SwitchingBindingAfterModifyingValueTriggersPropertyChanged - Add new test Issue29459_BindingContextNullAfterSwitchingBindingsRetainsLastBoundValue documenting that BindingContext=null resets to the property default value (0) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…g switch Documents intentional behavior: when switching bindings while a higher-priority setter (e.g. Trigger) is active, both the Trigger entry and any ManualValueSetter entry are cleaned up so the new binding applies correctly. - Expand code comment in BindableObject.SetBinding to explain the trade-off: manually-set fallback values are discarded when switching bindings to prevent stale TwoWay write-back values from suppressing propertyChanged notifications. - Add Issue29459_SwitchingBindingWithActiveTriggerAlsoRemovesManualValueSetter test that explicitly exercises and documents this behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use Internals.SetValueFlags and BindableObject.SetValuePrivateFlags to match the pattern used in MultiBindingTests.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
9e2f048 to
55c4cb8
Compare
|
/azp run |
|
Azure Pipelines successfully started running 3 pipeline(s). |
🚦 Gate - Test Before and After Fix📊 Expand Full Gate —
|
🤖 AI Summary📊 Expand Full Review —
|
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #32914 | Remove ManualValueSetter from context.Values in SetBinding() before applying new binding |
❌ Gate FAILED | BindableObject.cs |
Original PR fix |
🔧 Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix (claude-opus-4.6) | In SetValueActual, remove ManualValueSetter when FromHandler arrives and it's the top entry |
✅ PASS | 1 file | Reactive fix — mirrors existing FromHandler cleanup pattern |
| 2 | try-fix (claude-sonnet-4.6) | In SetBinding, while-loop removes ALL entries above FromBinding |
✅ PASS | 1 file | Most general — handles all stale specificity entries |
| 3 | try-fix (gpt-5.3-codex) | In SetValueActual, normalize ManualValueSetter writes to FromHandler |
❌ FAIL | — | Blocked by unrelated RS0016 PublicAPI errors |
| 4 | try-fix (gpt-4.1, gemini unavailable) | In SetValueActual, skip ManualValueSetter for sameValue when applying binding |
❌ BLOCKED | — | Blocked by unrelated RS0016 PublicAPI errors |
| 5 | try-fix (claude-sonnet-4.6, round 2) | In SetValueActual, compare against binding-layer slot instead of stale ManualValueSetter value |
✅ PASS | 1 file | Comparison redirect; avoids stack mutation |
| 6 | try-fix (gpt-5.3-codex, round 2) | Per-context force-notify flag in BindablePropertyContext, consumed in SetValueActual |
❌ FAIL | — | Blocked by unrelated RS0016 PublicAPI errors |
| PR | PR #32914 | In SetBinding, explicitly remove ManualValueSetter if currentSpecificity != ManualValueSetter |
❌ Gate FAILED | BindableObject.cs |
Original PR fix |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| claude-opus-4.6 | 2 | No | All viable locations covered |
| claude-sonnet-4.6 | 2 | Yes | → Attempt 5 (compare against binding-layer slot) |
| gpt-5.3-codex | 2 | Yes | → Attempt 6 (force-notify flag) |
| gpt-4.1 | 2 | No | No new ideas |
| claude-sonnet-4.6 | 3 | Yes | BindingBase.Apply + RaiseOnEqual — risk of behavioral regression; not pursued |
Exhausted: Yes (3 rounds complete, 3 passing candidates found)
Selected Fix: Candidate #2 — while-loop in SetBinding() removes all stale entries above FromBinding. More general and robust than the PR's explicit ManualValueSetter check. Simpler than Attempt 5's comparison-redirect approach.
📋 Report — Final Recommendation
⚠️ Final Recommendation: REQUEST CHANGES
Phase Status
| Phase | Status | Notes |
|---|---|---|
| Pre-Flight | ✅ COMPLETE | Issue #29459, 1 impl file + 3 test files |
| Gate | ❌ FAILED | android — tests did NOT behave as expected |
| Try-Fix | ✅ COMPLETE | 6 attempts, 3 passing alternatives found |
| Report | ✅ COMPLETE |
Summary
PR #32914 fixes a real bug (#29459) where switching TwoWay bindings suppresses propertyChanged when the new binding's value matches a stale ManualValueSetter entry. The underlying fix concept is correct — clearing stale ManualValueSetter entries on binding switch. However:
- Gate failed: The PR's test suite did not reproduce and verify the fix as expected on the android platform
- A better alternative exists: The try-fix exploration found a cleaner, more general implementation (Candidate Update README.md #2: while-loop removes all entries above
FromBinding) - Review comments are unaddressed: @mattleibow raised concerns about the test naming inconsistency (
SwitchingBindingToSameValueDoesNotTriggerPropertyChangedvs. the comment saying "may or may not fire"). While the author pushed a compile fix, the test naming concern remains - PR scope vs. description mismatch: The PR description states changes to 4 files, but the branch also contains unrelated changes: logging refactoring (
MauiLogger<T>.Log),SetterSpecificityList.SetValue()method (replacing[]indexer), and lazy initialization ofBindings/Values/_triggerSpecificity
Root Cause
In BindableObject.SetBinding(), when switching from an old binding to a new one, the code correctly moves the current value to FromBinding specificity, but does not clear stale ManualValueSetter entries left over from TwoWay write-backs. When the new binding applies in SetValueActual, original is read from the stale ManualValueSetter (the highest specificity), and sameValue = true suppresses propertyChanged.
Fix Quality
PR's fix: Adds context.Values.Remove(SetterSpecificity.ManualValueSetter) guarded by if (currentSpecificity != SetterSpecificity.ManualValueSetter). This is correct in concept but fragile: it only handles ManualValueSetter and does not account for other potential stale entries that could accumulate above FromBinding.
Better alternative (Candidate #2):
// Remove ALL entries above FromBinding (not just ManualValueSetter) to prevent
// stale intermediate entries from causing false sameValue=true on binding switch (#29459).
while (context.Values.GetSpecificity() > SetterSpecificity.FromBinding)
context.Values.Remove(context.Values.GetSpecificity());This is more defensive, simpler to reason about, and future-proof.
Test quality issues:
- Core.UnitTests adds tests for issue MVVM binding doen't work properly for int types #8342 (unrelated to this PR's stated fix)
- XAML test
SwitchingBindingToSameValueDoesNotTriggerPropertyChangedhas contradictory documentation (comment says "may or may not fire" but test name says "DoesNotTrigger") - The XAML test's
Maui29459CustomControlmust bepublic(notinternal) due to XAML namespace requirements, which is fine
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description
Fixes #29459
When dynamically switching bindings on a TwoWay bindable property (e.g., switching between binding to property A and property B), the
propertyChangedcallback was not being fired if the new binding's value matched a previously manually-set value that was still cached in the property context.Reproduction scenario (from issue):
propertyChangednot called, internal ViewModel stays at 0Root cause
In
BindableObject.SetBinding(), when a new binding is applied:FromBindingspecificityManualValueSetterentries (from code likecontrol.Value = newValue) were NOT clearedSetValueActualcompared values, it used the highest-specificity value (the staleManualValueSetter)sameValuewastrueandpropertyChangeddidn't fireFix
Clear
ManualValueSetterentries when switching bindings to ensure proper value comparison.Changes
ManualValueSetterentries when setting a new bindingTesting