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(); + } +}