[Skia] ContentPresenter: outgoing DataTemplate tree stays alive and rebinds when Content changes, letting its TwoWay bindings corrupt the new content's view model
Current behavior
When a ContentPresenter with a ContentTemplateSelector gets a new Content value, the previous template's visual tree is not unloaded/unbound. The old tree stays live, rebinds to the new content (both our templates share the same x:DataType), and its TwoWay bindings keep firing against the new content's objects.
The most vivid consequence in our app: a leaked RadioButton tree (from a template previously shown for a single-select model) rebinds to a multi-select model's option objects. From then on, the visible CheckBox field behaves like a radio group:
- User checks a CheckBox → TwoWay binding sets
Option.IsSelected = true. - The invisible leaked RadioButton bound to the same object gets checked via its own TwoWay binding.
RadioButton.UpdateRadioButtonGroup()unchecks the previously checked radio in the (shared GroupName) group.- That radio's TwoWay binding writes
IsSelected = falseinto the previously checked option → its CheckBox unchecks.
We captured this exact cycle with a stack trace in the option setter (excerpt below). As more content swaps accumulate, more stale trees pile up and the symptoms get stranger: checking a box gets instantly reverted (two leaked radios for the same option in one group cancel each other), selections get wiped when navigating away and back, and clicks appear to go dead. It looks random to the user, but the randomness is just how many stale trees have accumulated.
Expected behavior
Changing Content should detach the outgoing tree's bindings so it can never write to (or rebind against) any view model afterwards. This is the WinAppSDK behavior: the identical setup runs clean on WinUI.
How to reproduce it (as minimally and precisely as possible)
Single page, no packages beyond a blank Uno app (repro project attached - UnoBindingRepro.zip):
<Page.Resources>
<local:PickerTemplateSelector x:Key="PickerTemplateSelector">
<local:PickerTemplateSelector.RadioTemplate>
<DataTemplate x:DataType="local:PickerModel">
<ItemsControl ItemsSource="{x:Bind Options}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="local:Option">
<RadioButton Content="{x:Bind Label}" IsChecked="{x:Bind IsSelected, Mode=TwoWay}" GroupName="SharedGroup" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DataTemplate>
</local:PickerTemplateSelector.RadioTemplate>
<local:PickerTemplateSelector.CheckBoxTemplate>
<DataTemplate x:DataType="local:PickerModel">
<ItemsControl ItemsSource="{x:Bind Options}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="local:Option">
<CheckBox Content="{x:Bind Label}" IsChecked="{x:Bind IsSelected, Mode=TwoWay}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DataTemplate>
</local:PickerTemplateSelector.CheckBoxTemplate>
</local:PickerTemplateSelector>
</Page.Resources>
<StackPanel Padding="20" Spacing="12">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Show radio field" Click="OnShowRadio" />
<Button Content="Show checkbox field" Click="OnShowCheckBox" />
</StackPanel>
<ContentPresenter x:Name="Presenter" ContentTemplateSelector="{StaticResource PickerTemplateSelector}" />
<TextBlock x:Name="StateText" FontFamily="Consolas" />
</StackPanel>public sealed partial class MainPage : Page
{
private readonly PickerModel _radioField = new(isMultiSelect: false, "Radio", ["Small", "Medium", "Large"]);
private readonly PickerModel _checkBoxField = new(isMultiSelect: true, "CheckBox", ["Option A", "Option B", "Option C", "Option D"]);
public MainPage()
{
InitializeComponent();
foreach (var option in _radioField.Options.Concat(_checkBoxField.Options))
option.PropertyChanged += (_, _) => UpdateStateText();
Presenter.Content = _radioField;
UpdateStateText();
}
private void OnShowRadio(object sender, RoutedEventArgs e) => Presenter.Content = _radioField;
private void OnShowCheckBox(object sender, RoutedEventArgs e) => Presenter.Content = _checkBoxField;
private void UpdateStateText() => StateText.Text = $"{_radioField}\n{_checkBoxField}";
}
public class PickerModel(bool isMultiSelect, string name, string[] optionLabels)
{
public bool IsMultiSelect { get; } = isMultiSelect;
public string Name { get; } = name;
public ObservableCollection<Option> Options { get; } = [.. optionLabels.Select(l => new Option(l))];
public override string ToString() =>
$"{Name}: [{string.Join(", ", Options.Where(o => o.IsSelected).Select(o => o.Label))}]";
}
public class Option(string label) : INotifyPropertyChanged
{
private bool _isSelected;
public event PropertyChangedEventHandler? PropertyChanged;
public string Label { get; } = label;
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected == value)
return;
_isSelected = value;
PropertyChanged?.Invoke(this, new(nameof(IsSelected)));
}
}
}
public partial class PickerTemplateSelector : DataTemplateSelector
{
public DataTemplate? RadioTemplate { get; set; }
public DataTemplate? CheckBoxTemplate { get; set; }
protected override DataTemplate? SelectTemplateCore(object? item) => SelectTemplateCore(item, null!);
protected override DataTemplate? SelectTemplateCore(object? item, DependencyObject container) =>
item is PickerModel model ? (model.IsMultiSelect ? CheckBoxTemplate : RadioTemplate) : null;
}- Run the desktop head. Select a radio option.
- Click "Show checkbox field" and check some boxes.
- Checking a box unchecks the previously checked one (radio-group behavior on checkboxes), and the state line shows the false writes reaching the model. Swap back and forth a few times for the wilder variants (self-cancelling clicks, wiped state).
Workaround ️
Nevermind - the workaround below did not completely solve the issue in the real app...there is still something that messes up radio button groups after the template reloads. The only "workaround" that actually works is reworking things to use a RadioButtons control instead. Even binding each RadioButton.GroupName to a unique group name for a particular field selection did not fix the issue (and seemingly made it even worse).
Never let the presenter rebind an existing tree: drop the Content binding / selector from XAML and apply both from code-behind, nulling Content first so the outgoing tree's bindings suspend instead of reacting to the change, then forcing a fresh template materialization:
private void ApplyContent(object? newContent)
{
Presenter.Content = null;
Presenter.ContentTemplate = null;
if (newContent is not null)
{
Presenter.ContentTemplate = _selector.SelectTemplate(newContent, Presenter);
Presenter.Content = newContent;
}
}This eliminates the corruption completely with all the TwoWay bindings kept as-is.
Renderer
- Skia
- Native
Affected platforms ️
All
Uno.Sdk version (and other relevant versions)
Uno.Sdk 6.7.18 and 6.7.0-dev.165
IDE version
No response
Anything else we need to know?
Stack trace captured in the real app at the moment a corrupting write landed, showing the full cycle - CheckBox click writes true to the model, a leaked SingleChoiceTemplate RadioButton binding picks it up, and UpdateRadioButtonGroup pushes false back through x:Bind into a different option (trimmed to the relevant frames):
at ChoiceOption.set_IsSelected(Boolean value) // false written into previously checked option
at ...FieldInputControl...SinChoTemΞ0...RadBut...TrySetInstance_xBind_21(ChoiceOption ___tctx, Object __value)
at Microsoft.UI.Xaml.Data.BindingExpression.UpdateSource(Object value)
at Microsoft.UI.Xaml.Controls.Primitives.ToggleButton.set_IsChecked(Nullable`1 value)
at Microsoft.UI.Xaml.Controls.RadioButton.UpdateRadioButtonGroup() // radio group semantics kick in
at Microsoft.UI.Xaml.Controls.RadioButton.OnChecked()
at Microsoft.UI.Xaml.Controls.Primitives.ToggleButton.OnIsCheckedChanged()
at Microsoft.UI.Xaml.Data.BindingExpression.SetTargetValueForXBindSelector() // leaked radio checked via binding
at Uno.UI.DataBinding.BindingPath.BindingItem.PropertyChangedValueHandler.NewValue()
at ChoiceOption.set_IsSelected(Boolean value) // true written by the user's CheckBox click
at ...FieldInputControl...MulChoTemΞ0...CheBox...TrySetInstance_xBind_24(ChoiceOption ___tctx, Object __value)
at Microsoft.UI.Xaml.Data.BindingExpression.UpdateSource(Object value)Note the two template origins in the generated-code frames: the true write comes from the CheckBox template currently on screen, the false write from a RadioButton template that was replaced earlier and should no longer exist.
- The radio group makes the leak loudly visible, but the underlying problem is general: any TwoWay binding in the stale tree stays attached, so state can be silently written into whichever model the presenter shows next.
- WinAppSDK behavior difference confirmed side by side in the same app: with the workaround removed, Uno corrupts and WinUI runs clean.
- This feels related to the reasons why FrameworkTemplatePool is currently hard-disabled, but it's reachable with pooling off, through plain ContentPresenter content changes.
Source: unoplatform/uno