Skip to content

Commit bab552d

Browse files
authored
feat: add AOT-safe ViewLocator with source-generated view dispatch (#15)
* feat: add AOT-safe ViewLocator with source-generated view dispatch ## New Features - Add IViewLocator, DefaultViewLocator, ViewLocator, and ViewMappingBuilder runtime types supporting three-tier view resolution: generated dispatch → explicit mappings → service locator fallback. - Add source generator that scans IViewFor<T> implementations at compile time and generates a type-switch dispatch function for AOT-safe view resolution. - Add ViewRegistrationExtractor and ViewRegistrationInfo model for extracting view/view-model pairs from the compilation. - Integrate ConfigureViewLocator into the builder pattern via IReactiveUIBindingBuilder, ReactiveUIBindingBuilder, and BuilderMixins. ## Tests - Add 7 snapshot tests for ViewLocatorDispatchGenerator covering single/multiple views, abstract exclusion, private/missing constructors, deduplication, and non-IViewFor classes. - Add 31 runtime tests for DefaultViewLocator, ViewLocator, ViewMappingBuilder, ViewLocatorNotFoundException, and builder integration. - Add TestExecutor infrastructure following ReactiveUI's executor pattern for clean test isolation. * fix: minor corrections to ViewLocator implementation - Update BindingGenerator XML doc to mention all three pipelines (A, B, C) - Make DefaultViewLocator _lock instance-scoped instead of static since it only guards the per-instance _mappings dictionary
1 parent 0281c27 commit bab552d

43 files changed

Lines changed: 2594 additions & 1 deletion

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ namespace ReactiveUI.Binding.SourceGenerators;
1515

1616
/// <summary>
1717
/// The main incremental source generator entry point for ReactiveUI property observation and binding.
18-
/// Orchestrates two pipelines:
18+
/// Orchestrates three pipelines:
1919
/// Pipeline A (Type Detection): Detects notification mechanisms and generates high-affinity fallback binders.
2020
/// Pipeline B (Invocation Detection): Detects WhenChanged/WhenChanging/Bind calls and generates per-invocation code.
21+
/// Pipeline C (View Dispatch): Scans IViewFor&lt;T&gt; implementations and generates AOT-safe view locator dispatch.
2122
/// </summary>
2223
[Generator]
2324
public class BindingGenerator : IIncrementalGenerator
@@ -91,6 +92,9 @@ internal static partial class __ReactiveUIGeneratedBindings
9192
consolidated,
9293
static (ctx, data) => RegistrationGenerator.Generate(ctx, data));
9394

95+
// Pipeline C: View locator dispatch (IViewFor<T> scanning)
96+
ViewLocatorDispatchGenerator.Register(context);
97+
9498
// Pipeline B: Invocation detection (separate pipelines per API)
9599
// Each invocation generator receives supportsCallerArgExpr to control dispatch strategy
96100
WhenChangedInvocationGenerator.Register(context, allClasses, supportsCallerArgExpr);

src/ReactiveUI.Binding.SourceGenerators/Constants.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ internal static class Constants
130130
/// </summary>
131131
internal const string BindCommandMethodName = "BindCommand";
132132

