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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/skills/reactiveproperty-maintainer/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
name: reactiveproperty-maintainer
description: >-
Repository-specific guidance for maintaining ReactiveProperty source, tests,
documentation, samples, and release artifacts. Use before making changes in
the runceel/ReactiveProperty repository.
user-invocable: true
---

# ReactiveProperty maintainer

Use this skill when changing ReactiveProperty source code, tests, samples, documentation, packaging, or release notes.

## Repository orientation

- Main source code is under `Source/`.
- Tests are under `Test/`.
- User documentation is under `docs/docs/` and is built with VuePress from the `docs/` directory.
- Samples are under `Samples/` and snippets are under `Snippet/`.
- The repository contains English and Japanese top-level README and release note files.

## Maintenance guidance

- Preserve public API compatibility unless the task explicitly requires a breaking change.
- Keep target framework and package changes aligned with existing project files.
- Update documentation when behavior, supported platforms, package usage, or samples change.
- For decisions that affect project structure, compatibility, packaging, or repeated maintenance work, add an ADR under `docs/docs/adr/`.
- Prefer small, focused changes and avoid unrelated cleanup.

## Validation guidance

- For documentation-only changes, build the documentation from `docs/` with `npm run docs:build` when practical.
- For source or test changes, run the relevant .NET build and tests for the affected solution or project.
- Scan changed files for secrets before committing.
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<!-- Core Dependencies -->
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageVersion Include="System.Reactive" Version="6.1.0" />
<PackageVersion Include="R3" Version="1.3.1" />
<PackageVersion Include="ObservableCollections.R3" Version="3.3.4" />
<!-- Platform Dependencies -->
<PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.135" />
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.22" Condition="'$(TargetFramework)' == 'net8.0'" />
Expand Down
15 changes: 15 additions & 0 deletions ReactiveProperty.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@
<Project Path="Source/ReactiveProperty.NETStandard/ReactiveProperty.NETStandard.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Source/ReactiveProperty.R3.Compatibility.Core/ReactiveProperty.R3.Compatibility.Core.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Source/ReactiveProperty.R3.Compatibility.Validation/ReactiveProperty.R3.Compatibility.Validation.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveProperty.R3.Compatibility.Commands.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Source/ReactiveProperty.R3.Compatibility.Collections/ReactiveProperty.R3.Compatibility.Collections.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Source/ReactiveProperty.Platform.Blazor/ReactiveProperty.Platform.Blazor.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
Expand All @@ -79,6 +91,9 @@
<Project Path="Test/ReactiveProperty.NETStandard.Tests/ReactiveProperty.NETStandard.Tests.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Test/ReactiveProperty.R3.Compatibility.Tests/ReactiveProperty.R3.Compatibility.Tests.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Test/ReactiveProperty.WPF.ManualTests/ReactiveProperty.WPF.ManualTests.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>ReactiveProperty.R3.Compatibility.Collections</PackageId>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<AssemblyName>ReactiveProperty.R3.Compatibility.Collections</AssemblyName>
<Description>Minimal R3 collection compatibility APIs for ReactiveProperty migration.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="R3" />
<PackageReference Include="ObservableCollections.R3" />
<ProjectReference Include="..\ReactiveProperty.R3.Compatibility.Core\ReactiveProperty.R3.Compatibility.Core.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using R3;
using Reactive.Bindings.R3Compat;

namespace Reactive.Bindings.R3Compat.Collections;

public sealed class ReadOnlyReactiveCollectionCompat<T> : ReadOnlyObservableCollection<T>, IDisposable
{
private readonly ObservableCollection<T> _source;
private readonly IDisposable _subscription;
private readonly bool _disposeElement;
private bool _isDisposed;

private ReadOnlyReactiveCollectionCompat(ObservableCollection<T> source, IDisposable subscription, bool disposeElement)
: base(source)
{
_source = source;
_subscription = subscription;
_disposeElement = disposeElement;
CompatibilityTelemetry.Track("R3Compat.Collections.ReadOnlyReactiveCollectionCompat", ".ctor");
}

public static ReadOnlyReactiveCollectionCompat<T> Create<TSource>(
Observable<CollectionChanged<TSource>> source,
Func<TSource, T> converter,
bool disposeElement = true)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(converter);

var collection = new ObservableCollection<T>();
IDisposable? subscription = null;
var result = new ReadOnlyReactiveCollectionCompat<T>(
collection,
new DeferredDisposable(() => subscription),
disposeElement);
subscription = source.Subscribe(result.Apply(converter));
return result;
}

public void Dispose()
{
if (_isDisposed)
{
return;
}

_isDisposed = true;
_subscription.Dispose();
if (_disposeElement)
{
foreach (var item in _source.OfType<IDisposable>().ToArray())
{
item.Dispose();
}
}
}

private Action<CollectionChanged<TSource>> Apply<TSource>(Func<TSource, T> converter) => change =>
{
if (_isDisposed)
{
return;
}

switch (change.Action)
{
case NotifyCollectionChangedAction.Add:
var addIndex = NormalizeAddIndex(change.Index);
foreach (var item in change.Values.Select(converter))
{
_source.Insert(addIndex++, item);
}

break;
case NotifyCollectionChangedAction.Remove:
DisposeItem(_source[change.Index]);
_source.RemoveAt(change.Index);
break;
case NotifyCollectionChangedAction.Replace:
DisposeItem(_source[change.Index]);
_source[change.Index] = converter(change.Value!);
break;
case NotifyCollectionChangedAction.Move:
_source.Move(change.OldIndex, change.Index);
break;
case NotifyCollectionChangedAction.Reset:
foreach (var item in _source.ToArray())
{
DisposeItem(item);
}

_source.Clear();
if (change.Source != null)
{
foreach (var item in change.Source.Select(converter))
{
_source.Add(item);
}
}

break;
}
};

