From 5b942d432d9a7e331a87f9408c9fc5e290fb19bc Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Tue, 29 Sep 2026 02:45:52 +1000 Subject: [PATCH] fix: bind a field control to the view's current view model, so a view can bind before it has one A binding whose view side started at a field, such as a control named in XAML, bound the view model instance it was handed and threw when it was null. A field link's owner info now lists the view's ViewModel property, so the binding roots at the view and follows each view model it is given, as a binding onto a property already did. --- .../Helpers/SyntaxHelpers.cs | 4 +- .../Helpers/TypeDetectionExtractor.cs | 37 +++++++++++- .../FieldPathLinkRuntimeTests.cs | 2 +- .../ViewModelReplacementRuntimeTests.cs | 57 +++++++++++++++++++ 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/SyntaxHelpers.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/SyntaxHelpers.cs index 74a12f62..b835c8c1 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/SyntaxHelpers.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/SyntaxHelpers.cs @@ -166,7 +166,7 @@ IFieldSymbol field when IsReadableFieldLink(field, isLeaf) => field.Type, // A field raises no notification, so it resolves no mechanism and is read once, like any other link whose // owner cannot notify. It still carries its owner, which is how a call site whose first path starts at a - // field learns how the observed type notifies. + // field learns how the observed type notifies, and which view model property a view binding follows. return member is IPropertySymbol propertySymbol ? new( propertySymbol.Name, @@ -179,7 +179,7 @@ IFieldSymbol field when IsReadableFieldLink(field, isLeaf) => field.Type, memberType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), owner.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), memberType.IsReferenceType, - TypeDetectionExtractor.CreateTypeInfo(owner, semanticModel.Compilation, new([]), ct), + TypeDetectionExtractor.ExtractFieldOwner(owner, semanticModel.Compilation, ct), IsField: true); } diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/TypeDetectionExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/TypeDetectionExtractor.cs index 60756648..8f23e093 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/TypeDetectionExtractor.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/TypeDetectionExtractor.cs @@ -13,6 +13,9 @@ namespace ReactiveUI.Binding.SourceGenerators.Helpers; /// Extracts notification capabilities from property-owner symbols. internal static class TypeDetectionExtractor { + /// The name a view exposes its view model under. + private const string ViewModelPropertyName = "ViewModel"; + /// Reads a type's notification mechanisms and observable properties from its symbol. /// The type to inspect, declared in this compilation or referenced from another. /// The compilation the type is resolved against. @@ -39,13 +42,35 @@ internal static ClassBindingInfo ExtractPropertyOwner( CancellationToken ct) { var propertyInfo = ExtractProperty(owner, property); - var properties = property.Name != "ViewModel" - && PlatformSymbols.FindMember(owner, "ViewModel") is IPropertySymbol { IsStatic: false, GetMethod.DeclaredAccessibility: Accessibility.Public } viewModel + var properties = property.Name != ViewModelPropertyName && FindViewModelProperty(owner) is { } viewModel ? new EquatableArray([propertyInfo, ExtractProperty(owner, viewModel)]) : new EquatableArray([propertyInfo]); return CreateTypeInfo(owner, compilation, properties, ct); } + /// Captures the concrete owner of a field link: its notification interfaces and its view model property. + /// The type through which the field is read. + /// The consumer compilation. + /// The cancellation token. + /// The owner's capabilities, listing its view model property when it exposes one. + /// + /// A view binding usually starts at a field, a control named in XAML or by a designer, as in + /// v => v.NameBox.Text. The view model property is recorded so the binding follows the view model the view + /// currently holds, as it does when the path starts at a property. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static ClassBindingInfo ExtractFieldOwner( + INamedTypeSymbol owner, + Compilation compilation, + CancellationToken ct) => + CreateTypeInfo( + owner, + compilation, + FindViewModelProperty(owner) is { } viewModel + ? new EquatableArray([ExtractProperty(owner, viewModel)]) + : new EquatableArray([]), + ct); + /// Reads one property's native candidates while its owner symbols are available. /// The concrete property owner. /// The selected declaration. @@ -150,6 +175,14 @@ internal static EquatableArray ExtractProperties( return new([.. properties]); } + /// Finds the public instance view model property a view exposes, through its base classes. + /// The type to search. + /// The view model property, or null when the type exposes none. + private static IPropertySymbol? FindViewModelProperty(INamedTypeSymbol owner) => + PlatformSymbols.FindMember(owner, ViewModelPropertyName) is IPropertySymbol { IsStatic: false, GetMethod.DeclaredAccessibility: Accessibility.Public } viewModel + ? viewModel + : null; + /// Finds the dependency-property field and change event a property notifies through. /// The members of the type and of the bases its own assembly declares. /// The property being described. diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/FieldPathLinkRuntimeTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/FieldPathLinkRuntimeTests.cs index b1b3fb0c..f08b0806 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/FieldPathLinkRuntimeTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/FieldPathLinkRuntimeTests.cs @@ -137,7 +137,7 @@ public static string BindTwoWayToFieldControl() public static string BindCommandToFieldControl() { var person = new Person(); - var view = new PersonView(); + var view = new PersonView { ViewModel = person }; using (view.BindCommand(person, x => x.Save, v => v.SaveButton)) { view.SaveButton.PerformClick(); diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ViewModelReplacementRuntimeTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ViewModelReplacementRuntimeTests.cs index 66f2fa97..af6e0879 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ViewModelReplacementRuntimeTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RuntimeExecution/ViewModelReplacementRuntimeTests.cs @@ -284,6 +284,37 @@ public Task Bind_AfterTheViewModelIsReplaced_TracksTheNewViewModel() => "view.Bind(view.ViewModel, vm => vm.Name, v => v.Text)", StringComparison.Ordinal)); + /// A one-way binding created while the view has no view model follows the first assigned model. + /// A task representing the asynchronous test operation. + [Test] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task OneWayBind_WhenViewModelInitiallyNull_TracksTheAssignedViewModel() => + AssertTracksReplacement(WithNullViewModel(ViewModelReplacementSource, "view.OneWayBind")); + + /// A two-way binding created while the view has no view model follows the first assigned model. + /// A task representing the asynchronous test operation. + [Test] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task Bind_WhenViewModelInitiallyNull_TracksTheAssignedViewModel() => + AssertTracksReplacement(WithNullViewModel(ToBind(ViewModelReplacementSource), "view.Bind")); + + /// + /// A one-way binding onto a control the view holds in a field, as a control named in XAML is, follows the view's + /// view model when the view has none yet. + /// + /// A task representing the asynchronous test operation. + [Test] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task OneWayBind_ToAFieldControl_WhenViewModelInitiallyNull_TracksTheAssignedViewModel() => + AssertTracksReplacement(WithNullViewModel(ToFieldControl(ViewModelReplacementSource), "view.OneWayBind")); + + /// A two-way binding onto a control the view holds in a field follows the view model it is given later. + /// A task representing the asynchronous test operation. + [Test] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task Bind_ToAFieldControl_WhenViewModelInitiallyNull_TracksTheAssignedViewModel() => + AssertTracksReplacement(WithNullViewModel(ToFieldControl(ToBind(ViewModelReplacementSource)), "view.Bind")); + /// /// A command binding follows the view's current view model, so clicking the control invokes the command the /// view model on display exposes rather than the one belonging to the view model it replaced. @@ -324,6 +355,32 @@ public Task BindInteraction_WhenViewModelInitiallyNull_HandlesTheAssignedInterac "view.ViewModel = null; var binding = view.BindInteraction", StringComparison.Ordinal)); + /// Makes the one-way replacement scenario bind two-way. + /// The one-way scenario. + /// The same scenario, calling Bind. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string ToBind(string source) => + source.Replace("view.OneWayBind(", "view.Bind(", StringComparison.Ordinal); + + /// Clears the view's view model just before the scenario makes its binding. + /// The scenario. + /// The binding call the scenario makes, such as view.OneWayBind. + /// The same scenario, binding while the view holds no view model. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string WithNullViewModel(string source, string call) => + source.Replace($"var binding = {call}", $"view.ViewModel = null; var binding = {call}", StringComparison.Ordinal); + + /// Moves the bound view property onto a control the view holds in a field. + /// The scenario, binding v => v.Text. + /// The same scenario, binding v => v.NameBox.Text. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string ToFieldControl(string source) => + source + .Replace("public string Text { get; set; } = \"\";", "public TextBox NameBox = new TextBox();", StringComparison.Ordinal) + .Replace("public static class Usage", "public class TextBox { public string Text { get; set; } = \"\"; }\n\npublic static class Usage", StringComparison.Ordinal) + .Replace("v => v.Text)", "v => v.NameBox.Text)", StringComparison.Ordinal) + .Replace("return view.Text;", "return view.NameBox.Text;", StringComparison.Ordinal); + /// Runs a replacement scenario and asserts the view ends up showing the replacement's value. /// The scenario source to generate, compile and run. /// A task representing the asynchronous assertion.