Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
effect.hpp
Go to the documentation of this file.
1#pragma once
2
3// ============================================================================
4// reactive/effect.hpp
5// ----------------------------------------------------------------------------
6// `Effect` is the public Reaction primitive: it runs a user function
7// every time any reactive value it read has changed.
8//
9// Conceptual model
10// ----------------
11// * Effects are *pure side effects* -- they produce no value.
12// * Effects are auto-tracked (same mechanism as Computed).
13// * Effects live as long as the `Effect` object is alive. Destroying the
14// object disconnects it from the graph in O(deps).
15// * For a library-user, `Effect` is the direct equivalent of MobX's
16// `autorun`, SolidJS's `createEffect`, or Svelte 5's `$effect`.
17//
18// Typical usage
19// -------------
20// Property<int> count{0};
21// Effect logger{[&]{
22// std::cout << "count is now " << count.get() << '\n';
23// }};
24// count = 1; // prints "count is now 1"
25// count = 2; // prints "count is now 2"
26//
27// This file also provides the out-of-line implementations of
28// `Computed<T>::on_changed / bind / observe`, which share the same
29// ReactionNode machinery as Effect.
30// ============================================================================
31
34#include "aria/reactive/property.hpp" // for detail::ReactionNode
36
37#include "aria/subscription.hpp" // unified aria::Subscription handle
38
39#include <cstddef>
40#include <deque>
41#include <functional>
42#include <memory>
43#include <utility>
44
45namespace aria::reactive {
46
47// ---------------------------------------------------------------------------
48// Internal: an auto-tracking ReactionNode whose body is re-run on every
49// upstream change. Unlike `detail::ReactionNode` (a plain callback with
50// hand-wired edges for Property::on_changed), this one discovers its
51// dependencies via a TrackingContext on each run -- making it suitable
52// for arbitrary user lambdas (`Effect`).
53// ---------------------------------------------------------------------------
54namespace detail {
55
56class AutoReactionNode final : public Node, public std::enable_shared_from_this<AutoReactionNode> {
57public:
58 explicit AutoReactionNode(std::function<void()> fn)
59 : Node(NodeKind::Reaction), fn_(std::move(fn)) {
60 // Eager first run so that side effects fire immediately, mirroring
61 // MobX's autorun / SolidJS's createEffect contract.
62 (void)recompute();
63 }
64
65 ~AutoReactionNode() override {
66 // See detail::ReactionNode / Computed for the rationale: derived
67 // members (`edge_pool_`) are destroyed before the `Node` base, so
68 // we must detach upstream edges here, while the backing storage is
69 // still alive, to avoid a use-after-free in `~Node`.
70 retire_();
71 }
72
73 [[nodiscard]] std::shared_ptr<Node> retain_for_recompute() noexcept override {
74 return weak_from_this().lock();
75 }
76
89 bool recompute() override {
90 TrackingContext ctx{read_buffer_};
91 {
92 TrackerScope guard(ctx);
93 if (fn_) fn_();
94 }
95
96 // User body succeeded — swap dependency sets, reusing edge slots.
97 const auto& reads = ctx.reads();
98 // Grow before detaching. Allocation failure preserves the previous
99 // dependency set so a later source change can retry this evaluation.
100 while (edge_pool_.size() < reads.size()) edge_pool_.emplace_back();
102 set_depth(0);
103 active_edges_ = 0;
104 for (const auto& read : reads) {
105 if (read) attach_as_observer_of(*read, edge_pool_[active_edges_++]);
106 }
107
108 // Reactions do not have a value, so they never need to propagate
109 // any further. Returning `false` stops the graph cleanly.
110 return false;
111 }
112
113private:
114 std::function<void()> fn_;
116 std::deque<Edge> edge_pool_;
117 std::size_t active_edges_ = 0;
118 TrackingContext::Buffer read_buffer_;
119};
120
121} // namespace detail
122
123// ---------------------------------------------------------------------------
124// Effect -- user-facing Reaction wrapper. The Effect object owns the
125// underlying AutoReactionNode; destroying the Effect detaches it.
126// ---------------------------------------------------------------------------
127class Effect {
128public:
131 template<std::invocable<> Fn>
132 explicit Effect(Fn fn)
133 : node_(std::make_shared<detail::AutoReactionNode>(std::move(fn))) {}
134
135 Effect(const Effect&) = delete;
136 Effect& operator=(const Effect&) = delete;
137 Effect(Effect&&) noexcept = default;
138 Effect& operator=(Effect&&) noexcept = default;
139
141 void stop() noexcept { node_.reset(); }
142
143 [[nodiscard]] bool active() const noexcept { return static_cast<bool>(node_); }
144
148 [[nodiscard]] ::aria::Subscription into_subscription() && noexcept {
149 return ::aria::Subscription{std::move(node_)};
150 }
151
152private:
153 std::shared_ptr<detail::AutoReactionNode> node_;
154};
155
156// ---------------------------------------------------------------------------
157// Computed<T>::on_changed / bind / observe -- deferred definitions.
158// ---------------------------------------------------------------------------
159template<PropertyValue T>
160::aria::Subscription Computed<T>::on_changed(std::function<void(const T&)> fn) {
161 // `AutoReactionNode`'s constructor runs the body eagerly once to
162 // collect its dependency set. For `on_changed` we must NOT surface
163 // that first run to the user callback (otherwise it would fire with
164 // the current value as a "change" event, diverging from
165 // `Property::on_changed`). A shared "primed" flag swallows the first
166 // invocation; subsequent ones are real changes.
167 auto primed = std::make_shared<bool>(false);
168 auto reaction = std::make_shared<detail::AutoReactionNode>(
169 [this, fn = std::move(fn), primed] {
170 const auto& v = this->get();
171 if (!*primed) { *primed = true; return; }
172 fn(v);
173 });
174 reaction->set_debug_name("Computed::on_changed");
175 return ::aria::Subscription{std::move(reaction)};
176}
177
178template<PropertyValue T>
179::aria::Subscription Computed<T>::bind(std::function<void(const T&)> fn) {
180 // `bind` = initial sync + follow-up updates. `AutoReactionNode` runs
181 // its body eagerly in the constructor, which is exactly that
182 // semantics -- no extra first-call guard needed.
183 auto reaction = std::make_shared<detail::AutoReactionNode>(
184 [this, fn = std::move(fn)] { fn(this->get()); });
185 reaction->set_debug_name("Computed::bind");
186 return ::aria::Subscription{std::move(reaction)};
187}
188
189template<PropertyValue T>
190::aria::Subscription Computed<T>::observe(std::function<void(const T&, const T&)> fn) {
191 auto last = std::make_shared<T>(peek());
192 auto reaction = std::make_shared<detail::AutoReactionNode>(
193 [this, fn = std::move(fn), last] {
194 T new_val = this->get();
195 T old = std::move(*last);
196 *last = new_val;
197 // Suppress the initial edge (old == new on first run) so
198 // `observe` only fires on actual changes.
199 if (!(old == new_val)) fn(old, new_val);
200 });
201 reaction->set_debug_name("Computed::observe");
202 return ::aria::Subscription{std::move(reaction)};
203}
204
205} // namespace aria::reactive
Effect(Fn fn)
Runs fn once eagerly (to collect its initial dependency set), then automatically re-runs it whenever ...
Definition effect.hpp:132
RAII handle to a single subscription.
Definition subscription.hpp:44
T peek() const noexcept(std::is_nothrow_copy_constructible_v< T >)
Non-tracking snapshot read.
Definition computed.hpp:166
::aria::Subscription on_changed(std::function< void(const T &)> fn)
Definition effect.hpp:160
T get() const
Return the cached value, ensuring it is up to date.
Definition computed.hpp:135
::aria::Subscription observe(std::function< void(const T &, const T &)> fn)
Definition effect.hpp:190
::aria::Subscription bind(std::function< void(const T &)> fn)
Definition effect.hpp:179
Effect(Effect &&) noexcept=default
Effect(const Effect &)=delete
::aria::Subscription into_subscription() &&noexcept
Transfer ownership into a unified aria::Subscription, so that an Effect can be dropped into any Subsc...
Definition effect.hpp:148
void stop() noexcept
Explicitly cancel (without waiting for destruction).
Definition effect.hpp:141
Effect(Fn fn)
Runs fn once eagerly (to collect its initial dependency set), then automatically re-runs it whenever ...
Definition effect.hpp:132
Effect & operator=(const Effect &)=delete
bool active() const noexcept
Definition effect.hpp:143
void set_depth(std::uint32_t d) noexcept
Definition node.hpp:209
Node(NodeKind kind) noexcept
Definition node.hpp:144
void retire_() noexcept
Retire before derived members (including user captures) are destroyed.
Definition graph.inl:42
void attach_as_observer_of(Node &source, Edge &edge) noexcept
Definition graph.inl:64
void clear_sources() noexcept
Drop every upstream edge.
Definition graph.inl:107
std::vector< detail::NodeHandle > Buffer
Definition graph.hpp:105
Definition computed.hpp:60
NodeKind
Definition node.hpp:87
@ Reaction
Definition node.hpp:90
Definition validation_key.hpp:110