ankurah_react_signals/
react_binding.rs

1use std::{
2    cell::RefCell,
3    sync::{atomic::AtomicU64, Arc, RwLock},
4};
5
6use futures::StreamExt;
7use or_poisoned::OrPoisoned;
8use reactive_graph::graph::{AnySource, AnySubscriber, ReactiveNode, Subscriber};
9use std::sync::Weak;
10
11use wasm_bindgen::prelude::*;
12
13#[wasm_bindgen(module = "react")]
14extern "C" {
15    #[wasm_bindgen(catch)]
16    fn useRef() -> Result<JsValue, JsValue>;
17    #[wasm_bindgen(catch)]
18    fn useSyncExternalStore(
19        subscribe: &Closure<dyn Fn(js_sys::Function) -> JsValue>,
20        get_snapshot: &Closure<dyn Fn() -> JsValue>,
21        get_server_snapshot: &Closure<dyn Fn() -> JsValue>,
22    ) -> Result<JsValue, JsValue>;
23}
24
25// Substantially an homage to https://github.com/preactjs/signals/blob/main/packages/react/runtime/src/auto.ts
26// TODO need to get fancier with this, probably by hooking into the react internals, similar technique to preact-signals
27// For now it works, but the render cycle start/finish is thrashy:
28// INFO react-signals/src/react_binding.rs:65 effectstore new
29// example_wasm_bindings_bg.wasm:0x78592 INFO react-signals/src/react_binding.rs:91 effectstore start
30// example_wasm_bindings_bg.wasm:0x78592 INFO react-signals/src/react_binding.rs:91 effectstore start
31// example_wasm_bindings_bg.wasm:0x78592 INFO react-signals/src/react_binding.rs:95 effectstore finish
32// example_wasm_bindings_bg.wasm:0x78592 INFO react-signals/src/react_binding.rs:91 effectstore start
33// example_wasm_bindings_bg.wasm:0x78592 INFO react-signals/src/react_binding.rs:95 effectstore finish
34// example_wasm_bindings_bg.wasm:0x78592 INFO react-signals/src/react_binding.rs:91 effectstore start
35// example_wasm_bindings_bg.wasm:0x78592 INFO react-signals/src/react_binding.rs:95 effectstore finish
36
37/// Creates a subscription to track signal usage within a React component.
38///
39/// This hook enables automatic re-rendering of React components when signals they access are updated.
40/// It returns a controller object that must be used with a try-finally pattern to properly clean up
41/// signal subscriptions.
42///
43/// # Usage
44///
45/// ```typescript
46/// import { withSignals } from 'example-wasm-bindings';
47///
48/// function MyComponent() {
49///     // withSignals is a temporary hack. Signal management will change soon
50///     withSignals(() => {
51///         // Your component logic here
52///         return <div>{my_signal.value}</div>;
53///     });
54///     }
55/// }
56/// ```
57///
58/// # Note
59/// This implementation follows the pattern used by Preact Signals, adapted for
60/// WebAssembly-based signal integration with React
61#[wasm_bindgen(js_name = withSignals)]
62pub fn with_signals(f: &js_sys::Function) -> Result<JsValue, JsValue> {
63    let ref_value = useRef()?;
64
65    let mut store = js_sys::Reflect::get(&ref_value, &"current".into()).unwrap();
66    if store.is_undefined() {
67        let new_store = EffectStore::new();
68        useSyncExternalStore(&new_store.0.subscribe_fn, &new_store.0.get_snapshot, &new_store.0.get_snapshot)?;
69
70        // TODO: Check to see if this sets up the finalizer in JS land
71        store = JsValue::from(new_store.clone());
72        js_sys::Reflect::set(&ref_value, &"current".into(), &store).unwrap();
73        Ok(new_store.with_observer(f))
74    } else {
75        let ptr = js_sys::Reflect::get(&store, &JsValue::from_str("__wbg_ptr")).unwrap();
76        let store = {
77            // workaround for lack of downcasting in wasm-bindgen
78            // https://github.com/rustwasm/wasm-bindgen/pull/3088
79            let ptr_u32: u32 = ptr.as_f64().unwrap() as u32;
80            use wasm_bindgen::convert::RefFromWasmAbi;
81            unsafe { EffectStore::ref_from_abi(ptr_u32) }
82        };
83
84        useSyncExternalStore(&store.0.subscribe_fn, &store.0.get_snapshot, &store.0.get_snapshot)?;
85        Ok(store.with_observer(f))
86    }
87}
88
89#[derive(Clone)]
90#[wasm_bindgen]
91pub struct EffectStore(Arc<Inner>);
92
93struct Inner {
94    // The function which gets called by useSyncExternalStore to subscribe the react component to changes to the "store"
95    subscribe_fn: Closure<dyn Fn(js_sys::Function) -> JsValue>,
96    // The function which gets called by useSyncExternalStore to get the current value of the "store"
97    get_snapshot: Closure<dyn Fn() -> JsValue>,
98
99    // Tracks the sources which the "store" depends on
100    tracker: Tracker,
101}
102
103// Thread local for currently active SignalContext
104thread_local! {
105    pub static CURRENT_STORE: RefCell<Option<EffectStore>> = const { RefCell::new(None) };
106}
107
108impl Default for EffectStore {
109    fn default() -> Self { Self::new() }
110}
111
112impl EffectStore {
113    pub fn new() -> Self {
114        // Necessary for hooking into the react render cycle
115        let version = Arc::new(AtomicU64::new(0));
116        let (sender, rx) = crate::effect::channel::channel();
117        let tracker = Tracker(Arc::new(TrackerInner(RwLock::new(TrackerState {
118            dirty: false,
119            notifier: sender,
120            sources: Vec::new(),
121            version: version.clone(),
122        }))));
123        let rx = RefCell::new(Some(rx));
124        let subscribe_fn = {
125            let tracker = tracker.clone();
126            let version = version.clone();
127            Closure::wrap(Box::new(move |on_store_change: js_sys::Function| {
128                let Some(mut rx) = rx.borrow_mut().take() else {
129                    return JsValue::UNDEFINED;
130                };
131
132                any_spawner::Executor::spawn_local({
133                    //     // let value = Arc::clone(&value);
134                    let subscriber = tracker.to_any_subscriber();
135
136                    async move {
137                        while rx.next().await.is_some() {
138                            // Do we need to call with_observer here? I can't see why
139                            // if subscriber.with_observer(|| subscriber.update_if_necessary())
140                            if subscriber.update_if_necessary() {
141                                subscriber.clear_sources(&subscriber);
142                                on_store_change.call0(&JsValue::NULL).unwrap();
143                            }
144                        }
145                    }
146                });
147
148                version.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
149
150                // TODO: return unsubscribe closure
151                JsValue::UNDEFINED
152            }) as Box<dyn Fn(js_sys::Function) -> JsValue>)
153        };
154        let get_snapshot = {
155            let version = version.clone();
156
157            Closure::wrap(Box::new(move || {
158                let version = version.load(std::sync::atomic::Ordering::Relaxed);
159                JsValue::from(version)
160            }) as Box<dyn Fn() -> JsValue>)
161        };
162
163        Self(Arc::new(Inner { subscribe_fn, get_snapshot, tracker }))
164    }
165}
166
167#[wasm_bindgen]
168impl EffectStore {
169    // pub fn start(&self) { reactive_graph::graph::Observer::set(Some(self.0.tracker.to_any_subscriber())); }
170    // pub fn finish(&self) { reactive_graph::graph::Observer::set(None); }
171
172    pub fn with_observer(&self, f: &js_sys::Function) -> JsValue {
173        use reactive_graph::graph::WithObserver;
174        self.0.tracker.to_any_subscriber().with_observer(|| f.call0(&JsValue::NULL).unwrap_or(JsValue::UNDEFINED))
175    }
176}
177#[derive(Debug, Clone)]
178pub struct Tracker(Arc<TrackerInner>);
179
180#[derive(Debug)]
181pub struct TrackerInner(RwLock<TrackerState>);
182
183#[derive(Debug)]
184struct TrackerState {
185    dirty: bool,
186    notifier: crate::effect::channel::Sender,
187    sources: Vec<AnySource>,
188    version: Arc<AtomicU64>,
189}
190
191impl ReactiveNode for TrackerInner {
192    fn mark_subscribers_check(&self) {}
193
194    fn update_if_necessary(&self) -> bool {
195        let mut guard = self.0.write().or_poisoned();
196        let (is_dirty, sources) = (guard.dirty, (!guard.dirty).then(|| guard.sources.clone()));
197
198        if is_dirty {
199            guard.dirty = false;
200            return true;
201        }
202
203        drop(guard);
204        for source in sources.into_iter().flatten() {
205            if source.update_if_necessary() {
206                return true;
207            }
208        }
209        false
210    }
211
212    fn mark_check(&self) { self.0.write().or_poisoned().notifier.notify(); }
213
214    fn mark_dirty(&self) {
215        let mut lock = self.0.write().or_poisoned();
216        lock.dirty = true;
217        lock.version.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
218        lock.notifier.notify();
219    }
220}
221
222impl Subscriber for TrackerInner {
223    fn add_source(&self, source: AnySource) { self.0.write().or_poisoned().sources.push(source); }
224
225    fn clear_sources(&self, _subscriber: &AnySubscriber) { self.0.write().or_poisoned().sources.clear(); }
226}
227
228impl ToAnySubscriber for Tracker {
229    fn to_any_subscriber(&self) -> AnySubscriber {
230        AnySubscriber(Arc::as_ptr(&self.0) as usize, Arc::downgrade(&self.0) as Weak<dyn Subscriber + Send + Sync>)
231    }
232}
233
234pub trait ToAnySubscriber {
235    /// Converts this type to its type-erased equivalent.
236    fn to_any_subscriber(&self) -> AnySubscriber;
237}