private int NormalizeAddIndex(int index) => index < 0 || index > _source.Count ? _source.Count : index;

private void DisposeItem(T item)
{
if (_disposeElement && item is IDisposable disposable)
{
disposable.Dispose();
}
}

private sealed class DeferredDisposable(Func<IDisposable?> getDisposable) : IDisposable
{
public void Dispose() => getDisposable()?.Dispose();
}
}

public sealed class CollectionChanged<T>
{
public static CollectionChanged<T> Reset { get; } = new() { Action = NotifyCollectionChangedAction.Reset };

public static CollectionChanged<T> ResetWithSource(IEnumerable<T>? source) => new()
{
Action = NotifyCollectionChangedAction.Reset,
Source = source?.ToArray(),
};

public static CollectionChanged<T> Add(int index, T value) => Add(index, new[] { value });

public static CollectionChanged<T> Add(int index, IEnumerable<T> values) => new()
{
Action = NotifyCollectionChangedAction.Add,
Index = index,
Values = values.ToArray(),
};

public static CollectionChanged<T> Remove(int index, T value) => new()
{
Action = NotifyCollectionChangedAction.Remove,
Index = index,
Values = new[] { value },
};

public static CollectionChanged<T> Replace(int index, T value) => new()
{
Action = NotifyCollectionChangedAction.Replace,
Index = index,
Values = new[] { value },
};

public static CollectionChanged<T> Move(int oldIndex, int newIndex, T value) => new()
{
Action = NotifyCollectionChangedAction.Move,
OldIndex = oldIndex,
Index = newIndex,
Values = new[] { value },
};

public IEnumerable<T>? Source { get; init; }

public IEnumerable<T> Values { get; init; } = [];

public T? Value => Values.FirstOrDefault();

public int Index { get; init; }

public int OldIndex { get; init; }

public NotifyCollectionChangedAction Action { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using R3;
using Reactive.Bindings.R3Compat;

namespace Reactive.Bindings.R3Compat.Commands;

public sealed class AsyncReactiveCommandCompat<T> : ICommand, IDisposable
{
private readonly IReactiveProperty<bool> _sharedCanExecute;
private readonly List<Func<T, ValueTask>> _actions = [];
private readonly List<IDisposable> _subscriptions = [];
private readonly R3.ReactiveProperty<bool> _canExecuteState;
private bool _sourceCanExecute;
private bool _isDisposed;

public AsyncReactiveCommandCompat(Observable<bool>? canExecuteSource = null)
: this(canExecuteSource, new CompatReactiveProperty<bool>(true))
{
}

public AsyncReactiveCommandCompat(IReactiveProperty<bool> sharedCanExecute)
: this(null, sharedCanExecute)
{
}

private AsyncReactiveCommandCompat(Observable<bool>? canExecuteSource, IReactiveProperty<bool> sharedCanExecute)
{
_sharedCanExecute = sharedCanExecute;
_sourceCanExecute = canExecuteSource is null;
_canExecuteState = new R3.ReactiveProperty<bool>(CurrentCanExecute());
_sharedCanExecute.PropertyChanged += SharedCanExecutePropertyChanged;
if (canExecuteSource != null)
{
_subscriptions.Add(canExecuteSource.Subscribe(value =>
{
_sourceCanExecute = value;
PublishCanExecute();
}));
}

CompatibilityTelemetry.Track("R3Compat.Commands.AsyncReactiveCommandCompat", ".ctor");
}

public event EventHandler? CanExecuteChanged;

public Observable<bool> CanExecute => _canExecuteState;

public AsyncReactiveCommandCompat<T> WithSubscribe(Func<T, ValueTask> asyncAction)
{
ArgumentNullException.ThrowIfNull(asyncAction);
_actions.Add(asyncAction);
return this;
}

public async ValueTask ExecuteAsync(T parameter)
{
if (!CurrentCanExecute())
{
return;
}

_sharedCanExecute.Value = false;
try
{
foreach (var action in _actions.ToArray())
{
await action(parameter);
}
}
finally
{
if (!_isDisposed)
{
_sharedCanExecute.Value = true;
}
}
}

bool ICommand.CanExecute(object? parameter) => CurrentCanExecute();

async void ICommand.Execute(object? parameter) => await ExecuteAsync((T)parameter!);

public void Dispose()
{
if (_isDisposed)
{
return;
}

_isDisposed = true;
_sharedCanExecute.PropertyChanged -= SharedCanExecutePropertyChanged;
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}

_canExecuteState.Value = false;
_canExecuteState.Dispose();
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

private bool CurrentCanExecute() => !_isDisposed && _sharedCanExecute.Value && _sourceCanExecute;

private void SharedCanExecutePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(IReactiveProperty<bool>.Value))
{
PublishCanExecute();
}
}

private void PublishCanExecute()
{
var value = CurrentCanExecute();
if (_canExecuteState.Value == value)
{
return;
}

_canExecuteState.Value = value;
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
}
Loading
Loading