adaptite 0.2.0

Fine-grained reactivity (signals, thunks, memos, effects, events) for the runite runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
use alloc::rc::Rc;

use crate::{NodeId, Reactor, current, trace_targets};

/// Creates a low-level reactive source node in the current reactor.
#[track_caller]
pub fn source() -> Source {
    current().source()
}

/// Creates a low-level reactive source node associated with `reactor`.
#[track_caller]
pub fn source_in(reactor: &Reactor) -> Source {
    reactor.source()
}

/// Creates a source that reports when it gains its first observer and loses its last, in the
/// current reactor.
///
/// See [`Reactor::source_with_hooks`] for the delivery contract.
#[track_caller]
pub fn source_with_hooks(on_watch: impl Fn() + 'static, on_unwatch: impl Fn() + 'static) -> Source {
    current().source_with_hooks(on_watch, on_unwatch)
}

/// Creates a source with observation hooks associated with `reactor`.
#[track_caller]
pub fn source_with_hooks_in(
    reactor: &Reactor,
    on_watch: impl Fn() + 'static,
    on_unwatch: impl Fn() + 'static,
) -> Source {
    reactor.source_with_hooks(on_watch, on_unwatch)
}

/// Low-level observable source node.
///
/// `Source` is useful for advanced data structures that want precise control over when reads
/// observe and writes trigger invalidation without storing their state in a [`crate::Signal`].
///
/// # Examples
///
/// Wrapping state that lives outside the graph:
///
/// ```rust
/// use std::cell::Cell;
/// use std::rc::Rc;
///
/// use adaptite::{source, thunk};
///
/// let external = Rc::new(Cell::new(1));
/// let node = source();
///
/// let view = thunk({
///     let node = node.clone();
///     let external = Rc::clone(&external);
///     move || {
///         node.observe(); // reads of `external` depend on `node`
///         external.get() * 10
///     }
/// });
///
/// assert_eq!(view.get(), 10);
///
/// external.set(2);
/// assert_eq!(view.get(), 10); // the graph has not been told about the write
///
/// node.trigger();
/// assert_eq!(view.get(), 20); // now the thunk recomputes
/// ```
#[derive(Clone)]
pub struct Source {
    inner: Rc<SourceInner>,
}

impl Reactor {
    /// Creates a low-level source node associated with this reactor.
    #[track_caller]
    pub fn source(&self) -> Source {
        Source::new(self.clone())
    }

    /// Creates a source that calls `on_watch` when it gains its first observer and `on_unwatch`
    /// when it loses its last.
    ///
    /// This ties an external resource's lifetime to whether anyone is actually reading the node:
    /// connect a websocket, file watcher, or upstream subscription on the first observer and tear
    /// it down when the last one leaves. [`Source::is_observed`] answers the same question by
    /// polling, which suffices for sweep-based garbage collection; hooks are for the case where
    /// the release must be prompt rather than swept.
    ///
    /// # Delivery
    ///
    /// Callbacks are **deferred**, not inline. The "last observer left" transition happens while
    /// the reactor is clearing dependencies during an observer's rerun or disposal, with graph
    /// maps borrowed, so hooks are queued as reactor jobs and run on the next flush. Two
    /// consequences follow:
    ///
    /// - A leave/arrive pair within one flush — exactly what an observer's rerun looks like —
    ///   collapses to nothing, so a rerunning reader does not churn the resource.
    /// - Only a genuine change from the last delivered state fires a callback. Neither hook is
    ///   ever called twice in a row.
    ///
    /// Hooks are unregistered when the `Source` is dropped, and a callback already queued at that
    /// point is cancelled.
    ///
    /// # Staleness
    ///
    /// "Observed" here means *any recorded dependency edge*, which reflects each observer's most
    /// recent run. A reader that stopped reading this source still counts until it reruns or is
    /// disposed, so `on_unwatch` can be late — but it is never early, which is the safe direction
    /// for resource lifetimes. It also counts a cold cached memo nobody has pulled recently, which
    /// TC39's `Signal.subtle.watched` would not; that finer notion needs liveness counts
    /// propagated through every edge, and this ships the coarse one until real usage shows the
    /// difference matters.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use std::cell::RefCell;
    /// use std::rc::Rc;
    ///
    /// use adaptite::{Reactor, source_with_hooks_in, thunk_in};
    ///
    /// let reactor = Reactor::new();
    /// let log = Rc::new(RefCell::new(Vec::new()));
    ///
    /// let node = source_with_hooks_in(
    ///     &reactor,
    ///     { let log = Rc::clone(&log); move || log.borrow_mut().push("connect") },
    ///     { let log = Rc::clone(&log); move || log.borrow_mut().push("disconnect") },
    /// );
    ///
    /// let reader = thunk_in(&reactor, {
    ///     let node = node.clone();
    ///     move || { node.observe(); 1 }
    /// });
    ///
    /// let _ = reader.get();
    /// reactor.flush_now();
    /// assert_eq!(*log.borrow(), ["connect"]);
    ///
    /// // The last reader goes away, and the resource is released.
    /// drop(reader);
    /// reactor.flush_now();
    /// assert_eq!(*log.borrow(), ["connect", "disconnect"]);
    /// ```
    #[track_caller]
    pub fn source_with_hooks(
        &self,
        on_watch: impl Fn() + 'static,
        on_unwatch: impl Fn() + 'static,
    ) -> Source {
        let source = Source::new(self.clone());
        self.register_observation_hooks(source.id(), on_watch, on_unwatch);
        source
    }
}

