Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

One-Way Binding immediately lost when the target Dependency Property is internally updated #9910

Open
amine2050 opened this issue Oct 8, 2024 · 14 comments

Comments

@amine2050
Copy link

amine2050 commented Oct 8, 2024

Description

Hi,

I've been writing advanced WPF stuff for the last 8 years. This is the first time I notice this odd behavior of One-Way bindings in such a basic use case:

The One-Way binding is cleared by the WPF engine when the target Dependency Property (in a custom Control/UserControl) is internally updated (in code or via internal binding).

I prepared a very simple sample to reproduce that, to which I added a BindingObserver control that continuously outputs the state of the BindingExpression on the UI.

Here is the sample:
https://github.com/amine2050/wpf-lost-binding

Am I missing a basic knowledge of how One-Way bindings behave? Or is this a known issue? I could not find any literature about the topic.

Reproduction Steps

  • Start sample app
  • Click on "Change Value" button => content of TextBox changes (binding is working)
  • Type something in the TextBox, then Tab to update value
  • Click on "Change Value" button => content of TextBox does not change anymore (binding is broken)

Expected behavior

One-way binding to ViewModel is expected to continue working even after changing the value of the DependencyProperty (by changing TextBox content).

The new content of the TextBox should not propagate to the binding Source (which is correct here), but changes in the Source are expected to be reflected on the TextBox (this does not happen anymore).

I want the same behavior of a simple TextBox bound to ViewModel using One-Way binding.

Actual behavior

One-way binding to ViewModel has been cleared by the WPF engine without any warnings or output. Changes in ViewModel are no loger propagating to UI.

Regression?

I tested this with .NET 8.0 and .NET Framework 7.2.1 and got the same behavior. So no regression is known.

Known Workarounds

The only workaround I found is to use Two-Way binding instead of One-Way. But this is not necessarily what I want actually.

Impact

This applies to any custom Control/UserControl that has properties you want to change internally while allowing binding them using One-Way binding.

Configuration

  • .NET runtime 8.0.3
  • Windows 10 Pro x64 build 19045.4957

Other information

I added PresentationTraceSources.TraceLevel=High to the binding and got the following output when the binding gets broken:

System.Windows.Data Warning: 79 : BindingExpression (hash=53180767): Deactivate
System.Windows.Data Warning: 103 : BindingExpression (hash=53180767): Replace item at level 0 with {NullDataItem}
System.Windows.Data Warning: 63 : BindingExpression (hash=53180767): Detach
@h3xds1nz
Copy link
Contributor

h3xds1nz commented Oct 8, 2024

The UserControl property needs to have OneWay mode as well.

@amine2050
Copy link
Author

The UserControl property needs to have OneWay mode as well.

Sorry, can you explain what you mean?

@h3xds1nz
Copy link
Contributor

h3xds1nz commented Oct 8, 2024

Sorry, I went with 8 years of professional WPF experience but probably also accounted for telepathy:

In MyCustomInput.xaml, adjust the binding to TextBox.Text property with Mode=OneWay.

https://source.dot.net/#PresentationFramework/System/Windows/Controls/TextBox.cs,666 Text property binds TwoWay by default, so I think you know what happens there.

If you set UpdateSourceTrigger=PropertyChanged on the binding and type into the TextBox without setting it as OneWay, you will notice the binding is thrown away instantly and has nothing to do with you updating the backing property programmatically.

@amine2050
Copy link
Author

Thanks for explaining! 😅 telepathy didn't work as expected

The TwoWay binding between TextBox.Text and MyCustomInput.Value was intended.

That is my "internal technique" inside of MyCustomInput to update Value. I want Value to be internally synchronized with the TextBox, but not necessarily with the external consumers (ViewModel). I even tried updating Value manually in code (without using a TwoWay binding), but got same "broken binding" behavior.

Using PropertyChanged or on LostFocus as trigger does not matter, you'll get the binding broken as soon as you try to update the DependencyProperty Value.

Needless to say this is just a sample to reproduce the issue, not my productive code, so it does not make a lot of sense on first sight.

@h3xds1nz
Copy link
Contributor

h3xds1nz commented Oct 8, 2024

I just wanted to show you with the trigger that you updating the property via code-behind has no effect on whether it breaks or not (so the "reproduction" steps could have been simplified).

Your "internal technique" has a basic design flaw if I understand you correctly, and that's you wanting to have TwoWay (TextBox.Text<->MyCustomInput.Value) binding on one side but OneWay (MyCustomInput.Value<-TheValue) onto the other side with one property. That's not really OneWay if you look at it from the property perspective, is it.

