Skip to main content

spark_signals/
lib.rs

1// ============================================================================
2// spark-signals - A Reactive Signals Library for Rust
3// ============================================================================
4//
5// A faithful port of @rlabs-inc/signals TypeScript package.
6// See CLAUDE.md for implementation notes and .planning/ for roadmap.
7// ============================================================================
8
9pub mod collections;
10pub mod core;
11#[macro_use]
12pub mod macros;
13pub mod primitives;
14pub mod reactivity;
15pub mod shared;
16
17// Re-export core items at crate root for ergonomic access
18pub use core::constants;
19pub use core::context::{
20    is_batching, is_tracking, is_untracking, read_version, with_context, write_version,
21    ReactiveContext,
22};
23pub use core::types::{default_equals, AnyReaction, AnySource, EqualsFn, SourceInner};
24
25// Re-export primitives at crate root (TypeScript-like API)
26pub use primitives::bind::{
27    bind, bind_chain, bind_getter, bind_readonly, bind_readonly_from, bind_readonly_static,
28    bind_static, bind_value, binding_has_internal_source, disconnect_binding, disconnect_source,
29    is_binding, unwrap_binding, unwrap_readonly, Binding, IsBinding, ReadonlyBinding,
30};
31pub use primitives::derived::{derived, derived_with_equals, Derived, DerivedInner};
32pub use primitives::effect::{
33    effect, effect_root, effect_sync, effect_sync_with_cleanup, effect_tracking,
34    effect_with_cleanup, CleanupFn, DisposeFn, Effect, EffectFn, EffectInner,
35};
36pub use primitives::linked::{
37    is_linked_signal, linked_signal, linked_signal_full, linked_signal_with_options,
38    IsLinkedSignal, LinkedSignal, LinkedSignalOptionsSimple, PreviousValue,
39};
40pub use primitives::props::{into_derived, reactive_prop, PropValue, PropsBuilder, UnwrapProp};
41pub use primitives::selector::{create_selector, create_selector_eq, Selector};
42pub use primitives::scope::{
43    effect_scope, get_current_scope, on_scope_dispose, EffectScope, ScopeCleanupFn,
44};
45pub use primitives::signal::{
46    mutable_source, signal, signal_f32, signal_f64, signal_with_equals, source, Signal,
47    SourceOptions,
48};
49pub use primitives::slot::{
50    dirty_set, is_slot, slot, slot_array, slot_with_value, tracked_slot, tracked_slot_array,
51    DirtySet, IsSlot, Slot, SlotArray, SlotWriteError, TrackedSlot, TrackedSlotArray,
52};
53
54// Re-export reactivity functions
55pub use reactivity::batching::{batch, peek, tick, untrack};
56pub use reactivity::equality::{
57    always_equals, by_field, deep_equals, equals, never_equals, safe_equals_f32, safe_equals_f64,
58    safe_equals_option_f64, safe_not_equal_f32, safe_not_equal_f64, shallow_equals_slice,
59    shallow_equals_vec,
60};
61pub use reactivity::scheduling::flush_sync;
62pub use reactivity::tracking::{
63    is_dirty, mark_reactions, notify_write, remove_reactions, set_signal_status, track_read,
64};
65
66// Re-export collections
67pub use collections::{ReactiveMap, ReactiveSet, ReactiveVec};
68
69// Re-export repeater
70pub use primitives::repeater::{repeat, RepeaterInner};
71
72// Re-export shared memory primitives (for FFI bridges)
73pub use shared::{
74    wait_for_wake, wait_for_wake_timeout, MutableSharedArray, MutableSharedF32Array,
75    ReactiveSharedArray, ReactiveSharedF32Array, ReactiveSharedI32Array, ReactiveSharedU32Array,
76    ReactiveSharedU8Array, SharedBufferContext,
77};
78
79// Re-export new shared primitives (Layer 1 + Notifier)
80pub use shared::notify::{platform_wake, AtomicsNotifier, Notifier, NoopNotifier};
81pub use shared::shared_slot_buffer::SharedSlotBuffer;
82
83// =============================================================================
84// TESTS
85// =============================================================================
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::rc::Rc;
91
92    // =========================================================================
93    // Phase 1 Success Criteria Tests
94    // =========================================================================
95
96    #[test]
97    fn phase1_success_criteria_1_flags_defined() {
98        // Type flags
99        assert_eq!(constants::SOURCE, 1 << 0);
100        assert_eq!(constants::DERIVED, 1 << 1);
101        assert_eq!(constants::EFFECT, 1 << 2);
102
103        // Status flags
104        assert_eq!(constants::CLEAN, 1 << 10);
105        assert_eq!(constants::DIRTY, 1 << 11);
106        assert_eq!(constants::MAYBE_DIRTY, 1 << 12);
107
108        // All distinct
109        assert_eq!(constants::CLEAN & constants::DIRTY, 0);
110        assert_eq!(constants::DIRTY & constants::MAYBE_DIRTY, 0);
111    }
112
113    #[test]
114    fn phase1_success_criteria_2_traits_compile() {
115        let source: Rc<SourceInner<i32>> = Rc::new(SourceInner::new(42));
116        let _any_source: Rc<dyn AnySource> = source;
117
118        let source = SourceInner::new(100);
119        assert!(source.flags() & constants::SOURCE != 0);
120        source.mark_dirty();
121        assert!(source.is_dirty());
122    }
123
124    #[test]
125    fn phase1_success_criteria_3_thread_local_context() {
126        with_context(|ctx| {
127            assert_eq!(ctx.get_write_version(), 1);
128            assert!(!ctx.has_active_reaction());
129
130            ctx.increment_write_version();
131            assert_eq!(ctx.get_write_version(), 2);
132        });
133
134        assert!(write_version() >= 1);
135        assert!(!is_tracking());
136    }
137
138    #[test]
139    fn phase1_success_criteria_4_heterogeneous_storage() {
140        let int_source: Rc<dyn AnySource> = Rc::new(SourceInner::new(42i32));
141        let string_source: Rc<dyn AnySource> = Rc::new(SourceInner::new(String::from("hello")));
142        let float_source: Rc<dyn AnySource> = Rc::new(SourceInner::new(3.14f64));
143        let bool_source: Rc<dyn AnySource> = Rc::new(SourceInner::new(true));
144        let vec_source: Rc<dyn AnySource> = Rc::new(SourceInner::new(vec![1, 2, 3]));
145
146        let sources: Vec<Rc<dyn AnySource>> = vec![
147            int_source,
148            string_source,
149            float_source,
150            bool_source,
151            vec_source,
152        ];
153
154        assert_eq!(sources.len(), 5);
155
156        for source in &sources {
157            assert!(source.flags() & constants::SOURCE != 0);
158            assert!(source.is_clean());
159        }
160
161        sources[0].mark_dirty();
162        sources[2].mark_maybe_dirty();
163
164        assert!(sources[0].is_dirty());
165        assert!(sources[1].is_clean());
166        assert!(sources[2].is_maybe_dirty());
167    }
168
169    // =========================================================================
170    // Phase 2 Success Criteria Tests
171    // =========================================================================
172
173    #[test]
174    fn phase2_success_criteria_1_signal_api() {
175        // User can create signal with signal(value)
176        let count = signal(0);
177
178        // Read with .get()
179        assert_eq!(count.get(), 0);
180
181        // Write with .set()
182        count.set(42);
183        assert_eq!(count.get(), 42);
184    }
185
186    #[test]
187    fn phase2_success_criteria_2_heterogeneous_signal_storage() {
188        // Signal<i32> and Signal<String> can be stored in same Vec<Rc<dyn AnySource>>
189        let int_signal = signal(42i32);
190        let string_signal = signal(String::from("hello"));
191
192        let sources: Vec<Rc<dyn AnySource>> = vec![
193            int_signal.as_any_source(),
194            string_signal.as_any_source(),
195        ];
196
197        assert_eq!(sources.len(), 2);
198
199        // Can operate uniformly
200        for source in &sources {
201            assert!(source.flags() & constants::SOURCE != 0);
202        }
203    }
204
205    #[test]
206    fn phase2_success_criteria_3_combinators() {
207        let items = signal(vec![1, 2, 3, 4, 5]);
208
209        // .try_get() works
210        assert_eq!(items.try_get(), Some(vec![1, 2, 3, 4, 5]));
211
212        // .with(f) works
213        let sum = items.with(|v| v.iter().sum::<i32>());
214        assert_eq!(sum, 15);
215
216        // .update(f) works
217        items.update(|v| v.push(6));
218        assert_eq!(items.get(), vec![1, 2, 3, 4, 5, 6]);
219    }
220
221    #[test]
222    fn phase2_success_criteria_4_equality_checking() {
223        let count = signal(42);
224
225        // Setting same value doesn't "change"
226        let changed = count.set(42);
227        assert!(!changed);
228
229        // Setting different value does "change"
230        let changed = count.set(100);
231        assert!(changed);
232    }
233
234    // =========================================================================
235    // Phase 3 Success Criteria Tests
236    // =========================================================================
237
238    use std::any::Any;
239    use std::cell::{Cell, RefCell};
240
241    /// Mock reaction for testing - implements AnyReaction
242    struct TestReaction {
243        flags: Cell<u32>,
244        deps: RefCell<Vec<Rc<dyn AnySource>>>,
245    }
246
247    impl TestReaction {
248        fn new() -> Rc<Self> {
249            Rc::new(Self {
250                flags: Cell::new(constants::EFFECT | constants::CLEAN),
251                deps: RefCell::new(Vec::new()),
252            })
253        }
254    }
255
256    impl AnyReaction for TestReaction {
257        fn flags(&self) -> u32 {
258            self.flags.get()
259        }
260
261        fn set_flags(&self, flags: u32) {
262            self.flags.set(flags);
263        }
264
265        fn dep_count(&self) -> usize {
266            self.deps.borrow().len()
267        }
268
269        fn add_dep(&self, source: Rc<dyn AnySource>) {
270            self.deps.borrow_mut().push(source);
271        }
272
273        fn clear_deps(&self) {
274            self.deps.borrow_mut().clear();
275        }
276
277        fn remove_deps_from(&self, start: usize) {
278            self.deps.borrow_mut().truncate(start);
279        }
280
281        fn for_each_dep(&self, f: &mut dyn FnMut(&Rc<dyn AnySource>) -> bool) {
282            for dep in self.deps.borrow().iter() {
283                if !f(dep) {
284                    break;
285                }
286            }
287        }
288
289        fn remove_source(&self, source: &Rc<dyn AnySource>) {
290            let source_ptr = Rc::as_ptr(source) as *const ();
291            self.deps.borrow_mut().retain(|dep| {
292                let dep_ptr = Rc::as_ptr(dep) as *const ();
293                dep_ptr != source_ptr
294            });
295        }
296
297        fn update(&self) -> bool {
298            false
299        }
300
301        fn as_any(&self) -> &dyn Any {
302            self
303        }
304
305        fn as_derived_source(&self) -> Option<Rc<dyn AnySource>> {
306            None
307        }
308    }
309
310    #[test]
311    fn phase3_success_criteria_1_read_registers_dependency() {
312        // Reading a signal inside a reaction registers the signal as a dependency
313        let count = signal(42);
314        let reaction = TestReaction::new();
315
316        // Simulate being inside a reaction
317        with_context(|ctx| {
318            ctx.set_active_reaction(Some(Rc::downgrade(
319                &(reaction.clone() as Rc<dyn AnyReaction>),
320            )));
321        });
322
323        // Read the signal - this should register the dependency
324        let value = count.get();
325        assert_eq!(value, 42);
326
327        // Clean up
328        with_context(|ctx| {
329            ctx.set_active_reaction(None);
330        });
331
332        // Verify: reaction should have count as a dependency
333        assert_eq!(reaction.dep_count(), 1);
334
335        // Verify: count should have reaction in its reactions list
336        assert_eq!(count.inner().reaction_count(), 1);
337    }
338
339    #[test]
340    fn phase3_success_criteria_2_write_marks_reactions_dirty() {
341        // Writing to a signal marks all dependent reactions as DIRTY
342        let count = signal(0);
343        let reaction = TestReaction::new();
344
345        // Wire up the dependency manually
346        count.inner().add_reaction(Rc::downgrade(
347            &(reaction.clone() as Rc<dyn AnyReaction>),
348        ));
349
350        // Reaction starts CLEAN
351        assert!(reaction.is_clean());
352        assert!(!reaction.is_dirty());
353
354        // Write to signal
355        count.set(42);
356
357        // Reaction should now be DIRTY
358        assert!(reaction.is_dirty());
359        assert!(!reaction.is_clean());
360    }
361
362    #[test]
363    fn phase3_success_criteria_3_is_dirty_reports_correctly() {
364        // isDirty(reaction) correctly reports dirty state
365        let reaction = TestReaction::new();
366
367        // Clean state
368        assert!(!is_dirty(&*reaction));
369
370        // Dirty state
371        reaction.mark_dirty();
372        assert!(is_dirty(&*reaction));
373
374        // Maybe dirty state (treated as dirty)
375        reaction.mark_maybe_dirty();
376        assert!(is_dirty(&*reaction));
377
378        // Clean again
379        reaction.mark_clean();
380        assert!(!is_dirty(&*reaction));
381    }
382
383    #[test]
384    fn phase3_success_criteria_4_remove_reactions_cleanup() {
385        // removeReactions(reaction, start) cleans up old dependencies
386        let source1 = signal(1);
387        let source2 = signal(2);
388        let source3 = signal(3);
389        let reaction = TestReaction::new();
390
391        // Add deps manually
392        reaction.add_dep(source1.as_any_source());
393        reaction.add_dep(source2.as_any_source());
394        reaction.add_dep(source3.as_any_source());
395
396        // Register reaction with sources
397        source1
398            .inner()
399            .add_reaction(Rc::downgrade(&(reaction.clone() as Rc<dyn AnyReaction>)));
400        source2
401            .inner()
402            .add_reaction(Rc::downgrade(&(reaction.clone() as Rc<dyn AnyReaction>)));
403        source3
404            .inner()
405            .add_reaction(Rc::downgrade(&(reaction.clone() as Rc<dyn AnyReaction>)));
406
407        assert_eq!(reaction.dep_count(), 3);
408
409        // Remove deps from index 1 onwards
410        remove_reactions(reaction.clone(), 1);
411
412        // Should only have source1 left
413        assert_eq!(reaction.dep_count(), 1);
414
415        // source2 and source3 should no longer have this reaction
416        assert_eq!(source2.inner().reaction_count(), 0);
417        assert_eq!(source3.inner().reaction_count(), 0);
418
419        // source1 should still have it
420        assert_eq!(source1.inner().reaction_count(), 1);
421    }
422
423    #[test]
424    fn phase3_success_criteria_5_no_borrow_panics_cascade() {
425        // No RefCell borrow panics during cascade updates
426        // This test proves the collect-then-mutate pattern works
427
428        let source = signal(0);
429
430        // Create multiple reactions that depend on the source
431        let reactions: Vec<Rc<TestReaction>> = (0..10).map(|_| TestReaction::new()).collect();
432
433        // Wire up all reactions to the source
434        for reaction in &reactions {
435            source.inner().add_reaction(Rc::downgrade(
436                &(reaction.clone() as Rc<dyn AnyReaction>),
437            ));
438        }
439
440        // This should NOT panic - the collect-then-mutate pattern prevents it
441        source.set(42);
442
443        // All reactions should be dirty
444        for reaction in &reactions {
445            assert!(
446                reaction.is_dirty(),
447                "All reactions should be marked dirty after signal write"
448            );
449        }
450    }
451
452    #[test]
453    fn phase3_integration_full_cycle() {
454        // Integration test: full dependency tracking cycle
455
456        let a = signal(1);
457        let b = signal(2);
458        let reaction = TestReaction::new();
459
460        // Simulate entering a reaction's update
461        with_context(|ctx| {
462            ctx.set_active_reaction(Some(Rc::downgrade(
463                &(reaction.clone() as Rc<dyn AnyReaction>),
464            )));
465        });
466
467        // Read both signals
468        let sum = a.get() + b.get();
469        assert_eq!(sum, 3);
470
471        // Exit reaction
472        with_context(|ctx| {
473            ctx.set_active_reaction(None);
474        });
475
476        // Both should be registered as deps
477        assert_eq!(reaction.dep_count(), 2);
478        assert_eq!(a.inner().reaction_count(), 1);
479        assert_eq!(b.inner().reaction_count(), 1);
480
481        // Reaction starts clean
482        reaction.mark_clean();
483        assert!(reaction.is_clean());
484
485        // Change a - should mark reaction dirty
486        a.set(10);
487        assert!(reaction.is_dirty());
488
489        // Reset and test b
490        reaction.mark_clean();
491        b.set(20);
492        assert!(reaction.is_dirty());
493    }
494
495    // =========================================================================
496    // Phase 4 Success Criteria Tests
497    // =========================================================================
498
499    #[test]
500    fn phase4_success_criteria_1_derived_api() {
501        // User can create derived with `derived(|| computation)`
502        let count = signal(1);
503        let doubled = derived({
504            let count = count.clone();
505            move || count.get() * 2
506        });
507
508        assert_eq!(doubled.get(), 2);
509
510        count.set(5);
511        assert_eq!(doubled.get(), 10);
512    }
513
514    #[test]
515    fn phase4_success_criteria_2_caches_and_recomputes() {
516        // Derived caches value, only recomputes when dependencies change
517        let compute_count = Rc::new(Cell::new(0));
518
519        let a = signal(1);
520        let d = derived({
521            let a = a.clone();
522            let compute_count = compute_count.clone();
523            move || {
524                compute_count.set(compute_count.get() + 1);
525                a.get() * 2
526            }
527        });
528
529        // First read computes
530        assert_eq!(d.get(), 2);
531        assert_eq!(compute_count.get(), 1);
532
533        // Second read uses cache (no recompute)
534        assert_eq!(d.get(), 2);
535        assert_eq!(compute_count.get(), 1);
536
537        // After dependency changes, recomputes
538        a.set(5);
539        assert_eq!(d.get(), 10);
540        assert_eq!(compute_count.get(), 2);
541
542        // Reading again uses cache
543        assert_eq!(d.get(), 10);
544        assert_eq!(compute_count.get(), 2);
545    }
546
547    #[test]
548    fn phase4_success_criteria_3_maybe_dirty_optimization() {
549        // MAYBE_DIRTY optimization prevents unnecessary recomputation in chains
550        // A -> B -> C
551        // If B's value doesn't change when A changes, C shouldn't recompute
552
553        let compute_c_count = Rc::new(Cell::new(0));
554
555        let a = signal(0);
556
557        // B clamps to range [0, 10]
558        let b = derived({
559            let a = a.clone();
560            move || a.get().clamp(0, 10)
561        });
562
563        let c = derived({
564            let b = b.clone();
565            let compute_c_count = compute_c_count.clone();
566            move || {
567                compute_c_count.set(compute_c_count.get() + 1);
568                b.get() * 100
569            }
570        });
571
572        // Initial computation
573        assert_eq!(c.get(), 0); // 0 * 100 = 0
574        assert_eq!(compute_c_count.get(), 1);
575
576        // Change a within clamp range - B's output stays 0
577        a.set(0);
578        assert_eq!(c.get(), 0);
579        // Note: With full MAYBE_DIRTY optimization, C wouldn't recompute
580        // Our implementation may be conservative
581
582        // Change a to different clamped value
583        a.set(5);
584        assert_eq!(c.get(), 500);
585    }
586
587    #[test]
588    fn phase4_success_criteria_4_diamond_dependency() {
589        // Diamond dependency patterns work correctly (A->B, A->C, B->D, C->D)
590        //
591        //      A
592        //     / \
593        //    B   C
594        //     \ /
595        //      D
596
597        let a = signal(1);
598
599        let b = derived({
600            let a = a.clone();
601            move || a.get() + 10
602        });
603
604        let c = derived({
605            let a = a.clone();
606            move || a.get() * 10
607        });
608
609        let d = derived({
610            let b = b.clone();
611            let c = c.clone();
612            move || b.get() + c.get()
613        });
614
615        // Initial: A=1, B=11, C=10, D=21
616        assert_eq!(d.get(), 21);
617
618        // Change A to 2: B=12, C=20, D=32
619        a.set(2);
620        assert_eq!(d.get(), 32);
621
622        // D correctly gets both updated values
623    }
624
625    #[test]
626    fn phase4_success_criteria_5_cascade_propagation() {
627        // Circular dependency injection works (tracking calls derived update)
628        // This tests that as_derived_source() enables cascade propagation
629
630        let a = signal(1);
631
632        let b = derived({
633            let a = a.clone();
634            move || a.get() * 2
635        });
636
637        let c = derived({
638            let b = b.clone();
639            move || b.get() + 10
640        });
641
642        // Initial read sets up the graph
643        assert_eq!(c.get(), 12);
644
645        // Get inner refs to check flags
646        let b_inner = b.inner();
647        let c_inner = c.inner();
648
649        // Both should be clean
650        assert!(AnySource::is_clean(&**b_inner));
651        assert!(AnySource::is_clean(&**c_inner));
652
653        // Change A
654        a.set(5);
655
656        // B should be DIRTY (direct dependency)
657        // C should be DIRTY or MAYBE_DIRTY (cascade via as_derived_source)
658        let b_flags = AnySource::flags(&**b_inner);
659        let c_flags = AnySource::flags(&**c_inner);
660
661        assert!(
662            (b_flags & constants::DIRTY) != 0,
663            "B should be marked DIRTY"
664        );
665        assert!(
666            (c_flags & (constants::DIRTY | constants::MAYBE_DIRTY)) != 0,
667            "C should be marked DIRTY or MAYBE_DIRTY via cascade"
668        );
669
670        // Reading C should trigger the cascade update
671        assert_eq!(c.get(), 20); // (5*2) + 10 = 20
672
673        // Both clean again
674        assert!(AnySource::is_clean(&**b_inner));
675        assert!(AnySource::is_clean(&**c_inner));
676    }
677}