Browse Source

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
pull/20862/head
Steven Kirk 2 weeks ago
committed by GitHub
parent
commit
0f2760afce
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 49
      src/Avalonia.Controls/Presenters/ContentPresenter.cs
  2. 50
      src/Avalonia.Controls/TabControl.cs
  3. 432
      tests/Avalonia.Controls.UnitTests/TabControlTests.cs

49
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();
/// <summary>
@ -420,6 +421,39 @@ namespace Avalonia.Controls.Presenters
/// </summary>
internal IContentPresenterHost? Host { get; private set; }
/// <summary>
/// Sets the <see cref="Content"/> and <see cref="StyledElement.DataContext"/> properties atomically,
/// ensuring that the content's DataContext is never temporarily set to an incorrect value.
/// </summary>
/// <param name="content">The new content.</param>
/// <param name="dataContext">The DataContext to set on the presenter.</param>
/// <remarks>
/// When <see cref="Content"/> is set to a <see cref="Control"/>, the presenter normally
/// clears its <see cref="StyledElement.DataContext"/> to allow the content to inherit it. This method
/// overrides that behavior, setting the <see cref="StyledElement.DataContext"/> 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.
/// </remarks>
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;
}
}
}
/// <inheritdoc/>
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;
}

50
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;

432
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<object?>();
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<IPageTransition>();
transition
.Setup(t => t.Start(
It.IsAny<Visual?>(), It.IsAny<Visual?>(),
It.IsAny<bool>(), It.IsAny<CancellationToken>()))
.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<object?>();
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<UserControl>((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<UserControl>((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)

Loading…
Cancel
Save