Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ src/
│ │ ├── WhenChangingInvocationGenerator.cs # Before-change observation
│ │ ├── BindOneWayInvocationGenerator.cs # One-way binding
│ │ ├── BindTwoWayInvocationGenerator.cs # Two-way binding
│ │ └── WhenAnyValueInvocationGenerator.cs # WhenAnyValue compat shim
│ │ ├── WhenAnyValueInvocationGenerator.cs # WhenAnyValue compat shim
│ │ └── InvokeCommandInvocationGenerator.cs # Stream-driven command execution
│ ├── Helpers/ # Extraction and validation helpers
│ │ ├── ViewRegistrationExtractor.cs # IViewFor<T> → ViewRegistrationInfo extraction
│ │ └── ... # ExtractorValidation, SymbolHelpers, etc.
Expand Down Expand Up @@ -263,7 +264,7 @@ generic type inference dominate the `GcVerbose` trace), so a second semantic pas
not affordable. It also means a type from a *referenced* assembly is observed correctly even though the
declaration scan never sees it.

**Pipeline B (Invocation Detection)**: Scans method invocations (`WhenChanged`, `WhenChanging`, `BindOneWay`, `BindTwoWay`, `WhenAnyValue`) → extracts lambda property paths → generates optimized per-call-site observation/binding code. Uses **CallerFilePath + CallerLineNumber dispatch**: API stubs capture caller info, generated dispatch table routes to compile-time generated methods.
**Pipeline B (Invocation Detection)**: Scans method invocations (`WhenChanged`, `WhenChanging`, `BindOneWay`, `BindTwoWay`, `WhenAnyValue`, `InvokeCommand`) → extracts lambda property paths → generates optimized per-call-site observation/binding code. Uses **CallerFilePath + CallerLineNumber dispatch**: API stubs capture caller info, generated dispatch table routes to compile-time generated methods.

**Pipeline C (View Dispatch)**: Scans classes implementing `IViewFor<T>` → extracts `ViewRegistrationInfo` POCOs (VM FQN, View FQN, constructor availability, `[ViewContract]` contract, `[SingleInstanceView]` flag) → generates `ViewDispatch.g.cs` with a type-switch dispatch function. Supports contract-based multi-view resolution (contract checks emitted before default), singleton caching via `Interlocked.CompareExchange`, and 3-tier resolution (service locator → direct construction → null). Views can be excluded with `[ExcludeFromViewRegistration]`.

Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ generation. Zero reflection, fully AOT/trimming safe, 3-7x faster than the legac

ReactiveUI.Binding.SourceGenerators is an incremental source generator that analyses your `WhenChanged`, `WhenChanging`,
`WhenAnyValue`, `WhenAny`, `WhenAnyObservable`, `BindOneWay`, `BindTwoWay`, `OneWayBind`, `Bind`, `BindTo`,
`BindCommand`, and `BindInteraction` call sites at compile time and emits optimised, strongly-typed observation and
`BindCommand`, `BindInteraction`, and `InvokeCommand` call sites at compile time and emits optimised, strongly-typed observation and
binding code. It eliminates:

- **Runtime expression-tree compilation** -- no `Expression<Func<T>>` evaluation at runtime
Expand Down Expand Up @@ -186,6 +186,7 @@ Platform-specific packages provide DependencyProperty observation and other plat
| `BindTo` | Apply an observable stream to a target property |
| `BindCommand` | Bind a command property to a UI element |
| `BindInteraction` | Bind an interaction to a handler |
| `InvokeCommand` | Execute a command with each value an observable produces |

All APIs support single properties, deep property chains (e.g. `x => x.Address.City`), and multi-property observation (
up to 12 properties for `WhenAnyValue`/`WhenChanged`).
Expand Down Expand Up @@ -271,6 +272,20 @@ IDisposable binding = view.OneWayBind(vm, x => x.Name, x => x.NameLabel);
IDisposable binding = view.Bind(vm, x => x.Name, x => x.NameTextBox);
```

### Invoking a Command

```csharp
// Execute the command a view model property holds with each value the stream produces. The value is the
// command parameter, and a value the command refuses through CanExecute is dropped.
IDisposable invocation = searchText.InvokeCommand(vm, x => x.Search);

