diff --git a/.github/skills/reactiveproperty-maintainer/SKILL.md b/.github/skills/reactiveproperty-maintainer/SKILL.md
new file mode 100644
index 00000000..2a242a59
--- /dev/null
+++ b/.github/skills/reactiveproperty-maintainer/SKILL.md
@@ -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.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c4e6dd76..0620df43 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,6 +6,8 @@
+
+
diff --git a/ReactiveProperty.slnx b/ReactiveProperty.slnx
index 41cbe4c0..361d4198 100644
--- a/ReactiveProperty.slnx
+++ b/ReactiveProperty.slnx
@@ -64,6 +64,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -79,6 +91,9 @@
+
+
+
diff --git a/Source/ReactiveProperty.R3.Compatibility.Collections/ReactiveProperty.R3.Compatibility.Collections.csproj b/Source/ReactiveProperty.R3.Compatibility.Collections/ReactiveProperty.R3.Compatibility.Collections.csproj
new file mode 100644
index 00000000..40c915aa
--- /dev/null
+++ b/Source/ReactiveProperty.R3.Compatibility.Collections/ReactiveProperty.R3.Compatibility.Collections.csproj
@@ -0,0 +1,13 @@
+
+
+ ReactiveProperty.R3.Compatibility.Collections
+ net8.0;net9.0
+ ReactiveProperty.R3.Compatibility.Collections
+ Minimal R3 collection compatibility APIs for ReactiveProperty migration.
+
+
+
+
+
+
+
diff --git a/Source/ReactiveProperty.R3.Compatibility.Collections/ReadOnlyReactiveCollectionCompat.cs b/Source/ReactiveProperty.R3.Compatibility.Collections/ReadOnlyReactiveCollectionCompat.cs
new file mode 100644
index 00000000..ae7983c6
--- /dev/null
+++ b/Source/ReactiveProperty.R3.Compatibility.Collections/ReadOnlyReactiveCollectionCompat.cs
@@ -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 : ReadOnlyObservableCollection, IDisposable
+{
+ private readonly ObservableCollection _source;
+ private readonly IDisposable _subscription;
+ private readonly bool _disposeElement;
+ private bool _isDisposed;
+
+ private ReadOnlyReactiveCollectionCompat(ObservableCollection source, IDisposable subscription, bool disposeElement)
+ : base(source)
+ {
+ _source = source;
+ _subscription = subscription;
+ _disposeElement = disposeElement;
+ CompatibilityTelemetry.Track("R3Compat.Collections.ReadOnlyReactiveCollectionCompat", ".ctor");
+ }
+
+ public static ReadOnlyReactiveCollectionCompat Create(
+ Observable> source,
+ Func converter,
+ bool disposeElement = true)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ ArgumentNullException.ThrowIfNull(converter);
+
+ var collection = new ObservableCollection();
+ IDisposable? subscription = null;
+ var result = new ReadOnlyReactiveCollectionCompat(
+ 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().ToArray())
+ {
+ item.Dispose();
+ }
+ }
+ }
+
+ private Action> Apply(Func 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 getDisposable) : IDisposable
+ {
+ public void Dispose() => getDisposable()?.Dispose();
+ }
+}
+
+public sealed class CollectionChanged
+{
+ public static CollectionChanged Reset { get; } = new() { Action = NotifyCollectionChangedAction.Reset };
+
+ public static CollectionChanged ResetWithSource(IEnumerable? source) => new()
+ {
+ Action = NotifyCollectionChangedAction.Reset,
+ Source = source?.ToArray(),
+ };
+
+ public static CollectionChanged Add(int index, T value) => Add(index, new[] { value });
+
+ public static CollectionChanged Add(int index, IEnumerable values) => new()
+ {
+ Action = NotifyCollectionChangedAction.Add,
+ Index = index,
+ Values = values.ToArray(),
+ };
+
+ public static CollectionChanged Remove(int index, T value) => new()
+ {
+ Action = NotifyCollectionChangedAction.Remove,
+ Index = index,
+ Values = new[] { value },
+ };
+
+ public static CollectionChanged Replace(int index, T value) => new()
+ {
+ Action = NotifyCollectionChangedAction.Replace,
+ Index = index,
+ Values = new[] { value },
+ };
+
+ public static CollectionChanged Move(int oldIndex, int newIndex, T value) => new()
+ {
+ Action = NotifyCollectionChangedAction.Move,
+ OldIndex = oldIndex,
+ Index = newIndex,
+ Values = new[] { value },
+ };
+
+ public IEnumerable? Source { get; init; }
+
+ public IEnumerable Values { get; init; } = [];
+
+ public T? Value => Values.FirstOrDefault();
+
+ public int Index { get; init; }
+
+ public int OldIndex { get; init; }
+
+ public NotifyCollectionChangedAction Action { get; init; }
+}
diff --git a/Source/ReactiveProperty.R3.Compatibility.Commands/AsyncReactiveCommandCompat.cs b/Source/ReactiveProperty.R3.Compatibility.Commands/AsyncReactiveCommandCompat.cs
new file mode 100644
index 00000000..ad39906a
--- /dev/null
+++ b/Source/ReactiveProperty.R3.Compatibility.Commands/AsyncReactiveCommandCompat.cs
@@ -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 : ICommand, IDisposable
+{
+ private readonly IReactiveProperty _sharedCanExecute;
+ private readonly List> _actions = [];
+ private readonly List _subscriptions = [];
+ private readonly R3.ReactiveProperty _canExecuteState;
+ private bool _sourceCanExecute;
+ private bool _isDisposed;
+
+ public AsyncReactiveCommandCompat(Observable? canExecuteSource = null)
+ : this(canExecuteSource, new CompatReactiveProperty(true))
+ {
+ }
+
+ public AsyncReactiveCommandCompat(IReactiveProperty sharedCanExecute)
+ : this(null, sharedCanExecute)
+ {
+ }
+
+ private AsyncReactiveCommandCompat(Observable? canExecuteSource, IReactiveProperty sharedCanExecute)
+ {
+ _sharedCanExecute = sharedCanExecute;
+ _sourceCanExecute = canExecuteSource is null;
+ _canExecuteState = new R3.ReactiveProperty(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 CanExecute => _canExecuteState;
+
+ public AsyncReactiveCommandCompat WithSubscribe(Func 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.Value))
+ {
+ PublishCanExecute();
+ }
+ }
+
+ private void PublishCanExecute()
+ {
+ var value = CurrentCanExecute();
+ if (_canExecuteState.Value == value)
+ {
+ return;
+ }
+
+ _canExecuteState.Value = value;
+ CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveCommandSlimCompat.cs b/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveCommandSlimCompat.cs
new file mode 100644
index 00000000..34a363ff
--- /dev/null
+++ b/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveCommandSlimCompat.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows.Input;
+using R3;
+using Reactive.Bindings.R3Compat;
+
+namespace Reactive.Bindings.R3Compat.Commands;
+
+public sealed class ReactiveCommandSlimCompat : ICommand, IDisposable
+{
+ private readonly List> _actions = [];
+ private readonly List _subscriptions = [];
+ private bool _canExecute;
+ private bool _isDisposed;
+
+ public ReactiveCommandSlimCompat(Observable? canExecuteSource = null)
+ {
+ _canExecute = canExecuteSource is null;
+ if (canExecuteSource != null)
+ {
+ _subscriptions.Add(canExecuteSource.Subscribe(value =>
+ {
+ if (_canExecute == value)
+ {
+ return;
+ }
+
+ _canExecute = value;
+ CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+ }));
+ }
+
+ CompatibilityTelemetry.Track("R3Compat.Commands.ReactiveCommandSlimCompat", ".ctor");
+ }
+
+ public event EventHandler? CanExecuteChanged;
+
+ public ReactiveCommandSlimCompat WithSubscribe(Action action)
+ {
+ ArgumentNullException.ThrowIfNull(action);
+ _actions.Add(action);
+ return this;
+ }
+
+ public bool CanExecute(T parameter) => !_isDisposed && _canExecute;
+
+ public void Execute(T parameter)
+ {
+ if (!CanExecute(parameter))
+ {
+ return;
+ }
+
+ foreach (var action in _actions.ToArray())
+ {
+ action(parameter);
+ }
+ }
+
+ bool ICommand.CanExecute(object? parameter) => CanExecute((T)parameter!);
+
+ void ICommand.Execute(object? parameter) => Execute((T)parameter!);
+
+ public void Dispose()
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+
+ _isDisposed = true;
+ foreach (var subscription in _subscriptions)
+ {
+ subscription.Dispose();
+ }
+
+ if (_canExecute)
+ {
+ _canExecute = false;
+ CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+}
diff --git a/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveProperty.R3.Compatibility.Commands.csproj b/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveProperty.R3.Compatibility.Commands.csproj
new file mode 100644
index 00000000..7bb1f9b1
--- /dev/null
+++ b/Source/ReactiveProperty.R3.Compatibility.Commands/ReactiveProperty.R3.Compatibility.Commands.csproj
@@ -0,0 +1,12 @@
+
+
+ ReactiveProperty.R3.Compatibility.Commands
+ net8.0;net9.0
+ ReactiveProperty.R3.Compatibility.Commands
+ Minimal R3 command compatibility APIs for ReactiveProperty migration.
+
+
+
+
+
+
diff --git a/Source/ReactiveProperty.R3.Compatibility.Core/CompatibilityOptions.cs b/Source/ReactiveProperty.R3.Compatibility.Core/CompatibilityOptions.cs
new file mode 100644
index 00000000..c00d6b9c
--- /dev/null
+++ b/Source/ReactiveProperty.R3.Compatibility.Core/CompatibilityOptions.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+
+namespace Reactive.Bindings.R3Compat;
+
+[Flags]
+public enum CompatibilityMode
+{
+ None = 0,
+ DistinctUntilChanged = 1,
+ RaiseLatestValueOnSubscribe = 2,
+ IgnoreInitialValidationError = 4,
+ IgnoreException = 8,
+ Default = DistinctUntilChanged | RaiseLatestValueOnSubscribe,
+}
+
+public sealed class CompatibilityOptions
+{
+ public CompatibilityMode Mode { get; init; } = CompatibilityMode.Default;
+
+ public TimeProvider TimeProvider { get; init; } = TimeProvider.System;
+
+ public bool IgnoreInitialValidationError => Mode.HasFlag(CompatibilityMode.IgnoreInitialValidationError);
+}
+
+public static class CompatibilityTelemetry
+{
+ private static readonly ConcurrentDictionary Usage = new();
+ private static readonly ConcurrentDictionary> UsageByCallSite = new();
+
+ public static void Track(string compatApiId, string callSite)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(compatApiId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(callSite);
+ Usage.AddOrUpdate(compatApiId, 1, static (_, count) => count + 1);
+ UsageByCallSite.GetOrAdd(compatApiId, static _ => new ConcurrentDictionary())
+ .AddOrUpdate(callSite, 1, static (_, count) => count + 1);
+ }
+
+ public static IReadOnlyDictionary Snapshot() => new ReadOnlyDictionary(Usage.ToDictionary());
+
+ public static void WriteUsageReport(string path)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ File.WriteAllText(path, JsonSerializer.Serialize(Snapshot(), new JsonSerializerOptions { WriteIndented = true }));
+ }
+}
+
+public interface IReactiveProperty : INotifyPropertyChanged
+{
+ T Value { get; set; }
+}
+
+public sealed class CompatReactiveProperty : IReactiveProperty, IDisposable
+{
+ private T _value;
+ private bool _isDisposed;
+
+ public CompatReactiveProperty(T value) => _value = value;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public T Value
+ {
+ get => _value;
+ set
+ {
+ if (_isDisposed || EqualityComparer.Default.Equals(_value, value))
+ {
+ return;
+ }
+
+ _value = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
+ }
+ }
+
+ public void Dispose() => _isDisposed = true;
+}
diff --git a/Source/ReactiveProperty.R3.Compatibility.Core/MigrationRuleEngine.cs b/Source/ReactiveProperty.R3.Compatibility.Core/MigrationRuleEngine.cs
new file mode 100644
index 00000000..42679e5e
--- /dev/null
+++ b/Source/ReactiveProperty.R3.Compatibility.Core/MigrationRuleEngine.cs
@@ -0,0 +1,58 @@
+using System;
+
+namespace Reactive.Bindings.R3Compat;
+
+public enum MigrationCategory
+{
+ Auto,
+ Compat,
+ Manual,
+}
+
+public sealed record MigrationRule(
+ string RuleId,
+ string Symbol,
+ MigrationCategory Category,
+ bool RequiresCompatibilityLayer,
+ string DowngradeWhen,
+ string DowngradeTarget,
+ string Deprecation,
+ bool AutoFix = false,
+ string? Replacement = null);
+
+public sealed record MigrationContext(bool DowngradeWhenSatisfied);
+
+public sealed record MigrationClassification(
+ string RuleId,
+ MigrationCategory Category,
+ bool RequiresCompatibilityLayer,
+ string? Replacement,
+ string DowngradeWhen,
+ string Deprecation);
+
+public static class MigrationRuleEngine
+{
+ public static MigrationClassification Classify(MigrationRule rule, MigrationContext context)
+ {
+ ArgumentNullException.ThrowIfNull(rule);
+
+ if (context.DowngradeWhenSatisfied)
+ {
+ return new MigrationClassification(
+ rule.RuleId,
+ MigrationCategory.Auto,
+ RequiresCompatibilityLayer: false,
+ rule.DowngradeTarget,
+ rule.DowngradeWhen,
+ rule.Deprecation);
+ }
+
+ return new MigrationClassification(
+ rule.RuleId,
+ rule.Category,
+ rule.RequiresCompatibilityLayer,
+ rule.Replacement,
+ rule.DowngradeWhen,
+ rule.Deprecation);
+ }
+}
diff --git a/Source/ReactiveProperty.R3.Compatibility.Core/ReactiveProperty.R3.Compatibility.Core.csproj b/Source/ReactiveProperty.R3.Compatibility.Core/ReactiveProperty.R3.Compatibility.Core.csproj
new file mode 100644
index 00000000..81a7a74a
--- /dev/null
+++ b/Source/ReactiveProperty.R3.Compatibility.Core/ReactiveProperty.R3.Compatibility.Core.csproj
@@ -0,0 +1,11 @@
+
+
+ ReactiveProperty.R3.Compatibility.Core
+ net8.0;net9.0
+ ReactiveProperty.R3.Compatibility.Core
+ Minimal R3 migration compatibility core for ReactiveProperty.
+
+
+
+
+
diff --git a/Source/ReactiveProperty.R3.Compatibility.Validation/CompatValidatableProperty.cs b/Source/ReactiveProperty.R3.Compatibility.Validation/CompatValidatableProperty.cs
new file mode 100644
index 00000000..0a8942b3
--- /dev/null
+++ b/Source/ReactiveProperty.R3.Compatibility.Validation/CompatValidatableProperty.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Linq.Expressions;
+using R3;
+using Reactive.Bindings.R3Compat;
+
+namespace Reactive.Bindings.R3Compat.Validation;
+
+public sealed class CompatValidatableProperty : IDisposable, INotifyDataErrorInfo
+{
+ private readonly CompatibilityOptions _options;
+ private readonly BehaviorSubject _valueSource;
+ private readonly R3.ReactiveProperty _hasErrors;
+ private readonly List> _validators = [];
+ private readonly List _subscriptions = [];
+ private IReadOnlyList