Skip to main content

fusor/
js.rs

1//! Optional, component-local JavaScript modules and explicit Rust inputs.
2//!
3//! Derive [`JsInputs`] and mark `Signal<T>` fields with `#[js]`. JavaScript gets
4//! read-only `get()` and `subscribe(callback)` methods. Subscribe delivers the
5//! current value immediately, then one snapshot after each Rust reactive batch.
6//! Supported values are bool, String, f64, i32, u32, JsValue, Option and Vec.
7//! Use native CustomEvent payloads and [`event_detail`] to send values to Rust.
8use crate::{Effect, OwnerHandle, Signal, dom::Scope, effect, untrack};
9use js_sys::{Array, Object, Reflect};
10use std::{
11    cell::{Cell, RefCell},
12    rc::Rc,
13};
14use wasm_bindgen::{JsCast, JsValue, closure::Closure, prelude::wasm_bindgen};
15use web_sys::{CustomEvent, Element, Event};
16
17#[wasm_bindgen(module = "/src/js/runtime.js")]
18extern "C" {
19    fn inputs_create() -> Object;
20    fn input_create(read: &JsValue, observe: &JsValue) -> JsValue;
21    fn input_api(state: &JsValue) -> JsValue;
22    fn input_publish(state: &JsValue, value: &JsValue);
23    fn input_dispose(state: &JsValue);
24    fn module_create(root: &Element, inputs: &JsValue, id: &str) -> JsValue;
25    #[wasm_bindgen(catch)]
26    fn module_activate(state: &JsValue) -> Result<(), JsValue>;
27    fn module_dispose(state: &JsValue);
28}
29
30mod sealed {
31    pub trait Sealed {}
32}
33
34/// Bounded, checked JavaScript value conversion. This trait is sealed so an
35/// exposed input cannot silently acquire arbitrary serialization semantics.
36pub trait JsInputValue: sealed::Sealed + Clone + 'static {
37    #[doc(hidden)]
38    fn to_js(&self) -> JsValue;
39    #[doc(hidden)]
40    fn from_js(value: &JsValue) -> Result<Self, JsValue>;
41}
42fn mismatch(expected: &str) -> JsValue {
43    JsValue::from_str(&format!("fusor: event detail must be {expected}"))
44}
45macro_rules! primitive {
46    ($ty:ty, $method:ident, $label:literal) => {
47        impl sealed::Sealed for $ty {}
48        impl JsInputValue for $ty {
49            fn to_js(&self) -> JsValue {
50                JsValue::from(self.clone())
51            }
52            fn from_js(value: &JsValue) -> Result<Self, JsValue> {
53                value.$method().ok_or_else(|| mismatch($label))
54            }
55        }
56    };
57}
58primitive!(bool, as_bool, "a boolean");
59primitive!(String, as_string, "a string");
60primitive!(f64, as_f64, "a number");
61macro_rules! integer {
62    ($ty:ty, $label:literal) => {
63        impl sealed::Sealed for $ty {}
64        impl JsInputValue for $ty {
65            fn to_js(&self) -> JsValue {
66                JsValue::from(*self)
67            }
68            fn from_js(value: &JsValue) -> Result<Self, JsValue> {
69                value
70                    .as_f64()
71                    .filter(|value| {
72                        value.is_finite()
73                            && value.fract() == 0.0
74                            && *value >= <$ty>::MIN as f64
75                            && *value <= <$ty>::MAX as f64
76                    })
77                    .map(|value| value as $ty)
78                    .ok_or_else(|| mismatch($label))
79            }
80        }
81    };
82}
83integer!(i32, "an integer in the i32 range");
84integer!(u32, "an integer in the u32 range");
85impl sealed::Sealed for JsValue {}
86impl JsInputValue for JsValue {
87    fn to_js(&self) -> JsValue {
88        self.clone()
89    }
90    fn from_js(value: &JsValue) -> Result<Self, JsValue> {
91        Ok(value.clone())
92    }
93}
94impl<T: JsInputValue> sealed::Sealed for Option<T> {}
95impl<T: JsInputValue> JsInputValue for Option<T> {
96    fn to_js(&self) -> JsValue {
97        self.as_ref().map_or(JsValue::NULL, JsInputValue::to_js)
98    }
99    fn from_js(value: &JsValue) -> Result<Self, JsValue> {
100        if value.is_null() {
101            Ok(None)
102        } else {
103            T::from_js(value).map(Some)
104        }
105    }
106}
107impl<T: JsInputValue> sealed::Sealed for Vec<T> {}
108impl<T: JsInputValue> JsInputValue for Vec<T> {
109    fn to_js(&self) -> JsValue {
110        self.iter()
111            .map(JsInputValue::to_js)
112            .collect::<Array>()
113            .into()
114    }
115    fn from_js(value: &JsValue) -> Result<Self, JsValue> {
116        if !Array::is_array(value) {
117            return Err(mismatch("an array"));
118        }
119        Array::from(value)
120            .iter()
121            .map(|item| T::from_js(&item))
122            .collect()
123    }
124}
125
126/// Read a native CustomEvent payload, checking its entire supported value shape.
127/// Null is accepted only by Option; integers reject fractional/out-of-range data.
128pub fn event_detail<T: JsInputValue>(event: &Event) -> Result<T, JsValue> {
129    let event = event
130        .dyn_ref::<CustomEvent>()
131        .ok_or_else(|| mismatch("a native CustomEvent"))?;
132    T::from_js(&event.detail())
133}
134
135/// Fields explicitly exposed to the component's JavaScript module. Usually
136/// implemented with `#[derive(fusor::JsInputs)]` and `#[js]` field markers.
137pub trait JsInputs {
138    #[doc(hidden)]
139    fn js_inputs(&self) -> Inputs;
140}
141
142trait InputGuard {
143    fn close(&self);
144    fn set_owner(&self, owner: OwnerHandle);
145}
146struct InputState<T> {
147    signal: RefCell<Option<Signal<T>>>,
148    owner: RefCell<Option<OwnerHandle>>,
149    observer: RefCell<Option<Effect>>,
150    javascript: RefCell<Option<JsValue>>,
151    pending: Cell<bool>,
152    generation: Cell<u64>,
153}
154impl<T> InputState<T> {
155    fn disposed(&self) -> bool {
156        self.owner
157            .borrow()
158            .as_ref()
159            .is_some_and(OwnerHandle::is_disposed)
160    }
161    fn close(&self) {
162        let js = self.javascript.take();
163        if let Some(js) = js {
164            input_dispose(&js);
165        }
166        self.pending.set(false);
167        let observer = self.observer.take();
168        drop(observer);
169        self.signal.take();
170    }
171}
172
173impl<T: JsInputValue> InputState<T> {
174    fn read(&self) -> Result<JsValue, JsValue> {
175        if self.disposed() {
176            return Err(JsValue::from_str(
177                "fusor: input belongs to a disposed component",
178            ));
179        }
180        let signal = self
181            .signal
182            .borrow()
183            .clone()
184            .ok_or_else(|| JsValue::from_str("fusor: input disposed"))?;
185        Ok(signal.with_untracked(JsInputValue::to_js))
186    }
187
188    fn stop_observing(&self) {
189        self.generation.set(self.generation.get().wrapping_add(1));
190        self.pending.set(false);
191        let observer = self.observer.take();
192        drop(observer);
193    }
194
195    fn observe(self: &Rc<Self>) {
196        if self.disposed() {
197            self.close();
198            return;
199        }
200        if self.observer.borrow().is_some() {
201            return;
202        }
203        let Some(signal) = self.signal.borrow().clone() else {
204            return;
205        };
206        let weak = Rc::downgrade(self);
207        let mut first = true;
208        let observer = effect(move || {
209            // Track only this field; publication runs after ordinary Rust effects.
210            signal.with(|_| ());
211            if first {
212                first = false;
213                return;
214            }
215            if let Some(state) = weak.upgrade() {
216                state.queue_publication();
217            }
218        });
219        *self.observer.borrow_mut() = Some(observer);
220    }
221
222    fn queue_publication(self: &Rc<Self>) {
223        if self.disposed() {
224            self.close();
225            return;
226        }
227        if self.pending.replace(true) {
228            return;
229        }
230        let weak = Rc::downgrade(self);
231        let generation = self.generation.get();
232        crate::reactive::after_flush(move || {
233            if let Some(state) = weak.upgrade() {
234                state.publish(generation);
235            }
236        });
237    }
238
239    fn publish(&self, generation: u64) {
240        if self.disposed() {
241            self.close();
242            return;
243        }
244        if generation != self.generation.get() {
245            return;
246        }
247        self.pending.set(false);
248        let signal = self.signal.borrow().clone();
249        let js = self.javascript.borrow().clone();
250        let (Some(signal), Some(js)) = (signal, js) else {
251            return;
252        };
253        if self.observer.borrow().is_none() {
254            return;
255        }
256        let value = signal.with_untracked(JsInputValue::to_js);
257        untrack(|| input_publish(&js, &value));
258    }
259}
260
261struct Input<T> {
262    state: Rc<InputState<T>>,
263    _read: Closure<dyn Fn() -> Result<JsValue, JsValue>>,
264    _observe: Closure<dyn Fn(bool)>,
265}
266impl<T> InputGuard for Input<T> {
267    fn close(&self) {
268        self.state.close();
269    }
270    fn set_owner(&self, owner: OwnerHandle) {
271        *self.state.owner.borrow_mut() = Some(owner);
272    }
273}
274impl<T> Drop for Input<T> {
275    fn drop(&mut self) {
276        self.state.close();
277    }
278}
279
280/// Generated input declarations; constructing these creates no reactive work.
281#[doc(hidden)]
282pub struct Inputs {
283    object: Object,
284    guards: Vec<Box<dyn InputGuard>>,
285}
286impl Default for Inputs {
287    fn default() -> Self {
288        Self {
289            object: inputs_create(),
290            guards: Vec::new(),
291        }
292    }
293}
294impl Inputs {
295    pub fn add<T: JsInputValue>(&mut self, name: &str, signal: Signal<T>) {
296        let state = Rc::new(InputState {
297            signal: RefCell::new(Some(signal)),
298            owner: RefCell::new(None),
299            observer: RefCell::new(None),
300            javascript: RefCell::new(None),
301            pending: Cell::new(false),
302            generation: Cell::new(0),
303        });
304        let weak = Rc::downgrade(&state);
305        let read = Closure::wrap(Box::new(move || {
306            let state = weak
307                .upgrade()
308                .ok_or_else(|| JsValue::from_str("fusor: input disposed"))?;
309            state.read()
310        }) as Box<dyn Fn() -> Result<JsValue, JsValue>>);
311        let weak = Rc::downgrade(&state);
312        let observe = Closure::wrap(Box::new(move |active: bool| {
313            let Some(state) = weak.upgrade() else {
314                return;
315            };
316            if active {
317                state.observe();
318            } else {
319                state.stop_observing();
320            }
321        }) as Box<dyn Fn(bool)>);
322        let javascript = input_create(read.as_ref(), observe.as_ref());
323        Reflect::set(
324            &self.object,
325            &JsValue::from_str(name),
326            &input_api(&javascript),
327        )
328        .expect("fresh input object");
329        *state.javascript.borrow_mut() = Some(javascript);
330        self.guards.push(Box::new(Input {
331            state,
332            _read: read,
333            _observe: observe,
334        }));
335    }
336    fn close(&self) {
337        for guard in &self.guards {
338            guard.close();
339        }
340    }
341}
342
343/// Autoref fallback lets modules without a JsInputs derive receive empty inputs.
344#[doc(hidden)]
345pub struct InputSource<'a, T>(pub &'a T);
346#[doc(hidden)]
347pub trait MaybeInputs {
348    fn inputs(self) -> Inputs;
349}
350impl<T> MaybeInputs for &InputSource<'_, T> {
351    fn inputs(self) -> Inputs {
352        Inputs::default()
353    }
354}
355impl<T: JsInputs> MaybeInputs for InputSource<'_, T> {
356    fn inputs(self) -> Inputs {
357        self.0.js_inputs()
358    }
359}
360
361struct Module {
362    inputs: Inputs,
363    javascript: JsValue,
364    closed: Cell<bool>,
365}
366impl Module {
367    fn close(&self) {
368        if self.closed.replace(true) {
369            return;
370        }
371        self.inputs.close();
372        module_dispose(&self.javascript);
373    }
374}
375impl Drop for Module {
376    fn drop(&mut self) {
377        self.close();
378    }
379}
380
381/// Compiler entry point: install once, after bindings but before activation.
382#[doc(hidden)]
383pub fn mount(scope: &mut Scope, id: &str, inputs: Inputs) -> Result<(), JsValue> {
384    if scope.is_coherent() || scope.is_hydrating() {
385        return Err(JsValue::from_str(
386            "fusor: component JavaScript requires a browser component outside coherent Async and island/server delivery",
387        ));
388    }
389    #[cfg(feature = "islands")]
390    if crate::dom::delivery::enabled() {
391        return Err(JsValue::from_str(
392            "fusor: component JavaScript is unsupported in island delivery",
393        ));
394    }
395    for guard in &inputs.guards {
396        guard.set_owner(scope.owner());
397    }
398    let module = Rc::new(Module {
399        javascript: module_create(scope.root(), &inputs.object, id),
400        inputs,
401        closed: Cell::new(false),
402    });
403    let weak = Rc::downgrade(&module);
404    let cleanup = scope.owner().on_cleanup(move || {
405        if let Some(module) = weak.upgrade() {
406            untrack(|| module.close());
407        }
408    });
409    let weak = Rc::downgrade(&module);
410    let owner: OwnerHandle = scope.owner();
411    let activate = owner.on_activate(move || {
412        if let Some(module) = weak.upgrade() {
413            if let Err(error) = untrack(|| module_activate(&module.javascript)) {
414                module.close();
415                web_sys::console::error_1(&error);
416            }
417        }
418    });
419    scope.retain((module, cleanup, activate));
420    Ok(())
421}