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
16 changes: 11 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -474,12 +474,16 @@ a view's unit test. The MAUI invoker catches it. It treats the object as having
1. It uses the first registered invoker that claims the target. A generated binding also passes a fallback: an
invoker to use when no registered one claims the target. With no invoker at all, the source comes back
unchanged.
2. A notification runs inline when nothing is queued and `CheckAccess` passes.
3. Any other notification is queued. One callback empties the queue. It runs on `BindingSchedulers.MainThread`
when that is set, and through `Post` otherwise.
2. A notification runs inline when nothing is waiting and `CheckAccess` passes.
3. Any other notification waits. One drain delivers what waits. It runs on `BindingSchedulers.MainThread` when that
is set, and through `Post` otherwise.

A notification that arrives behind queued ones waits its turn, even on the owning thread. The view sees values in
the order the source produced them.
Only the latest value waits. A newer value replaces it, even one raised on the owning thread while a drain runs.
Completion and errors wait beside the value and are delivered after it.

Keeping every value breaks two-way bindings. Writing a view raises the view's own change at once. If a newer value
is still waiting, that echo writes the older value back to the view model. The write raises another change, and
the two sides bounce forever. `ViewWriteSchedulingRuntimeTests` covers this for `Bind` and `BindTwoWay`.

`MainThread` only carries writes from another thread to a claimed object. It never sees an on-thread write. It
never sees a write to an unclaimed object.
Expand All @@ -497,6 +501,8 @@ never sees a write to an unclaimed object.
The generator declares a class when its platform type resolves in the compilation. It does not look at call
sites. A call site can only name an invoker for a type that resolves. So every reference has a declaration.

An `Unsafe` binding only has the registered invokers. It routes writes only when the platform module is registered.

There is no WinUI invoker. No runtime package registers one. A generated one would route writes that the `Unsafe`
twin does not.

Expand Down
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,10 +616,11 @@ right one.

The binding asks on every write.

- A write on the owning thread runs straight away. Set a property on the UI thread, and the control has the new
value on the next line.
- A write from another thread waits for the owning thread.
- Writes keep their order.
- A write on the owning thread runs straight away when no earlier write is waiting. Set a property on the UI
thread, and the control has the new value on the next line.
- A write from another thread waits for the owning thread. A write on the owning thread also waits while an
earlier write is waiting.
- Only the latest value waits. A newer change replaces the waiting one, so a burst of changes becomes one write.

Some objects have no owning thread. The binding writes to them straight away.

Expand All @@ -629,7 +630,7 @@ Some objects have no owning thread. The binding writes to them straight away.
- Any object that is not a WPF, WinForms or MAUI object, such as a plain view model.

Every binding API does this: `BindOneWay`, `BindTwoWay`, `OneWayBind`, `Bind`, `BindTo`, and `BindCommand` when
it binds a new command to the control. Each `Unsafe` twin does the same.
it binds a new command to the control. Each `Unsafe` twin does the same through the registered invokers.

### Invokers

Expand All @@ -640,6 +641,9 @@ An invoker you register is asked first.
A generated binding knows its target's type when it compiles. For a WPF, WinForms or MAUI target, it carries that
platform's invoker. So it routes writes even when the platform module is not registered.

An `Unsafe` binding only finds its target's type while the app runs. It uses the registered invokers alone. Register
the platform module when you use `Unsafe` bindings.

### Choosing the thread yourself

> [!TIP]
Expand Down Expand Up @@ -751,7 +755,12 @@ message loop later.

Where ReactiveUI does move a write, the order is the same. A write on the owning thread runs straight away. A
write from another thread goes through the main-thread scheduler. Set `BindingSchedulers.MainThread` to
ReactiveUI's main-thread scheduler to match it exactly.
ReactiveUI's main-thread scheduler to use the same scheduler.

A burst of changes from another thread is handled differently. ReactiveUI's one-way bindings and `BindTo` write
every value, on the thread that raised it. Its two-way `Bind` queues one signal per change and reads the current
value when each signal runs. Here every binding writes only the latest value, once. A binding's change stream
skips the values in between.

