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