Code/Derived.cs
#if DEBUG
#endif
using Sandbox.Reactivity.Internals;
#if JETBRAINS_ANNOTATIONS
using JetBrains.Annotations;
#endif
namespace Sandbox.Reactivity;
/// <summary>
/// A reactive object that derives its value from a function. Whenever any reactive values inside the function change,
/// the function will be re-run to get the new value.
/// </summary>
/// <typeparam name="T">The type of value this derived state contains.</typeparam>
#if JETBRAINS_ANNOTATIONS
[PublicAPI]
#endif
public sealed class Derived<T> : IProducer<T>, IWritableProducer<T>, IReaction, IState<T>, IReadOnlyState<T>
{
/// <summary>
/// The function to call when this derived state needs to recompute its value.
/// </summary>
private readonly Func<T> _compute;
private readonly List<IProducer> _dependencies = [];
private readonly List<IReaction> _reactions = [];
private bool _isConnectedToEffect;
private uint _readVersion;
private ReactionState _state = ReactionState.Stale;
/// <summary>
/// The last computed value.
/// </summary>
private T _value = default!;
/// <inheritdoc cref="IProducer.WriteVersion" />
/// <remarks>This will not change if this derived state recomputes to the same value.</remarks>
private uint _writeVersion;
internal Derived(Func<T> compute)
{
_compute = compute;
}
List<IReaction> IProducer.Reactions => _reactions;
uint IProducer.WriteVersion => _writeVersion;
object? IProducer.NonReactiveValue
{
get => _value;
set => _value = (T)value!;
}
/// <summary>
/// The current value of this derived state. Reading this inside of an effect will cause it to add this state as a
/// dependency. You can override the current value and it will remain until the next time this derived state
/// recomputes its value due to a dependency changing.
/// </summary>
public T Value
{
get
{
// always return the stored value inside an effect teardown since the value has been temporarily assigned
// something else for the duration of the teardown, and we don't want to recompute to a new value either
if (Reactive.Runtime.IsRunningTeardown)
{
return _value;
}
this.TrackRead();
// always check here since we could be evaluating outside a tracking context
if (this.ShouldRun)
{
((IReaction)this).Run();
}
return _value;
}
set
{
if (EqualityComparer<T>.Default.Equals(_value, value))
{
return;
}
if (_state == ReactionState.Stale)
{
// if we're being assigned an overridden value while it's stale, this derived state has either not
// computed yet, or is already potentially changing its dependencies due to a reactivity propagation.
// we'll compute in order to track the correct dependencies
((IReaction)this).Run();
}
_value = value;
_writeVersion = ++Reactive.Runtime.Version;
_state = _isConnectedToEffect ? ReactionState.UpToDate : ReactionState.PossiblyStale;
this.PropagateReactivity();
}
}
void IProducer.AddReaction(IReaction reaction)
{
if (!reaction.IsConnectedToEffect || _reactions.Contains(reaction))
{
return;
}
_reactions.Add(reaction);
if (!_isConnectedToEffect)
{
// we're now connected to an effect; re-add this reaction to all of our dependencies
_isConnectedToEffect = true;
foreach (var dependency in _dependencies)
{
dependency.AddReaction(this);
}
}
}
void IProducer.RemoveReaction(IReaction reaction)
{
if (!_reactions.Remove(reaction))
{
return;
}
if (_reactions.Count == 0)
{
// this derived state is no longer being used in an effect; remove it from its dependencies to avoid
// unnecessary recomputation
_isConnectedToEffect = false;
_state = ReactionState.PossiblyStale;
foreach (var dependency in _dependencies)
{
dependency.RemoveReaction(this);
}
}
}
List<IProducer> IReaction.Dependencies => _dependencies;
uint IReaction.ReadVersion => _readVersion;
ReactionState IReaction.State
{
get => _state;
set => _state = value;
}
bool IReaction.IsConnectedToEffect => _isConnectedToEffect;
void IReaction.OnDependencyChanged(ReactionState newState)
{
if (_state < newState)
{
return;
}
_state = newState;
this.PropagateReactivity(ReactionState.PossiblyStale);
}
void IReaction.Run()
{
foreach (var dep in _dependencies)
{
dep.RemoveReaction(this);
}
_dependencies.Clear();
var previousReaction = Reactive.Runtime.CurrentReaction;
var previousIsUntracking = Reactive.Runtime.IsUntracking;
Reactive.Runtime.CurrentReaction = this;
Reactive.Runtime.IsUntracking = false;
try
{
var oldValue = _value;
_value = _compute();
// we can't know for sure if we're up to date if we're not connected to an effect; we remove ourselves as
// reactions to dependencies to avoid recomputation in this case
_state = _isConnectedToEffect ? ReactionState.UpToDate : ReactionState.PossiblyStale;
if (!EqualityComparer<T>.Default.Equals(oldValue, _value))
{
_writeVersion = ++Reactive.Runtime.Version;
}
_readVersion = Reactive.Runtime.Version;
}
finally
{
Reactive.Runtime.CurrentReaction = previousReaction;
Reactive.Runtime.IsUntracking = previousIsUntracking;
}
}
public static implicit operator T(Derived<T> state)
{
return state.Value;
}
#if DEBUG && SANDBOX
string? IReactiveObject.Name { get; set; } = "Derived";
string? IReactiveObject.Icon { get; set; } = "functions";
string? IReactiveObject.Location { get; set; }
object? IReactiveObject.Parent { get; set; }
PropertyDescription? IReactiveObject.Container { get; set; }
#endif
}