From 0f2760afce49fadefb0767b2cff6504a9ec77b79 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 10 Mar 2026 14:43:20 +0100 Subject: [PATCH] Fix `TabControl` `DataContext` issues when switching tabs (#20856) * Add failing tests for #18280 and #20845 Tests cover: - TabItem child DataContext binding not resolving (#20845) - DataContext binding not propagating to TabItem children (#20845) - DataContext binding not surviving tab switch round-trip (#20845) - UserControl content losing DataContext on tab switch (#18280) - Content temporarily getting wrong DataContext when switching tabs - Transition not applying new DataContext to old content Co-Authored-By: Claude Opus 4.6 * Fix TabControl DataContext issues (#18280, #20845) Add ContentPresenter.SetContentWithDataContext to atomically set Content and DataContext, preventing the intermediate state where setting Content to a Control clears DataContext and causes the content to briefly inherit the wrong DataContext from higher up the tree. TabControl.UpdateSelectedContent now uses this method, and the DataContext subscription no longer applies the new container's DataContext to the old content during page transitions. Co-Authored-By: Claude Opus 4.6 * Add tests for ContentTemplate with Control content DataContext When a TabItem has a ContentTemplate and its Content is a Control, the ContentPresenter should set DataContext to the content (so the template can bind to the control's properties), not the TabItem's DataContext. Co-Authored-By: Claude Opus 4.6 * Only use SetContentWithDataContext when no ContentTemplate is set When a ContentTemplate is present and content is a Control, the ContentPresenter should set DataContext = content so the template can bind to the control's properties. Only override DataContext with the container's DataContext when there's no template (i.e. the presenter displays the control directly). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../Presenters/ContentPresenter.cs | 49 +- src/Avalonia.Controls/TabControl.cs | 50 +- .../TabControlTests.cs | 432 ++++++++++++++++++ 3 files changed, 509 insertions(+), 22 deletions(-) diff --git a/src/Avalonia.Controls/Presenters/ContentPresenter.cs b/src/Avalonia.Controls/Presenters/ContentPresenter.cs index a5079f8344..183882ca50 100644 --- a/src/Avalonia.Controls/Presenters/ContentPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ContentPresenter.cs @@ -177,6 +177,7 @@ namespace Avalonia.Controls.Presenters private Control? _child; private bool _createdChild; private IRecyclingDataTemplate? _recyclingDataTemplate; + private (bool IsSet, object? Value) _overrideDataContext; private readonly BorderRenderHelper _borderRenderer = new BorderRenderHelper(); /// @@ -420,6 +421,39 @@ namespace Avalonia.Controls.Presenters /// internal IContentPresenterHost? Host { get; private set; } + /// + /// Sets the and properties atomically, + /// ensuring that the content's DataContext is never temporarily set to an incorrect value. + /// + /// The new content. + /// The DataContext to set on the presenter. + /// + /// When is set to a , the presenter normally + /// clears its to allow the content to inherit it. This method + /// overrides that behavior, setting the to the specified value + /// before updating the child, preventing any intermediate state where the content could + /// inherit an incorrect DataContext from higher up the tree. + /// + internal void SetContentWithDataContext(object? content, object? dataContext) + { + _overrideDataContext = (true, dataContext); + + try + { + SetCurrentValue(ContentProperty, content); + } + finally + { + // If Content didn't change, UpdateChild wasn't called and the + // override wasn't consumed. Apply the DataContext directly. + if (_overrideDataContext.IsSet) + { + _overrideDataContext = default; + DataContext = dataContext; + } + } + } + /// public sealed override void ApplyTemplate() { @@ -484,8 +518,19 @@ namespace Avalonia.Controls.Presenters } } - // Set the DataContext if the data isn't a control. - if (contentTemplate is { } || !(content is Control)) + // Consume the override immediately so any reentrant/cascading calls + // to UpdateChild don't incorrectly apply the stale override. + var overrideDataContext = _overrideDataContext; + _overrideDataContext = default; + + // Set the DataContext: use the caller-provided override if set, + // otherwise set to content when a template is present or content + // isn't a control, or clear for template-less control content. + if (overrideDataContext.IsSet) + { + DataContext = overrideDataContext.Value; + } + else if (contentTemplate is { } || !(content is Control)) { DataContext = content; } diff --git a/src/Avalonia.Controls/TabControl.cs b/src/Avalonia.Controls/TabControl.cs index f0c624489f..67274247c2 100644 --- a/src/Avalonia.Controls/TabControl.cs +++ b/src/Avalonia.Controls/TabControl.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Avalonia.Animation; @@ -297,8 +296,6 @@ namespace Avalonia.Controls _selectedItemSubscriptions = new CompositeDisposable( container.GetObservable(ContentControl.ContentProperty).Subscribe(content => { - var contentElement = content as StyledElement; - var contentDataContext = contentElement?.DataContext; SelectedContent = content; if (isInitialFire && shouldTransition) @@ -306,11 +303,13 @@ namespace Avalonia.Controls var template = SelectContentTemplate(container.GetValue(ContentControl.ContentTemplateProperty)); SelectedContentTemplate = template; - _contentPresenter2!.Content = content; - _contentPresenter2.ContentTemplate = template; - _contentPresenter2.IsVisible = true; - if (contentElement is not null && contentElement.DataContext != contentDataContext) - _contentPresenter2.DataContext = contentDataContext; + _contentPresenter2!.ContentTemplate = template; + _contentPresenter2!.IsVisible = true; + + if (content is Control && template is null) + _contentPresenter2.SetContentWithDataContext(content, container.DataContext); + else + _contentPresenter2.Content = content; _pendingForward = forward; _shouldAnimate = true; @@ -320,18 +319,11 @@ namespace Avalonia.Controls { if (ContentPart != null) { - ContentPart.Content = content; - // When ContentPart displays a Control, it doesn't set its - // DataContext to that of the Control's. If the content doesn't - // set a DataContext it gets inherited from the TabControl. - // Work around this by setting ContentPart's DataContext to - // the content's original DataContext (inherited from container). - if (contentElement is not null && - contentElement.DataContext != contentDataContext) - { - Debug.Assert(!contentElement.IsSet(DataContextProperty)); - ContentPart.DataContext = contentDataContext; - } + var template = SelectContentTemplate(container.GetValue(ContentControl.ContentTemplateProperty)); + if (content is Control && template is null) + ContentPart.SetContentWithDataContext(content, container.DataContext); + else + ContentPart.Content = content; } } @@ -342,6 +334,24 @@ namespace Avalonia.Controls SelectedContentTemplate = SelectContentTemplate(v); if (ContentPart != null && !_shouldAnimate) ContentPart.ContentTemplate = _selectedContentTemplate; + }), + container.GetObservable(StyledElement.DataContextProperty).Subscribe(dc => + { + // During a transition, ContentPart holds the old tab's content + // and _contentPresenter2 holds the new tab's content. Only update + // the presenter that is showing this container's content. + // Only override DataContext when there's no ContentTemplate; + // with a template, the presenter's DataContext should be the + // content itself (so the template can bind to it). + if (_contentPresenter2 is { IsVisible: true }) + { + if (_contentPresenter2.Content is Control && _contentPresenter2.ContentTemplate is null) + _contentPresenter2.DataContext = dc; + } + else if (ContentPart is { Content: Control } && ContentPart.ContentTemplate is null) + { + ContentPart.DataContext = dc; + } })); IDataTemplate? SelectContentTemplate(IDataTemplate? containerTemplate) => containerTemplate ?? ContentTemplate; diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index 62c453a18b..8bad019c2b 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -1046,6 +1046,438 @@ namespace Avalonia.Controls.UnitTests assetLoader: new StandardAssetLoader())); } + [Fact] + public void Switching_Tab_Should_Preserve_DataContext_Binding_On_UserControl_Content() + { + // Issue #18280: When switching tabs, a UserControl inside a TabItem has its + // DataContext set to null, causing two-way bindings on child controls (like + // DataGrid.SelectedItem) to propagate null back to the view model. + // Verify that after switching away and back, the DataContext binding still + // resolves correctly. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new TabDataContextViewModel { SelectedItem = "Item1" }; + + // Create a UserControl with an explicit DataContext binding, + // matching the issue scenario. + var userControl = new UserControl + { + [~UserControl.DataContextProperty] = new Binding("SelectedItem"), + }; + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + Content = userControl, + }, + new TabItem + { + Header = "Tab2", + Content = "Other content", + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + // Verify initial state + Assert.Equal(0, target.SelectedIndex); + Assert.Equal("Item1", userControl.DataContext); + + // Switch to second tab and back + target.SelectedIndex = 1; + target.SelectedIndex = 0; + + // The UserControl's DataContext binding should still resolve correctly. + Assert.Equal("Item1", userControl.DataContext); + + // Verify the binding is still live by changing the source property. + viewModel.SelectedItem = "Item2"; + Assert.Equal("Item2", userControl.DataContext); + } + + [Fact] + public void TabItem_Child_DataContext_Binding_Should_Work() + { + // Issue #20845: When a DataContext binding is placed on the child of a TabItem, + // the DataContext is null. The binding hasn't resolved when the content's + // DataContext is captured in UpdateSelectedContent, so the captured value is null. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + + var tab1View = new UserControl(); + tab1View.Bind(UserControl.DataContextProperty, new Binding("Tab1")); + + // Add a child TextBlock that binds to a property on Tab1ViewModel. + var textBlock = new TextBlock(); + textBlock.Bind(TextBlock.TextProperty, new Binding("Name")); + tab1View.Content = textBlock; + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + Content = tab1View, + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + // The UserControl's DataContext should be the Tab1ViewModel. + Assert.Same(viewModel.Tab1, tab1View.DataContext); + + // The TextBlock should display the Name from Tab1ViewModel. + Assert.Equal("Tab 1 message here", textBlock.Text); + } + + [Fact] + public void TabItem_Child_With_DataContext_Binding_Should_Propagate_To_Children() + { + // Issue #20845 (comment): Putting the DataContext binding on the TabItem itself + // is also broken. The child should inherit the TabItem's DataContext. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + + var textBlock = new TextBlock(); + textBlock.Bind(TextBlock.TextProperty, new Binding("Name")); + var tab1View = new UserControl { Content = textBlock }; + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + Content = tab1View, + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + // The TabItem's DataContext should be the Tab1ViewModel. + var tabItem = (TabItem)target.Items[0]!; + Assert.Same(viewModel.Tab1, tabItem.DataContext); + + // The UserControl should inherit the TabItem's DataContext. + Assert.Same(viewModel.Tab1, tab1View.DataContext); + + // The TextBlock should display the Name from Tab1ViewModel. + Assert.Equal("Tab 1 message here", textBlock.Text); + } + + [Fact] + public void Switching_Tabs_Should_Not_Null_Out_DataContext_Bound_Properties() + { + // Issue #20845: DataContext binding should survive tab switches. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + + var tab1View = new UserControl(); + tab1View.Bind(UserControl.DataContextProperty, new Binding("Tab1")); + var textBlock = new TextBlock(); + textBlock.Bind(TextBlock.TextProperty, new Binding("Name")); + tab1View.Content = textBlock; + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + Content = tab1View, + }, + new TabItem + { + Header = "Tab2", + Content = "Other content", + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + Assert.Same(viewModel.Tab1, tab1View.DataContext); + Assert.Equal("Tab 1 message here", textBlock.Text); + + // Switch to tab 2 and back + target.SelectedIndex = 1; + target.SelectedIndex = 0; + + // DataContext binding should still be resolved correctly. + Assert.Same(viewModel.Tab1, tab1View.DataContext); + Assert.Equal("Tab 1 message here", textBlock.Text); + } + + [Fact] + public void Content_Should_Not_Temporarily_Get_Wrong_DataContext_When_Switching_Tabs() + { + // When ContentPart.Content is set, ContentPresenter.UpdateChild clears its + // DataContext before we can set it to the container's DataContext. This causes + // the content to briefly inherit TabControl's DataContext instead of TabItem's. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + + var tab1View = new UserControl(); + var tab2View = new UserControl(); + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + Content = tab1View, + }, + new TabItem + { + Header = "Tab2", + [~TabItem.DataContextProperty] = new Binding("Tab2"), + Content = tab2View, + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + Assert.Same(viewModel.Tab1, tab1View.DataContext); + + // Track all DataContext values the new content receives during the switch. + var dataContexts = new List(); + tab2View.PropertyChanged += (s, e) => + { + if (e.Property == StyledElement.DataContextProperty) + dataContexts.Add(e.NewValue); + }; + + target.SelectedIndex = 1; + + // tab2View should only have received the correct DataContext (Tab2ViewModel). + // It should NOT have temporarily received the TabControl's DataContext (MainViewModel). + Assert.All(dataContexts, dc => Assert.Same(viewModel.Tab2, dc)); + Assert.Same(viewModel.Tab2, tab2View.DataContext); + } + + [Fact] + public void Transition_Should_Not_Apply_New_DataContext_To_Old_Content() + { + // When a PageTransition is set, the old content stays in ContentPart while the + // new content goes into _contentPresenter2. The DataContext subscription for the + // new container should not update ContentPart's DataContext (which still holds + // the old content). + using var app = Start(); + + var viewModel = new MainViewModel(); + + var tab1View = new UserControl(); + var tab2View = new UserControl(); + + var transition = new Mock(); + transition + .Setup(t => t.Start( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var target = new TabControl + { + PageTransition = transition.Object, + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + Content = tab1View, + }, + new TabItem + { + Header = "Tab2", + [~TabItem.DataContextProperty] = new Binding("Tab2"), + Content = tab2View, + }, + }, + }; + + var root = CreateRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + Assert.Same(viewModel.Tab1, tab1View.DataContext); + + // Track all DataContext values the OLD content receives during the transition. + var oldContentDataContexts = new List(); + tab1View.PropertyChanged += (s, e) => + { + if (e.Property == StyledElement.DataContextProperty) + oldContentDataContexts.Add(e.NewValue); + }; + + // Switch tab — triggers transition + target.SelectedIndex = 1; + root.LayoutManager.ExecuteLayoutPass(); + + // The old content (tab1View) should NOT have received Tab2's DataContext. + Assert.DoesNotContain(viewModel.Tab2, oldContentDataContexts); + } + + [Fact] + public void ContentTemplate_With_Control_Content_Should_Set_DataContext_To_Content() + { + // When a TabItem has a ContentTemplate and its Content is a Control, the + // ContentPresenter should set DataContext = content (so the template can bind + // to the control's properties), not the TabItem's DataContext. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + var userControl = new UserControl { Tag = "my-content" }; + + TextBlock? templateChild = null; + var contentTemplate = new FuncDataTemplate((x, _) => + { + templateChild = new TextBlock(); + templateChild.Bind(TextBlock.TextProperty, new Binding("Tag")); + return templateChild; + }); + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + ContentTemplate = contentTemplate, + Content = userControl, + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + // The ContentPresenter's DataContext should be the content (UserControl), + // not the TabItem's DataContext (Tab1ViewModel), because ContentTemplate is set. + Assert.Same(userControl, target.ContentPart!.DataContext); + Assert.NotNull(templateChild); + Assert.Equal("my-content", templateChild!.Text); + } + + [Fact] + public void ContentTemplate_With_Control_Content_Should_Set_DataContext_To_Content_After_Tab_Switch() + { + // Same as above but verifies the behavior after switching tabs. + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var viewModel = new MainViewModel(); + var userControl = new UserControl { Tag = "my-content" }; + + TextBlock? templateChild = null; + var contentTemplate = new FuncDataTemplate((x, _) => + { + templateChild = new TextBlock(); + templateChild.Bind(TextBlock.TextProperty, new Binding("Tag")); + return templateChild; + }); + + var target = new TabControl + { + Template = TabControlTemplate(), + DataContext = viewModel, + Items = + { + new TabItem + { + Header = "Tab1", + [~TabItem.DataContextProperty] = new Binding("Tab1"), + ContentTemplate = contentTemplate, + Content = userControl, + }, + new TabItem + { + Header = "Tab2", + Content = "Other content", + }, + }, + }; + + var root = new TestRoot(target); + Prepare(target); + + Assert.Same(userControl, target.ContentPart!.DataContext); + + // Switch away and back. + target.SelectedIndex = 1; + target.SelectedIndex = 0; + + // DataContext should still be the content, not the TabItem's DataContext. + Assert.Same(userControl, target.ContentPart!.DataContext); + Assert.NotNull(templateChild); + Assert.Equal("my-content", templateChild!.Text); + } + + private class TabDataContextViewModel : NotifyingBase + { + private string? _selectedItem; + + public string? SelectedItem + { + get => _selectedItem; + set => SetField(ref _selectedItem, value); + } + } + + private class MainViewModel + { + public Tab1ViewModel Tab1 { get; set; } = new(); + public Tab2ViewModel Tab2 { get; set; } = new(); + } + + private class Tab1ViewModel + { + public string Name { get; set; } = "Tab 1 message here"; + } + + private class Tab2ViewModel + { + public string Name { get; set; } = "Tab 2 message here"; + } + private class Item { public Item(string value)