diff --git a/src/System.Windows.Forms/PublicAPI.Unshipped.txt b/src/System.Windows.Forms/PublicAPI.Unshipped.txt index 9a731e8d912..fc8c764c81a 100644 --- a/src/System.Windows.Forms/PublicAPI.Unshipped.txt +++ b/src/System.Windows.Forms/PublicAPI.Unshipped.txt @@ -22,9 +22,13 @@ System.Windows.Forms.SystemColorMode.Classic = 0 -> System.Windows.Forms.SystemC System.Windows.Forms.SystemColorMode.Dark = 2 -> System.Windows.Forms.SystemColorMode System.Windows.Forms.SystemColorMode.System = 1 -> System.Windows.Forms.SystemColorMode System.Windows.Forms.VisualStyles.ComboBoxState.Focused = 5 -> System.Windows.Forms.VisualStyles.ComboBoxState -static System.Windows.Forms.BindingContext.RewireRelatedCurrencyManagerParent(System.Windows.Forms.BindingManagerBase! bindingManagerBase) -> void virtual System.Windows.Forms.BindingContext.OnListManagerAdded(System.Windows.Forms.BindingManagerBase! bindingManagerBase) -> void virtual System.Windows.Forms.Form.OnFormBorderColorChanged(System.EventArgs! e) -> void virtual System.Windows.Forms.Form.OnFormCaptionBackColorChanged(System.EventArgs! e) -> void virtual System.Windows.Forms.Form.OnFormCaptionTextColorChanged(System.EventArgs! e) -> void virtual System.Windows.Forms.Form.OnFormCornerPreferenceChanged(System.EventArgs! e) -> void +System.Windows.Forms.BindingContext.IInterceptedRelatedManager +System.Windows.Forms.BindingContext.IInterceptedRelatedManager.BindToEmptyParentPlaceholder(System.Collections.IList! placeholder) -> void +System.Windows.Forms.BindingContext.IInterceptedRelatedManager.ParentHasRows.get -> bool +System.Windows.Forms.BindingContext.IInterceptedRelatedManager.RaiseParentCurrentItemChanged(object? sender, System.EventArgs! e) -> void +virtual System.Windows.Forms.BindingContext.CreateInterceptedParentHandler(System.Windows.Forms.BindingContext.IInterceptedRelatedManager! relatedManager) -> System.EventHandler? diff --git a/src/System.Windows.Forms/System/Windows/Forms/DataBinding/BindingContext.cs b/src/System.Windows.Forms/System/Windows/Forms/DataBinding/BindingContext.cs index a3a04cafd24..a59d2d73aa2 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/DataBinding/BindingContext.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/DataBinding/BindingContext.cs @@ -267,7 +267,7 @@ private BindingManagerBase EnsureListManager(object dataSource, string? dataMemb ?? throw new ArgumentException(string.Format(SR.RelatedListManagerChild, dataField)); bindingManagerBase = typeof(IList).IsAssignableFrom(prop.PropertyType) - ? new RelatedCurrencyManager(formerManager, dataField) + ? CreateRelatedCurrencyManager(formerManager, dataField) : new RelatedPropertyManager(formerManager, dataField); } @@ -288,6 +288,74 @@ private BindingManagerBase EnsureListManager(object dataSource, string? dataMemb return bindingManagerBase; } + /// + /// Opt-in interface that exposes the members of an internal + /// that a custom handler supplied via needs to drive + /// the child manager without reflection. This is the public surface for .NET Framework-style + /// interception from a derived in another assembly. + /// + public interface IInterceptedRelatedManager + { + /// + /// Gets a value indicating whether the parent currency manager currently has any rows. + /// + bool ParentHasRows { get; } + + /// + /// Invokes the default ParentManager_CurrentItemChanged handler on the related manager. + /// Use this when the parent has rows so the child refreshes its list normally. + /// + void RaiseParentCurrentItemChanged(object? sender, EventArgs e); + + /// + /// Binds the related manager to the supplied list and raises + /// position/current notifications — without entering the Everett AddNew() / + /// CancelCurrentEdit() app-compat branch. Use this when the parent has no rows. + /// + void BindToEmptyParentPlaceholder(IList placeholder); + } + + /// + /// Optional, opt-in factory that a derived can override to supply a + /// custom handler for a newly-created . When a non- + /// handler is returned it is subscribed to the parent's CurrentChanged event (instead of the + /// default CurrentItemChanged subscription), and is used for the initial prime during construction — + /// reproducing the .NET Framework parent-change interception behaviour exactly. + /// + /// + /// + /// The base implementation returns , leaving stock WinForms behaviour completely + /// unchanged — no extra allocations occur on the base path. + /// + /// + /// When a non- handler is returned, the empty-parent Everett + /// AddNew() / CancelCurrentEdit() app-compat branch in + /// ParentManager_CurrentItemChanged is bypassed; the handler is responsible for calling + /// when the parent has no rows. + /// + /// + /// + /// The newly-created related manager, exposed through so the + /// returned handler can drive it without reflection. + /// + /// + /// A custom event handler, or to use the default behaviour. + /// + protected virtual EventHandler? CreateInterceptedParentHandler(IInterceptedRelatedManager relatedManager) + => null; + + private RelatedCurrencyManager CreateRelatedCurrencyManager(BindingManagerBase parentManager, string dataField) + { + // Only a derived BindingContext can supply an intercepted handler; the base class must remain identical to + // stock WinForms, so it does not even allocate the factory delegate. + Func? interceptedParentHandlerFactory = + GetType() == typeof(BindingContext) + ? null + : relatedCurrencyManager => CreateInterceptedParentHandler(relatedCurrencyManager); + + return new RelatedCurrencyManager(parentManager, dataField, interceptedParentHandlerFactory); + } + /// /// Invoked from immediately after a newly-created /// has been inserted into the internal collection. The base implementation @@ -304,22 +372,6 @@ protected virtual void OnListManagerAdded(BindingManagerBase bindingManagerBase) { } - /// - /// Helper for subclasses overriding : if the supplied manager is a - /// child currency manager, detaches its parent's - /// subscription and re-attaches it to , then primes the child - /// once with the parent's current state. No-op for managers that are not - /// instances. Exposed because is internal and cannot be referenced from - /// subclasses in other assemblies. - /// - protected static void RewireRelatedCurrencyManagerParent(BindingManagerBase bindingManagerBase) - { - if (bindingManagerBase is RelatedCurrencyManager relatedCurrencyManager) - { - relatedCurrencyManager.RewireParentChangeHandler(); - } - } - private static void CheckPropertyBindingCycles(BindingContext newBindingContext, Binding propBinding) { Debug.Assert(newBindingContext is not null, "Always called with a non-null BindingContext"); diff --git a/src/System.Windows.Forms/System/Windows/Forms/DataBinding/RelatedCurrencyManager.cs b/src/System.Windows.Forms/System/Windows/Forms/DataBinding/RelatedCurrencyManager.cs index ffc115dfcb5..197ede4f468 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/DataBinding/RelatedCurrencyManager.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/DataBinding/RelatedCurrencyManager.cs @@ -10,15 +10,27 @@ namespace System.Windows.Forms; /// Represents the child version of the System.Windows.Forms.ListManager /// that is used when a parent/child relationship exists in a System.Windows.Forms.DataSet. /// -internal class RelatedCurrencyManager : CurrencyManager +internal class RelatedCurrencyManager : CurrencyManager, BindingContext.IInterceptedRelatedManager { private BindingManagerBase _parentManager; private PropertyDescriptor _fieldInfo; + private readonly EventHandler? _interceptedParentHandler; private static List IgnoreItemChangedTable { get; } = []; internal RelatedCurrencyManager(BindingManagerBase parentManager, string dataField) + : this(parentManager, dataField, interceptedParentHandlerFactory: null) + { + } + + internal RelatedCurrencyManager( + BindingManagerBase parentManager, + string dataField, + Func? interceptedParentHandlerFactory) : base(dataSource: null) { + // The factory receives the fully-constructed child so a handler can close over it, mirroring + // `new ParentCurrentChangedHandler(cm)` on .NET Framework. It is built before Bind() primes. + _interceptedParentHandler = interceptedParentHandlerFactory?.Invoke(this); Bind(parentManager, dataField); } @@ -43,14 +55,30 @@ internal void Bind(BindingManagerBase parentManager, string dataField) // Wire new BindingManagerBase WireParentManager(_parentManager); - ParentManager_CurrentItemChanged(parentManager, EventArgs.Empty); + if (_interceptedParentHandler is null) + { + // DEFAULT PATH - identical to current behaviour. + ParentManager_CurrentItemChanged(parentManager, EventArgs.Empty); + } + else + { + // OPT-IN PATH - prime through the supplied handler (Framework parity); avoids constructor-time AddNew(). + _interceptedParentHandler(parentManager, EventArgs.Empty); + } } private void UnwireParentManager(BindingManagerBase? bmb) { if (bmb is not null) { - bmb.CurrentItemChanged -= ParentManager_CurrentItemChanged; + if (_interceptedParentHandler is not null && bmb is CurrencyManager interceptedParent) + { + interceptedParent.CurrentChanged -= _interceptedParentHandler; + } + else + { + bmb.CurrentItemChanged -= ParentManager_CurrentItemChanged; + } if (bmb is CurrencyManager currencyManager) { @@ -63,7 +91,15 @@ private void WireParentManager(BindingManagerBase bmb) { if (bmb is not null) { - bmb.CurrentItemChanged += ParentManager_CurrentItemChanged; + if (_interceptedParentHandler is not null && bmb is CurrencyManager interceptedParent) + { + // Framework parity: drive the child via the parent's CurrentChanged + the supplied handler. + interceptedParent.CurrentChanged += _interceptedParentHandler; + } + else + { + bmb.CurrentItemChanged += ParentManager_CurrentItemChanged; + } if (bmb is CurrencyManager currencyManager) { @@ -135,25 +171,6 @@ private void ParentManager_MetaDataChanged(object? sender, EventArgs e) OnMetaDataChanged(e); } - // WiseTech: RelatedCurrencyManager normally refreshes its child list whenever the parent manager raises - // CurrentItemChanged. In CargoWise that can be too broad: item-change notifications may be raised while - // bindings/business collections are already reacting to the same edit or AddNew flow, and refreshing the - // child list from that path can re-enter the same notification chain until the stack overflows. ZBindingContext - // avoided this on .NET Framework by intercepting BindingContextHashtable.Add, removing the default - // CurrentItemChanged subscription, and refreshing the child only when the parent's CurrentChanged event fires. - // BindingContext uses a Dictionary on .NET 10, so this helper gives subclasses the same event swap without - // reflecting over RelatedCurrencyManager internals. - internal void RewireParentChangeHandler() - { - if (_parentManager is CurrencyManager parentCurrencyManager) - { - parentCurrencyManager.CurrentItemChanged -= ParentManager_CurrentItemChanged; - parentCurrencyManager.CurrentChanged -= ParentManager_CurrentItemChanged; - parentCurrencyManager.CurrentChanged += ParentManager_CurrentItemChanged; - ParentManager_CurrentItemChanged(parentCurrencyManager, EventArgs.Empty); - } - } - private void ParentManager_CurrentItemChanged(object? sender, EventArgs e) { if (IgnoreItemChangedTable.Contains(_parentManager)) @@ -226,4 +243,23 @@ private void ParentManager_CurrentItemChanged(object? sender, EventArgs e) OnCurrentChanged(EventArgs.Empty); OnCurrentItemChanged(EventArgs.Empty); } + + // Explicit implementation of BindingContext.IInterceptedRelatedManager — + // exposes the members a custom handler in a derived BindingContext needs to drive this manager + // without reflection, mirroring the .NET Framework ParentCurrentChangedHandler targets. + + bool BindingContext.IInterceptedRelatedManager.ParentHasRows + => _parentManager is CurrencyManager currencyManager && currencyManager.Count > 0; + + void BindingContext.IInterceptedRelatedManager.RaiseParentCurrentItemChanged(object? sender, EventArgs e) + => ParentManager_CurrentItemChanged(sender, e); + + void BindingContext.IInterceptedRelatedManager.BindToEmptyParentPlaceholder(IList placeholder) + { + SetDataSource(placeholder); + listposition = -1; + OnPositionChanged(EventArgs.Empty); + OnCurrentChanged(EventArgs.Empty); + OnCurrentItemChanged(EventArgs.Empty); + } } diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/BindingContextTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/BindingContextTests.cs index d38110dc4b0..1793762ba2e 100644 --- a/src/test/unit/System.Windows.Forms/System/Windows/Forms/BindingContextTests.cs +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/BindingContextTests.cs @@ -1017,6 +1017,92 @@ public void BindingContext_UpdateBinding_NullBinding_ThrowsArgumentNullException Assert.Throws("binding", () => BindingContext.UpdateBinding([], null)); } + [Fact] + public void BindingContext_DefaultPath_SubscribesCurrentItemChangedAndPrimesOnce() + { + // Default (base) BindingContext must subscribe via CurrentItemChanged and call + // ParentManager_CurrentItemChanged exactly once during RelatedCurrencyManager construction. + BindingContext context = []; + List parentList = [new() { Property = [1, 2, 3] }]; + + // Access the related manager — this triggers EnsureListManager → new RelatedCurrencyManager + CurrencyManager parentManager = (CurrencyManager)context[parentList]; + CurrencyManager relatedManager = (CurrencyManager)context[parentList, "Property"]; + + // With the default path, the related manager should have been primed with the current parent row's list + Assert.Equal(3, relatedManager.Count); + } + + [Fact] + public void BindingContext_InterceptedHandler_InvokedForInitialPrime_EmptyParent() + { + // A derived BindingContext returning a non-null handler must use it for the initial prime; + // the empty-parent Everett AddNew()/CancelCurrentEdit() branch must NOT be reached. + List emptyParentList = []; + List placeholder = []; + + InterceptingBindingContext context = new(placeholder); + CurrencyManager parentManager = (CurrencyManager)context[emptyParentList]; + + // Access the related manager — handler should be installed and used for priming + CurrencyManager relatedManager = (CurrencyManager)context[emptyParentList, "Property"]; + + // The intercepted handler should have been invoked (initial prime call count >= 1) + Assert.True(context.HandlerInvokeCount >= 1, "Intercepted handler was not invoked during construction."); + + // The placeholder was bound (not the Everett AddNew path), so data source == placeholder + Assert.Same(placeholder, relatedManager.List); + } + + [Fact] + public void BindingContext_InterceptedHandler_InvokedForInitialPrime_NonEmptyParent() + { + // When the parent has rows and an intercepted handler is supplied, the handler is invoked and + // should delegate to RaiseParentCurrentItemChanged so the child refreshes normally. + List parentList = [new() { Property = [10, 20] }]; + + InterceptingBindingContext context = new(placeholder: null); + CurrencyManager parentManager = (CurrencyManager)context[parentList]; + CurrencyManager relatedManager = (CurrencyManager)context[parentList, "Property"]; + + Assert.True(context.HandlerInvokeCount >= 1, "Intercepted handler was not invoked."); + Assert.Equal(2, relatedManager.Count); + } + + /// + /// A derived that opts in to intercepted parent handling for testing. + /// When the parent is empty it binds the child to the supplied placeholder (avoiding Everett AddNew); + /// when the parent has rows it delegates to . + /// + private sealed class InterceptingBindingContext : BindingContext + { + private readonly IList? _placeholder; + + public int HandlerInvokeCount { get; private set; } + + public InterceptingBindingContext(IList? placeholder) + { + _placeholder = placeholder; + } + + protected override EventHandler? CreateInterceptedParentHandler(IInterceptedRelatedManager relatedManager) + { + return (sender, e) => + { + HandlerInvokeCount++; + + if (relatedManager.ParentHasRows) + { + relatedManager.RaiseParentCurrentItemChanged(sender, e); + } + else if (_placeholder is not null) + { + relatedManager.BindToEmptyParentPlaceholder(_placeholder); + } + }; + } + } + private class ParentDataSource { public DataSource ParentProperty { get; set; }