From 344714a75689c5fc2e70d2c0109483ea8046ad57 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:52:51 +1000 Subject: [PATCH] feat: follow WinForms host properties through ReactiveUI.Binding's ObservedProperty With ReactiveUI.Binding 8.4.0 or later, the [RoutedControlHost] and [ViewModelControlHost] hosts follow DefaultContent, Content, ViewModel, Router and ViewContractObservable through Binding's ObservedProperty, in the flavour their view API comes from: Create for WhenAnyValue, and Then and Switch for WhenAnyObservable, with static lambdas. ObservedProperty is not intercepted, so it works from generated code, and keeps WhenAnyValue's semantics. With an older ReactiveUI.Binding, or none, the hosts keep following their own PropertyChanged, and the new ControlHostAnalyzer reports RXUISG0022 (Info) on each host. The generator and the analyzer decide from the same ViewApiRules file, which now also picks the view API. There is no package dependency on Binding: the decision follows the Binding the project references. --- CLAUDE.md | 5 + README.md | 4 +- src/Directory.Packages.props | 2 + .../ReactiveUI.SourceGenerators.Tests.csproj | 11 + .../UnitTests/ControlHostAnalyzerTests.cs | 114 ++++++ .../ControlHostObservedPropertyTests.cs | 366 ++++++++++++++++++ .../AnalyzerReleases.Shipped.md | 1 + .../CodeFixers/ControlHostAnalyzer.cs | 63 +++ .../Diagnostics/DiagnosticDescriptors.cs | 11 + ...ourceGenerators.Analyzers.CodeFixes.csproj | 2 + ...HostWriter.cs => ControlHostExtensions.cs} | 56 ++- .../Core/Extensions/ContextExtensions.cs | 46 +-- .../Core/Helpers/ViewApiRules.cs | 75 ++++ .../Core/Models/ReactiveUiIntegration.cs | 11 +- .../RoutedControlHostGenerator.Execute.cs | 21 +- .../ViewModelControlHostGenerator.Execute.cs | 21 +- 16 files changed, 754 insertions(+), 55 deletions(-) create mode 100644 src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostAnalyzerTests.cs create mode 100644 src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostObservedPropertyTests.cs create mode 100644 src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/CodeFixers/ControlHostAnalyzer.cs rename src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/{ControlHostWriter.cs => ControlHostExtensions.cs} (55%) create mode 100644 src/ReactiveUI.SourceGenerators.Roslyn/Core/Helpers/ViewApiRules.cs diff --git a/CLAUDE.md b/CLAUDE.md index 3811a22b..9272a7ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,10 @@ copies (CS0436). read it from source, so a consumer keeps no reference to the attributes assembly. - Generators, analyzers and code fixes stay `netstandard2.0`, the Roslyn host's framework. Only the attributes library targets `$(LibraryTfms)`. +- Never emit a call ReactiveUI.Binding has to intercept (`WhenAny*`, `Bind`, `OneWayBind`, `BindCommand`): its + generator cannot see another generator's output, so the call has no dispatch and throws at run time. Generated code + that follows properties uses Binding's non-intercepted `ObservedProperty` when `ViewApiRules.HasObservedProperty` + finds it (ReactiveUI.Binding 8.4.0 or later), and its own `PropertyChanged` following otherwise. Generators report only the `RXUISG*` diagnostics about input they cannot generate from (see [Analyzer Separation](#analyzer-separation-roslyn-best-practice)). Diagnostics about how code should be written, and @@ -173,6 +177,7 @@ All diagnostics use the `RXUISG` prefix. All suppressions use the `RXUISPR` pref | `PropertyToReactiveFieldAnalyzer` | RXUISG0016 | Suggests converting auto-properties to `[Reactive]` properties | | `ReactiveAttributeMisuseAnalyzer` | RXUISG0020 | Detects `[Reactive]` on non-partial or non-partial-type members | | `ReactiveCommandAnalyzer` | RXUISG0002, RXUISG0008, RXUISG0021 | Reports `[ReactiveCommand]` methods and schedulers the generator skips | +| `ControlHostAnalyzer` | RXUISG0022 | Reports WinForms hosts generated without ReactiveUI.Binding 8.4.0's `ObservedProperty` (Info) | | `PropertyToReactiveFieldCodeFixProvider` | — | Converts auto-property → `[Reactive]` partial property (C# 13+; C# 14+ with an initializer), else a `[Reactive]` field | | `ReactiveAttributeMisuseCodeFixProvider` | — | Fixes misuse of `[Reactive]` attribute | diff --git a/README.md b/README.md index acdef769..687c84ed 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,9 @@ now covers three features this package used to generate. They have been removed | RXUISG0014, RXUISG0017 and the RXUISPR0002 suppression | Nothing: they only applied to `[ObservableAsProperty]` | The Windows Forms `[RoutedControlHost]` and `[ViewModelControlHost]` remain. They resolve views through ReactiveUI's -view locator, or through ReactiveUI.Binding's when the project's ReactiveUI is built on it. +view locator, or through ReactiveUI.Binding's when the project's ReactiveUI is built on it. With ReactiveUI.Binding +8.4.0 or later they follow their own properties through Binding's `ObservedProperty`, with `WhenAnyValue`'s semantics; +with anything older they follow them through `PropertyChanged`, and RXUISG0022 notes the difference. The ReactiveUI.Binding replacements need a ReactiveUI release built on ReactiveUI.Binding. ReactiveUI 24.3 and earlier are not: there, ReactiveUI's own `ObservableAsPropertyHelper` and `IViewFor` are the ones in use, so diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7226d700..0a852bd5 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -24,6 +24,8 @@ + + diff --git a/src/ReactiveUI.SourceGenerator.Tests/ReactiveUI.SourceGenerators.Tests.csproj b/src/ReactiveUI.SourceGenerator.Tests/ReactiveUI.SourceGenerators.Tests.csproj index a81664e3..eaad9bd3 100644 --- a/src/ReactiveUI.SourceGenerator.Tests/ReactiveUI.SourceGenerators.Tests.csproj +++ b/src/ReactiveUI.SourceGenerator.Tests/ReactiveUI.SourceGenerators.Tests.csproj @@ -31,11 +31,22 @@ + + + + + + + + + + diff --git a/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostAnalyzerTests.cs b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostAnalyzerTests.cs new file mode 100644 index 00000000..7acda80f --- /dev/null +++ b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostAnalyzerTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.SourceGenerators.WinForms; + +namespace ReactiveUI.SourceGenerator.Tests; + +/// Unit tests for . +public sealed class ControlHostAnalyzerTests +{ + /// A WinForms host generated without ObservedProperty. + private const string WithoutObservedPropertyId = "RXUISG0022"; + + /// Both hosts, and a class that is neither. + private const string HostsSource = """ + using ReactiveUI.SourceGenerators.WinForms; + + namespace Hosts + { + [RoutedControlHost("System.Windows.Forms.UserControl")] + public partial class RoutedHost { } + + [ViewModelControlHost("System.Windows.Forms.UserControl")] + public partial class ViewModelHost { } + + [System.Obsolete] + public class NotAHost { } + } + """; + + /// ReactiveUI.Binding without ObservedProperty: a release before 8.4.0. + private const string BindingWithoutObservedProperty = """ + namespace ReactiveUI.Binding + { + public interface IViewFor { } + } + """; + + /// ReactiveUI.Binding 8.4.0 or later, with ObservedProperty. + private const string BindingWithObservedProperty = """ + namespace ReactiveUI.Binding + { + public interface IViewFor { } + + public static class ObservedProperty { } + } + """; + + /// Each host is reported when the compilation has no ReactiveUI.Binding with ObservedProperty. + /// Source standing in for ReactiveUI.Binding, or none. + /// A task that represents the asynchronous test operation. + [Test] + [Arguments("")] + [Arguments(BindingWithoutObservedProperty)] + public async Task ReportsHostsWithoutObservedProperty(string bindingSource) + { + var reported = await GetReported(bindingSource); + + await Assert.That(reported).IsEquivalentTo(["RoutedHost", "ViewModelHost"]); + } + + /// No host is reported when ReactiveUI.Binding has ObservedProperty. + /// A task that represents the asynchronous test operation. + [Test] + public async Task DoesNotReportHostsWithObservedProperty() => + await Assert.That(await GetReported(BindingWithObservedProperty)).IsEmpty(); + + /// The diagnostic names the ReactiveUI.Binding version that has ObservedProperty. + /// A task that represents the asynchronous test operation. + [Test] + public async Task NamesTheMinimumBindingVersion() + { + var descriptor = new ControlHostAnalyzer().SupportedDiagnostics[0]; + var message = Diagnostic.Create(descriptor, Location.None, "RoutedHost", "8.4.0").GetMessage(System.Globalization.CultureInfo.InvariantCulture); + + await Assert.That(descriptor.Id).IsEqualTo(WithoutObservedPropertyId); + await Assert.That(descriptor.DefaultSeverity).IsEqualTo(DiagnosticSeverity.Info); + await Assert.That(message).Contains("ReactiveUI.Binding 8.4.0 or later"); + } + + /// A null analysis context is rejected. + /// A task that represents the asynchronous test operation. + [Test] + public async Task InitializeRejectsANullContext() => + await Assert.That(static () => new ControlHostAnalyzer().Initialize(null!)).Throws(); + + /// Analyzes the hosts with a ReactiveUI.Binding stand-in and gets the text each host is reported at. + /// Source standing in for ReactiveUI.Binding, or none. + /// The source text at each reported location. + private static async Task> GetReported(string bindingSource) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13); + var tree = CSharpSyntaxTree.ParseText(HostsSource, parseOptions); + var compilation = CSharpCompilation.Create( + nameof(ControlHostAnalyzerTests), + [tree, CSharpSyntaxTree.ParseText(bindingSource, parseOptions)], + TestCompilationReferences.CreateForAssemblies(typeof(object).Assembly, typeof(RoutedControlHostAttribute).Assembly), + new(OutputKind.DynamicallyLinkedLibrary)); + var diagnostics = await compilation.WithAnalyzers([new ControlHostAnalyzer()]).GetAnalyzerDiagnosticsAsync(); + var text = await tree.GetTextAsync(); + + var reported = new List(); + foreach (var diagnostic in diagnostics) + { + if (diagnostic.Id == WithoutObservedPropertyId) + { + reported.Add(text.ToString(diagnostic.Location.SourceSpan)); + } + } + + return reported; + } +} diff --git a/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostObservedPropertyTests.cs b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostObservedPropertyTests.cs new file mode 100644 index 00000000..04c6cd0e --- /dev/null +++ b/src/ReactiveUI.SourceGenerator.Tests/UnitTests/ControlHostObservedPropertyTests.cs @@ -0,0 +1,366 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections; +using System.Reflection; +using System.Runtime.Loader; +using ReactiveUI.SourceGenerators.WinForms; + +namespace ReactiveUI.SourceGenerator.Tests; + +/// +/// Runs the generated Windows Forms hosts against ReactiveUI.Binding 8.4.0 in each flavour: the hosts follow their +/// properties through Binding's ObservedProperty, show their default content, and swap in the view for a routed or +/// hosted view model. +/// +/// +/// A ReactiveUI built on ReactiveUI.Binding declares no IViewFor of its own; no such release is published yet, so +/// the few ReactiveUI types a host touches are declared in source, and Binding is the real package. +/// +public class ControlHostObservedPropertyTests +{ + /// The ReactiveUI types a host touches, as a ReactiveUI built on ReactiveUI.Binding declares them. + private const string ReactiveUISource = """ + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.Runtime.CompilerServices; + + namespace ReactiveUI + { + public interface IReactiveObject : INotifyPropertyChanged, INotifyPropertyChanging + { + void RaisePropertyChanging(PropertyChangingEventArgs args); + + void RaisePropertyChanged(PropertyChangedEventArgs args); + } + + public static class IReactiveObjectExtensions + { + public static TRet RaiseAndSetIfChanged(this TObj source, ref TRet field, TRet value, [CallerMemberName] string? name = null) + where TObj : IReactiveObject + { + if (EqualityComparer.Default.Equals(field, value)) + { + return value; + } + + source.RaisePropertyChanging(new(name)); + field = value; + source.RaisePropertyChanged(new(name)); + return value; + } + + public static void SubscribePropertyChangedEvents(this TObj source) where TObj : IReactiveObject { } + + public static void SubscribePropertyChangingEvents(this TObj source) where TObj : IReactiveObject { } + } + + public interface IScreen + { + RoutingState Router { get; } + } + + public interface IRoutableViewModel + { + string? UrlPathSegment { get; } + + IScreen HostScreen { get; } + } + + public sealed class RoutingState + { + private readonly List> _observers = new(); + + public IObservable CurrentViewModel => new Current(this); + + public void Navigate(IRoutableViewModel viewModel) + { + foreach (var observer in _observers.ToArray()) + { + observer.OnNext(viewModel); + } + } + + private sealed class Current(RoutingState state) : IObservable + { + public IDisposable Subscribe(IObserver observer) + { + state._observers.Add(observer); + return new Unsubscribe(() => state._observers.Remove(observer)); + } + } + + private sealed class Unsubscribe(Func remove) : IDisposable + { + public void Dispose() => remove(); + } + } + + public static class RxApp + { + public static IObserver DefaultExceptionHandler { get; } = new Thrower(); + + private sealed class Thrower : IObserver + { + public void OnCompleted() { } + + public void OnError(Exception error) => throw error; + + public void OnNext(Exception value) => throw value; + } + } + } + """; + + /// The hosts, a view and a view locator; BINDING_NAMESPACE is the ReactiveUI.Binding flavour. + private const string HostsSource = """ + using System.ComponentModel; + using System.Windows.Forms; + using ReactiveUI.SourceGenerators.WinForms; + + namespace Hosts + { + [RoutedControlHost("System.Windows.Forms.UserControl")] + public partial class RoutedHost + { + private IContainer? components; + + private void InitializeComponent() { } + } + + [ViewModelControlHost("System.Windows.Forms.UserControl")] + public partial class ViewModelHost + { + private IContainer? components; + + private void InitializeComponent() { } + } + + public sealed class Shell : ReactiveUI.IScreen + { + public ReactiveUI.RoutingState Router { get; } = new(); + } + + public sealed class PageViewModel : ReactiveUI.IRoutableViewModel + { + public string? UrlPathSegment => "page"; + + public ReactiveUI.IScreen HostScreen { get; } = new Shell(); + } + + public sealed class PageView : UserControl, BINDING_NAMESPACE.IViewFor + { + public PageViewModel? ViewModel { get; set; } + + object? BINDING_NAMESPACE.IViewFor.ViewModel { get => ViewModel; set => ViewModel = (PageViewModel?)value; } + } + + public sealed class Locator : BINDING_NAMESPACE.IViewLocator + { + public BINDING_NAMESPACE.IViewFor? ResolveView(TViewModel viewModel, string? contract) + where TViewModel : class => ResolveView((object?)viewModel, contract); + + public BINDING_NAMESPACE.IViewFor? ResolveView(object? viewModel, string? contract) => + viewModel is PageViewModel ? new PageView() : null; + + public BINDING_NAMESPACE.IViewFor? ResolveViewUnsafe(object? viewModel, string? contract) => ResolveView(viewModel, contract); + } + } + """; + + /// The lean ReactiveUI.Binding namespace. + private const string Binding = "ReactiveUI.Binding"; + + /// The System.Reactive ReactiveUI.Binding namespace. + private const string BindingReactive = "ReactiveUI.Binding.Reactive"; + + /// The hosts' default content property. + private const string DefaultContent = nameof(DefaultContent); + + /// The view-model host's view model property, and a view's. + private const string ViewModel = nameof(ViewModel); + + /// Both hosts follow their properties through ObservedProperty, and no longer write their own observable. + /// The ReactiveUI.Binding flavour. + /// A task to monitor the async. + [Test] + [Arguments(Binding)] + [Arguments(BindingReactive)] + public async Task HostsFollowTheirPropertiesThroughObservedProperty(string bindingNamespace) + { + var (_, generated) = Generate(bindingNamespace); + + foreach (var source in generated) + { + await Assert.That(source).Contains($"global::{bindingNamespace}.ObservedProperty.Create(this, static x => x.DefaultContent, static x => x.DefaultContent)"); + await Assert.That(source).Contains($"global::{bindingNamespace}.ObservedProperty.Switch(global::{bindingNamespace}.ObservedProperty.Create(this, static x => x.ViewContractObservable"); + await Assert.That(source).DoesNotContain("PropertyObservable<"); + await Assert.That(source).DoesNotContain("WhenAny"); + } + } + + /// The routed host shows its default content, then the view for the view model the router navigates to. + /// The ReactiveUI.Binding flavour. + /// A task to monitor the async. + [Test] + [Arguments(Binding)] + [Arguments(BindingReactive)] + public async Task RoutedHostShowsDefaultContentThenTheRoutedView(string bindingNamespace) + { + var assembly = Compile(bindingNamespace); + var host = Create(assembly, "Hosts.RoutedHost"); + var defaultContent = Create(assembly, "System.Windows.Forms.UserControl"); + Set(host, nameof(ViewLocator), Create(assembly, "Hosts.Locator")); + + Set(host, DefaultContent, defaultContent); + await AssertShows(host, defaultContent); + + var router = Create(assembly, "ReactiveUI.RoutingState"); + Set(host, "Router", router); + _ = router.GetType().GetMethod("Navigate")!.Invoke(router, [Create(assembly, "Hosts.PageViewModel")]); + + await AssertShowsPageView(host); + } + + /// The view-model host shows its default content, then the view for the view model it is given. + /// The ReactiveUI.Binding flavour. + /// A task to monitor the async. + [Test] + [Arguments(Binding)] + [Arguments(BindingReactive)] + public async Task ViewModelHostShowsDefaultContentThenTheViewForItsViewModel(string bindingNamespace) + { + var assembly = Compile(bindingNamespace); + var host = Create(assembly, "Hosts.ViewModelHost"); + var defaultContent = Create(assembly, "System.Windows.Forms.UserControl"); + Set(host, nameof(ViewLocator), Create(assembly, "Hosts.Locator")); + + Set(host, DefaultContent, defaultContent); + await AssertShows(host, defaultContent); + + var viewModel = Create(assembly, "Hosts.PageViewModel"); + Set(host, ViewModel, viewModel); + + await AssertShowsPageView(host); + await Assert.That(Get(ShownControls(host)[0]!, ViewModel)).IsSameReferenceAs(viewModel); + } + + /// Gets the source of the hosts, a view and view locator, for a flavour. + /// The ReactiveUI.Binding flavour. + /// The source. + private static string GetHostsSource(string bindingNamespace) => + HostsSource.Replace("BINDING_NAMESPACE", bindingNamespace, StringComparison.Ordinal); + + /// Runs both host generators over a flavour's source, the desktop stubs and ReactiveUI.Binding. + /// The ReactiveUI.Binding flavour. + /// The output compilation and the generated host sources. + private static (Compilation Output, List GeneratedSources) Generate(string bindingNamespace) + { + var bindingAssembly = bindingNamespace == Binding + ? typeof(ReactiveUI.Binding.ObservedProperty).Assembly + : typeof(ReactiveUI.Binding.Reactive.ObservedProperty).Assembly; + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13); + var compilation = CSharpCompilation.Create( + $"ControlHostObservedProperty.{bindingNamespace}", + [ + CSharpSyntaxTree.ParseText(ReactiveUISource, parseOptions), + CSharpSyntaxTree.ParseText(GetHostsSource(bindingNamespace), parseOptions), + CSharpSyntaxTree.ParseText(TestCompilationReferences.WindowsDesktopStubs, parseOptions), + ], + TestCompilationReferences.CreateForAssemblies( + typeof(object).Assembly, + typeof(System.ComponentModel.Component).Assembly, + typeof(System.ComponentModel.INotifyPropertyChanged).Assembly, + typeof(System.Linq.Expressions.Expression).Assembly, + typeof(RoutedControlHostAttribute).Assembly, + bindingAssembly), + new(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + var driver = CSharpGeneratorDriver + .Create([new RoutedControlHostGenerator().AsSourceGenerator(), new ViewModelControlHostGenerator().AsSourceGenerator()], parseOptions: parseOptions) + .RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + + var generated = new List(); + foreach (var result in driver.GetRunResult().Results) + { + foreach (var file in result.GeneratedSources) + { + generated.Add(file.SourceText.ToString()); + } + } + + return (output, generated); + } + + /// Generates, compiles and loads a flavour's hosts. + /// The ReactiveUI.Binding flavour. + /// The loaded assembly. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "SES1402", Justification = "The test loads the assembly it has just compiled from its own source.")] + private static Assembly Compile(string bindingNamespace) + { + var (output, _) = Generate(bindingNamespace); + using var image = new MemoryStream(); + var emit = output.Emit(image); + if (!emit.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, emit.Diagnostics)); + } + + image.Position = 0; + return new AssemblyLoadContext($"{nameof(ControlHostObservedPropertyTests)}.{bindingNamespace}", isCollectible: true).LoadFromStream(image); + } + + /// Creates an instance of a type from the compiled assembly. + /// The compiled assembly. + /// The type's full name. + /// The instance. + private static object Create(Assembly assembly, string typeName) => Activator.CreateInstance(assembly.GetType(typeName, throwOnError: true)!)!; + + /// Reads a public property. + /// The object. + /// The property name. + /// The property's value. + private static object Get(object target, string name) => target.GetType().GetProperty(name)!.GetValue(target)!; + + /// Sets a public property. + /// The object. + /// The property name. + /// The value. + private static void Set(object target, string name, object? value) => target.GetType().GetProperty(name)!.SetValue(target, value); + + /// Lists the controls a host shows. + /// The host. + /// The controls. + private static List ShownControls(object host) + { + var controls = new List(); + foreach (var control in (IEnumerable)Get(host, "Controls")) + { + controls.Add(control); + } + + return controls; + } + + /// Asserts that a host shows exactly one control, the given one. + /// The host. + /// The control it should show. + /// A task to monitor the async. + private static async Task AssertShows(object host, object control) + { + var shown = ShownControls(host); + await Assert.That(shown.Count).IsEqualTo(1); + await Assert.That(shown[0]).IsSameReferenceAs(control); + } + + /// Asserts that a host shows exactly one control, a page view. + /// The host. + /// A task to monitor the async. + private static async Task AssertShowsPageView(object host) + { + var shown = ShownControls(host); + await Assert.That(shown.Count).IsEqualTo(1); + await Assert.That(shown[0]!.GetType().FullName).IsEqualTo("Hosts.PageView"); + } +} diff --git a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/AnalyzerReleases.Shipped.md b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/AnalyzerReleases.Shipped.md index f21a6dcb..ba84b6ab 100644 --- a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/AnalyzerReleases.Shipped.md +++ b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/AnalyzerReleases.Shipped.md @@ -18,6 +18,7 @@ Rule ID | Category | Severity | Notes RXUISG0002 | ReactiveUI.SourceGenerators.CodeFixers.ReactiveCommandAnalyzer | Error | A `[ReactiveCommand]` method takes more than one parameter besides a CancellationToken RXUISG0008 | ReactiveUI.SourceGenerators.CodeFixers.ReactiveCommandAnalyzer | Error | A `[ReactiveCommand]` method is async void RXUISG0021 | ReactiveUI.SourceGenerators.CodeFixers.ReactiveCommandAnalyzer | Warning | A `[ReactiveCommand]` OutputScheduler or BackgroundScheduler does not resolve to a scheduler +RXUISG0022 | ReactiveUI.SourceGenerators.CodeFixers.ControlHostAnalyzer | Info | A WinForms host follows its properties without ReactiveUI.Binding 8.4.0's ObservedProperty ## Rules diff --git a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/CodeFixers/ControlHostAnalyzer.cs b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/CodeFixers/ControlHostAnalyzer.cs new file mode 100644 index 00000000..7adeaf18 --- /dev/null +++ b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/CodeFixers/ControlHostAnalyzer.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using ReactiveUI.SourceGenerators.Helpers; +using static ReactiveUI.SourceGenerators.CodeFixers.Diagnostics.DiagnosticDescriptors; + +namespace ReactiveUI.SourceGenerators.CodeFixers; + +/// Reports WinForms hosts generated without ReactiveUI.Binding's ObservedProperty. +/// The rule is the host generators' own, from the both compile. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ControlHostAnalyzer : DiagnosticAnalyzer +{ + /// Gets the diagnostics this analyzer reports. + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(ControlHostWithoutObservedPropertyRule); + + /// Registers the host analysis for compilations without ObservedProperty. + /// The analysis context. + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(static startContext => + { + if (ViewApiRules.HasObservedProperty(startContext.Compilation)) + { + return; + } + + startContext.RegisterSymbolAction(static symbolContext => AnalyzeType(in symbolContext), SymbolKind.NamedType); + }); + } + + /// Reports a type that is a WinForms host. + /// The symbol analysis context. + private static void AnalyzeType(in SymbolAnalysisContext context) + { + foreach (var attribute in context.Symbol.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() is "ReactiveUI.SourceGenerators.WinForms.RoutedControlHostAttribute" + or "ReactiveUI.SourceGenerators.WinForms.ViewModelControlHostAttribute") + { + context.ReportDiagnostic(Diagnostic.Create( + ControlHostWithoutObservedPropertyRule, + context.Symbol.Locations[0], + context.Symbol.Name, + ViewApiRules.ObservedPropertyMinimumBindingVersion)); + return; + } + } + } +} diff --git a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/DiagnosticDescriptors.cs b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/DiagnosticDescriptors.cs index 1b3c0d85..60918337 100644 --- a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/DiagnosticDescriptors.cs +++ b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/Diagnostics/DiagnosticDescriptors.cs @@ -54,6 +54,17 @@ internal static class DiagnosticDescriptors + "such as RxSchedulers.MainThreadScheduler, whose type is the scheduler type ReactiveUI's commands take.", helpLinkUri: "https://www.reactiveui.net/docs/handbook/view-models/boilerplate-code.html"); + /// A WinForms host follows its properties without ReactiveUI.Binding's ObservedProperty. + internal static readonly DiagnosticDescriptor ControlHostWithoutObservedPropertyRule = new( + id: "RXUISG0022", + title: "WinForms host follows its properties without ObservedProperty", + messageFormat: "`{0}` follows its own properties through PropertyChanged; with ReactiveUI.Binding {1} or later it would follow them with WhenAnyValue semantics", + category: typeof(ControlHostAnalyzer).FullName, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "The [RoutedControlHost] and [ViewModelControlHost] hosts use ReactiveUI.Binding's ObservedProperty when the referenced ReactiveUI.Binding has it.", + helpLinkUri: "https://www.reactiveui.net/docs/handbook/view-models/boilerplate-code.html"); + /// The `[Reactive]` attribute was used on a property, but required `partial` modifiers are missing. internal static readonly DiagnosticDescriptor ReactiveAttributeRequiresPartialRule = new( id: "RXUISG0020", diff --git a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/ReactiveUI.SourceGenerators.Analyzers.CodeFixes.csproj b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/ReactiveUI.SourceGenerators.Analyzers.CodeFixes.csproj index 69f62a31..4447fb54 100644 --- a/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/ReactiveUI.SourceGenerators.Analyzers.CodeFixes.csproj +++ b/src/ReactiveUI.SourceGenerators.Analyzers.CodeFixes/ReactiveUI.SourceGenerators.Analyzers.CodeFixes.csproj @@ -32,6 +32,8 @@ + + diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostWriter.cs b/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostExtensions.cs similarity index 55% rename from src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostWriter.cs rename to src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostExtensions.cs index b40106ec..060a7bfc 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostWriter.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/Core/CodeGeneration/ControlHostExtensions.cs @@ -2,16 +2,64 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using ReactiveUI.SourceGenerators.Models; + namespace ReactiveUI.SourceGenerators.CodeGeneration; /// The members both Windows Forms hosts share, written through a . /// -/// A host follows its own properties through its PropertyChanged event, never through WhenAny: the -/// binding engine's generator cannot see call sites in this generator's output, so a WhenAny in a host would not -/// be dispatched. +/// A host never calls WhenAny: the binding engine's generator cannot see call sites in this generator's output, +/// so a WhenAny in a host would not be dispatched. With ReactiveUI.Binding 8.4.0 or later a host follows its +/// properties through Binding's ObservedProperty, which has WhenAnyValue's semantics; with anything older +/// it follows them through its own PropertyObservable over its PropertyChanged event. /// -internal static class ControlHostWriter +internal static class ControlHostExtensions { + /// Writes the observables a host follows its own properties with. + /// The writer the observable expression is appended to. + extension(SourceWriter writer) + { + /// Writes an observable of a host property's value: its current value, then each change. + /// The detected ReactiveUI integration, which says whether ObservedProperty exists. + /// The property's type. + /// The property's name. + /// The writer. + internal SourceWriter AppendPropertyValue(ReactiveUiIntegration integration, string type, string property) => + integration.HasObservedProperty + ? writer.AppendObservedPropertyCreate(integration, property) + : writer.Append("new PropertyObservable<").Append(type).Append(">(this, nameof(").Append(property) + .Append("), () => new ReturnObservable<").Append(type).Append(">(").Append(property).Append("))"); + + /// Writes an observable of what the observable a host property holds produces, switching as it changes. + /// The ReactiveUI integration, which names the ObservedProperty flavour when there is one. + /// The type the property's observable produces. + /// The name of the property holding the observable. + /// The writer, after the expression. + internal SourceWriter AppendPropertyObservable(ReactiveUiIntegration integration, string type, string property) => + integration.HasObservedProperty + ? writer.Append(integration.ObservedProperty).Append(".Switch(").AppendObservedPropertyCreate(integration, property).Append(')') + : writer.Append("new PropertyObservable<").Append(type).Append(">(this, nameof(").Append(property) + .Append("), () => ").Append(property).Append(')'); + + /// Writes an observable of the routed host's current view model, following Router.CurrentViewModel. + /// The integration that decides between ObservedProperty and the host's own observable. + /// The writer, after the routed view model expression. + internal SourceWriter AppendRoutedViewModel(ReactiveUiIntegration integration) => + integration.HasObservedProperty + ? writer.Append(integration.ObservedProperty).Append(".Switch(") + .Append(integration.ObservedProperty).Append(".Then(").AppendObservedPropertyCreate(integration, "Router") + .Append(", static router => router.CurrentViewModel, static router => router.CurrentViewModel))") + : writer.Append("new PropertyObservable(this, nameof(Router), () => Router?.CurrentViewModel)"); + + /// Writes ObservedProperty.Create for a host property, with static lambdas that do not allocate. + /// The integration naming the flavour ObservedProperty is written from. + /// The observed property's name. + /// The writer, after the call. + private SourceWriter AppendObservedPropertyCreate(ReactiveUiIntegration integration, string property) => + writer.Append(integration.ObservedProperty).Append(".Create(this, static x => x.").Append(property) + .Append(", static x => x.").Append(property).Append(')'); + } + /// Writes the host's change events and its IReactiveObject implementation. /// The writer, at the level of the host's members. /// diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Extensions/ContextExtensions.cs b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Extensions/ContextExtensions.cs index 0db54264..aa408cd6 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Extensions/ContextExtensions.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Extensions/ContextExtensions.cs @@ -22,23 +22,18 @@ internal static class ContextExtensions /// The metadata name of the ReactiveUI primitive void type. private const string RxVoidMetadataName = "ReactiveUI.Primitives.RxVoid"; - /// The metadata name of the IViewFor<T> declared by ReactiveUI releases not built on ReactiveUI.Binding. - private const string ReactiveUIViewForMetadataName = "ReactiveUI.IViewFor`1"; - - /// The metadata name of ReactiveUI.Binding's IViewFor<T>. - private const string BindingViewForMetadataName = "ReactiveUI.Binding.IViewFor`1"; - - /// The metadata name of ReactiveUI.Binding.Reactive's IViewFor<T>. - private const string BindingReactiveViewForMetadataName = "ReactiveUI.Binding.Reactive.IViewFor`1"; - /// Provides extension members for compilations. /// The compilation to extend. extension(Compilation compilation) { /// Gets the ReactiveUI integration supported by this compilation. - /// The ReactiveUI API, command behavior and view API supported by this compilation. + /// The ReactiveUI API, command behavior, view API and property observation supported by this compilation. internal ReactiveUiIntegration GetReactiveUiIntegration() => - GetApiIntegration(compilation) with { ViewApi = GetViewApi(compilation) }; + GetApiIntegration(compilation) with + { + ViewApi = GetViewApi(compilation), + HasObservedProperty = ViewApiRules.HasObservedProperty(compilation), + }; } /// Provides extension members for generator attribute syntax contexts. @@ -315,29 +310,14 @@ private static ReactiveUiIntegration GetApiIntegration(Compilation compilation) /// Gets which assembly declares the IViewFor interfaces and the view locator a compilation uses. /// The compilation to inspect. - /// The view API. - /// - /// A ReactiveUI that declares its own IViewFor<T> is not built on ReactiveUI.Binding, so its interface - /// wins even when ReactiveUI.Binding is referenced too. Otherwise the ReactiveUI.Binding flavour matching the - /// ReactiveUI flavour is used: the System.Reactive one when ReactiveUI.Reactive is referenced or it is the only - /// one present. - /// - private static ReactiveUiViewApi GetViewApi(Compilation compilation) - { - if (compilation.GetTypeByMetadataName(ReactiveUIViewForMetadataName) is not null) + /// The view API, chosen by . + private static ReactiveUiViewApi GetViewApi(Compilation compilation) => + ViewApiRules.GetViewNamespace(compilation) switch { - return ReactiveUiViewApi.ReactiveUI; - } - - var hasBinding = compilation.GetTypeByMetadataName(BindingViewForMetadataName) is not null; - if (compilation.GetTypeByMetadataName(BindingReactiveViewForMetadataName) is not null - && (!hasBinding || compilation.GetTypeByMetadataName("ReactiveUI.Reactive.ReactiveCommand") is not null)) - { - return ReactiveUiViewApi.BindingReactive; - } - - return hasBinding ? ReactiveUiViewApi.Binding : ReactiveUiViewApi.ReactiveUI; - } + ViewApiRules.BindingNamespace => ReactiveUiViewApi.Binding, + ViewApiRules.BindingReactiveNamespace => ReactiveUiViewApi.BindingReactive, + _ => ReactiveUiViewApi.ReactiveUI, + }; /// Gets the integration details from a referenced ReactiveUI assembly. /// The compilation to inspect. diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Helpers/ViewApiRules.cs b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Helpers/ViewApiRules.cs new file mode 100644 index 00000000..8c5bce6d --- /dev/null +++ b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Helpers/ViewApiRules.cs @@ -0,0 +1,75 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace ReactiveUI.SourceGenerators.Helpers; + +/// The rules that pick the assembly whose view API, and property observation, generated hosts use. +/// +/// The generators and the code-fix analyzers both compile this file, so a host the generator writes without +/// ObservedProperty is exactly the host the analyzer reports. +/// +internal static class ViewApiRules +{ + /// The namespace of ReactiveUI's own view API, on releases not built on ReactiveUI.Binding. + internal const string ReactiveUINamespace = "ReactiveUI"; + + /// The namespace of the lean ReactiveUI.Binding. + internal const string BindingNamespace = "ReactiveUI.Binding"; + + /// The namespace of the System.Reactive ReactiveUI.Binding. + internal const string BindingReactiveNamespace = "ReactiveUI.Binding.Reactive"; + + /// The first ReactiveUI.Binding release with ObservedProperty. + internal const string ObservedPropertyMinimumBindingVersion = "8.4.0"; + + /// The metadata name of ReactiveUI's own IViewFor<T>. + private const string ReactiveUIViewForMetadataName = $"{ReactiveUINamespace}.IViewFor`1"; + + /// The metadata name of the lean ReactiveUI.Binding's IViewFor<T>. + private const string BindingViewForMetadataName = $"{BindingNamespace}.IViewFor`1"; + + /// The metadata name of the System.Reactive ReactiveUI.Binding's IViewFor<T>. + private const string BindingReactiveViewForMetadataName = $"{BindingReactiveNamespace}.IViewFor`1"; + + /// Gets the namespace declaring the IViewFor interfaces and the view locator a compilation uses. + /// The compilation. + /// The namespace, without the global:: alias. + /// + /// A ReactiveUI that declares its own IViewFor<T> is not built on ReactiveUI.Binding, so its interface + /// wins even when ReactiveUI.Binding is referenced too. Otherwise the ReactiveUI.Binding flavour matching the + /// ReactiveUI flavour is used: the System.Reactive one when ReactiveUI.Reactive is referenced or it is the only + /// one present. + /// + internal static string GetViewNamespace(Compilation compilation) + { + if (compilation.GetTypeByMetadataName(ReactiveUIViewForMetadataName) is not null) + { + return ReactiveUINamespace; + } + + var hasBinding = compilation.GetTypeByMetadataName(BindingViewForMetadataName) is not null; + if (compilation.GetTypeByMetadataName(BindingReactiveViewForMetadataName) is not null + && (!hasBinding || compilation.GetTypeByMetadataName("ReactiveUI.Reactive.ReactiveCommand") is not null)) + { + return BindingReactiveNamespace; + } + + return hasBinding ? BindingNamespace : ReactiveUINamespace; + } + + /// Determines whether the view API a compilation uses has ReactiveUI.Binding's ObservedProperty. + /// The compilation. + /// + /// when the compilation uses a ReactiveUI.Binding flavour at version + /// or later. + /// + internal static bool HasObservedProperty(Compilation compilation) + { + var viewNamespace = GetViewNamespace(compilation); + return viewNamespace != ReactiveUINamespace + && compilation.GetTypeByMetadataName($"{viewNamespace}.ObservedProperty") is not null; + } +} diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Models/ReactiveUiIntegration.cs b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Models/ReactiveUiIntegration.cs index cdbdb953..2e0762b2 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/Core/Models/ReactiveUiIntegration.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/Core/Models/ReactiveUiIntegration.cs @@ -8,8 +8,17 @@ namespace ReactiveUI.SourceGenerators.Models; /// The implementation API selected by the compilation. /// Whether the compilation references ReactiveUI 22 or later. /// The assembly declaring the IViewFor interfaces and the view locator. -internal readonly record struct ReactiveUiIntegration(ReactiveUiApi Api, bool IsNewerThan22, ReactiveUiViewApi ViewApi = ReactiveUiViewApi.ReactiveUI) +/// Whether the view API's assembly has ReactiveUI.Binding's ObservedProperty. +internal readonly record struct ReactiveUiIntegration( + ReactiveUiApi Api, + bool IsNewerThan22, + ReactiveUiViewApi ViewApi = ReactiveUiViewApi.ReactiveUI, + bool HasObservedProperty = false) { + /// Gets ReactiveUI.Binding's ObservedProperty in the view API's flavour. + /// Only meaningful when is set. + internal string ObservedProperty => $"{ViewNamespace}.ObservedProperty"; + /// Gets the namespace containing the selected ReactiveUI implementation types. internal string Namespace => Api == ReactiveUiApi.SystemReactive ? "global::ReactiveUI.Reactive" diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs b/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs index 80b79d0c..97eefd80 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/RoutedControlHost/RoutedControlHostGenerator.Execute.cs @@ -113,11 +113,15 @@ private static string GenerateSource(RoutedControlHostInfo info, ReactiveUiInteg .Append(" : ").Append(info.BaseTypeName).Line(", IReactiveObject") .OpenBlock(); - WriteConstructor(writer, info.TargetName, exceptionHandler); + WriteConstructor(writer, info.TargetName, exceptionHandler, integration); WriteProperties(writer.BlankLine(), integration); WriteDispose(writer.BlankLine()); WriteRouting(writer.BlankLine(), integration); - ControlHostWriter.WritePropertyObservable(writer.BlankLine()); + if (!integration.HasObservedProperty) + { + ControlHostExtensions.WritePropertyObservable(writer.BlankLine()); + } + WriteObservableHelpers(writer.BlankLine()); WriteCombineLatestSubscription(writer.BlankLine()); WriteDisposableCollection(writer.BlankLine()); @@ -156,7 +160,8 @@ private static void WriteFileHeader(SourceWriter writer, ReactiveUiIntegration i /// The writer, at the level of the host's members. /// The host type's name. /// The ReactiveUI default exception handler subscriptions report to. - private static void WriteConstructor(SourceWriter writer, string typeName, string exceptionHandler) => + /// The detected ReactiveUI integration, which picks how the host follows its properties. + private static void WriteConstructor(SourceWriter writer, string typeName, string exceptionHandler, ReactiveUiIntegration integration) => _ = writer.Lines(""" private readonly DisposableCollection _disposables = new(); private RoutingState? _router; @@ -170,9 +175,9 @@ private static void WriteConstructor(SourceWriter writer, string typeName, strin .Line("/// ") .Append("public ").Append(typeName).Line("()") .OpenBlock() + .Line("InitializeComponent();") + .Append("_disposables.Add(").AppendPropertyValue(integration, "Control?", "DefaultContent").Line(".Subscribe(new ValueObserver(x =>") .Lines(""" - InitializeComponent(); - _disposables.Add(new PropertyObservable(this, nameof(DefaultContent), () => new ReturnObservable(DefaultContent)).Subscribe(new ValueObserver(x => { if (x is not null && Controls.Count == 0) { @@ -190,9 +195,9 @@ private static void WriteConstructor(SourceWriter writer, string typeName, strin .Lines(""" _disposables.Add(routeSubscription); routeSubscription.Connect( - new PropertyObservable(this, nameof(Router), () => Router?.CurrentViewModel), - new PropertyObservable(this, nameof(ViewContractObservable), () => ViewContractObservable)); """) + .Indent().AppendRoutedViewModel(integration).Line(",") + .AppendPropertyObservable(integration, "string", "ViewContractObservable").Line(");").Outdent() .CloseBlock(); /// Writes the host's events and properties, and its IReactiveObject implementation. @@ -200,7 +205,7 @@ private static void WriteConstructor(SourceWriter writer, string typeName, strin /// The detected ReactiveUI integration, which names the view locator's interface. private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration integration) { - ControlHostWriter.WritePropertyChangeEvents(writer); + ControlHostExtensions.WritePropertyChangeEvents(writer); _ = writer.BlankLine().Lines(""" /// /// Gets or sets the default content. diff --git a/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs b/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs index 21331514..12bfdb10 100644 --- a/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs +++ b/src/ReactiveUI.SourceGenerators.Roslyn/ViewModelControlHost/ViewModelControlHostGenerator.Execute.cs @@ -111,9 +111,13 @@ private static string GenerateSource(ViewModelControlHostInfo info, ReactiveUiIn WriteProperties(writer.BlankLine(), integration); WriteBindableProperties(writer.BlankLine()); WriteDispose(writer.BlankLine()); - WriteSetupBindings(writer.BlankLine(), exceptionHandler); + WriteSetupBindings(writer.BlankLine(), exceptionHandler, integration); WriteUpdateContent(writer.BlankLine(), integration); - ControlHostWriter.WritePropertyObservable(writer.BlankLine()); + if (!integration.HasObservedProperty) + { + ControlHostExtensions.WritePropertyObservable(writer.BlankLine()); + } + WriteObservableHelpers(writer.BlankLine()); WriteCombineLatestSubscription(writer.BlankLine()); WriteDisposableCollection(writer.BlankLine()); @@ -178,7 +182,7 @@ private static void WriteConstructor(SourceWriter writer, string typeName) => /// The detected ReactiveUI integration, which names the view locator's interface. private static void WriteProperties(SourceWriter writer, ReactiveUiIntegration integration) { - ControlHostWriter.WritePropertyChangeEvents(writer); + ControlHostExtensions.WritePropertyChangeEvents(writer); _ = writer.BlankLine().Lines(""" /// /// Gets or sets a value indicating whether [default cache views enabled]. @@ -265,11 +269,12 @@ protected override void Dispose(bool disposing) /// Writes the bindings that swap the hosted view as the content, default content, and view model change. /// The writer, at the level of the host's members. /// The ReactiveUI default exception handler subscriptions report to. - private static void WriteSetupBindings(SourceWriter writer, string exceptionHandler) => + /// The detected ReactiveUI integration, which picks how the host follows its properties. + private static void WriteSetupBindings(SourceWriter writer, string exceptionHandler, ReactiveUiIntegration integration) => _ = writer.Line("private void SetupBindings()") .OpenBlock() + .Append("AddSubscription(").AppendPropertyValue(integration, "object?", "Content").Line(", new ValueObserver(x =>") .Lines(""" - AddSubscription(new PropertyObservable(this, nameof(Content), () => new ReturnObservable(Content)), new ValueObserver(x => { if (x is not Control control) { @@ -290,8 +295,8 @@ private static void WriteSetupBindings(SourceWriter writer, string exceptionHand ResumeLayout(); """) .Append("}, ").Append(exceptionHandler).Line("));") + .Append("AddSubscription(").AppendPropertyValue(integration, "Control?", "DefaultContent").Line(", new ValueObserver(x =>") .Lines(""" - AddSubscription(new PropertyObservable(this, nameof(DefaultContent), () => new ReturnObservable(DefaultContent)), new ValueObserver(x => { if (x is not null) { @@ -308,9 +313,9 @@ private static void WriteSetupBindings(SourceWriter writer, string exceptionHand .Lines(""" _disposables.Add(viewModelSubscription); viewModelSubscription.Connect( - new PropertyObservable(this, nameof(ViewModel), () => new ReturnObservable(ViewModel)), - new PropertyObservable(this, nameof(ViewContractObservable), () => ViewContractObservable)); """) + .Indent().AppendPropertyValue(integration, "object?", "ViewModel").Line(",") + .AppendPropertyObservable(integration, "string", "ViewContractObservable").Line(");").Outdent() .CloseBlock(); /// Writes the methods that register a subscription and resolve the view for a new view model.