// Execute a command the caller already has, which needs no property to observe.
IDisposable invocation = searchText.InvokeCommand(vm.Search);
```

A `ReactiveCommand` is reached through these overloads like any other `ICommand`; the parameter arrives as
`object` rather than the command's declared input type.

### Scheduler Overloads

Scheduler overloads require the `ReactiveUI.Binding.Reactive` package:
Expand Down
36 changes: 34 additions & 2 deletions src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,22 @@ internal static bool HasBeforeChangeSupport(
/// <param name="methodSymbol">The method symbol to extract from.</param>
/// <returns>The first type argument as <see cref="INamedTypeSymbol"/>, or null.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static INamedTypeSymbol? ExtractFirstTypeArgument(IMethodSymbol methodSymbol) => methodSymbol.TypeArguments.IsEmpty ? null : methodSymbol.TypeArguments[0] as INamedTypeSymbol;
internal static INamedTypeSymbol? ExtractFirstTypeArgument(IMethodSymbol methodSymbol) => ExtractTypeArgument(methodSymbol, 0);

/// <summary>
/// Extracts one of a method's type arguments as an <see cref="INamedTypeSymbol"/>. Returns null when the
/// method has fewer arguments than that, or when the one asked for is not a named type.
/// </summary>
/// <param name="methodSymbol">The method symbol to extract from.</param>
/// <param name="index">Which type argument to read.</param>
/// <returns>The type argument as <see cref="INamedTypeSymbol"/>, or null.</returns>
/// <remarks>
/// Which argument names the observed object differs by API: most name it first, while the ones taking a
/// stream the caller already built name that stream's value type there instead.
/// </remarks>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static INamedTypeSymbol? ExtractTypeArgument(IMethodSymbol methodSymbol, int index) =>
methodSymbol.TypeArguments.Length <= index ? null : methodSymbol.TypeArguments[index] as INamedTypeSymbol;

/// <summary>
/// Determines whether a method's first type argument lacks any observable notification mechanism.
Expand All @@ -110,12 +125,29 @@ internal static bool HasBeforeChangeSupport(
/// <param name="compilation">The current compilation.</param>
/// <param name="sourceType">The resolved source type, if the check matched.</param>
/// <returns><c>true</c> if the type has no observable mechanism.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static bool LacksObservableMechanism(
IMethodSymbol methodSymbol,
Compilation compilation,
out INamedTypeSymbol? sourceType) =>
LacksObservableMechanism(methodSymbol, compilation, 0, out sourceType);

/// <summary>
/// Determines whether the type argument naming this API's observed object lacks any observable notification
/// mechanism. Returns <c>false</c> when the method has no such argument (a non-generic dispatch overload).
/// </summary>
/// <param name="methodSymbol">The method symbol.</param>
/// <param name="compilation">The current compilation.</param>
/// <param name="typeArgumentIndex">Which type argument names the observed object.</param>
/// <param name="sourceType">The resolved source type, if the check matched.</param>
/// <returns><c>true</c> if the type has no observable mechanism.</returns>
internal static bool LacksObservableMechanism(
IMethodSymbol methodSymbol,
Compilation compilation,
int typeArgumentIndex,
out INamedTypeSymbol? sourceType)
{
sourceType = ExtractFirstTypeArgument(methodSymbol);
sourceType = ExtractTypeArgument(methodSymbol, typeArgumentIndex);
return sourceType is not null && !TypeAnalyzer.HasObservableMechanism(sourceType, compilation);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public class MixinShadowAnalyzer : DiagnosticAnalyzer
Constants.BindToMethodName,
Constants.BindCommandMethodName,
Constants.BindInteractionMethodName,
Constants.InvokeCommandMethodName,
}.ToImmutableHashSet(StringComparer.Ordinal);

/// <summary>The diagnostics this analyzer reports.</summary>
Expand Down
12 changes: 10 additions & 2 deletions src/ReactiveUI.Binding.Analyzer/Analyzers/TypeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,24 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context)
return;
}

// The check reads the first type argument, which every other API names the observed object with.
// The check reads the first type argument, which most APIs name the observed object with.
// BindTo names the value type of a stream the caller already built, and nothing about that type is
// ever observed, so asking whether it notifies has no answer worth reporting.
if (methodSymbol.Name == Constants.BindToMethodName)
{
return;
}

// InvokeCommand names that stream's value type first as well; the object it observes is the one holding
// the command, which it names second.
var observedTypeArgument = methodSymbol.Name == Constants.InvokeCommandMethodName ? 1 : 0;

// Check if the source type lacks any observable mechanism
if (!AnalyzerHelpers.LacksObservableMechanism(methodSymbol, context.Compilation, out var sourceType))
if (!AnalyzerHelpers.LacksObservableMechanism(
methodSymbol,
context.Compilation,
observedTypeArgument,
out var sourceType))
{
return;
}
Expand Down
156 changes: 156 additions & 0 deletions src/ReactiveUI.Binding.Shared/CommandBinding/CommandInvoker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// 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.ComponentModel;
using System.Runtime.ExceptionServices;
using System.Windows.Input;

#if REACTIVE_SHIM
namespace ReactiveUI.Binding.Reactive.CommandBinding;
#else
namespace ReactiveUI.Binding.CommandBinding;
#endif

/// <summary>Executes a command with each value a sequence produces.</summary>
/// <remarks>
/// <para>
/// What an <c>InvokeCommand</c> does once the command is in hand, whether the generator resolved the command
/// property at compile time or the runtime engine read it from the expression. Both hand the command here rather
/// than writing the gating themselves, so a generated call site emits one call and the two paths cannot drift.
/// </para>
/// <para>
/// <see cref="ICommand.CanExecute"/> is asked at each emission rather than tracked from
/// <see cref="ICommand.CanExecuteChanged"/>: the value being offered is the command parameter, so only the
/// command can answer for that value, and an answer tracked from the event would have to be recomputed per value
/// anyway. A value the command refuses is dropped rather than held.
/// </para>
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class CommandInvoker
{
/// <summary>Executes one command with each value the sequence produces.</summary>
/// <typeparam name="T">The type of the value offered as the command parameter.</typeparam>
/// <param name="source">The sequence driving the executions.</param>
/// <param name="command">The command to execute.</param>
/// <returns>A disposable that, when disposed, stops executing the command.</returns>
/// <exception cref="ArgumentNullException"><paramref name="source"/> or <paramref name="command"/> is null.</exception>
public static IDisposable Invoke<T>(IObservable<T> source, ICommand command)
{
ArgumentExceptionHelper.ThrowIfNull(source);
ArgumentExceptionHelper.ThrowIfNull(command);

return source.Subscribe(new FixedCommandObserver<T>(command));
}

/// <summary>Executes whichever command the observed sequence of commands last produced.</summary>
/// <typeparam name="T">The type of the value offered as the command parameter.</typeparam>
/// <param name="source">The sequence driving the executions.</param>
/// <param name="commands">The command to execute, as the observed property produces it.</param>
/// <returns>A disposable that, when disposed, stops executing and stops observing the property.</returns>
/// <exception cref="ArgumentNullException"><paramref name="source"/> or <paramref name="commands"/> is null.</exception>
/// <remarks>
/// <para>
/// The commands are subscribed first, so the command the property already holds is latched before any value
/// can arrive. Subscribing in the other order drops a value produced immediately.
/// </para>
/// <para>
/// A null command - a property not yet assigned, or a path through an absent parent - drops the values
/// offered while it stands, and a later command picks up from the next value. Replacing the command executes
/// nothing by itself: values are what drive an execution, which is why the two sequences are not combined.
/// </para>
/// </remarks>
public static IDisposable Invoke<T>(IObservable<T> source, IObservable<ICommand?> commands)
{
ArgumentExceptionHelper.ThrowIfNull(source);
ArgumentExceptionHelper.ThrowIfNull(commands);

var latch = new CommandLatch();
var commandSubscription = commands.Subscribe(latch);

return new MultipleDisposable(commandSubscription, source.Subscribe(new LatchedCommandObserver<T>(latch)));
}

/// <summary>Offers each value to a command fixed for the lifetime of the subscription.</summary>
/// <typeparam name="T">The type of the value offered as the command parameter.</typeparam>
/// <param name="command">The command to execute.</param>
private sealed class FixedCommandObserver<T>(ICommand command) : IObserver<T>
{
/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnCompleted()
{
}

/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnError(Exception error) => ExceptionDispatchInfo.Capture(error).Throw();

/// <inheritdoc/>
public void OnNext(T value)
{
if (!command.CanExecute(value))
{
return;
}

command.Execute(value);
}
}

/// <summary>Holds the command an observed property last produced.</summary>
/// <remarks>
/// The command is written on whichever thread raised the property notification and read on whichever thread
/// a value arrives on, which are not the same thread in the general case.
/// </remarks>
private sealed class CommandLatch : IObserver<ICommand?>
{
/// <summary>The command the property last produced.</summary>
private ICommand? _command;

/// <summary>Gets the command the property last produced.</summary>
internal ICommand? Command => Volatile.Read(ref _command);

/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnCompleted()
{
}

/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnError(Exception error) => ExceptionDispatchInfo.Capture(error).Throw();

/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnNext(ICommand? value) => Volatile.Write(ref _command, value);
}

/// <summary>Offers each value to whichever command the latch currently holds.</summary>
/// <typeparam name="T">The type of the value offered as the command parameter.</typeparam>
/// <param name="latch">The latch holding the command.</param>
private sealed class LatchedCommandObserver<T>(CommandLatch latch) : IObserver<T>
{
/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnCompleted()
{
}

/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnError(Exception error) => ExceptionDispatchInfo.Capture(error).Throw();

/// <inheritdoc/>
public void OnNext(T value)
{
var command = latch.Command;
if (command is null || !command.CanExecute(value))
{
return;
}

command.Execute(value);
}
}
}
53 changes: 53 additions & 0 deletions src/ReactiveUI.Binding.Shared/Fallback/RuntimeCommandFallback.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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.ComponentModel;
using System.Windows.Input;

#if REACTIVE_SHIM
namespace ReactiveUI.Binding.Reactive.Fallback;
#else
namespace ReactiveUI.Binding.Fallback;
#endif

/// <summary>Resolves the command an <c>InvokeCommand</c> executes through the runtime expression engine.</summary>
/// <remarks>
/// Reached only where the generator could not serve the call site - a receiver it cannot name, a selector that is
/// not an inline lambda, or a build whose compiler predates the dispatch this package emits. The executions
/// themselves are the generated path's, because both hand the resolved command to
/// <see cref="CommandBinding.CommandInvoker"/>; only how the command is found differs.
/// </remarks>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class RuntimeCommandFallback
{
/// <summary>Executes the command an observed property holds with each value the sequence produces.</summary>
/// <typeparam name="T">The type of the value offered as the command parameter.</typeparam>
/// <typeparam name="TTarget">The type declaring the observed command property.</typeparam>
/// <param name="source">The sequence driving the executions.</param>
/// <param name="target">The object declaring the command property.</param>
/// <param name="commandProperty">The property holding the command to execute.</param>
/// <returns>A disposable that, when disposed, stops executing the command.</returns>
/// <exception cref="ArgumentNullException"><paramref name="source"/> or <paramref name="commandProperty"/> is null.</exception>
/// <remarks>
/// A null target holds no property to observe, so the values are dropped rather than faulting the sequence -
/// the same outcome as a target whose command property is null, which is the ordinary case before a view
/// model is assigned.
/// </remarks>
[RequiresUnreferencedCode("Runtime command fallback resolves the property chain by reflection.")]
public static IDisposable InvokeCommand<T, TTarget>(
IObservable<T> source,
TTarget? target,
Expression<Func<TTarget, ICommand?>> commandProperty)
where TTarget : class
{
ArgumentExceptionHelper.ThrowIfNull(source);
ArgumentExceptionHelper.ThrowIfNull(commandProperty);

return target is null
? EmptyDisposable.Instance
: CommandBinding.CommandInvoker.Invoke(
source,
RuntimeObservationFallback.WhenAnyValue(target, commandProperty));
}
}
Loading
Loading