You're gonna need two dependency properties (one for each binding, TwoWay for TextBox, OneWay for your case) and handle the "OneWay" synchronization via SetValue inside PropertyChangedCallback on the other, that's the best I can think of rn for this. At least you didn't want generics 😀

@amine2050
Copy link
Author

amine2050 commented Oct 8, 2024

What you understood is correct: In my sample, I want to have this:
TextBox.Text<->MyCustomInput.Value <- TheValue

However, the internal binding (TextBox.Text<->MyCustomInput.Value) does not matter, it is just how MyCustomInput.Value gets updated in the example.

My requirement in more simple words:

I have a custom Control/UserControl with an exposed DependencyProperty Value that may change its value by some internal logic. As a consumer of that control, I want to bind Value to my ViewModel (or other DependencyProperty) using OneWay binding, so that changes in my VM are reflected in the Control, but changes from the control itself do not propagate back to my ViewModel.

The predefined TextBox in WPF perfectly fulfills this simple requirement with its DP Text. However when trying to implement this on my own in a straightforward way, the first change on the DP breaks the binding silently!

Is the TextBox doing some special magic to prevent breaking the binding?

Is this a desired behavior of WPF OneWay binding? Any documentation/literature?

(PS: I'll give it a try later with 2 DPs, but I don't think that would make a difference, I already tried setting the DP value in code instead of the binding, no chance)

@h3xds1nz
Copy link
Contributor

h3xds1nz commented Oct 8, 2024

There's no magic, the difference is that your VM property is not a DependencyProperty but a raw CLR POCO, you're mixing up binding DP-DP and DP-POCO.

Here's the working example for your desired effect, this way TextBox is living its life, changes propagated into TextBound and once VMBound changes, it is propagated into TextBound/TextBox.Text.
For clarity I've noted the binding modes in markup and named the properties accordingly.

MyCustomInput.xaml.cs:

public static readonly DependencyProperty VMBoundProperty = DependencyProperty.Register(
  nameof(VMBound), typeof(string), typeof(MyCustomInput),
  new FrameworkPropertyMetadata(default(string), (d, e) => { d.SetValue(TextBoundProperty, e.NewValue); Debug.WriteLine($"VM-Old: {e.OldValue}"); Debug.WriteLine($"VM-New: {e.NewValue}"); }));

public string VMBound
{
    get => (string)GetValue(VMBoundProperty); 
    set => SetValue(VMBoundProperty, value); 
}

public static readonly DependencyProperty TextBoundProperty = DependencyProperty.Register(
  nameof(TextBound), typeof(string), typeof(MyCustomInput),
  new FrameworkPropertyMetadata(default(string), (d, e) => { Debug.WriteLine($"Text-Old: {e.OldValue}"); Debug.WriteLine($"Text-New: {e.NewValue}"); }));

public string TextBound
{
    get => (string)GetValue(TextBoundProperty);
    set => SetValue(TextBoundProperty, value);
}

MyCustomInput.xaml:

<TextBox Text="{Binding ElementName=UserControl, Path=TextBound, UpdateSourceTrigger=PropertyChanged, Mode=TwoWay}"/>

MyView.xaml:

<lostBindingSample:MyCustomInput x:Name="MyCustomInput" VMBound="{Binding TheValue, Mode=OneWay}"/>

@amine2050
Copy link
Author

Thanks a lot for the working example! I see now what you mean by using 2 DPs. You propagate the OneWay binding manually into the "internal" DP, I first thought you meant the other way around, which would cause the same issue. Got that!

I consider this proposition an alternative workaround for the problem.

