├── .github └── workflows │ ├── push.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── SignalsDotnet ├── SignalsDotnet.PeformanceTests │ ├── MainThreadAwaitableExtensions.cs │ ├── Program.cs │ ├── SignalsDotnet.PeformanceTests.csproj │ └── TestSingleThreadSynchronizationContext.cs ├── SignalsDotnet.Tests │ ├── AsyncComputedSignalTests.cs │ ├── AsyncEffectTests.cs │ ├── CollectionSignalTests.cs │ ├── ComputedSignalTests.cs │ ├── EffectTests.cs │ ├── GlobalUsings.cs │ ├── Helpers │ │ ├── MainThreadAwaitableExtensions.cs │ │ ├── TestHelpers.cs │ │ └── TestSingleThreadSynchronizationContext.cs │ └── SignalsDotnet.Tests.csproj ├── SignalsDotnet.sln └── SignalsDotnet │ ├── CancellationSignal.cs │ ├── CollectionSignal.cs │ ├── ComputedSignalFactory │ ├── ComputedSignalFactory.cs │ ├── ComputedSignalFactoryEx.cs │ └── IComputedSignalFactory.cs │ ├── ConcurrentChangeStrategy.cs │ ├── Configuration │ ├── CollectionChangedSignalConfiguration.cs │ ├── ReadonlySignalConfiguration.cs │ └── SignalConfiguration.cs │ ├── Effect.cs │ ├── Helpers │ ├── ObservableEx.cs │ └── Optional.cs │ ├── IReadOnlySignal.cs │ ├── Internals │ ├── ComputedObservable.cs │ ├── ComputedSignalrFactory │ │ ├── CancelComputedSignalFactoryDecorator.cs │ │ ├── DefaultComputedSignalFactory.cs │ │ └── OnErrorComputedSignalFactoryDecorator.cs │ ├── FromObservableCollectionSignal.cs │ ├── FromObservableSignal.cs │ └── Helpers │ │ ├── GenericHelpers.cs │ │ ├── KeyEqualityComparer.cs │ │ ├── ObservableEx.cs │ │ ├── ObservableFromINotifyCollectionChanged.cs │ │ ├── ObservableFromPropertyChanged.cs │ │ └── WeakObservable.cs │ ├── Signal.cs │ ├── Signal_Computed.cs │ ├── Signal_Factory.cs │ ├── Signal_T.cs │ ├── Signal_WhenAnyChanged.cs │ └── SignalsDotnet.csproj └── assets ├── demo.gif └── icon.png /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: .NET 2 | 3 | on: 4 | push: 5 | branches: [ "*" ] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Setup .NET 15 | uses: actions/setup-dotnet@v4 16 | with: 17 | dotnet-version: 6.0.x 18 | - name: Restore dependencies 19 | run: dotnet restore ./SignalsDotnet 20 | - name: Build 21 | run: dotnet build -c Release ./SignalsDotnet --no-restore 22 | - name: Test 23 | run: dotnet test -c Release ./SignalsDotnet/SignalsDotnet.Tests --no-build --verbosity normal 24 | - name: Benchmarks 25 | run: | 26 | dotnet build -c Release ./SignalsDotnet/SignalsDotnet.PeformanceTests/ 27 | dotnet run -c Release --project ./SignalsDotnet/SignalsDotnet.PeformanceTests/ -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to NuGet 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 5 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - name: Setup .NET 15 | uses: actions/setup-dotnet@v4 16 | with: 17 | dotnet-version: 6.0.x 18 | - name: Restore dependencies 19 | run: dotnet restore ./SignalsDotnet 20 | - name: Build 21 | run: dotnet build -c Release ./SignalsDotnet --no-restore 22 | - name: Test 23 | run: dotnet test -c Release ./SignalsDotnet/SignalsDotnet.Tests --no-build --verbosity normal 24 | - name: Pack nugets 25 | run: dotnet pack SignalsDotnet/SignalsDotnet -c Release --no-build --output . 26 | - name: Push to NuGet 27 | run: dotnet nuget push "*.nupkg" --api-key ${{secrets.nuget_api_key}} --source https://api.nuget.org/v3/index.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml 399 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Federico Alterio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Nuget https://www.nuget.org/packages/SignalsDotnet 5 | 6 | # Angular Signals for .Net 7 | 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). 8 | If you need an introduction to what a signal is, try to see: https://angular.io/guide/signals. 9 | 10 | # Get Started 11 | It is really easy to get started. What you need to do is to replace all binded ViewModel Properties and ObservableCollections to Signals: 12 | 13 | ## Example 1 14 | ```c# 15 | public class LoginViewModel 16 | { 17 | public LoginViewModel() 18 | { 19 | CanLogin = Signal.Computed(() => !string.IsNullOrWhiteSpace(Username.Value) && !string.IsNullOrWhiteSpace(Password.Value)); 20 | LoginCommand = new DelegateCommand(Login, () => CanLogin.Value).RaiseCanExecuteChangedAutomatically(); 21 | } 22 | 23 | public Signal Username { get; } = new(); 24 | public Signal Password { get; } = new(); 25 | public IReadOnlySignal CanLogin { get; } 26 | 27 | public ICommand LoginCommand { get; } 28 | public void Login() { /* Login */ } 29 | } 30 | 31 | public static class DelegateCommandExtensions 32 | { 33 | // This is specific for Prism, but the same approach can be used in other MVVM Frameworks 34 | public static T RaiseCanExecuteChangedAutomatically(this T @this) where T : DelegateCommand 35 | { 36 | var signal = Signal.Computed(@this.CanExecute, config => config with { SubscribeWeakly = false }); 37 | signal.Subscribe(_ => @this.RaiseCanExecuteChanged()); 38 | _ = signal.Value; 39 | return @this; 40 | } 41 | } 42 | ``` 43 | 44 | ## Example 2 45 | ```c# 46 | public class LoginViewModel 47 | { 48 | // Value set from outside. 49 | public Signal IsDeactivated { get; } = new(false); 50 | public LoginViewModel() 51 | { 52 | var computedFactory = ComputedSignalFactory.Default 53 | .DisconnectEverythingWhen(isDeactivated.Values) 54 | .OnException(exception => 55 | { 56 | /* log or do something with it */ 57 | }); 58 | 59 | // Will be cancelled on deactivation, of if the username signal changes during the await 60 | IsUsernameValid = computedFactory.AsyncComputed(async cancellationToken => await IsUsernameValidAsync(Username.Value, cancellationToken), 61 | false, 62 | ConcurrentChangeStrategy.CancelCurrent); 63 | 64 | 65 | // async computed signals have a (sync) signal that notifies us when the async computation is running 66 | CanLogin = computedFactory.Computed(() => !IsUsernameValid.IsComputing.Value 67 | && IsUsernameValid.Value 68 | && !string.IsNullOrWhiteSpace(Password.Value)); 69 | 70 | computedFactory.Effect(UpdateApiCalls); 71 | 72 | // This signal will be recomputed both when the collection changes, and when endDate of the last element changes automatically! 73 | TotalApiCallsText = computedFactory.Computed(() => 74 | { 75 | var lastCall = ApiCalls.Value.LastOrDefault(); 76 | return $"Total api calls: {ApiCalls.Value.Count}. Last started at {lastCall?.StartedAt}, and ended at {lastCall?.EndedAt.Value}"; 77 | })!; 78 | } 79 | 80 | public ViewModelActivator Activator { get; } = new(); 81 | public ReactiveCommand LoginCommand { get; } 82 | public Signal Username { get; } = new(""); 83 | public Signal Password { get; } = new(""); 84 | public IAsyncReadOnlySignal IsUsernameValid { get; } 85 | public IReadOnlySignal CanLogin { get; } 86 | public IReadOnlySignal TotalApiCallsText { get; } 87 | public IReadOnlySignal> ApiCalls { get; } = new ObservableCollection().ToCollectionSignal(); 88 | 89 | async Task IsUsernameValidAsync(string? username, CancellationToken cancellationToken) 90 | { 91 | await Task.Delay(3000, cancellationToken); 92 | return username?.Length > 2; 93 | } 94 | void UpdateApiCalls() 95 | { 96 | var isComputingUsername = IsUsernameValid.IsComputing.Value; 97 | using var _ = Signal.UntrackedScope(); 98 | 99 | if (isComputingUsername) 100 | { 101 | ApiCalls.Value.Add(new ApiCall(startedAt: DateTime.Now)); 102 | return; 103 | } 104 | 105 | var call = ApiCalls.Value.LastOrDefault(); 106 | if (call is { EndedAt.Value: null }) 107 | { 108 | call.EndedAt.Value = DateTime.Now; 109 | } 110 | } 111 | } 112 | 113 | public class ApiCall(DateTime startedAt) 114 | { 115 | public DateTime StartedAt => startedAt; 116 | public Signal EndedAt { get; } = new(); 117 | } 118 | ``` 119 | 120 | 121 | ## Example 3 122 | ```c# 123 | public class YoungestPersonViewModel 124 | { 125 |     public YoungestPersonViewModel() 126 |     { 127 |         YoungestPerson = Signal.Computed(() => 128 |         { 129 |             var people = from city in Cities.Value.EmptyIfNull() 130 |                          from house in city.Houses.Value.EmptyIfNull() 131 |                          from room in house.Roooms.Value.EmptyIfNull() 132 |                          from person in room.People.Value.EmptyIfNull() 133 |                          select new PersonCoordinates(person, room, house, city); 134 | 135 |             var youngestPerson = people.DefaultIfEmpty() 136 |                                        .MinBy(x => x?.Person.Age.Value); 137 |             return youngestPerson; 138 |         }); 139 |     } 140 | 141 |     public IReadOnlySignal YoungestPerson { get; } 142 |     public CollectionSignal> Cities { get; } = new(); 143 | } 144 | 145 | public class Person 146 | { 147 |     public Signal Age { get; } = new(); 148 | } 149 | 150 | public class Room 151 | { 152 |     public CollectionSignal> People { get; } = new(); 153 | } 154 | 155 | public class House 156 | { 157 |     public CollectionSignal> Roooms { get; } = new(); 158 | } 159 | 160 | public class City 161 | { 162 |     public CollectionSignal> Houses { get; } = new(); 163 | } 164 | 165 | public record PersonCoordinates(Person Person, Room Room, House House, City City); 166 | ``` 167 | Every signal has a property `Values` that is an Observable and notifies us whenever the signal changes. 168 | ## `Signal` 169 | ```c# 170 | public Signal Person { get; } = new(); 171 | public Signal Person2 { get; } = new(config => config with { Comparer = new CustomPersonEqualityComparer() }); 172 | ``` 173 | 174 | A `Signal` is a wrapper around a `T`. It has a property `Value` that can be set, and that when changed raises the INotifyPropertyChanged event. 175 | 176 | 177 | 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 178 | 179 | ## `CollectionSignal` 180 | 181 | A `CollectionSignal` 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 182 | 183 | 184 | 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 185 | 186 | 187 | By default, it subscribes to the `INotifyCollection` event weakly in order to avoid memory leaks, but this behavior can be customized. 188 | 189 | 190 | It is also possible to Apply some Throttle-like behavior on the collection changes or more in generale map the IObservable used. 191 | ```c# 192 | // This signal notify changes whenever the collection is modified 193 | // ThrottleOneCycle is used to throttle notifications for one rendering cycle, 194 | // In that way we ensure that for example AddRange() calls over the observableCollection Will produce only 1 notification 195 | public CollectionSignal> People { get; } = new(collectionChangedConfiguration: config => config.ThrottleOneCycle(UIReactiveScheduler)) 196 | ``` 197 | 198 | ## Computed Signals 199 | ```c# 200 | public LoginViewModel() 201 | { 202 | IObservable isDeactivated = this.IsDeactivated(); 203 | 204 | var computedFactory = ComputedSignalFactory.Default 205 | .DisconnectEverythingWhen(isDeactivated) 206 | .OnException(exception => 207 | { 208 | /* log or do something with it */ 209 | }); 210 | 211 | IsUsernameValid = computedFactory.AsyncComputed(async cancellationToken => await IsUsernameValidAsync(Username.Value, cancellationToken), 212 | false, 213 | ConcurrentChangeStrategy.CancelCurrent); 214 | 215 | 216 | CanLogin = computedFactory.Computed(() => !IsUsernameValid.IsComputing.Value 217 | && IsUsernameValid.Value 218 | && !string.IsNullOrWhiteSpace(Password.Value)); 219 | } 220 | ``` 221 | A computed signal, is a signal that depends by other signals. 222 | 223 | Basically to create it you need to pass a function that computes the value. That function can be synchronous or asynchronous. 224 | 225 | 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). 226 | 227 | 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`. 228 | 229 | Usually you want to stop all asynchronous computation according to some boolean condition. 230 | 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. 231 | 232 | You can find useful also `CancellationSignal.Create(booleanObservable)`, that converts a boolean observable into a `IReadOnlySignal`, that automatically creates, cancels and disposes new cancellation tokens according to a boolean observable. 233 | 234 | ## ConcurrentChangeStrategy 235 | 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: 236 | 237 | - `ConcurrentChangeStrategy.CancelCurrent`: The current cancellationToken will be cancelled, and a new computation will start immediately 238 | 239 | - `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. 240 | 241 | 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. 242 | 243 | ### How it works? 244 | 245 | Basically the getter (not the setter!) of the Signals property Value raises a static event that notifies someone just requested that signal. 246 | 247 | This is used by the Computed signal before executing the computation function. 248 | 249 | The computed signals register to that event (filtering out notifications of other signals, using some async locals state), and in that way they know, when the function returns, what are the signals that have been just accessed. 250 | 251 | At this point it subscribes to the changes of all those signals in order to know when it should recompute again the value. 252 | 253 | When any signal changes, it repeats the same reasoning and tracks what signals are accessed before recomputing the next value (etc.) 254 | 255 | ## Untracked 256 | 257 | To shutdown the automatical tracking of signals changes in computed signals it is possible to use `Signal.Untracked` or the equivalent properties shortcuts 258 | ```c# 259 | public class LoginViewModel 260 | { 261 | public LoginViewModel() 262 | { 263 | CanLogin = Signal.Computed(() => 264 | { 265 | return !string.IsNullOrWhiteSpace(Username.Value) && Signal.Untracked(() => !string.IsNullOrWhiteSpace(Password.Value)); 266 | }); 267 | 268 | CanLogin = Signal.Computed(() => !string.IsNullOrWhiteSpace(Username.Value) && !string.IsNullOrWhiteSpace(Password.UntrackedValue)); 269 | 270 | var AnyPeople = Signal.Computed(() => People.UntrackedValue); 271 | var AnyPeople2 = Signal.Computed(() => People.UntrackedCollectionChangedValue); 272 | } 273 | 274 | public CollectionSignal> People { get; } = new(); 275 | public Signal Username { get; } = new(); 276 | public Signal Password { get; } = new(); 277 | public IReadOnlySignal CanLogin { get; } 278 | } 279 | 280 | ``` 281 | 282 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.PeformanceTests/MainThreadAwaitableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace SignalsDotnet.Tests.Helpers; 4 | 5 | public static class MainThreadAwaitableExtensions 6 | { 7 | public static MainThreadAwaitable SwitchToMainThread(this object _) => new(); 8 | } 9 | 10 | /// 11 | /// If awaited, force the continuation to run on a Single-threaded synchronization context. 12 | /// That's the exact behavior of Wpf Synchronization Context (DispatcherSynchronizationContext) 13 | /// So basically: 14 | /// 1) after the await we switch thread. 15 | /// 2) Every other continuation will run on the same thread as it happens in Wpf. 16 | /// 17 | public readonly struct MainThreadAwaitable : INotifyCompletion 18 | { 19 | public MainThreadAwaitable GetAwaiter() => this; 20 | public bool IsCompleted => SynchronizationContext.Current == TestSingleThreadSynchronizationContext.Instance; 21 | public void OnCompleted(Action action) => TestSingleThreadSynchronizationContext.Instance.Post(_ => action(), null); 22 | public void GetResult() { } 23 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.PeformanceTests/Program.cs: -------------------------------------------------------------------------------- 1 | using BenchmarkDotNet.Attributes; 2 | using BenchmarkDotNet.Configs; 3 | using BenchmarkDotNet.Jobs; 4 | using BenchmarkDotNet.Running; 5 | using BenchmarkDotNet.Toolchains.InProcess.NoEmit; 6 | using R3; 7 | using SignalsDotnet; 8 | using SignalsDotnet.Tests.Helpers; 9 | 10 | BenchmarkRunner.Run(); 11 | 12 | public class BenchmarkConfig : ManualConfig 13 | { 14 | public BenchmarkConfig() 15 | { 16 | AddJob(Job.MediumRun 17 | .WithToolchain(InProcessNoEmitToolchain.Instance)); 18 | } 19 | } 20 | 21 | [MemoryDiagnoser] 22 | [Config(typeof(BenchmarkConfig))] 23 | public class ComputedBenchmarks 24 | { 25 | readonly Signal _signal = new(0); 26 | readonly IAsyncReadOnlySignal _asyncComputed; 27 | readonly IReadOnlySignal _computed; 28 | 29 | public ComputedBenchmarks() 30 | { 31 | _computed = Signal.Computed(() => _signal.Value, x => x with{SubscribeWeakly = false}); 32 | _asyncComputed = Signal.AsyncComputed(async _ => 33 | { 34 | var x = _signal.Value; 35 | await Task.Yield(); 36 | return x; 37 | }, -1); 38 | } 39 | 40 | [Benchmark] 41 | public int ComputedRoundTrip() 42 | { 43 | _ = _computed.Value; 44 | _signal.Value = 0; 45 | _signal.Value = 1; 46 | return _computed.Value; 47 | } 48 | 49 | [Benchmark] 50 | public async ValueTask AsyncComputedRoundTrip() 51 | { 52 | await this.SwitchToMainThread(); 53 | 54 | _ = _asyncComputed.Value; 55 | _signal.Value = 0; 56 | _signal.Value = 1; 57 | return await _asyncComputed.Values 58 | .FirstAsync(x => x == 1) 59 | .ConfigureAwait(ConfigureAwaitOptions.ForceYielding); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.PeformanceTests/SignalsDotnet.PeformanceTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.PeformanceTests/TestSingleThreadSynchronizationContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace SignalsDotnet.Tests.Helpers; 4 | 5 | internal sealed class TestSingleThreadSynchronizationContext : SynchronizationContext 6 | { 7 | public Thread MainThread { get; } 8 | readonly BlockingCollection<(SendOrPostCallback callback, object? state)> _callbacksWithState = []; 9 | 10 | public TestSingleThreadSynchronizationContext() 11 | { 12 | MainThread = new Thread(MainThreadLoop) 13 | { 14 | IsBackground = true 15 | }; 16 | 17 | MainThread.Start(); 18 | } 19 | 20 | public static TestSingleThreadSynchronizationContext Instance { get; } = new(); 21 | 22 | void MainThreadLoop() 23 | { 24 | SetSynchronizationContext(this); 25 | 26 | foreach (var (callback, state) in _callbacksWithState.GetConsumingEnumerable()) 27 | callback.Invoke(state); 28 | } 29 | 30 | public override void Post(SendOrPostCallback callback, object? state) 31 | { 32 | _callbacksWithState.Add((callback, state)); 33 | } 34 | 35 | public override void Send(SendOrPostCallback callback, object? state) 36 | { 37 | if (Current == this) 38 | { 39 | callback(state); 40 | return; 41 | } 42 | 43 | _callbacksWithState.Add((callback, state)); 44 | } 45 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/AsyncComputedSignalTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using SignalsDotnet.Helpers; 3 | using SignalsDotnet.Tests.Helpers; 4 | using R3; 5 | 6 | namespace SignalsDotnet.Tests; 7 | public class AsyncComputedSignalTests 8 | { 9 | [Fact] 10 | public async Task ShouldNotifyWhenAnyChanged() 11 | { 12 | await this.SwitchToMainThread(); 13 | 14 | var prop1 = new Signal(); 15 | var prop2 = new Signal(); 16 | 17 | async ValueTask Sum(CancellationToken token = default) 18 | { 19 | await Task.Yield(); 20 | return prop1.Value + prop2.Value; 21 | } 22 | 23 | var computed = Signal.AsyncComputed(Sum, 0, () => Optional.Empty); 24 | int notifiedValue = 0; 25 | computed.Values.Subscribe(_ => notifiedValue++); 26 | _ = computed.Value; 27 | await TestHelpers.WaitUntil(() => notifiedValue == 1); 28 | 29 | notifiedValue = 0; 30 | prop1.Value = 2; 31 | await TestHelpers.WaitUntil(() => notifiedValue == 1); 32 | computed.Value.Should().Be(await Sum()); 33 | 34 | notifiedValue = 0; 35 | prop2.Value = 1; 36 | await TestHelpers.WaitUntil(() => notifiedValue == 1); 37 | computed.Value.Should().Be(await Sum()); 38 | } 39 | 40 | [Fact] 41 | public async Task SignalChangedWhileComputing_ShouldBeConsidered() 42 | { 43 | await this.SwitchToMainThread(); 44 | 45 | var prop1 = new Signal(); 46 | var prop2 = new Signal(); 47 | 48 | var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); 49 | 50 | var computed = Signal.AsyncComputed(Sum, 0, () => Optional.Empty); 51 | 52 | 53 | async ValueTask Sum(CancellationToken token = default) 54 | { 55 | await Task.Yield(); 56 | var sum = prop1.Value + prop2.Value; 57 | if (prop1.Value <= 3) 58 | { 59 | prop1.Value++; 60 | prop2.Value++; 61 | } 62 | else 63 | { 64 | tcs.TrySetResult(); 65 | } 66 | 67 | return sum; 68 | } 69 | 70 | _ = computed.Value; 71 | await tcs.Task; 72 | await TestHelpers.WaitUntil(() => computed.Value == prop1.Value + prop2.Value); 73 | } 74 | 75 | 76 | [Fact] 77 | public async Task ConcurrentUpdate_ShouldCancelCurrentIfRequested() 78 | { 79 | await this.SwitchToMainThread(); 80 | 81 | var prop1 = new Signal(); 82 | var prop2 = new Signal(); 83 | 84 | CancellationToken computeToken = default; 85 | var computed = Signal.AsyncComputed(async token => 86 | { 87 | var sum = prop1.Value + prop2.Value; 88 | if (prop1.Value == 1) 89 | return sum; 90 | 91 | prop1.Value++; 92 | computeToken = token; 93 | await Task.Delay(1, token); 94 | return sum; 95 | }, 0, ConcurrentChangeStrategy.CancelCurrent); 96 | 97 | _ = computed.Value; 98 | await TestHelpers.WaitUntil(() => computeToken.IsCancellationRequested); 99 | } 100 | 101 | [Fact] 102 | public async Task OtherSignalChanges_ShouldNotBeConsidered() 103 | { 104 | await this.SwitchToMainThread(); 105 | 106 | var signal1 = new Signal(); 107 | var signal2 = new Signal(); 108 | 109 | var signal3 = new Signal(); 110 | 111 | var middleComputationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); 112 | var signal3ChangedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); 113 | var stepNumber = 0; 114 | var sum = Signal.AsyncComputed(async _ => 115 | { 116 | middleComputationTcs.TrySetResult(); 117 | var ret = stepNumber + signal1.Value + signal2.Value; 118 | stepNumber++; 119 | await signal3ChangedTcs.Task; 120 | return ret; 121 | }, 0); 122 | 123 | var notifiedCount = 0; 124 | _ = sum.Value; 125 | await sum.Values.Where(x => x == 0) 126 | .Take(1) 127 | .WaitAsync() 128 | .ConfigureAwait(ConfigureAwaitOptions.ForceYielding); 129 | 130 | sum.Values.Skip(1).Subscribe(_ => notifiedCount++); 131 | await middleComputationTcs.Task; 132 | 133 | _ = signal3.Value; 134 | signal3.Value = 1; 135 | signal3ChangedTcs.SetResult(); 136 | 137 | await Task.Yield(); 138 | notifiedCount.Should().Be(0); 139 | signal1.Value = 1; 140 | await TestHelpers.WaitUntil(() => sum.Value == 2); 141 | } 142 | 143 | 144 | [Fact] 145 | public async Task CancellationSignal_ShouldCancel_AllComputedSignals() 146 | { 147 | await this.SwitchToMainThread(); 148 | var cancellationRequested = new Signal(); 149 | 150 | var waitForCancellationSignal = new Signal(false); 151 | var cancellationToken = new Signal(); 152 | var computedSignal = ComputedSignalFactory.Default 153 | .DisconnectEverythingWhen(cancellationRequested.Values) 154 | .AsyncComputed(async token => 155 | { 156 | await waitForCancellationSignal.Values.FirstAsync(x => x); 157 | cancellationToken.Value = token; 158 | return 1; 159 | }, 0); 160 | 161 | _ = computedSignal.Value; 162 | cancellationRequested.Value = true; 163 | waitForCancellationSignal.Value = true; 164 | 165 | await cancellationToken.Values.FirstAsync(x => x is not null); 166 | cancellationToken.Value!.Value.IsCancellationRequested.Should().Be(true); 167 | 168 | cancellationToken.Value = null; 169 | cancellationRequested.Value = false; 170 | 171 | await cancellationToken.Values.FirstAsync(x => x is not null); 172 | cancellationToken.Value!.Value.IsCancellationRequested.Should().BeFalse(); 173 | } 174 | 175 | 176 | [Fact] 177 | public async Task ConcurrentUpdate_ShouldScheduleNext_IfRequested() 178 | { 179 | await this.SwitchToMainThread(); 180 | 181 | var prop1 = new Signal(1); 182 | var prop2 = new Signal(); 183 | 184 | 185 | var computed = Signal.AsyncComputed(async token => 186 | { 187 | var sum = prop1.Value + prop2.Value; 188 | prop1.Value++; 189 | await Task.Delay(0, token); 190 | await Task.Yield(); 191 | return sum; 192 | }, -1); 193 | 194 | var task = computed.Values.FirstAsync(x => x == 20); 195 | await task; 196 | } 197 | 198 | [Fact] 199 | public async Task SimpleTest() 200 | { 201 | await this.SwitchToMainThread(); 202 | 203 | Signal signal = new(0); 204 | var asyncComputed = Signal.AsyncComputed(async _ => 205 | { 206 | var x = signal.Value; 207 | await Task.Yield(); 208 | await Task.Yield(); 209 | await Task.Yield(); 210 | return x; 211 | }, -1, configuration: x => x with 212 | { 213 | SubscribeWeakly = false 214 | }); 215 | 216 | _ = asyncComputed.Value; 217 | signal.Value = 0; 218 | signal.Value = 1; 219 | signal.Value = 2; 220 | signal.Value = 3; 221 | signal.Value = 4; 222 | signal.Value = 5; 223 | 224 | await asyncComputed.Values 225 | .Timeout(TimeSpan.FromSeconds(1)) 226 | .FirstAsync(x => x == 5) 227 | .ConfigureAwait(ConfigureAwaitOptions.ForceYielding); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/AsyncEffectTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using SignalsDotnet.Tests.Helpers; 3 | 4 | namespace SignalsDotnet.Tests; 5 | public class AsyncEffectTests 6 | { 7 | [Fact] 8 | public async Task ShouldRunWhenAnySignalChanges() 9 | { 10 | await this.SwitchToMainThread(); 11 | 12 | var number1 = new Signal(); 13 | var number2 = new Signal(); 14 | 15 | int sum = -1; 16 | var effect = new Effect(async _ => 17 | { 18 | await Task.Yield(); 19 | sum = number1.Value + number2.Value; 20 | await Task.Yield(); 21 | }); 22 | 23 | await TestHelpers.WaitUntil(() => sum == 0); 24 | 25 | number1.Value = 1; 26 | await TestHelpers.WaitUntil(() => sum == 1); 27 | 28 | number1.Value = 2; 29 | await TestHelpers.WaitUntil(() => sum == 2); 30 | 31 | number2.Value = 2; 32 | await TestHelpers.WaitUntil(() => sum == 4); 33 | 34 | effect.Dispose(); 35 | 36 | number2.Value = 3; 37 | await TestHelpers.WaitUntil(() => sum == 4); 38 | } 39 | 40 | 41 | [Fact] 42 | public async Task ShouldRunOnSpecifiedScheduler() 43 | { 44 | await this.SwitchToMainThread(); 45 | var scheduler = new TestScheduler(); 46 | var number1 = new Signal(); 47 | var number2 = new Signal(); 48 | 49 | int sum = -1; 50 | var effect = new Effect(async _ => 51 | { 52 | await Task.Yield(); 53 | sum = number1.Value + number2.Value; 54 | await Task.Yield(); 55 | }, scheduler: scheduler); 56 | 57 | await TestHelpers.WaitUntil(() => sum == 0); 58 | 59 | number1.Value = 1; 60 | await TestHelpers.WaitUntil(() => sum == 0); 61 | 62 | number1.Value = 2; 63 | await TestHelpers.WaitUntil(() => sum == 0); 64 | 65 | scheduler.ExecuteAllPendingActions(); 66 | await TestHelpers.WaitUntil(() => sum == 2); 67 | 68 | effect.Dispose(); 69 | 70 | number2.Value = 3; 71 | scheduler.ExecuteAllPendingActions(); 72 | await TestHelpers.WaitUntil(() => sum == 2); 73 | } 74 | 75 | 76 | [Fact] 77 | public async Task EffectsShouldRunAtTheEndOfAtomicOperations() 78 | { 79 | await this.SwitchToMainThread(); 80 | 81 | var number1 = new Signal(); 82 | var number2 = new Signal(); 83 | 84 | int sum = -1; 85 | _ = new Effect(async _ => 86 | { 87 | await Task.Yield(); 88 | sum = number1.Value + number2.Value; 89 | }); 90 | 91 | await TestHelpers.WaitUntil(() => sum == 0); 92 | 93 | await Task.Delay(10); 94 | await Effect.AtomicOperationAsync(async () => 95 | { 96 | await Task.Yield(); 97 | number1.Value = 1; 98 | await Task.Yield(); 99 | sum.Should().Be(0); 100 | 101 | await Task.Yield(); 102 | number1.Value = 2; 103 | await Task.Yield(); 104 | sum.Should().Be(0); 105 | }); 106 | await TestHelpers.WaitUntil(() => sum == 2); 107 | 108 | await Effect.AtomicOperationAsync(async () => 109 | { 110 | await Task.Yield(); 111 | number2.Value = 2; 112 | sum.Should().Be(2); 113 | 114 | await Effect.AtomicOperationAsync(async () => 115 | { 116 | await Task.Yield(); 117 | number2.Value = 3; 118 | await Task.Yield(); 119 | sum.Should().Be(2); 120 | await Task.Yield(); 121 | }); 122 | 123 | sum.Should().Be(2); 124 | }); 125 | 126 | await TestHelpers.WaitUntil(() => sum == 5); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/CollectionSignalTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using FluentAssertions; 3 | using R3; 4 | 5 | namespace SignalsDotnet.Tests; 6 | 7 | public class CollectionSignalTests 8 | { 9 | [Fact] 10 | public void ReactiveObservableCollection_ShouldObserve_NestedChanges() 11 | { 12 | var city = new City(); 13 | bool anyNiceChair = false; 14 | city.AnyNiceChair.Values.Subscribe(x => anyNiceChair = x); 15 | 16 | var house = new House(); 17 | city.Houses.Value = [house]; 18 | anyNiceChair.Should().BeFalse(); 19 | 20 | var room = new Room(); 21 | city.FirstRoom.Value.Should().BeNull(); 22 | house.Rooms.Value.Add(room); 23 | anyNiceChair.Should().BeFalse(); 24 | city.FirstRoom.Value.Should().Be(room); 25 | 26 | var badChair = new Chair("badChair"); 27 | room.Chairs.Value.Add(badChair); 28 | anyNiceChair.Should().BeFalse(); 29 | 30 | var niceChair = new Chair("NiceChair"); 31 | room.Chairs.Value.Add(niceChair); 32 | anyNiceChair.Should().BeTrue(); 33 | 34 | house.Rooms.Value.Remove(room); 35 | anyNiceChair.Should().BeFalse(); 36 | 37 | house.Rooms.Value.Add(room); 38 | anyNiceChair.Should().BeTrue(); 39 | 40 | city.Houses.Value = null; 41 | anyNiceChair.Should().BeFalse(); 42 | } 43 | } 44 | 45 | 46 | public class City 47 | { 48 | public City() 49 | { 50 | AnyNiceChair = Signal.Computed(() => 51 | { 52 | var niceChairs = from house in Houses.Value ?? Enumerable.Empty() 53 | from room in house.Rooms.Value 54 | from chair in room.Chairs.Value 55 | where chair.ChairName == "NiceChair" 56 | select chair; 57 | 58 | return niceChairs.Any(); 59 | }); 60 | 61 | FirstRoom = Signal.Computed(() => Houses.Value?.SelectMany(x => x.Rooms.Value).FirstOrDefault()); 62 | } 63 | 64 | 65 | public CollectionSignal> Houses { get; } = new(); 66 | public IReadOnlySignal AnyNiceChair { get; } 67 | public IReadOnlySignal FirstRoom { get; } 68 | } 69 | 70 | public class House 71 | { 72 | public IReadOnlySignal> Rooms { get; } = new ObservableCollection().ToCollectionSignal(); 73 | } 74 | 75 | public class Room 76 | { 77 | public IReadOnlySignal> Chairs { get; } = new ObservableCollection().ToCollectionSignal(); 78 | } 79 | 80 | public record Chair(string ChairName); -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/ComputedSignalTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using R3; 3 | 4 | namespace SignalsDotnet.Tests; 5 | 6 | public class ComputedSignalTests 7 | { 8 | [Fact] 9 | public void ShouldNotifyWhenAnyChanged() 10 | { 11 | var prop1 = new Signal(); 12 | var prop2 = new Signal(); 13 | 14 | int Sum() => prop1.Value + prop2.Value; 15 | var computed = Signal.Computed(Sum); 16 | int notifiedValue = 0; 17 | computed.Values.Subscribe(_ => notifiedValue++); 18 | _ = computed.Value; 19 | 20 | notifiedValue = 0; 21 | prop1.Value = 2; 22 | notifiedValue.Should().Be(1); 23 | computed.Value.Should().Be(Sum()); 24 | 25 | notifiedValue = 0; 26 | prop2.Value = 1; 27 | notifiedValue.Should().Be(1); 28 | computed.Value.Should().Be(Sum()); 29 | } 30 | 31 | [Fact] 32 | public void ShouldNotifyOnlyLatestScannedProperties() 33 | { 34 | var number1 = new Signal(); 35 | var number2 = new Signal(); 36 | var defaultValue = new Signal(); 37 | var shouldReturnDefault = new Signal(); 38 | 39 | int Op() 40 | { 41 | if (!shouldReturnDefault.Value) 42 | return number1.Value - number2.Value; 43 | 44 | return defaultValue.Value; 45 | } 46 | 47 | var computed = Signal.Computed(Op); 48 | computed.Value.Should().Be(Op()); 49 | 50 | var computedChanged = computed.Values.Skip(1); 51 | 52 | var notified = false; 53 | computedChanged.Subscribe(_ => notified = true); 54 | defaultValue.Value = 2; 55 | notified.Should().BeFalse(); 56 | 57 | notified = false; 58 | shouldReturnDefault.Value = true; 59 | notified.Should().BeTrue(); 60 | 61 | notified = false; 62 | defaultValue.Value = 3; 63 | notified.Should().BeTrue(); 64 | 65 | notified = false; 66 | shouldReturnDefault.Value = false; 67 | notified.Should().BeTrue(); 68 | 69 | notified = false; 70 | defaultValue.Value = 11; 71 | notified.Should().BeFalse(); 72 | 73 | notified = false; 74 | } 75 | 76 | [Fact] 77 | public void Untracked_ShouldNotTrack_SignalChanges() 78 | { 79 | var a = new Signal(); 80 | var b = new Signal(); 81 | 82 | var value = 0; 83 | var computed = Signal.Computed(() => a.Value + Signal.Untracked(() => b.Value)); 84 | computed.Values.Subscribe(x => value = x); 85 | a.Value = 1; 86 | value.Should().Be(1); 87 | a.Value = 2; 88 | value.Should().Be(2); 89 | 90 | b.Value = 1; 91 | value.Should().Be(2); 92 | 93 | computed.Should().NotBeNull(); 94 | } 95 | 96 | [Fact] 97 | public void UntrackedValue_ShouldNotTrack_SignalChanges() 98 | { 99 | var a = new Signal(); 100 | var b = new Signal(); 101 | 102 | var value = 0; 103 | var computed = Signal.Computed(() => a.Value + b.UntrackedValue); 104 | computed.Values.Subscribe(x => value = x); 105 | a.Value = 1; 106 | value.Should().Be(1); 107 | a.Value = 2; 108 | value.Should().Be(2); 109 | 110 | b.Value = 1; 111 | value.Should().Be(2); 112 | 113 | computed.Should().NotBeNull(); 114 | } 115 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/EffectTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using R3; 3 | 4 | namespace SignalsDotnet.Tests; 5 | 6 | public class EffectTests 7 | { 8 | [Fact] 9 | public void ShouldRunWhenAnySignalChanges() 10 | { 11 | var number1 = new Signal(); 12 | var number2 = new Signal(); 13 | 14 | int sum = -1; 15 | var effect = new Effect(() => sum = number1.Value + number2.Value); 16 | sum.Should().Be(0); 17 | 18 | number1.Value = 1; 19 | sum.Should().Be(1); 20 | 21 | number1.Value = 2; 22 | sum.Should().Be(2); 23 | 24 | number2.Value = 2; 25 | sum.Should().Be(4); 26 | 27 | effect.Dispose(); 28 | 29 | number2.Value = 3; 30 | sum.Should().Be(4); 31 | } 32 | 33 | [Fact] 34 | public void ShouldRunOnSpecifiedScheduler() 35 | { 36 | var scheduler = new TestScheduler(); 37 | var number1 = new Signal(); 38 | var number2 = new Signal(); 39 | 40 | int sum = -1; 41 | var effect = new Effect(() => sum = number1.Value + number2.Value, scheduler); 42 | sum.Should().Be(0); 43 | 44 | number1.Value = 1; 45 | sum.Should().Be(0); 46 | 47 | number1.Value = 2; 48 | sum.Should().Be(0); 49 | 50 | scheduler.ExecuteAllPendingActions(); 51 | sum.Should().Be(2); 52 | 53 | effect.Dispose(); 54 | 55 | number2.Value = 3; 56 | scheduler.ExecuteAllPendingActions(); 57 | sum.Should().Be(2); 58 | } 59 | 60 | [Fact] 61 | public void EffectShouldNotRunMultipleTimesInASingleSchedule() 62 | { 63 | var scheduler = new TestScheduler(); 64 | var number1 = new Signal(); 65 | var number2 = new Signal(); 66 | 67 | int executionsCount = 0; 68 | var effect = new Effect(() => 69 | { 70 | _ = number1.Value + number2.Value; 71 | executionsCount++; 72 | }, scheduler); 73 | executionsCount.Should().Be(1); 74 | 75 | number2.Value = 4; 76 | number2.Value = 3; 77 | 78 | number1.Value = 4; 79 | number1.Value = 3; 80 | executionsCount.Should().Be(1); 81 | 82 | scheduler.ExecuteAllPendingActions(); 83 | executionsCount.Should().Be(2); 84 | 85 | effect.Dispose(); 86 | 87 | number1.Value = 4; 88 | number1.Value = 3; 89 | scheduler.ExecuteAllPendingActions(); 90 | executionsCount.Should().Be(2); 91 | } 92 | 93 | [Fact] 94 | public async Task EffectsShouldRunAtTheEndOfAtomicOperations() 95 | { 96 | await Enumerable.Range(1, 33) 97 | .Select(__ => 98 | { 99 | return Observable.FromAsync(async token => await Task.Run(() => 100 | { 101 | var number1 = new Signal(); 102 | var number2 = new Signal(); 103 | 104 | int sum = -1; 105 | _ = new Effect(() => sum = number1.Value + number2.Value); 106 | //sum.Should().Be(0); 107 | 108 | Effect.AtomicOperation(() => 109 | { 110 | number1.Value = 1; 111 | sum.Should().Be(0); 112 | 113 | number1.Value = 2; 114 | sum.Should().Be(0); 115 | }); 116 | sum.Should().Be(2); 117 | 118 | Effect.AtomicOperation(() => 119 | { 120 | number2.Value = 2; 121 | sum.Should().Be(2); 122 | 123 | Effect.AtomicOperation(() => 124 | { 125 | number2.Value = 3; 126 | sum.Should().Be(2); 127 | }); 128 | 129 | sum.Should().Be(2); 130 | }); 131 | 132 | sum.Should().Be(5); 133 | }, token)); 134 | }) 135 | .Merge() 136 | .WaitAsync(); 137 | } 138 | 139 | [Fact] 140 | public void EffectsShouldRunAtTheEndOfAtomicOperationsWithScheduler() 141 | { 142 | var scheduler = new TestScheduler(); 143 | var number1 = new Signal(); 144 | var number2 = new Signal(); 145 | 146 | int sum = -1; 147 | _ = new Effect(() => sum = number1.Value + number2.Value, scheduler); 148 | sum.Should().Be(0); 149 | 150 | Effect.AtomicOperation(() => 151 | { 152 | number1.Value = 1; 153 | scheduler.ExecuteAllPendingActions(); 154 | sum.Should().Be(0); 155 | 156 | number1.Value = 2; 157 | scheduler.ExecuteAllPendingActions(); 158 | sum.Should().Be(0); 159 | }); 160 | sum.Should().Be(0); 161 | 162 | scheduler.ExecuteAllPendingActions(); 163 | sum.Should().Be(2); 164 | } 165 | } 166 | 167 | public class TestScheduler : TimeProvider 168 | { 169 | Action? _actions; 170 | public void ExecuteAllPendingActions() 171 | { 172 | _actions?.Invoke(); 173 | } 174 | 175 | public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) 176 | { 177 | _actions += () => callback(state); 178 | return new FakeTimer(); 179 | } 180 | 181 | class FakeTimer : ITimer 182 | { 183 | public void Dispose() 184 | { 185 | 186 | } 187 | 188 | public ValueTask DisposeAsync() => ValueTask.CompletedTask; 189 | 190 | public bool Change(TimeSpan dueTime, TimeSpan period) => throw new NotImplementedException(); 191 | } 192 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/Helpers/MainThreadAwaitableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace SignalsDotnet.Tests.Helpers; 4 | 5 | public static class MainThreadAwaitableExtensions 6 | { 7 | public static MainThreadAwaitable SwitchToMainThread(this object _) => new(); 8 | } 9 | 10 | /// 11 | /// If awaited, force the continuation to run on a Single-threaded synchronization context. 12 | /// That's the exact behavior of Wpf Synchronization Context (DispatcherSynchronizationContext) 13 | /// So basically: 14 | /// 1) after the await we switch thread. 15 | /// 2) Every other continuation will run on the same thread as it happens in Wpf. 16 | /// 17 | public readonly struct MainThreadAwaitable : INotifyCompletion 18 | { 19 | public MainThreadAwaitable GetAwaiter() => this; 20 | public bool IsCompleted => SynchronizationContext.Current == TestSingleThreadSynchronizationContext.Instance; 21 | public void OnCompleted(Action action) => TestSingleThreadSynchronizationContext.Instance.Post(_ => action(), null); 22 | public void GetResult() { } 23 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/Helpers/TestHelpers.cs: -------------------------------------------------------------------------------- 1 | namespace SignalsDotnet.Tests.Helpers; 2 | 3 | internal static class TestHelpers 4 | { 5 | public static async Task WaitUntil(Func predicate) 6 | { 7 | while (!predicate()) 8 | { 9 | await Task.Yield(); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/Helpers/TestSingleThreadSynchronizationContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace SignalsDotnet.Tests.Helpers; 4 | 5 | internal sealed class TestSingleThreadSynchronizationContext : SynchronizationContext 6 | { 7 | public Thread MainThread { get; } 8 | readonly BlockingCollection<(SendOrPostCallback callback, object? state)> _callbacksWithState = []; 9 | 10 | public TestSingleThreadSynchronizationContext() 11 | { 12 | MainThread = new Thread(MainThreadLoop) 13 | { 14 | IsBackground = true 15 | }; 16 | 17 | MainThread.Start(); 18 | } 19 | 20 | public static TestSingleThreadSynchronizationContext Instance { get; } = new(); 21 | 22 | void MainThreadLoop() 23 | { 24 | SetSynchronizationContext(this); 25 | 26 | foreach (var (callback, state) in _callbacksWithState.GetConsumingEnumerable()) 27 | callback.Invoke(state); 28 | } 29 | 30 | public override void Post(SendOrPostCallback callback, object? state) 31 | { 32 | _callbacksWithState.Add((callback, state)); 33 | } 34 | 35 | public override void Send(SendOrPostCallback callback, object? state) 36 | { 37 | if (Current == this) 38 | { 39 | callback(state); 40 | return; 41 | } 42 | 43 | _callbacksWithState.Add((callback, state)); 44 | } 45 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.Tests/SignalsDotnet.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | all 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.8.34330.188 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SignalsDotnet", "SignalsDotnet\SignalsDotnet.csproj", "{A54925EA-A0B1-4DD7-991A-4B55E21DC2C2}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SignalsDotnet.Tests", "SignalsDotnet.Tests\SignalsDotnet.Tests.csproj", "{D6DA2778-8C20-4D1D-A4E7-956FEAE51859}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SignalsDotnet.PeformanceTests", "SignalsDotnet.PeformanceTests\SignalsDotnet.PeformanceTests.csproj", "{C7366C63-2562-435B-B78D-9D51C0649CBA}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {A54925EA-A0B1-4DD7-991A-4B55E21DC2C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {A54925EA-A0B1-4DD7-991A-4B55E21DC2C2}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {A54925EA-A0B1-4DD7-991A-4B55E21DC2C2}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {A54925EA-A0B1-4DD7-991A-4B55E21DC2C2}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {D6DA2778-8C20-4D1D-A4E7-956FEAE51859}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {D6DA2778-8C20-4D1D-A4E7-956FEAE51859}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {D6DA2778-8C20-4D1D-A4E7-956FEAE51859}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {D6DA2778-8C20-4D1D-A4E7-956FEAE51859}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {C7366C63-2562-435B-B78D-9D51C0649CBA}.Debug|Any CPU.ActiveCfg = Debug|AnyCPU 27 | {C7366C63-2562-435B-B78D-9D51C0649CBA}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | EndGlobalSection 29 | GlobalSection(SolutionProperties) = preSolution 30 | HideSolutionNode = FALSE 31 | EndGlobalSection 32 | GlobalSection(ExtensibilityGlobals) = postSolution 33 | SolutionGuid = {1395FD1B-A0BE-49AD-B46F-8AEC100E338B} 34 | EndGlobalSection 35 | EndGlobal 36 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/CancellationSignal.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | 3 | namespace SignalsDotnet; 4 | 5 | public static class CancellationSignal 6 | { 7 | public static IReadOnlySignal Create(Observable isCancelledObservable) 8 | { 9 | return isCancelledObservable.DistinctUntilChanged() 10 | .Scan((cancelationTokenSource: (CancellationTokenSource?)null, cancellationToken: default(CancellationToken)), (x, isCancelled) => 11 | { 12 | if (isCancelled) 13 | { 14 | var cancellationTokenSource = x.cancelationTokenSource ?? new CancellationTokenSource(); 15 | var token = cancellationTokenSource.Token; 16 | cancellationTokenSource.Cancel(); 17 | cancellationTokenSource.Dispose(); 18 | 19 | return (cancellationTokenSource, token); 20 | } 21 | 22 | var newCancellationToken = new CancellationTokenSource(); 23 | return (newCancellationToken, newCancellationToken.Token); 24 | }) 25 | .Select(x => x.cancellationToken) 26 | .ToSignal(x => x with { RaiseOnlyWhenChanged = false }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/CollectionSignal.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Specialized; 2 | using System.ComponentModel; 3 | using R3; 4 | using SignalsDotnet.Configuration; 5 | 6 | namespace SignalsDotnet; 7 | 8 | public class CollectionSignal : IReadOnlySignal where T : class, INotifyCollectionChanged 9 | { 10 | readonly CollectionChangedSignalConfigurationDelegate? _collectionChangedConfiguration; 11 | readonly Signal?> _signal; 12 | 13 | public CollectionSignal(CollectionChangedSignalConfigurationDelegate? collectionChangedConfiguration = null, 14 | SignalConfigurationDelegate?>? propertyChangedConfiguration = null) 15 | { 16 | _collectionChangedConfiguration = collectionChangedConfiguration; 17 | _signal = new(propertyChangedConfiguration); 18 | 19 | _signal.PropertyChanged += (_, args) => 20 | { 21 | PropertyChanged?.Invoke(this, args); 22 | }; 23 | } 24 | 25 | public T? Value 26 | { 27 | get => _signal.Value?.Value; 28 | set => _signal.Value = value?.ToCollectionSignal(_collectionChangedConfiguration)!; 29 | } 30 | 31 | public Observable Values => _signal.Values 32 | .Select(static x => x?.Values ?? Observable.Return(null)!) 33 | .Switch()!; 34 | 35 | public Observable FutureValues => Values.Skip(1); 36 | 37 | 38 | public event PropertyChangedEventHandler? PropertyChanged; 39 | object? IReadOnlySignal.UntrackedValue => UntrackedValue; 40 | public T? UntrackedValue => _signal.UntrackedValue?.UntrackedValue; 41 | public T? UntrackedCollectionChangedValue => _signal.Value?.UntrackedValue; 42 | 43 | Observable IReadOnlySignal.Values => _signal.Values 44 | .Select(static x => ((IReadOnlySignal?)x)?.Values ?? Observable.Return(Unit.Default)) 45 | .Switch(); 46 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/ComputedSignalFactory/ComputedSignalFactory.cs: -------------------------------------------------------------------------------- 1 | using SignalsDotnet.Internals.ComputedSignalrFactory; 2 | 3 | namespace SignalsDotnet; 4 | 5 | public static class ComputedSignalFactory 6 | { 7 | public static IComputedSignalFactory Default => DefaultComputedSignalFactory.Instance; 8 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/ComputedSignalFactory/ComputedSignalFactoryEx.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | using SignalsDotnet.Configuration; 3 | using SignalsDotnet.Helpers; 4 | using SignalsDotnet.Internals.ComputedSignalrFactory; 5 | 6 | namespace SignalsDotnet; 7 | 8 | public static class ComputedSignalFactoryEx 9 | { 10 | public static IComputedSignalFactory DisconnectEverythingWhen(this IComputedSignalFactory @this, Observable shouldBeCancelled) 11 | { 12 | return new CancelComputedSignalFactoryDecorator(@this, CancellationSignal.Create(shouldBeCancelled)); 13 | } 14 | 15 | public static IComputedSignalFactory OnException(this IComputedSignalFactory @this, Action onException, bool ignoreOperationCancelled = true) 16 | { 17 | return new OnErrorComputedSignalFactoryDecorator(@this, ignoreOperationCancelled, onException); 18 | } 19 | 20 | public static IReadOnlySignal Computed(this IComputedSignalFactory @this, Func func, Func fallbackValue, ReadonlySignalConfigurationDelegate? configuration = null) 21 | { 22 | return @this.Computed(func, () => new Optional(fallbackValue()), configuration); 23 | } 24 | 25 | public static IReadOnlySignal Computed(this IComputedSignalFactory @this, Func func, ReadonlySignalConfigurationDelegate? configuration = null) 26 | { 27 | return @this.Computed(func, static () => default, configuration); 28 | } 29 | 30 | public static IAsyncReadOnlySignal AsyncComputed(this IComputedSignalFactory @this, 31 | Func> func, 32 | T startValue, 33 | Func fallbackValue, 34 | ConcurrentChangeStrategy concurrentChangeStrategy = default, 35 | ReadonlySignalConfigurationDelegate? configuration = null) 36 | { 37 | return @this.AsyncComputed(func, startValue, () => new Optional(fallbackValue()), concurrentChangeStrategy, configuration); 38 | } 39 | 40 | public static IAsyncReadOnlySignal AsyncComputed(this IComputedSignalFactory @this, 41 | Func> func, 42 | T startValue, 43 | ConcurrentChangeStrategy concurrentChangeStrategy = default, 44 | ReadonlySignalConfigurationDelegate? configuration = null) 45 | { 46 | return @this.AsyncComputed(func, startValue, static () => default, concurrentChangeStrategy, configuration); 47 | } 48 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/ComputedSignalFactory/IComputedSignalFactory.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | using SignalsDotnet.Configuration; 3 | using SignalsDotnet.Helpers; 4 | 5 | namespace SignalsDotnet; 6 | 7 | public interface IComputedSignalFactory 8 | { 9 | IReadOnlySignal Computed(Func func, Func> fallbackValue, ReadonlySignalConfigurationDelegate? configuration = null); 10 | Observable ComputedObservable(Func func, Func> fallbackValue); 11 | 12 | IAsyncReadOnlySignal AsyncComputed(Func> func, 13 | T startValue, 14 | Func> fallbackValue, 15 | ConcurrentChangeStrategy concurrentChangeStrategy = default, 16 | ReadonlySignalConfigurationDelegate? configuration = null); 17 | 18 | Observable AsyncComputedObservable(Func> func, 19 | T startValue, 20 | Func> fallbackValue, 21 | ConcurrentChangeStrategy concurrentChangeStrategy = default); 22 | 23 | Effect Effect(Action onChange, TimeProvider? scheduler = null); 24 | Effect AsyncEffect(Func onChange, ConcurrentChangeStrategy concurrentChangeStrategy = default, TimeProvider? scheduler = null); 25 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/ConcurrentChangeStrategy.cs: -------------------------------------------------------------------------------- 1 | namespace SignalsDotnet; 2 | 3 | public enum ConcurrentChangeStrategy 4 | { 5 | ScheduleNext, 6 | CancelCurrent 7 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Configuration/CollectionChangedSignalConfiguration.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | 3 | namespace SignalsDotnet.Configuration; 4 | 5 | public delegate CollectionChangedSignalConfiguration CollectionChangedSignalConfigurationDelegate(CollectionChangedSignalConfiguration startConfiguration); 6 | 7 | public record CollectionChangedSignalConfiguration(bool SubscribeWeakly, Func, Observable> CollectionChangedObservableMapper) 8 | { 9 | public static CollectionChangedSignalConfiguration Default => new(true, static x => x); 10 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Configuration/ReadonlySignalConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace SignalsDotnet.Configuration; 2 | 3 | public delegate ReadonlySignalConfiguration ReadonlySignalConfigurationDelegate(ReadonlySignalConfiguration startConfiguration); 4 | public record ReadonlySignalConfiguration(IEqualityComparer Comparer, 5 | bool RaiseOnlyWhenChanged, 6 | bool SubscribeWeakly) 7 | { 8 | public static ReadonlySignalConfiguration Default { get; } = new(EqualityComparer.Default, true, false); 9 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Configuration/SignalConfiguration.cs: -------------------------------------------------------------------------------- 1 | using SignalsDotnet.Internals.Helpers; 2 | 3 | namespace SignalsDotnet.Configuration; 4 | 5 | public delegate SignalConfiguration SignalConfigurationDelegate(SignalConfiguration startConfiguration); 6 | 7 | public record SignalConfiguration(IEqualityComparer Comparer, bool RaiseOnlyWhenChanged) 8 | { 9 | public static SignalConfiguration Default { get; } = new(EqualityComparer.Default, true); 10 | } 11 | 12 | public static class SignalConfigurationExtensions 13 | { 14 | public static SignalConfiguration ForEqualityCheck(this SignalConfiguration @this, 15 | Func equalitySelector) 16 | where TDest : notnull 17 | { 18 | return @this with { Comparer = new KeyEqualityComparer(equalitySelector) }; 19 | } 20 | 21 | public static ReadonlySignalConfiguration ForEqualityCheck(this ReadonlySignalConfiguration @this, 22 | Func equalitySelector) 23 | where TDest : notnull 24 | { 25 | return @this with { Comparer = new KeyEqualityComparer(equalitySelector) }; 26 | } 27 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Effect.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | using SignalsDotnet.Helpers; 3 | 4 | namespace SignalsDotnet; 5 | 6 | public class Effect : IDisposable 7 | { 8 | static readonly object _atomicOperationsLocker = new (); 9 | static readonly AsyncLocal> _atomicOperationsCounter = new(); 10 | readonly IDisposable _subscription; 11 | 12 | public Effect(Action onChange, TimeProvider? scheduler = null) 13 | { 14 | var computationDelayer = ComputationDelayer(scheduler ?? DefaultScheduler); 15 | _subscription = Signal.ComputedObservable(_ => 16 | { 17 | onChange(); 18 | return ValueTask.FromResult(Unit.Default); 19 | }, static () => Optional.Empty, computationDelayer) 20 | .Subscribe(); 21 | } 22 | 23 | public Effect(Func onChange, ConcurrentChangeStrategy concurrentChangeStrategy = default, TimeProvider? scheduler = null) 24 | { 25 | var computationDelayer = ComputationDelayer(scheduler ?? DefaultScheduler); 26 | _subscription = Signal.ComputedObservable(async token => 27 | { 28 | await onChange(token); 29 | return Unit.Default; 30 | }, static () => Optional.Empty, computationDelayer, concurrentChangeStrategy) 31 | .Subscribe(); 32 | } 33 | 34 | static Func> ComputationDelayer(TimeProvider? scheduler) 35 | { 36 | var atomicOperations = Observable.Defer(() => 37 | { 38 | lock (_atomicOperationsLocker) 39 | { 40 | return _atomicOperationsCounter.Value ??= new(0); 41 | } 42 | }); 43 | 44 | var noAtomicOperations = atomicOperations.Synchronize(_atomicOperationsCounter) 45 | .Where(counter => counter == 0) 46 | .Select(static _ => Unit.Default); 47 | 48 | return scheduler is null 49 | ? _ => noAtomicOperations 50 | : _ => noAtomicOperations.ObserveOn(scheduler); 51 | } 52 | 53 | public static void AtomicOperation(Action action) 54 | { 55 | lock (_atomicOperationsLocker) 56 | { 57 | _atomicOperationsCounter.Value ??= new(0); 58 | _atomicOperationsCounter.Value.OnNext(_atomicOperationsCounter.Value.Value + 1); 59 | } 60 | 61 | try 62 | { 63 | action(); 64 | } 65 | finally 66 | { 67 | lock (_atomicOperationsLocker) 68 | { 69 | _atomicOperationsCounter.Value.OnNext(_atomicOperationsCounter.Value.Value - 1); 70 | } 71 | } 72 | } 73 | 74 | public static async ValueTask AtomicOperationAsync(Func action) 75 | { 76 | lock (_atomicOperationsLocker) 77 | { 78 | _atomicOperationsCounter.Value ??= new(0); 79 | _atomicOperationsCounter.Value.OnNext(_atomicOperationsCounter.Value.Value + 1); 80 | } 81 | 82 | try 83 | { 84 | await action(); 85 | } 86 | finally 87 | { 88 | lock (_atomicOperationsLocker) 89 | { 90 | _atomicOperationsCounter.Value.OnNext(_atomicOperationsCounter.Value.Value - 1); 91 | } 92 | } 93 | } 94 | 95 | public static TimeProvider? DefaultScheduler { get; set; } 96 | public void Dispose() => _subscription.Dispose(); 97 | } 98 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Helpers/ObservableEx.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | 3 | namespace SignalsDotnet.Helpers; 4 | 5 | public static class ObservableEx 6 | { 7 | public static Observable DisconnectWhen(this Observable @this, Observable isDisconnected) 8 | { 9 | return isDisconnected.Select(x => x switch 10 | { 11 | false => @this, 12 | true => Observable.Empty() 13 | }) 14 | .Switch() 15 | .Publish() 16 | .RefCount(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Helpers/Optional.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace SignalsDotnet.Helpers; 4 | 5 | public readonly struct Optional 6 | { 7 | readonly T? _value; 8 | public Optional() => (_value, HasValue) = (default, false); 9 | public Optional(T value) => (_value, HasValue) = (value, true); 10 | public static Optional Empty => new(); 11 | public bool HasValue { get; } 12 | public T? Value => HasValue ? _value : throw new InvalidOperationException("Impossible retrieve a value for an empty optional"); 13 | } 14 | 15 | public static class OptionalExtensions 16 | { 17 | public static bool TryGetValue(this Optional @this, [NotNullWhen(true)] out T? value) 18 | { 19 | value = @this.HasValue ? @this.Value : default; 20 | return @this.HasValue; 21 | } 22 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/IReadOnlySignal.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using R3; 3 | 4 | namespace SignalsDotnet; 5 | 6 | public interface IReadOnlySignal : INotifyPropertyChanged 7 | { 8 | Observable Values { get; } 9 | Observable FutureValues => Values.Skip(1); 10 | object? Value { get; } 11 | object? UntrackedValue { get; } 12 | } 13 | 14 | public interface IReadOnlySignal : IReadOnlySignal 15 | { 16 | new Observable Values { get; } 17 | new Observable FutureValues { get; } 18 | new T Value { get; } 19 | new T UntrackedValue { get; } 20 | object? IReadOnlySignal.Value => Value; 21 | } 22 | 23 | public interface IAsyncReadOnlySignal : IReadOnlySignal 24 | { 25 | IReadOnlySignal IsComputing { get; } 26 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/ComputedObservable.cs: -------------------------------------------------------------------------------- 1 | using SignalsDotnet.Helpers; 2 | using R3; 3 | 4 | namespace SignalsDotnet.Internals; 5 | 6 | internal class ComputedObservable : Observable 7 | { 8 | readonly Func> _func; 9 | readonly Func> _fallbackValue; 10 | readonly Func>? _scheduler; 11 | readonly ConcurrentChangeStrategy _concurrentChangeStrategy; 12 | 13 | public ComputedObservable(Func> func, 14 | Func> fallbackValue, 15 | Func>? scheduler = null, 16 | ConcurrentChangeStrategy concurrentChangeStrategy = default) 17 | { 18 | _func = func; 19 | _fallbackValue = fallbackValue; 20 | _scheduler = scheduler; 21 | _concurrentChangeStrategy = concurrentChangeStrategy; 22 | } 23 | 24 | protected override IDisposable SubscribeCore(Observer observer) => new Subscription(this, observer); 25 | 26 | class Subscription : IDisposable 27 | { 28 | readonly ComputedObservable _observable; 29 | readonly Observer _observer; 30 | readonly BehaviorSubject _disposed = new(false); 31 | 32 | public Subscription(ComputedObservable observable, Observer observer) 33 | { 34 | _observable = observable; 35 | _observer = observer; 36 | Observable.FromAsync(ComputeResult) 37 | .Take(1) 38 | .TakeUntil(_disposed.Where(x => x)) 39 | .Subscribe(OnNewResult); 40 | } 41 | 42 | void OnNewResult(ComputationResult result) 43 | { 44 | var valueNotified = false; 45 | 46 | result.ShouldComputeNextResult 47 | .Take(1) 48 | .SelectMany(_ => 49 | { 50 | NotifyValueIfNotAlready(); 51 | return Observable.FromAsync(ComputeResult); 52 | }) 53 | .TakeUntil(_disposed.Where(x => x)) 54 | .Subscribe(OnNewResult); 55 | 56 | NotifyValueIfNotAlready(); 57 | 58 | // We notify a new value only if the func() evaluation succeeds. 59 | void NotifyValueIfNotAlready() 60 | { 61 | if (valueNotified) 62 | return; 63 | 64 | valueNotified = true; 65 | if (result.ResultOptional.TryGetValue(out var propertyValue)) 66 | { 67 | _observer.OnNext(propertyValue); 68 | } 69 | } 70 | } 71 | 72 | async ValueTask ComputeResult(CancellationToken cancellationToken) 73 | { 74 | var referenceEquality = ReferenceEqualityComparer.Instance; 75 | HashSet signalRequested = new(referenceEquality); 76 | Optional result; 77 | SingleNotificationObservable stopListeningForSignals = new(); 78 | 79 | var signalChangedObservable = Signal.SignalsRequested() 80 | .TakeUntil(stopListeningForSignals) 81 | .Where(signalRequested.Add) 82 | .Select(static x => x.FutureValues) 83 | .Merge() 84 | .Take(1); 85 | 86 | if (_observable._concurrentChangeStrategy == ConcurrentChangeStrategy.CancelCurrent) 87 | { 88 | var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); 89 | cancellationToken = cts.Token; 90 | signalChangedObservable = signalChangedObservable.Do(_ => cts.Cancel()) 91 | .DoCancelOnCompleted(cts); 92 | } 93 | 94 | var scheduler = _observable._scheduler; 95 | if (scheduler is not null) 96 | { 97 | signalChangedObservable = signalChangedObservable.Select(scheduler) 98 | .Switch(); 99 | } 100 | 101 | var shouldComputeNextResult = signalChangedObservable.Replay(1); 102 | 103 | var disconnect = shouldComputeNextResult.Connect(); 104 | 105 | try 106 | { 107 | try 108 | { 109 | result = new(await _observable._func(cancellationToken)); 110 | } 111 | finally 112 | { 113 | stopListeningForSignals.SetResult(true); 114 | } 115 | } 116 | catch (OperationCanceledException) 117 | { 118 | result = Optional.Empty; 119 | } 120 | catch 121 | { 122 | // If something fails, the property will have the previous result, 123 | // We still have to observe for the properties to change (maybe next time the exception will not be thrown) 124 | try 125 | { 126 | result = _observable._fallbackValue(); 127 | } 128 | catch 129 | { 130 | result = Optional.Empty; 131 | } 132 | } 133 | 134 | var resultObservable = new DisconnectOnDisposeObservable(shouldComputeNextResult, disconnect); 135 | 136 | return new(resultObservable, result); 137 | } 138 | 139 | 140 | public void Dispose() => _disposed.OnNext(true); 141 | } 142 | 143 | record struct ComputationResult(Observable ShouldComputeNextResult, Optional ResultOptional); 144 | class DisconnectOnDisposeObservable : Observable 145 | { 146 | readonly Observable _observable; 147 | readonly IDisposable _disconnect; 148 | 149 | public DisconnectOnDisposeObservable(Observable observable, IDisposable disconnect) 150 | { 151 | _observable = observable; 152 | _disconnect = disconnect; 153 | } 154 | 155 | protected override IDisposable SubscribeCore(Observer observer) 156 | { 157 | _observable.Subscribe(observer.OnNext, observer.OnErrorResume, observer.OnCompleted); 158 | return _disconnect; 159 | } 160 | } 161 | 162 | class SingleNotificationObservable : Observable, IDisposable 163 | { 164 | Observer? _observer; 165 | readonly object _locker = new(); 166 | Optional _value; 167 | 168 | protected override IDisposable SubscribeCore(Observer observer) 169 | { 170 | lock (_locker) 171 | { 172 | if (_value.TryGetValue(out var value)) 173 | { 174 | observer.OnNext(value); 175 | observer.OnCompleted(); 176 | } 177 | else 178 | { 179 | _observer = observer; 180 | } 181 | 182 | return this; 183 | } 184 | } 185 | 186 | public void SetResult(TNotification value) 187 | { 188 | lock (this) 189 | { 190 | var observer = _observer; 191 | if (observer is not null) 192 | { 193 | observer.OnNext(value); 194 | observer.OnCompleted(); 195 | return; 196 | } 197 | 198 | _value = new(value); 199 | } 200 | } 201 | 202 | public void Dispose() => Interlocked.Exchange(ref _observer, null); 203 | } 204 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/ComputedSignalrFactory/CancelComputedSignalFactoryDecorator.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | using SignalsDotnet.Configuration; 3 | using SignalsDotnet.Helpers; 4 | using SignalsDotnet.Internals.Helpers; 5 | 6 | namespace SignalsDotnet.Internals.ComputedSignalrFactory; 7 | 8 | internal class CancelComputedSignalFactoryDecorator : IComputedSignalFactory 9 | { 10 | readonly IComputedSignalFactory _parent; 11 | readonly IReadOnlySignal _cancellationSignal; 12 | 13 | public CancelComputedSignalFactoryDecorator(IComputedSignalFactory parent, IReadOnlySignal cancellationSignal) 14 | { 15 | _parent = parent; 16 | _cancellationSignal = cancellationSignal; 17 | } 18 | 19 | public IReadOnlySignal Computed(Func func, Func> fallbackValue, ReadonlySignalConfigurationDelegate? configuration = null) 20 | { 21 | return ComputedObservable(func, fallbackValue).ToSignal(configuration!); 22 | } 23 | 24 | public IAsyncReadOnlySignal AsyncComputed(Func> func, 25 | T startValue, 26 | Func> fallbackValue, 27 | ConcurrentChangeStrategy concurrentChangeStrategy = default, ReadonlySignalConfigurationDelegate? configuration = null) 28 | { 29 | func = func.TraceWhenExecuting(out var isExecuting); 30 | return AsyncComputedObservable(func, startValue, fallbackValue, concurrentChangeStrategy).ToAsyncSignal(isExecuting, configuration!); 31 | } 32 | 33 | public Observable ComputedObservable(Func func, Func> fallbackValue) 34 | { 35 | return _parent.ComputedObservable(() => 36 | { 37 | if (_cancellationSignal.Value.IsCancellationRequested) 38 | { 39 | return Optional.Empty; 40 | } 41 | 42 | return new Optional(func()); 43 | }, () => new Optional>(fallbackValue())) 44 | .Where(x => x.HasValue) 45 | .Select(x => x.Value!); 46 | } 47 | 48 | public Observable AsyncComputedObservable(Func> func, T startValue, Func> fallbackValue, ConcurrentChangeStrategy concurrentChangeStrategy = default) 49 | { 50 | return _parent.AsyncComputedObservable(async token => 51 | { 52 | if (_cancellationSignal.Value.IsCancellationRequested || token.IsCancellationRequested) 53 | { 54 | return Optional.Empty; 55 | } 56 | 57 | using var cts = CancellationTokenSource.CreateLinkedTokenSource(token, _cancellationSignal.UntrackedValue); 58 | var result = await func(cts.Token); 59 | return new Optional(result); 60 | }, new Optional(startValue), () => new Optional>(fallbackValue()), concurrentChangeStrategy) 61 | .Where(static x => x.HasValue) 62 | .Select(static x => x.Value)!; 63 | } 64 | 65 | public Effect Effect(Action onChange, TimeProvider? scheduler) 66 | { 67 | return new Effect(() => 68 | { 69 | if (_cancellationSignal.Value.IsCancellationRequested) 70 | { 71 | return; 72 | } 73 | 74 | onChange(); 75 | }, scheduler); 76 | } 77 | 78 | public Effect AsyncEffect(Func onChange, ConcurrentChangeStrategy concurrentChangeStrategy, TimeProvider? scheduler) 79 | { 80 | return new Effect(async token => 81 | { 82 | if (_cancellationSignal.Value.IsCancellationRequested || token.IsCancellationRequested) 83 | { 84 | return; 85 | } 86 | 87 | using var cts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationSignal.UntrackedValue, token); 88 | await onChange(cts.Token); 89 | }, concurrentChangeStrategy, scheduler); 90 | } 91 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/ComputedSignalrFactory/DefaultComputedSignalFactory.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | using SignalsDotnet.Configuration; 3 | using SignalsDotnet.Helpers; 4 | 5 | namespace SignalsDotnet.Internals.ComputedSignalrFactory; 6 | 7 | internal class DefaultComputedSignalFactory : IComputedSignalFactory 8 | { 9 | public IReadOnlySignal Computed(Func func, Func> fallbackValue, ReadonlySignalConfigurationDelegate? configuration = null) 10 | { 11 | return Signal.Computed(func, fallbackValue, configuration); 12 | } 13 | 14 | public IAsyncReadOnlySignal AsyncComputed(Func> func, 15 | T startValue, 16 | Func> fallbackValue, 17 | ConcurrentChangeStrategy concurrentChangeStrategy = default, 18 | ReadonlySignalConfigurationDelegate? configuration = null) 19 | { 20 | return Signal.AsyncComputed(func, startValue, fallbackValue, concurrentChangeStrategy, configuration); 21 | } 22 | 23 | 24 | public Observable ComputedObservable(Func func, Func> fallbackValue) 25 | { 26 | return Signal.ComputedObservable(func, fallbackValue); 27 | } 28 | 29 | public Observable AsyncComputedObservable(Func> func, T startValue, Func> fallbackValue, ConcurrentChangeStrategy concurrentChangeStrategy = default) 30 | { 31 | return Signal.AsyncComputedObservable(func, startValue, fallbackValue, concurrentChangeStrategy); 32 | } 33 | 34 | 35 | public Effect Effect(Action onChange, TimeProvider? scheduler) 36 | { 37 | return new Effect(onChange, scheduler); 38 | } 39 | 40 | public Effect AsyncEffect(Func onChange, ConcurrentChangeStrategy concurrentChangeStrategy, TimeProvider? scheduler) 41 | { 42 | return new Effect(onChange, concurrentChangeStrategy, scheduler); 43 | } 44 | 45 | 46 | public static DefaultComputedSignalFactory Instance { get; } = new(); 47 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/ComputedSignalrFactory/OnErrorComputedSignalFactoryDecorator.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | using SignalsDotnet.Configuration; 3 | using SignalsDotnet.Helpers; 4 | using SignalsDotnet.Internals.Helpers; 5 | 6 | namespace SignalsDotnet.Internals.ComputedSignalrFactory; 7 | 8 | internal class OnErrorComputedSignalFactoryDecorator : IComputedSignalFactory 9 | { 10 | readonly IComputedSignalFactory _parent; 11 | readonly bool _ignoreOperationCancelled; 12 | readonly Action _onException; 13 | 14 | public OnErrorComputedSignalFactoryDecorator(IComputedSignalFactory parent, bool ignoreOperationCancelled, Action onException) 15 | { 16 | _parent = parent; 17 | _ignoreOperationCancelled = ignoreOperationCancelled; 18 | _onException = onException; 19 | } 20 | 21 | 22 | public IReadOnlySignal Computed(Func func, Func> fallbackValue, ReadonlySignalConfigurationDelegate? configuration = null) 23 | { 24 | return ComputedObservable(func, fallbackValue).ToSignal(configuration!); 25 | } 26 | 27 | public Observable ComputedObservable(Func func, Func> fallbackValue) 28 | { 29 | return _parent.ComputedObservable(() => 30 | { 31 | try 32 | { 33 | return func(); 34 | } 35 | catch (Exception e) 36 | { 37 | NotifyException(e); 38 | throw; 39 | } 40 | }, fallbackValue); 41 | } 42 | 43 | public IAsyncReadOnlySignal AsyncComputed(Func> func, T startValue, Func> fallbackValue, ConcurrentChangeStrategy concurrentChangeStrategy = default, ReadonlySignalConfigurationDelegate? configuration = null) 44 | { 45 | func = func.TraceWhenExecuting(out var isExecuting); 46 | return AsyncComputedObservable(func, startValue, fallbackValue, concurrentChangeStrategy).ToAsyncSignal(isExecuting, configuration!); 47 | } 48 | 49 | public Observable AsyncComputedObservable(Func> func, T startValue, Func> fallbackValue, ConcurrentChangeStrategy concurrentChangeStrategy = default) 50 | { 51 | return _parent.AsyncComputedObservable(async token => 52 | { 53 | try 54 | { 55 | return await func(token); 56 | } 57 | catch (Exception e) 58 | { 59 | NotifyException(e); 60 | throw; 61 | } 62 | }, startValue, fallbackValue, concurrentChangeStrategy); 63 | } 64 | 65 | public Effect Effect(Action onChange, TimeProvider? scheduler = null) 66 | { 67 | return _parent.Effect(() => 68 | { 69 | try 70 | { 71 | onChange(); 72 | } 73 | catch (Exception e) 74 | { 75 | NotifyException(e); 76 | throw; 77 | } 78 | }, scheduler); 79 | } 80 | 81 | public Effect AsyncEffect(Func onChange, ConcurrentChangeStrategy concurrentChangeStrategy = default, TimeProvider? scheduler = null) 82 | { 83 | return _parent.AsyncEffect(async token => 84 | { 85 | try 86 | { 87 | await onChange(token); 88 | } 89 | catch (Exception e) 90 | { 91 | NotifyException(e); 92 | throw; 93 | } 94 | }, concurrentChangeStrategy); 95 | } 96 | 97 | void NotifyException(Exception e) 98 | { 99 | if (_ignoreOperationCancelled && e is OperationCanceledException) 100 | { 101 | return; 102 | } 103 | 104 | Signal.Untracked(() => _onException(e)); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/FromObservableCollectionSignal.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Specialized; 2 | using System.ComponentModel; 3 | using R3; 4 | using SignalsDotnet.Configuration; 5 | using SignalsDotnet.Internals.Helpers; 6 | 7 | namespace SignalsDotnet.Internals; 8 | 9 | internal class FromObservableCollectionSignal : IReadOnlySignal where T : INotifyCollectionChanged 10 | { 11 | readonly Subject _collectionChanged = new(); 12 | 13 | public FromObservableCollectionSignal(T collection, CollectionChangedSignalConfigurationDelegate? configurator = null) 14 | { 15 | _value = collection ?? throw new ArgumentNullException(nameof(collection)); 16 | var configuration = CollectionChangedSignalConfiguration.Default; 17 | if (configurator is not null) 18 | { 19 | configuration = configurator(configuration); 20 | } 21 | 22 | var observable = collection.OnCollectionChanged(); 23 | if (configuration.SubscribeWeakly) 24 | { 25 | observable.SubscribeWeakly(OnCollectionChanged); 26 | } 27 | else 28 | { 29 | observable.Subscribe(OnCollectionChanged); 30 | } 31 | } 32 | 33 | void OnCollectionChanged((object? sender, NotifyCollectionChangedEventArgs e) _) => _collectionChanged.OnNext(default); 34 | 35 | public Observable Values => _collectionChanged.Select(_ => Value) 36 | .Prepend(() => Value); 37 | 38 | public Observable FutureValues => _collectionChanged.Select(_ => Value); 39 | 40 | readonly T _value; 41 | public T Value => Signal.GetValue(this, in _value); 42 | 43 | object IReadOnlySignal.UntrackedValue => UntrackedValue; 44 | public T UntrackedValue => _value; 45 | public event PropertyChangedEventHandler? PropertyChanged; 46 | 47 | Observable IReadOnlySignal.Values => _collectionChanged.Prepend(Unit.Default); 48 | Observable IReadOnlySignal.FutureValues => _collectionChanged; 49 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/FromObservableSignal.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using R3; 3 | using SignalsDotnet.Configuration; 4 | using SignalsDotnet.Internals.Helpers; 5 | 6 | namespace SignalsDotnet.Internals; 7 | 8 | internal class FromObservableSignal : IReadOnlySignal, IEquatable> 9 | { 10 | readonly ReadonlySignalConfiguration _configuration; 11 | readonly Subject _someoneAskedValueSubject = new(); // lock 12 | int _someoneAskedValue; // 1 means true, 0 means false 13 | 14 | #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. 15 | public FromObservableSignal(Observable observable, 16 | #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. 17 | ReadonlySignalConfigurationDelegate? configuration = null) 18 | { 19 | if (observable is null) 20 | throw new ArgumentNullException(nameof(observable)); 21 | 22 | var options = ReadonlySignalConfiguration.Default; 23 | if (configuration != null) 24 | options = configuration(options); 25 | 26 | _configuration = options; 27 | 28 | _someoneAskedValueSubject.Take(1) 29 | .Subscribe(_ => 30 | { 31 | if (_configuration.SubscribeWeakly) 32 | observable.SubscribeWeakly(SetValue); 33 | else 34 | observable.Subscribe(SetValue); 35 | }); 36 | } 37 | 38 | 39 | /// 40 | /// Dont inline this function with a lambda 41 | /// 42 | void SetValue(T value) 43 | { 44 | Value = value; 45 | } 46 | 47 | T _value; 48 | public T Value 49 | { 50 | get 51 | { 52 | NotifySomeoneAskedAValue(); 53 | return Signal.GetValue(this, in _value); 54 | } 55 | private set 56 | { 57 | if (EqualityComparer.Default.Equals(_value, value)) 58 | return; 59 | 60 | _value = value; 61 | 62 | var propertyChanged = PropertyChanged; 63 | if (propertyChanged is null) 64 | { 65 | return; 66 | } 67 | 68 | using (Signal.UntrackedScope()) 69 | { 70 | propertyChanged(this, Signal.PropertyChangedArgs); 71 | } 72 | } 73 | } 74 | 75 | public T UntrackedValue => _value; 76 | object? IReadOnlySignal.UntrackedValue => UntrackedValue; 77 | 78 | void NotifySomeoneAskedAValue() 79 | { 80 | var someoneAlreadyAskedValue = Interlocked.Exchange(ref _someoneAskedValue, 1) == 1; 81 | if (someoneAlreadyAskedValue) 82 | return; 83 | 84 | _someoneAskedValueSubject.OnNext(default); 85 | _someoneAskedValueSubject.OnCompleted(); 86 | _someoneAskedValueSubject.Dispose(); 87 | } 88 | 89 | public Observable Values => this.OnPropertyChanged(false); 90 | public Observable FutureValues => this.OnPropertyChanged(true); 91 | 92 | public bool Equals(FromObservableSignal? other) 93 | { 94 | if (other is null) 95 | return false; 96 | if (ReferenceEquals(this, other)) 97 | return true; 98 | 99 | return _configuration.Comparer.Equals(_value, other._value); 100 | } 101 | 102 | public override bool Equals(object? obj) 103 | { 104 | if (obj is null) 105 | return false; 106 | if (ReferenceEquals(this, obj)) 107 | return true; 108 | if (obj.GetType() != GetType()) 109 | return false; 110 | 111 | return Equals((FromObservableSignal)obj); 112 | } 113 | 114 | public static bool operator ==(FromObservableSignal a, FromObservableSignal b) => Equals(a, b); 115 | public static bool operator !=(FromObservableSignal a, FromObservableSignal b) => !(a == b); 116 | 117 | public override int GetHashCode() => _value is null ? 0 : _configuration.Comparer.GetHashCode(_value); 118 | public event PropertyChangedEventHandler? PropertyChanged; 119 | 120 | Observable IReadOnlySignal.Values => this.OnPropertyChangedAsUnit(false); 121 | Observable IReadOnlySignal.FutureValues => this.OnPropertyChangedAsUnit(true); 122 | } 123 | 124 | internal class FromObservableAsyncSignal : FromObservableSignal, IAsyncReadOnlySignal 125 | { 126 | public FromObservableAsyncSignal(Observable observable, 127 | IReadOnlySignal isExecuting, 128 | ReadonlySignalConfigurationDelegate? configuration = null) : base(observable, configuration) 129 | { 130 | IsComputing = isExecuting; 131 | } 132 | 133 | public IReadOnlySignal IsComputing { get; } 134 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/Helpers/GenericHelpers.cs: -------------------------------------------------------------------------------- 1 | namespace SignalsDotnet.Internals.Helpers; 2 | 3 | internal static class GenericHelpers 4 | { 5 | public static Func> ToAsyncValueTask(this Func func) 6 | { 7 | if (func is null) 8 | throw new ArgumentNullException(nameof(func)); 9 | 10 | return token => 11 | { 12 | token.ThrowIfCancellationRequested(); 13 | return ValueTask.FromResult(func()); 14 | }; 15 | } 16 | 17 | public static Func> TraceWhenExecuting(this Func> func, out IReadOnlySignal isExecuting) 18 | { 19 | var isExecutingSignal = new Signal(); 20 | isExecuting = isExecutingSignal; 21 | 22 | return async token => 23 | { 24 | try 25 | { 26 | isExecutingSignal.Value = true; 27 | return await func(token); 28 | } 29 | finally 30 | { 31 | isExecutingSignal.Value = false; 32 | } 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/Helpers/KeyEqualityComparer.cs: -------------------------------------------------------------------------------- 1 | namespace SignalsDotnet.Internals.Helpers; 2 | 3 | internal class KeyEqualityComparer : IEqualityComparer where TDestination : notnull 4 | { 5 | readonly Func _keyExtractor; 6 | readonly EqualityComparer _equalityComparer = EqualityComparer.Default; 7 | 8 | public KeyEqualityComparer(Func keyExtractor) 9 | { 10 | _keyExtractor = keyExtractor; 11 | } 12 | 13 | public bool Equals(T? x, T? y) 14 | { 15 | return _equalityComparer.Equals(_keyExtractor(x), _keyExtractor(y)); 16 | } 17 | 18 | public int GetHashCode(T? obj) 19 | { 20 | return _equalityComparer.GetHashCode(_keyExtractor(obj)); 21 | } 22 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/Helpers/ObservableEx.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | 3 | namespace SignalsDotnet.Internals.Helpers; 4 | 5 | internal static class ObservableEx 6 | { 7 | public static FromAsyncContextObservable FromAsyncUsingAsyncContext(Func> asyncAction) 8 | { 9 | return new FromAsyncContextObservable(asyncAction); 10 | } 11 | 12 | public class FromAsyncContextObservable : Observable 13 | { 14 | readonly Func> _asyncAction; 15 | 16 | public FromAsyncContextObservable(Func> asyncAction) 17 | { 18 | _asyncAction = asyncAction; 19 | } 20 | 21 | protected override IDisposable SubscribeCore(Observer observer) 22 | { 23 | var disposable = new CancellationDisposable(); 24 | var token = disposable.Token; 25 | 26 | try 27 | { 28 | var task = _asyncAction(token); 29 | if (task.IsCompleted) 30 | { 31 | observer.OnNext(task.GetAwaiter().GetResult()); 32 | observer.OnCompleted(); 33 | return disposable; 34 | } 35 | 36 | BindObserverToTask(task, observer); 37 | } 38 | catch (Exception e) 39 | { 40 | observer.OnCompleted(e); 41 | } 42 | 43 | return disposable; 44 | } 45 | 46 | static async void BindObserverToTask(ValueTask task, Observer observer) 47 | { 48 | try 49 | { 50 | var result = await task; 51 | observer.OnNext(result); 52 | observer.OnCompleted(); 53 | } 54 | catch (Exception e) 55 | { 56 | try 57 | { 58 | observer.OnCompleted(e); 59 | } 60 | catch 61 | { 62 | // Ignored 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/Helpers/ObservableFromINotifyCollectionChanged.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Specialized; 2 | using R3; 3 | 4 | namespace SignalsDotnet.Internals.Helpers; 5 | 6 | internal static class ObservableFromINotifyCollectionChanged 7 | { 8 | public static Observable<(object? sender, NotifyCollectionChangedEventArgs e)> OnCollectionChanged(this INotifyCollectionChanged collection) 9 | { 10 | return new CollectionChangedObservable(collection); 11 | } 12 | 13 | class CollectionChangedObservable : Observable<(object? sender, NotifyCollectionChangedEventArgs e)> 14 | { 15 | readonly INotifyCollectionChanged _notifyCollectionChanged; 16 | 17 | public CollectionChangedObservable(INotifyCollectionChanged notifyCollectionChanged) 18 | { 19 | _notifyCollectionChanged = notifyCollectionChanged; 20 | } 21 | 22 | protected override IDisposable SubscribeCore(Observer<(object? sender, NotifyCollectionChangedEventArgs e)> observer) 23 | { 24 | return new Subscription(observer, this); 25 | } 26 | 27 | 28 | struct Subscription : IDisposable 29 | { 30 | readonly Observer<(object? sender, NotifyCollectionChangedEventArgs e)> _observer; 31 | readonly CollectionChangedObservable _observable; 32 | 33 | public Subscription(Observer<(object? sender, NotifyCollectionChangedEventArgs e)> observer, CollectionChangedObservable observable) 34 | { 35 | _observer = observer; 36 | _observable = observable; 37 | _observable._notifyCollectionChanged.CollectionChanged += OnCollectionChanged; 38 | } 39 | 40 | void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) 41 | { 42 | _observer.OnNext((sender, e)); 43 | } 44 | 45 | public void Dispose() 46 | { 47 | _observable._notifyCollectionChanged.CollectionChanged -= OnCollectionChanged; 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/Helpers/ObservableFromPropertyChanged.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using R3; 3 | 4 | namespace SignalsDotnet.Internals.Helpers; 5 | 6 | internal static class ObservableFromPropertyChanged 7 | { 8 | public static FromPropertyChangedObservable OnPropertyChanged(this IReadOnlySignal @this, bool futureChangesOnly) 9 | { 10 | return new FromPropertyChangedObservable(@this, futureChangesOnly); 11 | } 12 | 13 | public static FromPropertyChangedObservableUnit OnPropertyChangedAsUnit(this IReadOnlySignal @this, bool futureChangesOnly) 14 | { 15 | return new FromPropertyChangedObservableUnit(@this, futureChangesOnly); 16 | } 17 | 18 | public class FromPropertyChangedObservableUnit : Observable 19 | { 20 | readonly IReadOnlySignal _signal; 21 | readonly bool _futureChangesOnly; 22 | 23 | public FromPropertyChangedObservableUnit(IReadOnlySignal signal, bool futureChangesOnly) 24 | { 25 | _signal = signal; 26 | _futureChangesOnly = futureChangesOnly; 27 | } 28 | 29 | protected override IDisposable SubscribeCore(Observer observer) 30 | { 31 | return new FromPropertyChangedSubscriptionUnit(observer, this); 32 | } 33 | 34 | 35 | class FromPropertyChangedSubscriptionUnit : IDisposable 36 | { 37 | readonly Observer _observer; 38 | readonly FromPropertyChangedObservableUnit _observable; 39 | readonly object _locker = new(); 40 | bool _isDisposed; 41 | 42 | public FromPropertyChangedSubscriptionUnit(Observer observer, FromPropertyChangedObservableUnit observable) 43 | { 44 | _observer = observer; 45 | _observable = observable; 46 | if (_observable._futureChangesOnly) 47 | { 48 | observable._signal.PropertyChanged += OnPropertyChanged; 49 | return; 50 | } 51 | 52 | _observer.OnNext(Unit.Default); 53 | lock (_locker) 54 | { 55 | if (_isDisposed) 56 | { 57 | return; 58 | } 59 | 60 | observable._signal.PropertyChanged += OnPropertyChanged; 61 | } 62 | } 63 | 64 | void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) 65 | { 66 | _observer.OnNext(Unit.Default); 67 | } 68 | 69 | public void Dispose() 70 | { 71 | if (!_observable._futureChangesOnly) 72 | { 73 | lock (_locker) 74 | { 75 | if (_isDisposed) 76 | return; 77 | 78 | _isDisposed = true; 79 | } 80 | } 81 | 82 | _observable._signal.PropertyChanged -= OnPropertyChanged; 83 | } 84 | } 85 | } 86 | 87 | 88 | public class FromPropertyChangedObservable : Observable 89 | { 90 | readonly IReadOnlySignal _signal; 91 | readonly bool _futureChangesOnly; 92 | 93 | public FromPropertyChangedObservable(IReadOnlySignal signal, bool futureChangesOnly) 94 | { 95 | _signal = signal; 96 | _futureChangesOnly = futureChangesOnly; 97 | } 98 | 99 | protected override IDisposable SubscribeCore(Observer observer) 100 | { 101 | return new FromPropertyChangedSubscription(observer, this); 102 | } 103 | 104 | 105 | class FromPropertyChangedSubscription : IDisposable 106 | { 107 | readonly Observer _observer; 108 | readonly FromPropertyChangedObservable _observable; 109 | readonly object _locker = new(); 110 | bool _isDisposed; 111 | 112 | public FromPropertyChangedSubscription(Observer observer, FromPropertyChangedObservable observable) 113 | { 114 | _observer = observer; 115 | _observable = observable; 116 | if (_observable._futureChangesOnly) 117 | { 118 | observable._signal.PropertyChanged += OnPropertyChanged; 119 | return; 120 | } 121 | 122 | _observer.OnNext(_observable._signal.Value); 123 | lock (_locker) 124 | { 125 | if (_isDisposed) 126 | { 127 | return; 128 | } 129 | 130 | observable._signal.PropertyChanged += OnPropertyChanged; 131 | } 132 | } 133 | 134 | void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) 135 | { 136 | _observer.OnNext(_observable._signal.Value); 137 | } 138 | 139 | public void Dispose() 140 | { 141 | if (!_observable._futureChangesOnly) 142 | { 143 | lock (_locker) 144 | { 145 | if (_isDisposed) 146 | return; 147 | 148 | _isDisposed = true; 149 | } 150 | } 151 | 152 | _observable._signal.PropertyChanged -= OnPropertyChanged; 153 | } 154 | } 155 | } 156 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Internals/Helpers/WeakObservable.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using R3; 3 | 4 | namespace SignalsDotnet.Internals.Helpers; 5 | 6 | internal static class WeakObservable 7 | { 8 | public static IDisposable SubscribeWeakly(this Observable source, Action onNext) 9 | { 10 | var weakObserver = new WeakAction(onNext); 11 | var subscription = source.Subscribe(weakObserver.OnNext); 12 | weakObserver.GarbageCollected += subscription.Dispose; 13 | return subscription; 14 | } 15 | 16 | class WeakAction 17 | { 18 | public event Action? GarbageCollected; 19 | readonly WeakReference? _weakTarget; 20 | readonly MethodInfo _method; 21 | readonly bool _isStatic; 22 | 23 | public WeakAction(Action onNext) 24 | { 25 | var target = onNext.Target; 26 | _weakTarget = target is not null ? new(target) : null; 27 | _isStatic = target is null; 28 | _method = onNext.Method; 29 | } 30 | 31 | public void OnNext(T? value) 32 | { 33 | if (TryGetTargetOrNotifyCollected(out var target)) 34 | { 35 | _method.Invoke(target, new object?[] { value }); 36 | } 37 | } 38 | 39 | bool TryGetTargetOrNotifyCollected(out object? target) 40 | { 41 | if (_isStatic) 42 | { 43 | target = null; 44 | return true; 45 | } 46 | 47 | var ret = _weakTarget!.TryGetTarget(out target); 48 | if (!ret) 49 | { 50 | GarbageCollected?.Invoke(); 51 | } 52 | 53 | return ret; 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Signal.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.ComponentModel; 3 | using R3; 4 | 5 | namespace SignalsDotnet; 6 | 7 | public static partial class Signal 8 | { 9 | static uint _nextComputedSignalAffinityValue; 10 | static readonly AsyncLocal _computedSignalAffinityValue = new(); 11 | static readonly ConcurrentDictionary> _signalRequestedByComputedAffinity = new(); 12 | internal static readonly PropertyChangedEventArgs PropertyChangedArgs = new("Value"); 13 | 14 | internal static Observable SignalsRequested() 15 | { 16 | return new SignalsRequestedObservable(); 17 | } 18 | 19 | public static UntrackedReleaserDisposable UntrackedScope() 20 | { 21 | uint oldAffinity; 22 | lock (_computedSignalAffinityValue) 23 | { 24 | oldAffinity = _computedSignalAffinityValue.Value; 25 | _computedSignalAffinityValue.Value = _nextComputedSignalAffinityValue; 26 | unchecked 27 | { 28 | _nextComputedSignalAffinityValue++; 29 | } 30 | } 31 | 32 | return new UntrackedReleaserDisposable(oldAffinity); 33 | } 34 | 35 | public static async Task Untracked(Func> action) 36 | { 37 | if (action is null) 38 | throw new ArgumentNullException(nameof(action)); 39 | 40 | using (UntrackedScope()) 41 | { 42 | return await action(); 43 | } 44 | } 45 | 46 | public static async Task Untracked(Func action) 47 | { 48 | if (action is null) 49 | throw new ArgumentNullException(nameof(action)); 50 | 51 | using (UntrackedScope()) 52 | { 53 | await action(); 54 | } 55 | } 56 | 57 | public static T Untracked(Func action) 58 | { 59 | if (action is null) 60 | throw new ArgumentNullException(nameof(action)); 61 | 62 | using (UntrackedScope()) 63 | { 64 | return action(); 65 | } 66 | } 67 | 68 | public static void Untracked(Action action) 69 | { 70 | if (action is null) 71 | throw new ArgumentNullException(nameof(action)); 72 | 73 | using (UntrackedScope()) 74 | { 75 | action(); 76 | } 77 | } 78 | 79 | internal static T GetValue(IReadOnlySignal property, in T value) 80 | { 81 | uint affinityValue; 82 | lock (_computedSignalAffinityValue) 83 | { 84 | affinityValue = _computedSignalAffinityValue.Value; 85 | } 86 | 87 | if (_signalRequestedByComputedAffinity.TryGetValue(affinityValue, out var subject)) 88 | { 89 | subject.OnNext(property); 90 | } 91 | 92 | return value; 93 | } 94 | 95 | public class SignalsRequestedObservable : Observable 96 | { 97 | protected override IDisposable SubscribeCore(Observer observer) 98 | { 99 | lock (_computedSignalAffinityValue) 100 | { 101 | var affinityValue = _nextComputedSignalAffinityValue; 102 | 103 | unchecked 104 | { 105 | _nextComputedSignalAffinityValue++; 106 | } 107 | 108 | _computedSignalAffinityValue.Value = affinityValue; 109 | var subject = new Subject(); 110 | _signalRequestedByComputedAffinity.TryAdd(affinityValue, subject); 111 | 112 | subject.Subscribe(observer.OnNext, observer.OnErrorResume, observer.OnCompleted); 113 | return new SignalsRequestedDisposable(affinityValue, subject); 114 | } 115 | } 116 | } 117 | 118 | readonly struct SignalsRequestedDisposable : IDisposable 119 | { 120 | readonly uint _affinityValue; 121 | readonly Subject _subject; 122 | 123 | public SignalsRequestedDisposable(uint affinityValue, Subject subject) 124 | { 125 | _affinityValue = affinityValue; 126 | _subject = subject; 127 | } 128 | public void Dispose() 129 | { 130 | _signalRequestedByComputedAffinity.TryRemove(_affinityValue, out _); 131 | _subject.Dispose(); 132 | } 133 | } 134 | 135 | public readonly struct UntrackedReleaserDisposable : IDisposable 136 | { 137 | readonly uint _oldValue; 138 | 139 | public UntrackedReleaserDisposable(uint oldValue) 140 | { 141 | _oldValue = oldValue; 142 | } 143 | public void Dispose() 144 | { 145 | lock (_computedSignalAffinityValue) 146 | { 147 | _computedSignalAffinityValue.Value = _oldValue; 148 | } 149 | } 150 | } 151 | } 152 | 153 | -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Signal_Computed.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | using SignalsDotnet.Configuration; 3 | using SignalsDotnet.Helpers; 4 | using SignalsDotnet.Internals; 5 | using SignalsDotnet.Internals.Helpers; 6 | 7 | namespace SignalsDotnet; 8 | 9 | public partial class Signal 10 | { 11 | public static IReadOnlySignal Computed(Func func, Func fallbackValue, ReadonlySignalConfigurationDelegate? configuration = null) 12 | { 13 | return Computed(func.ToAsyncValueTask(), default, () => new Optional(fallbackValue()), default, configuration); 14 | } 15 | 16 | public static IReadOnlySignal Computed(Func func, Func> fallbackValue, ReadonlySignalConfigurationDelegate? configuration = null) 17 | { 18 | return Computed(func.ToAsyncValueTask(), default, fallbackValue, default, configuration); 19 | } 20 | 21 | public static IReadOnlySignal Computed(Func func, ReadonlySignalConfigurationDelegate? configuration = null) 22 | { 23 | return Computed(func.ToAsyncValueTask(), default, static () => Optional.Empty, default, configuration); 24 | } 25 | 26 | 27 | public static IAsyncReadOnlySignal AsyncComputed(Func> func, 28 | T startValue, 29 | Func> fallbackValue, 30 | ConcurrentChangeStrategy concurrentChangeStrategy = default, 31 | ReadonlySignalConfigurationDelegate? configuration = null) 32 | { 33 | func = func.TraceWhenExecuting(out var isExecuting); 34 | return AsyncComputed(func, new Optional(startValue), fallbackValue, isExecuting, concurrentChangeStrategy, configuration!); 35 | } 36 | 37 | public static IAsyncReadOnlySignal AsyncComputed(Func> func, 38 | T startValue, 39 | Func fallbackValue, 40 | ConcurrentChangeStrategy concurrentChangeStrategy = default, 41 | ReadonlySignalConfigurationDelegate? configuration = null) 42 | { 43 | return AsyncComputed(func, startValue, () => new Optional(fallbackValue()), concurrentChangeStrategy, configuration); 44 | } 45 | 46 | public static IAsyncReadOnlySignal AsyncComputed(Func> func, 47 | T startValue, 48 | ConcurrentChangeStrategy concurrentChangeStrategy = default, 49 | ReadonlySignalConfigurationDelegate? configuration = null) 50 | { 51 | return AsyncComputed(func, startValue, static () => Optional.Empty, concurrentChangeStrategy, configuration); 52 | } 53 | 54 | 55 | 56 | public static Observable ComputedObservable(Func func, 57 | Func> fallbackValue) 58 | { 59 | return ComputedObservable(func.ToAsyncValueTask(), fallbackValue); 60 | } 61 | 62 | 63 | public static Observable AsyncComputedObservable(Func> func, 64 | T startValue, 65 | Func> fallbackValue, 66 | ConcurrentChangeStrategy concurrentChangeStrategy = default) 67 | { 68 | return ComputedObservable(func, fallbackValue, concurrentChangeStrategy: concurrentChangeStrategy).Prepend(startValue); 69 | } 70 | 71 | 72 | internal static IReadOnlySignal Computed(Func> func, 73 | Optional startValueOptional, 74 | Func> fallbackValue, 75 | ConcurrentChangeStrategy concurrentChangeStrategy, 76 | ReadonlySignalConfigurationDelegate? configuration) 77 | { 78 | var valueObservable = ComputedObservable(func, fallbackValue, null, concurrentChangeStrategy); 79 | if (startValueOptional.TryGetValue(out var startValue)) 80 | { 81 | valueObservable = valueObservable.Prepend(startValue); 82 | } 83 | 84 | return new FromObservableSignal(valueObservable, configuration); 85 | } 86 | 87 | internal static IAsyncReadOnlySignal AsyncComputed(Func> func, 88 | Optional startValueOptional, 89 | Func> fallbackValue, 90 | IReadOnlySignal isExecuting, 91 | ConcurrentChangeStrategy concurrentChangeStrategy, 92 | ReadonlySignalConfigurationDelegate? configuration) 93 | { 94 | var valueObservable = ComputedObservable(func, fallbackValue, null, concurrentChangeStrategy); 95 | if (startValueOptional.TryGetValue(out var startValue)) 96 | { 97 | valueObservable = valueObservable.Prepend(startValue); 98 | } 99 | 100 | return new FromObservableAsyncSignal(valueObservable, isExecuting, configuration); 101 | } 102 | 103 | internal static Observable ComputedObservable(Func> func, 104 | Func> fallbackValue, 105 | Func>? scheduler = null, 106 | ConcurrentChangeStrategy concurrentChangeStrategy = default) 107 | { 108 | return new ComputedObservable(func, fallbackValue, scheduler, concurrentChangeStrategy); 109 | } 110 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Signal_Factory.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Specialized; 2 | using R3; 3 | using SignalsDotnet.Configuration; 4 | using SignalsDotnet.Internals; 5 | 6 | namespace SignalsDotnet; 7 | 8 | public partial class Signal 9 | { 10 | public static Signal Create(SignalConfigurationDelegate? configurator = null) 11 | { 12 | return new Signal(configurator!); 13 | } 14 | 15 | public static Signal Create(T startValue, SignalConfigurationDelegate? configurator = null) 16 | { 17 | return new Signal(startValue, configurator); 18 | } 19 | 20 | public static IReadOnlySignal FromObservableCollection(TCollection observableCollection, CollectionChangedSignalConfigurationDelegate? configurator = null) where TCollection : INotifyCollectionChanged 21 | { 22 | return new FromObservableCollectionSignal(observableCollection, configurator); 23 | } 24 | 25 | public static CollectionSignal CreateCollectionSignal(CollectionChangedSignalConfigurationDelegate? collectionChangedConfiguration = null, 26 | SignalConfigurationDelegate?>? propertyChangedConfiguration = null) where TCollection : class, INotifyCollectionChanged 27 | { 28 | return new CollectionSignal(collectionChangedConfiguration, propertyChangedConfiguration); 29 | } 30 | 31 | public static IReadOnlySignal FromObservable(Observable observable, ReadonlySignalConfigurationDelegate? configuration = null) 32 | { 33 | return new FromObservableSignal(observable, configuration); 34 | } 35 | } 36 | 37 | public static class SignalFactoryExtensions 38 | { 39 | public static IReadOnlySignal ToSignal(this Observable @this, 40 | ReadonlySignalConfigurationDelegate? configurator = null) 41 | { 42 | return new FromObservableSignal(@this, configurator); 43 | } 44 | 45 | internal static IAsyncReadOnlySignal ToAsyncSignal(this Observable @this, 46 | IReadOnlySignal isExecuting, 47 | ReadonlySignalConfigurationDelegate? configurator = null) 48 | { 49 | return new FromObservableAsyncSignal(@this, isExecuting, configurator); 50 | } 51 | 52 | 53 | public static IReadOnlySignal ToCollectionSignal(this TCollection collection, CollectionChangedSignalConfigurationDelegate? configurator = null) 54 | where TCollection : INotifyCollectionChanged 55 | { 56 | return Signal.FromObservableCollection(collection, configurator); 57 | } 58 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Signal_T.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using R3; 3 | using SignalsDotnet.Configuration; 4 | using SignalsDotnet.Internals.Helpers; 5 | 6 | namespace SignalsDotnet; 7 | 8 | public class Signal : IReadOnlySignal, IEquatable> 9 | { 10 | readonly SignalConfiguration _configuration; 11 | public Signal(SignalConfigurationDelegate? configurator = null) : this(default!, configurator!) 12 | { 13 | } 14 | 15 | public Signal(T startValue, SignalConfigurationDelegate? configurator = null) 16 | { 17 | var configuration = SignalConfiguration.Default; 18 | if (configurator != null) 19 | configuration = configurator(configuration); 20 | 21 | _configuration = configuration; 22 | _value = startValue; 23 | } 24 | 25 | 26 | T _value; 27 | public T Value 28 | { 29 | get => Signal.GetValue(this, in _value); 30 | set 31 | { 32 | if (EqualityComparer.Default.Equals(_value, value)) 33 | return; 34 | 35 | _value = value; 36 | 37 | var propertyChanged = PropertyChanged; 38 | if (propertyChanged is null) 39 | { 40 | return; 41 | } 42 | 43 | using (Signal.UntrackedScope()) 44 | { 45 | propertyChanged(this, Signal.PropertyChangedArgs); 46 | } 47 | } 48 | } 49 | 50 | public T UntrackedValue => _value; 51 | object? IReadOnlySignal.UntrackedValue => UntrackedValue; 52 | 53 | public Observable FutureValues => this.OnPropertyChanged(true); 54 | public Observable Values => this.OnPropertyChanged(false); 55 | 56 | public bool Equals(Signal? other) 57 | { 58 | if (other is null) 59 | return false; 60 | 61 | if (ReferenceEquals(this, other)) 62 | return true; 63 | 64 | return _configuration.Comparer.Equals(_value, other._value); 65 | } 66 | 67 | public override bool Equals(object? obj) 68 | { 69 | if (obj is null) 70 | return false; 71 | 72 | if (ReferenceEquals(this, obj)) 73 | return true; 74 | 75 | if (obj.GetType() != GetType()) 76 | return false; 77 | 78 | return Equals((Signal)obj); 79 | } 80 | 81 | public static bool operator ==(Signal a, Signal b) => Equals(a, b); 82 | public static bool operator !=(Signal a, Signal b) => !(a == b); 83 | 84 | public override int GetHashCode() => _value is null ? 0 : _configuration.Comparer.GetHashCode(_value!); 85 | 86 | public event PropertyChangedEventHandler? PropertyChanged; 87 | Observable IReadOnlySignal.Values => this.OnPropertyChangedAsUnit(false); 88 | Observable IReadOnlySignal.FutureValues => this.OnPropertyChangedAsUnit(true); 89 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/Signal_WhenAnyChanged.cs: -------------------------------------------------------------------------------- 1 | using R3; 2 | 3 | namespace SignalsDotnet; 4 | 5 | public static partial class Signal 6 | { 7 | public static Observable WhenAnyChanged(params IReadOnlySignal[] signals) 8 | { 9 | return WhenAnyChanged((IReadOnlyCollection)signals); 10 | } 11 | 12 | public static Observable WhenAnyChanged(IReadOnlyCollection signals) 13 | { 14 | if (signals is null) 15 | throw new ArgumentNullException(nameof(signals)); 16 | 17 | if (signals.Count == 0) 18 | return Observable.Empty(); 19 | 20 | return signals.Select(x => x.FutureValues) 21 | .Merge() 22 | .Prepend(Unit.Default); 23 | } 24 | } -------------------------------------------------------------------------------- /SignalsDotnet/SignalsDotnet/SignalsDotnet.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | Porting of Angular signals to dotnet 8 | README.md 9 | https://github.com/fedeAlterio/SignalsDotnet 10 | 2.0.4 11 | LICENSE 12 | icon.png 13 | https://github.com/fedeAlterio/SignalsDotnet 14 | 15 | 16 | 17 | 18 | True 19 | \ 20 | 21 | 22 | True 23 | \ 24 | 25 | 26 | True 27 | \ 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedeAlterio/SignalsDotnet/7cfc98395e4e06603d0088c0844f91a569c5151a/assets/demo.gif -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fedeAlterio/SignalsDotnet/7cfc98395e4e06603d0088c0844f91a569c5151a/assets/icon.png --------------------------------------------------------------------------------