133+
/// <summary>
134+
/// Metadata name for the open generic <c>IViewFor&lt;T&gt;</c> interface used for view resolution.
135+
/// </summary>
136+
internal const string IViewForGenericMetadataName = "ReactiveUI.Binding.IViewFor`1";
137+
133138
/// <summary>
134139
/// Metadata name for the <c>IBindingTypeConverter</c> interface used for custom type conversions.
135140
/// </summary>
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
2+
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
using System.Collections.Immutable;
6+
using System.Text;
7+
8+
using Microsoft.CodeAnalysis;
9+
10+
using ReactiveUI.Binding.SourceGenerators.Helpers;
11+
using ReactiveUI.Binding.SourceGenerators.Models;
12+
13+
namespace ReactiveUI.Binding.SourceGenerators.Generators;
14+
15+
/// <summary>
16+
/// Generates the AOT-safe view dispatch code for <see cref="ViewRegistrationInfo"/> entries.
17+
/// Emits a type-switch function that resolves views without reflection.
18+
/// </summary>
19+
internal static class ViewLocatorDispatchGenerator
20+
{
21+
/// <summary>
22+
/// Registers the view locator dispatch pipeline into the incremental generator.
23+
/// Scans for classes implementing <c>IViewFor&lt;T&gt;</c> and generates a dispatch method.
24+
/// </summary>
25+
/// <param name="context">The incremental generator initialization context.</param>
26+
internal static void Register(IncrementalGeneratorInitializationContext context)
27+
{
28+
// Reuse Pipeline A's class-with-base-list predicate
29+
var viewRegistrations = context.SyntaxProvider
30+
.CreateSyntaxProvider(
31+
predicate: RoslynHelpers.IsClassWithBaseList,
32+
transform: ViewRegistrationExtractor.ExtractFromIViewForImplementation)
33+
.Where(static x => x is not null)
34+
.Select(static (x, _) => x!);
35+
36+
// Collect and deduplicate
37+
var collected = viewRegistrations.Collect();
38+
39+
context.RegisterSourceOutput(collected, static (ctx, data) => Generate(ctx, data));
40+
}
41+
42+
/// <summary>
43+
/// Generates the ViewDispatch.g.cs source file from collected view registrations.
44+
/// </summary>
45+
/// <param name="context">The source production context.</param>
46+
/// <param name="registrations">All detected view registration infos.</param>
47+
internal static void Generate(SourceProductionContext context, ImmutableArray<ViewRegistrationInfo> registrations)
48+
{
49+
if (!ExtractorValidation.HasItems(registrations))
50+
{
51+
return;
52+
}
53+
54+
// Deduplicate by ViewModel fully qualified name (first occurrence wins)
55+
var deduplicated = Deduplicate(registrations);
56+
57+
var sb = new StringBuilder(2048 + (deduplicated.Count * 512));
58+
GenerateSource(sb, deduplicated);
59+
context.AddSource("ViewDispatch.g.cs", sb.ToString());
60+
}
61+
62+
/// <summary>
63+
/// Deduplicates view registrations by view model fully qualified name.
64+
/// </summary>
65+
/// <param name="registrations">The raw registrations.</param>
66+
/// <returns>A deduplicated list of registrations.</returns>
67+
private static List<ViewRegistrationInfo> Deduplicate(ImmutableArray<ViewRegistrationInfo> registrations)
68+
{
69+
var seen = new HashSet<string>(StringComparer.Ordinal);
70+
var result = new List<ViewRegistrationInfo>(registrations.Length);
71+
72+
for (var i = 0; i < registrations.Length; i++)
73+
{
74+
var reg = registrations[i];
75+
if (seen.Add(reg.ViewModelFullyQualifiedName))
76+
{
77+
result.Add(reg);
78+
}
79+
}
80+
81+
return result;
82+
}
83+
84+
/// <summary>
85+
/// Generates the full source output into the StringBuilder.
86+
/// </summary>
87+
/// <param name="sb">The string builder to write to.</param>
88+
/// <param name="registrations">The deduplicated registrations.</param>
89+
private static void GenerateSource(StringBuilder sb, List<ViewRegistrationInfo> registrations)
90+
{
91+
sb.AppendLine("// <auto-generated/>")
92+
.AppendLine("#pragma warning disable")
93+
.AppendLine()
94+
.AppendLine("namespace ReactiveUI.Binding")
95+
.AppendLine("{")
96+
.AppendLine(" internal static partial class __ReactiveUIGeneratedBindings")
97+
.AppendLine(" {");
98+
99+
// Static field initializer to trigger registration
100+
sb.AppendLine(" /// <summary>")
101+
.AppendLine(" /// Triggers view dispatch registration when the generated bindings class is loaded.")
102+
.AppendLine(" /// </summary>")
103+
.AppendLine(" private static readonly bool __viewDispatchRegistered = __RegisterViewDispatch();")
104+
.AppendLine();
105+
106+
// Registration method
107+
sb.AppendLine(" /// <summary>")
108+
.AppendLine(" /// Registers the source-generated view dispatch function with")
109+
.AppendLine(" /// <see cref=\"global::ReactiveUI.Binding.DefaultViewLocator\"/>.")
110+
.AppendLine(" /// Called once via static field initializer when this class is first accessed.")
111+
.AppendLine(" /// </summary>")
112+
.AppendLine(" /// <returns>Always returns <see langword=\"true\"/>.</returns>")
113+
.AppendLine(" private static bool __RegisterViewDispatch()")
114+
.AppendLine(" {")
115+
.AppendLine(" global::ReactiveUI.Binding.DefaultViewLocator.SetGeneratedViewDispatch(")
116+
.AppendLine(" __TryResolveView);")
117+
.AppendLine(" return true;")
118+
.AppendLine(" }")
119+
.AppendLine();
120+
121+
// Dispatch method
122+
sb.AppendLine(" /// <summary>")
123+
.AppendLine(" /// Compile-time generated type-switch dispatch for view resolution.")
124+
.AppendLine(" /// Attempts to resolve a view for the given view model instance without reflection.")
125+
.AppendLine(" /// </summary>")
126+
.AppendLine(" /// <param name=\"instance\">The view model instance to resolve a view for.</param>")
127+
.AppendLine(" /// <param name=\"contract\">The contract string (empty string for default).</param>")
128+
.AppendLine(" /// <returns>The resolved view, or <see langword=\"null\"/> if no generated mapping exists.</returns>")
129+
.AppendLine(" private static global::ReactiveUI.Binding.IViewFor __TryResolveView(")
130+
.AppendLine(" object instance, string contract)")
131+
.AppendLine(" {");
132+
133+
for (var i = 0; i < registrations.Count; i++)
134+
{
135+
var reg = registrations[i];
136+
var resolverMethodName = "__ResolveView_" + i;
137+
138+
sb.Append(" // ").Append(reg.ViewModelFullyQualifiedName)
139+
.Append(" -> ").AppendLine(reg.ViewFullyQualifiedName)
140+
.Append(" if (instance is ").Append(reg.ViewModelFullyQualifiedName).AppendLine(")")
141+
.AppendLine(" {")
142+
.Append(" return ").Append(resolverMethodName).AppendLine("(contract);")
143+
.AppendLine(" }")
144+
.AppendLine();
145+
}
146+
147+
sb.AppendLine(" // No compile-time mapping found; fall back to runtime resolution.")
148+
.AppendLine(" return null;")
149+
.AppendLine(" }");
150+
151+
// Per-view resolver methods
152+
for (var i = 0; i < registrations.Count; i++)
153+
{
154+
GenerateResolverMethod(sb, registrations[i], i);
155+
}
156+
157+
sb.AppendLine(" }")
158+
.AppendLine("}");
159+
}
160+
161+
/// <summary>
162+
/// Generates a per-view-model resolver method.
163+
/// </summary>
164+
/// <param name="sb">The string builder.</param>
165+
/// <param name="reg">The view registration info.</param>
166+
/// <param name="index">The unique index for method naming.</param>
167+
private static void GenerateResolverMethod(StringBuilder sb, ViewRegistrationInfo reg, int index)
168+
{
169+
var methodName = "__ResolveView_" + index;
170+
171+
sb.AppendLine()
172+
.AppendLine(" /// <summary>")
173+
.Append(" /// Resolves a view for <see cref=\"")
174+
.Append(reg.ViewModelFullyQualifiedName).AppendLine("\"/>.");
175+
176+
if (reg.HasParameterlessConstructor)
177+
{
178+
sb.AppendLine(" /// Tries the service locator first, then falls back to direct construction.");
179+
}
180+
else
181+
{
182+
sb.AppendLine(" /// Service locator only — no direct construction available.");
183+
}
184+
185+
sb.AppendLine(" /// </summary>")
186+
.AppendLine(" /// <param name=\"contract\">The contract string (empty string for default).</param>")
187+
.AppendLine(" /// <returns>The resolved view, or <see langword=\"null\"/> if resolution fails.</returns>")
188+
.Append(" private static global::ReactiveUI.Binding.IViewFor ")
189+
.Append(methodName).AppendLine("(string contract)")
190+
.AppendLine(" {");
191+
192+
// Normalize contract
193+
sb.AppendLine(" // Normalize contract: empty string means no contract (null for Splat lookup).")
194+
.AppendLine(" string svcContract = contract.Length == 0 ? null : contract;")
195+
.AppendLine();
196+
197+
// Service locator lookup
198+
sb.AppendLine(" // Prefer service-locator-registered view (supports DI-configured instances).")
199+
.AppendLine(" var view = global::Splat.AppLocator.Current")
200+
.Append(" .GetService<global::ReactiveUI.Binding.IViewFor<")
201+
.Append(reg.ViewModelFullyQualifiedName).AppendLine(">>(")
202+
.AppendLine(" svcContract);")
203+
.AppendLine(" if (view != null)")
204+
.AppendLine(" {")
205+
.AppendLine(" return view;")
206+
.AppendLine(" }");
207+
208+
// Direct construction fallback
209+
if (reg.HasParameterlessConstructor)
210+
{
211+
sb.AppendLine()
212+
.Append(" // Fallback: direct construction (")
213+
.Append(reg.ViewFullyQualifiedName).AppendLine(" has a parameterless constructor).")
214+
.Append(" return new ").Append(reg.ViewFullyQualifiedName).AppendLine("();");
215+
}
216+
else
217+
{
218+
sb.AppendLine()
219+
.AppendLine(" return null;");
220+
}
221+
222+
sb.AppendLine(" }");
223+
}
224+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
2+
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
using Microsoft.CodeAnalysis;
6+
using Microsoft.CodeAnalysis.CSharp.Syntax;
7+
8+
using ReactiveUI.Binding.SourceGenerators.Models;
9+
10+
namespace ReactiveUI.Binding.SourceGenerators.Helpers;
11+
12+
/// <summary>
13+
/// Extracts <see cref="ViewRegistrationInfo"/> from class declarations implementing <c>IViewFor&lt;T&gt;</c>.
14+
/// </summary>
15+
internal static class ViewRegistrationExtractor
16+
{
17+
/// <summary>
18+
/// Extracts a <see cref="ViewRegistrationInfo"/> from a class that implements <c>IViewFor&lt;T&gt;</c>.
19+
/// </summary>
20+
/// <param name="context">The generator syntax context.</param>
21+
/// <param name="ct">Cancellation token.</param>
22+
/// <returns>A <see cref="ViewRegistrationInfo"/> if the class implements <c>IViewFor&lt;T&gt;</c>; otherwise, <see langword="null"/>.</returns>
23+
internal static ViewRegistrationInfo? ExtractFromIViewForImplementation(
24+
GeneratorSyntaxContext context,
25+
CancellationToken ct)
26+
{
27+
var classDecl = (ClassDeclarationSyntax)context.Node;
28+
var semanticModel = context.SemanticModel;
29+
var typeSymbol = semanticModel.GetDeclaredSymbol(classDecl, ct) as INamedTypeSymbol;
30+
31+
if (typeSymbol is null || typeSymbol.IsAbstract)
32+
{
33+
return null;
34+
}
35+
36+
var iViewForGeneric = semanticModel.Compilation.GetTypeByMetadataName(
37+
Constants.IViewForGenericMetadataName);
38+
39+
// Walk AllInterfaces to find IViewFor<T>
40+
var allInterfaces = typeSymbol.AllInterfaces;
41+
for (var i = 0; i < allInterfaces.Length; i++)
42+
{
43+
ct.ThrowIfCancellationRequested();
44+
var iface = allInterfaces[i];
45+
46+
if (iViewForGeneric is object
47+
&& iface.IsGenericType
48+
&& SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, iViewForGeneric)
49+
&& iface.TypeArguments.Length == 1)
50+
{
51+
var viewModelType = iface.TypeArguments[0];
52+
var viewModelFqn = viewModelType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
53+
var viewFqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
54+
var hasParameterlessCtor = HasAccessibleParameterlessConstructor(typeSymbol);
55+
56+
return new ViewRegistrationInfo(viewModelFqn, viewFqn, hasParameterlessCtor);
57+
}
58+
}
59+
60+
return null;
61+
}
62+
63+
/// <summary>
64+
/// Checks whether the type has an accessible parameterless constructor (public or internal).
65+
/// </summary>
66+
/// <param name="type">The type to check.</param>
67+
/// <returns><see langword="true"/> if a parameterless constructor is accessible; otherwise, <see langword="false"/>.</returns>
68+
private static bool HasAccessibleParameterlessConstructor(INamedTypeSymbol type)
69+
{
70+
var constructors = type.InstanceConstructors;
71+
for (var i = 0; i < constructors.Length; i++)
72+
{
73+
var ctor = constructors[i];
74+
if (ctor.Parameters.Length == 0
75+
&& (ctor.DeclaredAccessibility == Accessibility.Public
76+
|| ctor.DeclaredAccessibility == Accessibility.Internal))
77+
{
78+
return true;
79+
}
80+
}
81+
82+
return false;
83+
}
84+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
2+
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
namespace ReactiveUI.Binding.SourceGenerators.Models;
6+
7+
/// <summary>
8+
/// Value-equatable POCO representing a view-to-view-model mapping detected at compile time.
9+
/// Produced by scanning <c>IViewFor&lt;T&gt;</c> implementations and <c>Map&lt;TVM,TView&gt;()</c> call sites.
10+
/// Contains no ISymbol, SyntaxNode, or Location references.
11+
/// </summary>
12+
/// <param name="ViewModelFullyQualifiedName">The fully qualified name of the view model type (global:: prefixed).</param>
13+
/// <param name="ViewFullyQualifiedName">The fully qualified name of the view type (global:: prefixed).</param>
14+
/// <param name="HasParameterlessConstructor">Whether the view type has a parameterless constructor for direct instantiation.</param>
15+
internal sealed record ViewRegistrationInfo(
16+
string ViewModelFullyQualifiedName,
17+
string ViewFullyQualifiedName,
18+
bool HasParameterlessConstructor) : IEquatable<ViewRegistrationInfo>;

src/ReactiveUI.Binding/Builder/IReactiveUIBindingBuilder.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ IReactiveUIBindingBuilder WithPlatformModule<T>(T module)
7373
/// <returns>The builder instance for chaining.</returns>
7474
IReactiveUIBindingBuilder WithCommandBinder(ICreatesCommandBinding binder);
7575

76+
/// <summary>
77+
/// Configures the default view locator with explicit view-to-view-model mappings.
78+
/// </summary>
79+
/// <param name="configure">An action that receives a <see cref="ViewMappingBuilder"/> for registering mappings.</param>
80+
/// <returns>The builder instance for chaining.</returns>
81+
IReactiveUIBindingBuilder ConfigureViewLocator(Action<ViewMappingBuilder> configure);
82+
7683
/// <summary>
7784
/// Builds the application and returns the configured instance.
7885
/// </summary>

0 commit comments

Comments
 (0)