### A binding made through a type parameter is not generated

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ namespace ReactiveUI.Binding.Reactive
public override int GetAffinityForObjects() { }
public override bool TryConvert(int from, object? conversionHint, out string? result) { }
}
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Count}")]
[System.Diagnostics.DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : ReactiveUI.Binding.Reactive.IInteraction<TInput, TOutput>
{
public Interaction() { }
Expand Down
6 changes: 5 additions & 1 deletion src/ReactiveUI.Binding.Shared/Bindings/BindingHooks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ namespace ReactiveUI.Binding;
public static class BindingHooks
{
/// <summary>Guards <see cref="_hooks"/> while it is (re)resolved.</summary>
/// <remarks>
/// Only the first resolve after a refresh takes it. Holding it while the locator is read stops a resolve
/// that races <see cref="Refresh"/> from publishing the set that refresh dropped.
/// </remarks>
private static readonly Lock Gate = new();

/// <summary>The resolved hooks, or null while none has been resolved yet.</summary>
Expand Down Expand Up @@ -87,7 +91,7 @@ public static bool ShouldBind(
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static IPropertyBindingHook[] Resolve()
{
var resolved = _hooks;
var resolved = Volatile.Read(ref _hooks);
if (resolved is not null)
{
return resolved;
Expand Down
44 changes: 16 additions & 28 deletions src/ReactiveUI.Binding.Shared/Builder/RxBindingBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,8 @@ namespace ReactiveUI.Binding.Builder;
/// </example>
public static class RxBindingBuilder
{
/// <summary>Synchronization gate for initialization and reset operations.</summary>
private static readonly Lock _resetLock = new();

/// <summary>Tracks whether ReactiveUI.Binding has been initialized (0 = not initialized, 1 = initialized).</summary>
private static int _hasBeenInitialized; // 0 = false, 1 = true
/// <summary>Whether ReactiveUI.Binding has been initialized: 0 until <see cref="MarkAsInitialized"/> runs, 1 after.</summary>
private static int _hasBeenInitialized;

/// <summary>Creates a new <see cref="ReactiveUIBindingBuilder"/> using the current Splat locator.</summary>
/// <returns>A new builder instance.</returns>
Expand All @@ -35,18 +32,17 @@ public static ReactiveUIBindingBuilder CreateReactiveUIBindingBuilder() =>
/// <exception cref="InvalidOperationException">Thrown if <c>BuildApp()</c> has not been called.</exception>
public static void EnsureInitialized()
{
lock (_resetLock)
if (Volatile.Read(ref _hasBeenInitialized) != 0)
{
if (_hasBeenInitialized == 0)
{
throw new InvalidOperationException(
"ReactiveUI.Binding has not been initialized. You must initialize using the builder pattern.\n\n"
+ "Example:\n"
+ "RxBindingBuilder.CreateReactiveUIBindingBuilder()\n"
+ " .WithCoreServices()\n"
+ " .BuildApp();");
}
return;
}

throw new InvalidOperationException(
"ReactiveUI.Binding has not been initialized. You must initialize using the builder pattern.\n\n"
+ "Example:\n"
+ "RxBindingBuilder.CreateReactiveUIBindingBuilder()\n"
+ " .WithCoreServices()\n"
+ " .BuildApp();");
}

/// <summary>Resets the initialization state for testing purposes only.</summary>
Expand All @@ -55,20 +51,12 @@ public static void EnsureInitialized()
/// </remarks>
internal static void ResetForTesting()
{
lock (_resetLock)
{
AppBuilder.ResetBuilderStateForTests();
AppLocator.SetLocator(new ModernDependencyResolver());
_hasBeenInitialized = 0;
}
AppBuilder.ResetBuilderStateForTests();
AppLocator.SetLocator(new ModernDependencyResolver());
Volatile.Write(ref _hasBeenInitialized, 0);
}

/// <summary>Marks ReactiveUI.Binding as initialized. Called by <see cref="ReactiveUIBindingBuilder.BuildApp"/>.</summary>
internal static void MarkAsInitialized()
{
lock (_resetLock)
{
_hasBeenInitialized = 1;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void MarkAsInitialized() => Volatile.Write(ref _hasBeenInitialized, 1);
}
57 changes: 57 additions & 0 deletions src/ReactiveUI.Binding.Shared/Helpers/CopyOnWriteArray.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// 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.

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

/// <summary>Changes an array by publishing a changed copy, so a reader walks a stable set without a lock.</summary>
internal static class CopyOnWriteArray
{
/// <summary>Publishes a copy of the array with <paramref name="item"/> added at the end.</summary>
/// <typeparam name="T">The element type.</typeparam>
/// <param name="location">The field holding the array.</param>
/// <param name="item">The item to add.</param>
internal static void Add<T>(ref T[] location, T item)
{
T[] current;
T[] updated;

do
{
current = Volatile.Read(ref location);
updated = new T[current.Length + 1];
Array.Copy(current, updated, current.Length);
updated[current.Length] = item;
}
while (!ReferenceEquals(Interlocked.CompareExchange(ref location, updated, current), current));
}

/// <summary>Publishes a copy of the array without the first occurrence of <paramref name="item"/>.</summary>
/// <typeparam name="T">The element type.</typeparam>
/// <param name="location">The field holding the array.</param>
/// <param name="item">The item to remove. An item that is not there leaves the array alone.</param>
internal static void Remove<T>(ref T[] location, T item)
{
T[] current;
T[] updated;

do
{
current = Volatile.Read(ref location);
var index = Array.IndexOf(current, item);
if (index < 0)
{
return;
}

updated = new T[current.Length - 1];
Array.Copy(current, updated, index);
Array.Copy(current, index + 1, updated, index, current.Length - index - 1);
}
while (!ReferenceEquals(Interlocked.CompareExchange(ref location, updated, current), current));
}
}
46 changes: 16 additions & 30 deletions src/ReactiveUI.Binding.Shared/Interactions/Interaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ namespace ReactiveUI.Binding;
/// <see cref="UnhandledInteractionException{TInput, TOutput}"/> if no handler handles the interaction.
/// </para>
/// </remarks>
[DebuggerDisplay("Handlers = {_handlers.Count}")]
[DebuggerDisplay("Handlers = {_handlers.Length}")]
public class Interaction<TInput, TOutput> : IInteraction<TInput, TOutput>
{
/// <summary>The list of registered interaction handlers, invoked in reverse order during <see cref="Handle"/>.</summary>
private readonly List<Func<IInteractionContext<TInput, TOutput>, Task>> _handlers = [];

/// <summary>Synchronization gate for thread-safe handler registration and removal.</summary>
private readonly Lock _sync = new();
/// <summary>
/// The registered handlers, invoked in reverse order during <see cref="Handle"/>. The array is replaced
/// rather than changed, so a question already being handled walks the set it started with.
/// </summary>
private Func<IInteractionContext<TInput, TOutput>, Task>[] _handlers = [];

/// <inheritdoc/>
public IDisposable RegisterHandler(Action<IInteractionContext<TInput, TOutput>> handler)
Expand Down Expand Up @@ -80,7 +80,7 @@ Task ContentHandler(IInteractionContext<TInput, TOutput> context)
public virtual async Task<TOutput> Handle(TInput input)
{
var context = GenerateContext(input);
var handlers = GetHandlers();
var handlers = Volatile.Read(ref _handlers);

for (var i = handlers.Length - 1; i >= 0; i--)
{
Expand All @@ -96,39 +96,25 @@ public virtual async Task<TOutput> Handle(TInput input)

/// <summary>Gets all registered handlers by order of registration.</summary>
/// <returns>All registered handlers.</returns>
protected Func<IInteractionContext<TInput, TOutput>, Task>[] GetHandlers()
{
lock (_sync)
{
return [.. _handlers];
}
}
protected Func<IInteractionContext<TInput, TOutput>, Task>[] GetHandlers() => [.. Volatile.Read(ref _handlers)];

/// <summary>Gets an interaction context which is used to provide information about the interaction.</summary>
/// <param name="input">The input that is being passed in.</param>
/// <returns>The interaction context.</returns>
protected virtual IOutputContext<TInput, TOutput> GenerateContext(TInput input) =>
new InteractionContext<TInput, TOutput>(input);

/// <summary>Adds a handler to the internal handler list under the synchronization gate.</summary>
/// <summary>Adds a handler to the registered set.</summary>
/// <param name="handler">The handler to add.</param>
private void AddHandler(Func<IInteractionContext<TInput, TOutput>, Task> handler)
{
lock (_sync)
{
_handlers.Add(handler);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void AddHandler(Func<IInteractionContext<TInput, TOutput>, Task> handler) =>
CopyOnWriteArray.Add(ref _handlers, handler);

/// <summary>Removes a handler from the internal handler list under the synchronization gate.</summary>
/// <summary>Removes the first registration of a handler from the registered set.</summary>
/// <param name="handler">The handler to remove.</param>
private void RemoveHandler(Func<IInteractionContext<TInput, TOutput>, Task> handler)
{
lock (_sync)
{
_ = _handlers.Remove(handler);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void RemoveHandler(Func<IInteractionContext<TInput, TOutput>, Task> handler) =>
CopyOnWriteArray.Remove(ref _handlers, handler);

/// <summary>An observer that bridges an observable sequence to a <see cref="TaskCompletionSource{TResult}"/>, completing the task when the observable completes or faults.</summary>
/// <typeparam name="T">The element type of the observable sequence.</typeparam>
Expand Down
Loading
Loading