diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/TabItem.cs b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/TabItem.cs
index 2e3effeafc8..ba40c7f7e92 100644
--- a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/TabItem.cs
+++ b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/TabItem.cs
@@ -330,7 +330,11 @@ protected override void OnPreviewGotKeyboardFocus(KeyboardFocusChangedEventArgs
}
}
- if (!e.Handled && GetBoolField(BoolField.SetFocusOnContent))
+ // Guard against re-entrancy: MoveFocus below can trigger a focus-changed
+ // cascade (e.g. a GotKeyboardFocus handler redirecting focus back to this
+ // TabItem), which would re-enter OnPreviewGotKeyboardFocus and overflow the stack.
+ if (!e.Handled && GetBoolField(BoolField.SetFocusOnContent)
+ && !GetBoolField(BoolField.MovingFocusToContent))
{
TabControl parentTabControl = TabControlParent;
if (parentTabControl != null)
@@ -341,26 +345,34 @@ protected override void OnPreviewGotKeyboardFocus(KeyboardFocusChangedEventArgs
if (selectedContentPresenter != null)
{
parentTabControl.UpdateLayout(); // Wait for layout
- bool success = selectedContentPresenter.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
-
- // If we successfully move focus inside the content then don't set focus to the header
- if (success)
+ SetBoolField(BoolField.MovingFocusToContent, true);
+ try
{
- e.Handled = true;
+ bool success = selectedContentPresenter.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
- // However, if the focus got switched to a different focus scope,
- // mark the header as the one last focused in its focus scope. #8293
- if (Keyboard.FocusedElement is DependencyObject focusedElement)
+ // If we successfully move focus inside the content then don't set focus to the header
+ if (success)
{
- DependencyObject thisFocusScope = FocusManager.GetFocusScope(this);
- if (thisFocusScope != null && Keyboard.FocusedElement is DependencyObject currentFocus)
+ e.Handled = true;
+
+ // However, if the focus got switched to a different focus scope,
+ // mark the header as the one last focused in its focus scope. #8293
+ if (Keyboard.FocusedElement is DependencyObject focusedElement)
{
- DependencyObject currentFocusScope = FocusManager.GetFocusScope(currentFocus);
- if (currentFocusScope != thisFocusScope && thisFocusScope != null)
- FocusManager.SetFocusedElement(thisFocusScope, this);
+ DependencyObject thisFocusScope = FocusManager.GetFocusScope(this);
+ if (thisFocusScope != null && Keyboard.FocusedElement is DependencyObject currentFocus)
+ {
+ DependencyObject currentFocusScope = FocusManager.GetFocusScope(currentFocus);
+ if (currentFocusScope != thisFocusScope && thisFocusScope != null)
+ FocusManager.SetFocusedElement(thisFocusScope, this);
+ }
}
}
}
+ finally
+ {
+ SetBoolField(BoolField.MovingFocusToContent, false);
+ }
}
}
}
@@ -531,6 +543,7 @@ private enum BoolField
{
SetFocusOnContent = 0x10, // This flag determine if we want to set focus on active TabItem content
SettingFocus = 0x20, // This flag indicates that the TabItem is in the process of setting focus
+ MovingFocusToContent = 0x40, // Re-entrancy guard for MoveFocus in OnPreviewGotKeyboardFocus
// By default ListBoxItem is selectable
DefaultValue = 0,
diff --git a/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/Controls/TabItemTests.cs b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/Controls/TabItemTests.cs
new file mode 100644
index 00000000000..25c03a5a887
--- /dev/null
+++ b/src/Microsoft.DotNet.Wpf/tests/UnitTests/PresentationFramework.Tests/Controls/TabItemTests.cs
@@ -0,0 +1,90 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Threading;
+
+namespace Wpf.UnitTests.Controls;
+
+///
+/// Unit tests for class.
+///
+public sealed class TabItemTests
+{
+ ///
+ /// Verifies that TabItem.OnPreviewGotKeyboardFocus does not cause a
+ /// StackOverflowException when a GotKeyboardFocus handler on an ancestor
+ /// redirects keyboard focus back to the TabItem after MoveFocus has moved
+ /// focus into the tab's content.
+ ///
+ /// The cycle is:
+ /// 1. TabItem.OnPreviewGotKeyboardFocus → MoveFocus(content) → content gets focus
+ /// 2. GotKeyboardFocus on content bubbles → ancestor handler → Keyboard.Focus(tabItem)
+ /// 3. TryChangeFocus → PreviewGotKeyboardFocus → back to step 1
+ ///
+ /// The MovingFocusToContent re-entrancy guard breaks this cycle by skipping
+ /// MoveFocus when OnPreviewGotKeyboardFocus is already in progress.
+ ///
+ [WpfFact]
+ public void OnPreviewGotKeyboardFocus_NoStackOverflow_WhenGotKeyboardFocusRedirectsFocusToTabItem()
+ {
+ // Arrange: Window > TabControl with 2 tabs, each containing a focusable TextBox
+ Window window = new Window { Width = 400, Height = 300 };
+ TabControl tabControl = new TabControl();
+
+ TextBox textBox1 = new TextBox { Text = "Content1" };
+ TabItem tabItem1 = new TabItem { Header = "Tab1", Content = textBox1 };
+
+ TextBox textBox2 = new TextBox { Text = "Content2" };
+ TabItem tabItem2 = new TabItem { Header = "Tab2", Content = textBox2 };
+
+ tabControl.Items.Add(tabItem1);
+ tabControl.Items.Add(tabItem2);
+ window.Content = tabControl;
+ window.Show();
+
+ // Select Tab1 and put keyboard focus inside the TabControl
+ // (IsKeyboardFocusWithin must be true for TabControl.OnSelectionChanged to call SetFocus)
+ tabItem1.IsSelected = true;
+ tabControl.UpdateLayout();
+ textBox1.Focus();
+ Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Background);
+
+ int focusRedirectCount = 0;
+ const int safetyLimit = 20;
+
+ // Simulate an ancestor GotKeyboardFocus handler that redirects focus back
+ // to the TabItem whenever content receives focus. This pattern occurs when
+ // a hosting container tries to keep focus on the tab header element.
+ // Without the re-entrancy guard, this causes infinite recursion.
+ tabControl.AddHandler(Keyboard.GotKeyboardFocusEvent, new KeyboardFocusChangedEventHandler((_, e) =>
+ {
+ if (e.NewFocus == textBox2 && focusRedirectCount < safetyLimit)
+ {
+ focusRedirectCount++;
+ Keyboard.Focus(tabItem2);
+ }
+ }));
+
+ // Act: Switch to Tab2. This triggers:
+ // TabControl.OnSelectionChanged → TabItem2.SetFocus() (SetFocusOnContent=true)
+ // → Focus() → TryChangeFocus → PreviewGotKeyboardFocus
+ // → TabItem.OnPreviewGotKeyboardFocus → MoveFocus(content) → textBox2 gets focus
+ // → GotKeyboardFocus(textBox2) bubbles → our handler → Keyboard.Focus(tabItem2)
+ // → TryChangeFocus → PreviewGotKeyboardFocus → TabItem.OnPreviewGotKeyboardFocus
+ //
+ // Without the re-entrancy guard, this recurses until stack overflow.
+ // With the guard, the second entry skips MoveFocus and the cycle stops.
+ tabItem2.IsSelected = true;
+ Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Background);
+
+ // Assert: focus redirect should fire only a small number of times (not hundreds)
+ Assert.True(focusRedirectCount <= 5,
+ $"Focus was redirected {focusRedirectCount} times, suggesting the re-entrancy guard is not working. " +
+ "Expected ≤ 5 redirects.");
+
+ window.Close();
+ }
+}