The requirement is having one single exposed DP that can be bound either OneWay or TwoWay (sorry didn't mention the latter explicitly) depending on the consumer of the control, just like the WPF TextBox.Text property.

I am more concerned about understanding the theoretical root cause of why the OneWay binding breaks and if it is a documented expected behavior, than finding a workaround for my current problem.

@amine2050
Copy link
Author

A very interesting point about TextBox when binding Text DP using OneWay binding:

When you edit the text via TextBox UI, the property Text gets instantly updated without breaking the binding. However, when you try setting the TextBox.Text value via code, the same "broken binding" behavior occurs.

This is what I called previously "some special magic". I'll try to dig into TextBox code to find out how that works.

@h3xds1nz
Copy link
Contributor

h3xds1nz commented Oct 8, 2024

Don't dig into TextBox but dig into BindingExpression documentation because setting the value explicitly breaks this.

And possibly into understanding the differences between SetValue and SetCurrentValue (and why do they exist).

Same as what happens when you attempt to have a single DependencyProperty operate in two exclusive modes.

And one more EDIT: It breaks because the expression tries to assign a value to DependencyProperty in OneWay mode, which fails, and therefore it detaches.

@amine2050
Copy link
Author

amine2050 commented Oct 8, 2024

Thanks for bringing up SetCurrentValue(), I just dug into TextBox code and found out it is internally using SetCurrentValue() (actually an internal SetCurrentDeferredValue()).

To summarize (correct me if I am wrong):

  • SetValue() would break/replace existing OneWay binding on the DP, but keeps existing TwoWay binding (therefore the confusion) and just propagates the new value.
  • SetCurrentValue() does not break existing bindings, it just alters the DP value and let the eventual TwoWay binding propagate back to source.
  • Theory (please confirm): Binding back to a DP uses internally SetValue(), which would break eventual existing OneWay binding on the DP.

That explains that this Binding chain has a "design flow" (as you mentioned):
TextBox.Text <-> MyCustomInput.Value <- TheValue

So, either this:
TextBox.Text <- MyCustomInput.Value <- TheValue

or this:
TextBox.Text <-> MyCustomInput.Value <-> TheValue

If all of this is correct, this introduces new rules to write custom Control/UserControl that does not accidentally break eventual OneWay bindings when used:

  • Do not use SetValue() to set a property of an "exposed" DP, use SetCurrentValue() instead
  • Do not internally use TwoWay bindings to update the value of your DP, because that might break eventual existing OneWay binding.

Unfortunately, these rules, despite being rational, seems to be unnatural and would complicate writing custom controls and bloat the code.

Alternatively, we do not introduce any new rules. Instead of that we make our WPF developers aware, that OneWay bindings are "easy to break" and should be used with caution only with "pure input" DPs.

Actually, I barely use OneWay bindings on properties that might change from inside the control, OneWay bindings are normally for "read only" information that you feed your control with.

@h3xds1nz
Copy link
Contributor

h3xds1nz commented Oct 8, 2024

What a nice summary you've made, this would be pretty much a good answer to someone. Unluckily I don't have that gift, haha.

Yeah, based on your models, there's also OneWayToSource that does "->" as you surely know (which has also its funny but expected quirk, yet may cause confusion for some from my experience: if you have a default value on ur POCO, then while it globs on that value, it will be replaced by whatever the DP has right after init, so there's no way to ever retrieve that default value on this specific binding without replacing it).

SetValue indeed works that way, the TwoWay binding will survive, which may cause confusion.

I do not agree with the "new rules", you should be aware how binding expressions work and what may break it.

Imho what should be said is; use SetCurrentValue if you're touching any DP (exposed) externally (unless you intend to replace/update binding), and preferrably use SetValue internally if value update is what you're after.

The main takeaway is, don't expect one DP to work in two exclusive modes as it was expected in this case. Binding between DPs is not the same as binding between POCO property and a DP:

@himgoyalmicro himgoyalmicro added the 📭 waiting-author-feedback To request more information from author. label Oct 9, 2024
@amine2050
Copy link
Author

I would also say, the awareness of the "breakability" of OneWay bindings is the way to go. Although it is a delicate topic and not easy to fully explain to newbies.

I like always to have clear best practices to "enforce" within my team to avoid such "silent" problems. But I think this is not the case.

To close up our discussion:

WPF bindings have the following behaviors that should be known by developers

  • TwoWay binding may break a OneWay binding
  • SetProperty() would detach OneWay binding but not TwoWay binding

Although these behaviors are somehow confusing and can theoretically be improved, I see nothing that may be touched here in the deepest WPF codes. We do not want to break half the world again 🤣

Nevertheless, I suggest adding a binding Error/Warning that can be seen in Output and in the "XAML Binding Failures" window in case a OneWay binding has been broken/detached by a TwoWay binding update. Probably also when it gets detached by using SetProperty() (maybe just a Warning here).

Such an Error/Warning would be very helpful for every developer struggling with such broken bindings. Since we have no clear best practices to 100% eliminate the risk.

What do you think?

@dotnet-policy-service dotnet-policy-service bot removed the 📭 waiting-author-feedback To request more information from author. label Oct 9, 2024
@h3xds1nz
Copy link
Contributor

h3xds1nz commented Oct 9, 2024

The tracing could be theoretically improved as to reason why the detach has happened. Touching anything around that is a thin ice though, it still uses goto statements, haha.

Documentation could be improved as to what is by design, I agree.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants