diff --git a/src/System.Windows.Forms/Properties/AssemblyInfo.cs b/src/System.Windows.Forms/Properties/AssemblyInfo.cs index 7c9560649ac..30fe290396a 100644 --- a/src/System.Windows.Forms/Properties/AssemblyInfo.cs +++ b/src/System.Windows.Forms/Properties/AssemblyInfo.cs @@ -15,3 +15,10 @@ // This is needed in order to Moq internal interfaces for testing [assembly: InternalsVisibleTo($"DynamicProxyGenAssembly2, PublicKey={PublicKeys.Moq}")] + +// TODO: Confirm the actual WiseTech CargoWise assembly name and strong-name public key before shipping. +// Replace "WiseTech.CargoWise" with the real assembly name and add a "PublicKey={PublicKeys.*}" +// token (adding a new PublicKeys constant if needed) once the key is confirmed. +// This entry grants CargoWise access to RelatedCurrencyManager.UseParentCurrentChanged, +// the supported .NET 10 replacement for the ZBindingContext reflection hack. +[assembly: InternalsVisibleTo("WiseTech.CargoWise")] 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 ff7a6899adc..b8ba44cb64e 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/DataBinding/RelatedCurrencyManager.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/DataBinding/RelatedCurrencyManager.cs @@ -24,21 +24,38 @@ internal class RelatedCurrencyManager : CurrencyManager /// /// When (the default) a /// refreshes from the parent manager's - /// event, which is the standard framework behavior. + /// event, which is the standard framework behavior. + /// fires on both parent position changes and parent item-property edits. /// /// /// When set to the related manager instead refreshes from the - /// parent's event. This reproduces a - /// behavior that consumers (for example CargoWise's ZBindingContext) previously - /// achieved through reflection, which is no longer possible in .NET 10 because the - /// list-manager dictionary can no longer be intercepted. + /// parent's event, which fires only on + /// parent position changes — not on item-property edits. This is the supported .NET 10 + /// replacement for a reflection-based event swap that consumers such as CargoWise's + /// ZBindingContext previously performed, which is no longer viable because the + /// list-manager storage can no longer be intercepted. /// /// - /// This switch must be set before related currency managers are created (for example in - /// the binding context constructor) so that the desired event is wired up from the start. - /// Changing it after managers have been created only affects managers created or rebound - /// afterwards. + /// This switch is process-wide and must be set before related currency managers are + /// created (for example, in the binding context constructor) so that the desired event is + /// wired up from the start. Changing it after managers have been created only affects + /// managers that are created or rebound afterwards. /// + /// + /// Example — setting the flag once in a custom binding context constructor + /// (the .NET 10 replacement for the ZBindingContext reflection hack): + /// + /// + /// public class ZBindingContext : BindingContext + /// { + /// public ZBindingContext() + /// { + /// // .NET 10 replacement for the old reflection hack: drive related currency + /// // managers off the parent's CurrentChanged event instead of CurrentItemChanged. + /// RelatedCurrencyManager.UseParentCurrentChanged = true; + /// } + /// } + /// /// internal static bool UseParentCurrentChanged { get; set; } diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/RelatedCurrencyManagerTests.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/RelatedCurrencyManagerTests.cs new file mode 100644 index 00000000000..8491f260e8d --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/RelatedCurrencyManagerTests.cs @@ -0,0 +1,256 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Windows.Forms.Tests; + +// NB: doesn't require thread affinity +public class RelatedCurrencyManagerTests +{ + /// + /// Verifies that defaults to + /// , preserving the standard framework behavior out of the box. + /// + [Fact] + public void UseParentCurrentChanged_DefaultValue_IsFalse() + { + bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged; + try + { + // Reset to the documented default so the assertion is deterministic. + RelatedCurrencyManager.UseParentCurrentChanged = false; + + Assert.False(RelatedCurrencyManager.UseParentCurrentChanged); + } + finally + { + RelatedCurrencyManager.UseParentCurrentChanged = originalValue; + } + } + + /// + /// Default behavior: the child manager refreshes when the parent fires + /// (e.g. an item-property edit on + /// the parent list). + /// + [Fact] + public void UseParentCurrentChanged_False_RefreshesOnCurrentItemChanged() + { + bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged; + try + { + RelatedCurrencyManager.UseParentCurrentChanged = false; + + BindingList parentList = [new ParentRow(), new ParentRow()]; + BindingContext context = []; + + CurrencyManager parentManager = (CurrencyManager)context[parentList]; + CurrencyManager childManager = (CurrencyManager)context[parentList, nameof(ParentRow.Children)]; + + int refreshCount = 0; + childManager.CurrentChanged += (s, e) => refreshCount++; + + // ResetItem fires ListChanged(ItemChanged) on the BindingList, which causes the + // parent CurrencyManager to fire OnCurrentItemChanged — but NOT OnCurrentChanged. + // Default mode is subscribed to CurrentItemChanged, so the child should refresh. + parentList.ResetItem(parentManager.Position); + + Assert.Equal(1, refreshCount); + } + finally + { + RelatedCurrencyManager.UseParentCurrentChanged = originalValue; + } + } + + /// + /// Opt-in behavior: when is + /// the child manager does not refresh on a parent item-property + /// edit that fires only . + /// + [Fact] + public void UseParentCurrentChanged_True_DoesNotRefreshOnCurrentItemChangedOnly() + { + bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged; + try + { + RelatedCurrencyManager.UseParentCurrentChanged = true; + + BindingList parentList = [new ParentRow(), new ParentRow()]; + BindingContext context = []; + + CurrencyManager parentManager = (CurrencyManager)context[parentList]; + CurrencyManager childManager = (CurrencyManager)context[parentList, nameof(ParentRow.Children)]; + + int refreshCount = 0; + childManager.CurrentChanged += (s, e) => refreshCount++; + + // ResetItem fires ListChanged(ItemChanged) which causes the parent to fire only + // CurrentItemChanged, not CurrentChanged. Opt-in mode is subscribed to CurrentChanged, + // so the child must NOT refresh. + parentList.ResetItem(parentManager.Position); + + Assert.Equal(0, refreshCount); + } + finally + { + RelatedCurrencyManager.UseParentCurrentChanged = originalValue; + } + } + + /// + /// Default behavior: the child manager refreshes when the parent position changes + /// (which fires and consequently + /// ). + /// + [Fact] + public void UseParentCurrentChanged_False_RefreshesOnParentPositionChange() + { + bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged; + try + { + RelatedCurrencyManager.UseParentCurrentChanged = false; + + BindingList parentList = [new ParentRow(), new ParentRow()]; + BindingContext context = []; + + CurrencyManager parentManager = (CurrencyManager)context[parentList]; + CurrencyManager childManager = (CurrencyManager)context[parentList, nameof(ParentRow.Children)]; + + int refreshCount = 0; + childManager.CurrentChanged += (s, e) => refreshCount++; + + // Moving the parent position fires CurrentChanged (and CurrentItemChanged) on the + // parent. In default mode the child is subscribed to CurrentItemChanged so it refreshes. + parentManager.Position = 1; + + Assert.Equal(1, refreshCount); + } + finally + { + RelatedCurrencyManager.UseParentCurrentChanged = originalValue; + } + } + + /// + /// Opt-in behavior: the child manager also refreshes when the parent position changes, + /// because a position change fires which + /// the opt-in mode subscribes to. + /// + [Fact] + public void UseParentCurrentChanged_True_RefreshesOnParentPositionChange() + { + bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged; + try + { + RelatedCurrencyManager.UseParentCurrentChanged = true; + + BindingList parentList = [new ParentRow(), new ParentRow()]; + BindingContext context = []; + + CurrencyManager parentManager = (CurrencyManager)context[parentList]; + CurrencyManager childManager = (CurrencyManager)context[parentList, nameof(ParentRow.Children)]; + + int refreshCount = 0; + childManager.CurrentChanged += (s, e) => refreshCount++; + + // Moving the parent position fires CurrentChanged on the parent. Opt-in mode is + // subscribed to CurrentChanged so the child must refresh exactly once. + parentManager.Position = 1; + + Assert.Equal(1, refreshCount); + } + finally + { + RelatedCurrencyManager.UseParentCurrentChanged = originalValue; + } + } + + /// + /// Symmetry: retrieving the same related manager from the + /// cache twice does not produce a double subscription — the child refreshes exactly once + /// per parent event. + /// + [Fact] + public void UseParentCurrentChanged_False_CachedManagerHasNoDoubleSubscription() + { + bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged; + try + { + RelatedCurrencyManager.UseParentCurrentChanged = false; + + BindingList parentList = [new ParentRow(), new ParentRow()]; + BindingContext context = []; + + CurrencyManager parentManager = (CurrencyManager)context[parentList]; + + // Retrieve the child manager twice; the second lookup must return the cached instance. + CurrencyManager childManager1 = (CurrencyManager)context[parentList, nameof(ParentRow.Children)]; + CurrencyManager childManager2 = (CurrencyManager)context[parentList, nameof(ParentRow.Children)]; + Assert.Same(childManager1, childManager2); + + int refreshCount = 0; + childManager1.CurrentChanged += (s, e) => refreshCount++; + + parentList.ResetItem(parentManager.Position); + + // Must fire exactly once — not twice — confirming no stale double subscription. + Assert.Equal(1, refreshCount); + } + finally + { + RelatedCurrencyManager.UseParentCurrentChanged = originalValue; + } + } + + /// + /// Symmetry: when the switch is , rebinding the manager + /// () does not leave a stale + /// subscription on the old parent. + /// + [Fact] + public void UseParentCurrentChanged_True_RebindDoesNotLeaveStaleSubscription() + { + bool originalValue = RelatedCurrencyManager.UseParentCurrentChanged; + try + { + RelatedCurrencyManager.UseParentCurrentChanged = true; + + BindingList parentList = [new ParentRow(), new ParentRow()]; + BindingContext context = []; + + CurrencyManager parentManager = (CurrencyManager)context[parentList]; + RelatedCurrencyManager childManager = + (RelatedCurrencyManager)context[parentList, nameof(ParentRow.Children)]; + + // Rebind to the same parent — Bind() unwires the old CurrentChanged subscription + // and wires a fresh one. The Bind() call itself fires an init refresh directly + // (not via the event), so add the counter handler after the rebind. + childManager.Bind(parentManager, nameof(ParentRow.Children)); + + int refreshCount = 0; + childManager.CurrentChanged += (s, e) => refreshCount++; + + // Move the parent position: fires CurrentChanged (and CurrentItemChanged) on the parent. + // The child must refresh exactly once — not twice — confirming no double subscription. + parentManager.Position = 1; + + Assert.Equal(1, refreshCount); + } + finally + { + RelatedCurrencyManager.UseParentCurrentChanged = originalValue; + } + } + + private sealed class ParentRow + { + public List Children { get; set; } = []; + } + + private sealed class ChildRow + { + public string Name { get; set; } = string.Empty; + } +}