impl Source {
    #[track_caller]
    fn new(reactor: Reactor) -> Self {
        let id = reactor.allocate_node();
        tracing::debug!(
            target: trace_targets::GRAPH,
            event = "create_source",
            node_id = id.0,
            "created low-level reactive source"
        );
        Self {
            inner: Rc::new(SourceInner { reactor, id }),
        }
    }

    /// Records a dependency on this source for the currently running observer.
    pub fn observe(&self) {
        self.inner.reactor.observe(self.inner.id);
    }

    /// Triggers this source's dependents.
    #[track_caller]
    pub fn trigger(&self) {
        self.inner.reactor.trigger(self.inner.id);
    }

    /// Returns the source node id.
    pub fn id(&self) -> NodeId {
        self.inner.id
    }

    /// Returns `true` if any live observer currently records a dependency on this source.
    ///
    /// The answer reflects each observer's most recent run: an observer that stopped reading
    /// this source still counts until it next re-runs or is disposed, so `is_observed` can
    /// return `true` for a source that will never be read again — but never `false` for one
    /// that is still depended on. Fine-grained data structures use this to garbage-collect
    /// per-key sources nobody reads anymore; see [`Reactor::is_observed`].
    ///
    /// # Examples
    ///
    /// ```rust
    /// use adaptite::{source, thunk};
    ///
    /// let node = source();
    /// let view = thunk({
    ///     let node = node.clone();
    ///     move || {
    ///         node.observe();
    ///         1
    ///     }
    /// });
    ///
    /// assert!(!node.is_observed(), "nothing has read through the source yet");
    /// let _ = view.get();
    /// assert!(node.is_observed());
    /// ```
    pub fn is_observed(&self) -> bool {
        self.inner.reactor.is_observed(self.inner.id)
    }
}

impl core::fmt::Debug for Source {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("Source")
            .field("id", &self.inner.id)
            .finish()
    }
}

struct SourceInner {
    reactor: Reactor,
    id: NodeId,
}

impl Drop for SourceInner {
    fn drop(&mut self) {
        self.reactor.dispose(self.id);
    }
}

#[cfg(test)]
mod tests {
    use std::cell::{Cell, RefCell};
    use std::rc::Rc;

    use runite::{queue_macrotask, run};

    use crate::{EffectHandle, Reactor, Source, source_in, source_with_hooks_in, thunk_in};

    /// Records watch/unwatch deliveries for a hooked source.
    fn hooked(reactor: &Reactor) -> (Source, Rc<RefCell<Vec<&'static str>>>) {
        let log = Rc::new(RefCell::new(Vec::new()));
        let node = source_with_hooks_in(
            reactor,
            {
                let log = Rc::clone(&log);
                move || log.borrow_mut().push("watch")
            },
            {
                let log = Rc::clone(&log);
                move || log.borrow_mut().push("unwatch")
            },
        );
        (node, log)
    }

    #[test]
    fn hooks_fire_on_the_first_observer_and_the_last_departure() {
        let reactor = Reactor::new();
        let (node, log) = hooked(&reactor);

        reactor.flush_now();
        assert!(log.borrow().is_empty(), "an unread source is not watched");

        let reader = thunk_in(&reactor, {
            let node = node.clone();
            move || {
                node.observe();
                1
            }
        });
        // The edge is recorded on read, not on construction.
        assert!(log.borrow().is_empty());
        let _ = reader.get();
        reactor.flush_now();
        assert_eq!(*log.borrow(), ["watch"]);

        drop(reader);
        reactor.flush_now();
        assert_eq!(*log.borrow(), ["watch", "unwatch"]);
    }

    #[test]
    fn hooks_are_deferred_not_inline() {
        // The unwatch transition happens while the reactor holds its graph maps borrowed, so a
        // hook that touches the graph must not run there.
        let reactor = Reactor::new();
        let (node, log) = hooked(&reactor);

        let reader = thunk_in(&reactor, {
            let node = node.clone();
            move || {
                node.observe();
                1
            }
        });
        let _ = reader.get();
        assert!(
            log.borrow().is_empty(),
            "delivery waits for the flush, even for the watch edge"
        );
        reactor.flush_now();
        assert_eq!(*log.borrow(), ["watch"]);
    }

