Skip to content

Commit

Permalink
Merge pull request #4 from fedeAlterio/async-computed
Browse files Browse the repository at this point in the history
Added AsyncComputed and AsyncEffect
Added support for cancellation
Created a factory interface for computed signals in order to centralize cancellation and exceptions
dotnet/reactive replaced by R3
  • Loading branch information
fedeAlterio authored Dec 20, 2024
2 parents 46c58e2 + ffb7b61 commit a1f1771
Show file tree
Hide file tree
Showing 52 changed files with 2,139 additions and 506 deletions.
23 changes: 0 additions & 23 deletions .github/workflows/dotnet.yml

This file was deleted.

27 changes: 27 additions & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: .NET

on:
push:
branches: [ "*" ]

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 6.0.x
- name: Restore dependencies
run: dotnet restore ./SignalsDotnet
- name: Build
run: dotnet build -c Release ./SignalsDotnet --no-restore
- name: Test
run: dotnet test -c Release ./SignalsDotnet/SignalsDotnet.Tests --no-build --verbosity normal
- name: Benchmarks
run: |
dotnet build -c Release ./SignalsDotnet/SignalsDotnet.PeformanceTests/
dotnet run -c Release --project ./SignalsDotnet/SignalsDotnet.PeformanceTests/
27 changes: 27 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Release to NuGet

on:
release:
types: [published]

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 6.0.x
- name: Restore dependencies
run: dotnet restore ./SignalsDotnet
- name: Build
run: dotnet build -c Release ./SignalsDotnet --no-restore
- name: Test
run: dotnet test -c Release ./SignalsDotnet/SignalsDotnet.Tests --no-build --verbosity normal
- name: Pack nugets
run: dotnet pack SignalsDotnet/SignalsDotnet -c Release --no-build --output .
- name: Push to NuGet
run: dotnet nuget push "*.nupkg" --api-key ${{secrets.nuget_api_key}} --source https://api.nuget.org/v3/index.json
147 changes: 127 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<img src="./assets/demo.gif"/>


# Nuget https://www.nuget.org/packages/SignalsDotnet

# Angular Signals for .Net
This library is a porting of the Angular Signals in the .Net World, adapted to the .Net MVVM UI Frameworks and based on ReactiveX.
This library is a porting of the Angular Signals in the .Net World, adapted to the .Net MVVM UI Frameworks and based on [R3](https://github.com/Cysharp/R3) (variant of ReactiveX).
If you need an introduction to what a signal is, try to see: https://angular.io/guide/signals.

# Get Started
Expand Down Expand Up @@ -40,6 +43,86 @@ public static class DelegateCommandExtensions

## Example 2
```c#
public class LoginViewModel : IActivatableViewModel
{
// Value set from outside.
public Signal<bool> IsDeactivated { get; } = new(false);
public LoginViewModel()
{
var computedFactory = ComputedSignalFactory.Default
.DisconnectEverythingWhen(isDeactivated.Values)
.OnException(exception =>
{
/* log or do something with it */
});

// Will be cancelled on deactivation, of if the username signal changes during the await
IsUsernameValid = computedFactory.AsyncComputed(async cancellationToken => await IsUsernameValidAsync(Username.Value, cancellationToken),
false,
ConcurrentChangeStrategy.CancelCurrent);


// async computed signals have a (sync) signal that notifies us when the async computation is running
CanLogin = computedFactory.Computed(() => !IsUsernameValid.IsComputing.Value
&& IsUsernameValid.Value
&& !string.IsNullOrWhiteSpace(Password.Value));

computedFactory.Effect(UpdateApiCalls);

// This signal will be recomputed both when the collection changes, and when endDate of the last element changes automatically!
TotalApiCallsText = computedFactory.Computed(() =>
{
var lastCall = ApiCalls.Value.LastOrDefault();
return $"Total api calls: {ApiCalls.Value.Count}. Last started at {lastCall?.StartedAt}, and ended at {lastCall?.EndedAt.Value}";
})!;

// Signals are observable, so they can easily integrated with reactiveUI
LoginCommand = ReactiveCommand.Create(() => { /* login.. */ }, CanLogin);
}

public ViewModelActivator Activator { get; } = new();
public ReactiveCommand<Unit, Unit> LoginCommand { get; }
public Signal<string?> Username { get; } = new("");
public Signal<string> Password { get; } = new("");
public IAsyncReadOnlySignal<bool> IsUsernameValid { get; }
public IReadOnlySignal<bool> CanLogin { get; }
public IReadOnlySignal<string> TotalApiCallsText { get; }
public IReadOnlySignal<ObservableCollection<ApiCall>> ApiCalls { get; } = new ObservableCollection<ApiCall>().ToCollectionSignal();

async Task<bool> IsUsernameValidAsync(string? username, CancellationToken cancellationToken)
{
await Task.Delay(3000, cancellationToken);
return username?.Length > 2;
}
void UpdateApiCalls()
{
var isComputingUsername = IsUsernameValid.IsComputing.Value;
using var _ = Signal.UntrackedScope();

if (isComputingUsername)
{
ApiCalls.Value.Add(new ApiCall(startedAt: DateTime.Now));
return;
}

var call = ApiCalls.Value.LastOrDefault();
if (call is { EndedAt.Value: null })
{
call.EndedAt.Value = DateTime.Now;
}
}
}

public class ApiCall(DateTime startedAt)
{
public DateTime StartedAt => startedAt;
public Signal<DateTime?> EndedAt { get; } = new();
}
```


## Example 3
```c#
public class YoungestPersonViewModel
{
    public YoungestPersonViewModel()
Expand Down Expand Up @@ -84,21 +167,21 @@ public class City

public record PersonCoordinates(Person Person, Room Room, House House, City City);
```
Every signal implements the IObservable interface, so we can apply against them all ReactiveX operators we want.
## `Singal<T>`
Every signal has a property `Values` that is an Observable and notifies us whenever the signal changes.
## `Signal<T>`
```c#
public Signal<Person> Person { get; } = new();
public Signal<Person> Person2 { get; } = new(config => config with { Comparer = new CustomPersonEqualityComparer() });
```

A `Singal<T>` is a wrapper around a `T`. It has a property `Value` that can be set, and that when changed raises the INotifyPropertyChanged event.
A `Signal<T>` is a wrapper around a `T`. It has a property `Value` that can be set, and that when changed raises the INotifyPropertyChanged event.


It is possible to specify a custom `EqualityComparer` that will be used to check if raise the `PropertyChanged` event. It is also possible to force it to raise the event everytime someone sets the property

## `CollectionSingal<TObservableCollection>`
## `CollectionSignal<TObservableCollection>`

A `CollectionSingal<TObservableCollection>` is a wrapper around an `ObservableCollection` (or in general something that implements the `INotifyCollectionChanged` interface). It listens to both changes of its Value Property, and modifications of the `ObservableCollection` it is wrapping
A `CollectionSignal<TObservableCollection>` is a wrapper around an `ObservableCollection` (or in general something that implements the `INotifyCollectionChanged` interface). It listens to both changes of its Value Property, and modifications of the `ObservableCollection` it is wrapping


It is possible to specify a custom `EqualityComparer` that will be used to check if raise the `PropertyChanged` event. It is also possible to force it to raise the event everytime someone sets the property
Expand All @@ -117,32 +200,56 @@ public CollectionSignal<ObservableCollection<Person>> People { get; } = new(coll

## Computed Signals
```c#
public class LoginViewModel
{
public LoginViewModel()
{
CanLogin = Signal.Computed(() => !string.IsNullOrWhiteSpace(Username.Value) && !string.IsNullOrWhiteSpace(Password.Value));
}

public Signal<string> Username { get; } = new();
public Signal<string> Password { get; } = new();
public IReadOnlySignal<bool> CanLogin { get; }
}
public LoginViewModel()
{
IObservable<bool> isDeactivated = this.IsDeactivated();

var computedFactory = ComputedSignalFactory.Default
.DisconnectEverythingWhen(isDeactivated)
.OnException(exception =>
{
/* log or do something with it */
});

IsUsernameValid = computedFactory.AsyncComputed(async cancellationToken => await IsUsernameValidAsync(Username.Value, cancellationToken),
false,
ConcurrentChangeStrategy.CancelCurrent);


CanLogin = computedFactory.Computed(() => !IsUsernameValid.IsComputing.Value
&& IsUsernameValid.Value
&& !string.IsNullOrWhiteSpace(Password.Value));
}
```
A computed signal, is a signal that depends by other signals.

Basically to create it you need to pass a function that computes the value.
Basically to create it you need to pass a function that computes the value. That function can be synchronous or asynchronous.

It automatically recognize which are the signals it depends by, and listen for them to change. Whenever a signal changes, the function is executed again, and a new value is produced (the `INotifyPropertyChanged` is raised).

It is possible to specify whether or not to subscribe weakly (default option), or strongly. It is possible also here to specify a custom `EqualityComparer`
It is possible to specify whether or not to subscribe weakly, or strongly (default option). It is possible also here to specify a custom `EqualityComparer`.

Usually you want to stop all asynchronous computation according to some boolean condition.
This can be easily done via `ComputedSignalFactory.DisconnectEverythingWhen(isDeactivated)`. Whenever the isDeactivated observable notfies `true`, every pending async computation will be cancelled. Later on, when it notifies a `false`, all the computed signals will be recomputed again.

You can find useful also `CancellationSignal.Create(booleanObservable)`, that converts a boolean observable into a `IReadOnlySignal<CancellationToken>`, that automatically creates, cancels and disposes new cancellation tokens according to a boolean observable.

## ConcurrentChangeStrategy
In an async computed signal, the signals it depends by can be changed while the computation function is running. You can use the enum `ConcurrentChangeStrategy` to specify what you want to do in that cases. For now there are 2 options:

- `ConcurrentChangeStrategy.CancelCurrent`: The current cancellationToken will be cancelled, and a new computation will start immediately

- `ConcurrentChangeStrategy.ScheduleNext`: The current cancellationToken will NOT be cancelled, and a new computation will be queued up immediately after the current. Note that only 1 computation can be queued up at most. So using this option, multiple concurrent changes are equivalent to a single concurrent change.

Note also that what already said about `DisconnectEverythingWhen` method is independent from that `ConcurrentChangeStrategy` enum. So in both cases, when the disconnection notification arrive, the async computation will be cancelled.

### How it works?

Basically the getter (not the setter!) of the Signals property Value raises a static event that notifies someone just requested that signal.

This is used by the Computed signal before executing the computation function.

The computed signals register to that event (filtering out notifications of other threads), and in that way they knowwhen the function returns, what are the signals that have been just accessed.
The computed signals register to that event (filtering out notifications of other signals, using some async locals state), and in that way they knowwhen the function returns, what are the signals that have been just accessed.

At this point it subscribes to the changes of all those signals in order to know when it should recompute again the value.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Runtime.CompilerServices;

namespace SignalsDotnet.Tests.Helpers;

public static class MainThreadAwaitableExtensions
{
public static MainThreadAwaitable SwitchToMainThread(this object _) => new();
}

/// <summary>
/// If awaited, force the continuation to run on a Single-threaded synchronization context.
/// That's the exact behavior of Wpf Synchronization Context (DispatcherSynchronizationContext)
/// So basically:
/// 1) after the await we switch thread.
/// 2) Every other continuation will run on the same thread as it happens in Wpf.
/// </summary>
public readonly struct MainThreadAwaitable : INotifyCompletion
{
public MainThreadAwaitable GetAwaiter() => this;
public bool IsCompleted => SynchronizationContext.Current == TestSingleThreadSynchronizationContext.Instance;
public void OnCompleted(Action action) => TestSingleThreadSynchronizationContext.Instance.Post(_ => action(), null);
public void GetResult() { }
}
61 changes: 61 additions & 0 deletions SignalsDotnet/SignalsDotnet.PeformanceTests/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
using BenchmarkDotNet.Toolchains.InProcess.NoEmit;
using R3;
using SignalsDotnet;
using SignalsDotnet.Tests.Helpers;

BenchmarkRunner.Run<ComputedBenchmarks>();

public class BenchmarkConfig : ManualConfig
{
public BenchmarkConfig()
{
AddJob(Job.MediumRun
.WithToolchain(InProcessNoEmitToolchain.Instance));
}
}

[MemoryDiagnoser]
[Config(typeof(BenchmarkConfig))]
public class ComputedBenchmarks
{
readonly Signal<int> _signal = new(0);
readonly IAsyncReadOnlySignal<int> _asyncComputed;
readonly IReadOnlySignal<int> _computed;

public ComputedBenchmarks()
{
_computed = Signal.Computed(() => _signal.Value, x => x with{SubscribeWeakly = false});
_asyncComputed = Signal.AsyncComputed(async _ =>
{
var x = _signal.Value;
await Task.Yield();
return x;
}, -1);
}

[Benchmark]
public int ComputedRoundTrip()
{
_ = _computed.Value;
_signal.Value = 0;
_signal.Value = 1;
return _computed.Value;
}

[Benchmark]
public async ValueTask<int> AsyncComputedRoundTrip()
{
await this.SwitchToMainThread();

_ = _asyncComputed.Value;
_signal.Value = 0;
_signal.Value = 1;
return await _asyncComputed.Values
.FirstAsync(x => x == 1)
.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\SignalsDotnet\SignalsDotnet.csproj" />
</ItemGroup>

</Project>
Loading

0 comments on commit a1f1771

Please sign in to comment.