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.