    #[test]
    fn a_leave_and_arrive_within_one_flush_collapses() {
        // An observer's rerun clears and re-records its edges. That must not churn the resource.
        let reactor = Reactor::new();
        let (node, log) = hooked(&reactor);
        let toggle = crate::signal_in(&reactor, 0);

        let reader = thunk_in(&reactor, {
            let node = node.clone();
            let toggle = toggle.clone();
            move || {
                toggle.get();
                node.observe();
                1
            }
        });
        let _ = reader.get();
        reactor.flush_now();
        assert_eq!(*log.borrow(), ["watch"]);

        // Force a rerun: the edge is dropped and immediately re-recorded.
        toggle.set(1);
        let _ = reader.get();
        reactor.flush_now();
        assert_eq!(
            *log.borrow(),
            ["watch"],
            "a rerunning reader must not disconnect and reconnect"
        );

        drop(reader);
        reactor.flush_now();
        assert_eq!(*log.borrow(), ["watch", "unwatch"]);
    }

    #[test]
    fn neither_hook_is_delivered_twice_in_a_row() {
        let reactor = Reactor::new();
        let (node, log) = hooked(&reactor);

        // Two independent readers: only the first arrival and the last departure are transitions.
        let readers: Vec<_> = (0..2)
            .map(|_| {
                thunk_in(&reactor, {
                    let node = node.clone();
                    move || {
                        node.observe();
                        1
                    }
                })
            })
            .collect();
        for reader in &readers {
            let _ = reader.get();
        }
        reactor.flush_now();
        assert_eq!(*log.borrow(), ["watch"], "one watch, not two");

        let mut readers = readers;
        drop(readers.pop());
        reactor.flush_now();
        assert_eq!(*log.borrow(), ["watch"], "one reader remains");

        drop(readers);
        reactor.flush_now();
        assert_eq!(*log.borrow(), ["watch", "unwatch"]);
    }

    #[test]
    fn dropping_the_source_cancels_a_queued_hook() {
        let reactor = Reactor::new();
        let (node, log) = hooked(&reactor);

        let reader = thunk_in(&reactor, {
            let node = node.clone();
            move || {
                node.observe();
                1
            }
        });
        let _ = reader.get();

        // The watch delivery is queued but not yet flushed.
        assert!(log.borrow().is_empty());
        drop(node);
        drop(reader);
        reactor.flush_now();
        assert!(
            log.borrow().is_empty(),
            "a disposed source must not deliver hooks"
        );
    }

    #[test]
    fn is_observed_agrees_with_the_hooks_but_answers_immediately() {
        // The polled and pushed views of the same edge state: `is_observed` is synchronous,
        // hooks are deferred. Both must describe the same graph.
        let reactor = Reactor::new();
        let (node, log) = hooked(&reactor);

        let reader = thunk_in(&reactor, {
            let node = node.clone();
            move || {
                node.observe();
                1
            }
        });
        let _ = reader.get();

        assert!(node.is_observed(), "the edge exists immediately");
        assert!(log.borrow().is_empty(), "the hook has not been delivered");

        reactor.flush_now();
        assert!(node.is_observed());
        assert_eq!(*log.borrow(), ["watch"]);
    }

    #[test]
    fn sources_have_distinct_ids() {
        let reactor = Reactor::new();
        let one = source_in(&reactor);
        let two = source_in(&reactor);
        assert_ne!(one.id(), two.id());
    }

    #[test]
    fn observe_and_trigger_drive_an_effect_around_external_state() {
        let seen = Rc::new(RefCell::new(Vec::new()));
        let keep_alive = Rc::new(RefCell::new(None::<(Source, EffectHandle)>));

        queue_macrotask({
            let seen = Rc::clone(&seen);
            let keep_alive = Rc::clone(&keep_alive);
            move || {
                let reactor = Reactor::new();
                let source = source_in(&reactor);
                // State managed outside the reactive graph; the source stands in for it.
                let external = Rc::new(Cell::new(1usize));

                let effect = reactor.effect({
                    let source = source.clone();
                    let external = Rc::clone(&external);
                    let seen = Rc::clone(&seen);
                    move || {
                        source.observe();
                        seen.borrow_mut().push(external.get());
                    }
                });
                *keep_alive.borrow_mut() = Some((source.clone(), effect));

                runite::queue_macrotask(move || {
                    external.set(2);
                    source.trigger();
                });
            }
        });

        run();
        assert_eq!(&*seen.borrow(), &[1, 2]);
    }
}