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}
172struct Input<T> {
173    state: Rc<InputState<T>>,
174    _read: Closure<dyn Fn() -> Result<JsValue, JsValue>>,
175    _observe: Closure<dyn Fn(bool)>,
176}
177impl<T> InputGuard for Input<T> {
178    fn close(&self) {
179        self.state.close();
180    }
181    fn set_owner(&self, owner: OwnerHandle) {
182        *self.state.owner.borrow_mut() = Some(owner);
183    }
184}
185impl<T> Drop for Input<T> {
186    fn drop(&mut self) {
187        self.state.close();
188    }
189}
190
191/// Generated input declarations; constructing these creates no reactive work.
192#[doc(hidden)]
193pub struct Inputs {
194    object: Object,
195    guards: Vec<Box<dyn InputGuard>>,
196}
197impl Default for Inputs {
198    fn default() -> Self {
199        Self {
200            object: inputs_create(),
201            guards: Vec::new(),
202        }
203    }
204}
205impl Inputs {
206    pub fn add<T: JsInputValue>(&mut self, name: &str, signal: Signal<T>) {
207        let state = Rc::new(InputState {
208            signal: RefCell::new(Some(signal)),
209            owner: RefCell::new(None),
210            observer: RefCell::new(None),
211            javascript: RefCell::new(None),
212            pending: Cell::new(false),
213            generation: Cell::new(0),
214        });
215        let weak = Rc::downgrade(&state);
216        let read = Closure::wrap(Box::new(move || {
217            let state = weak
218                .upgrade()
219                .ok_or_else(|| JsValue::from_str("fusor: input disposed"))?;
220            if state.disposed() {
221                return Err(JsValue::from_str(
222                    "fusor: input belongs to a disposed component",
223                ));
224            }
225            let signal = state
226                .signal
227                .borrow()
228                .clone()
229                .ok_or_else(|| JsValue::from_str("fusor: input disposed"))?;
230            Ok(signal.with_untracked(JsInputValue::to_js))
231        }) as Box<dyn Fn() -> Result<JsValue, JsValue>>);
232        let weak = Rc::downgrade(&state);
233        let observe = Closure::wrap(Box::new(move |active: bool| {
234            let Some(state) = weak.upgrade() else {
235                return;
236            };
237            if !active {
238                state.generation.set(state.generation.get().wrapping_add(1));
239                state.pending.set(false);
240                let observer = state.observer.take();
241                drop(observer);
242                return;
243            }
244            if state.disposed() {
245                state.close();
246                return;
247            }
248            if state.observer.borrow().is_some() {
249                return;
250            }
251            let Some(signal) = state.signal.borrow().clone() else {
252                return;
253            };
254            let weak = Rc::downgrade(&state);
255            let mut first = true;
256            let observer = effect(move || {
257                // Track only this field. Conversion and callbacks run after all
258                // ordinary Rust effects, with no RefCell borrow or tracking.
259                signal.with(|_| ());
260                if first {
261                    first = false;
262                    return;
263                }
264                let Some(state) = weak.upgrade() else {
265                    return;
266                };
267                if state.disposed() {
268                    state.close();
269                    return;
270                }
271                if state.pending.replace(true) {
272                    return;
273                }
274                let weak = Rc::downgrade(&state);
275                let generation = state.generation.get();
276                crate::reactive::after_flush(move || {
277                    let Some(state) = weak.upgrade() else {
278                        return;
279                    };
280                    if state.disposed() {
281                        state.close();
282                        return;
283                    }
284                    if generation != state.generation.get() {
285                        return;
286                    }
287                    state.pending.set(false);
288                    let signal = state.signal.borrow().clone();
289                    let js = state.javascript.borrow().clone();
290                    if let (Some(signal), Some(js)) = (signal, js) {
291                        if state.observer.borrow().is_none() {
292                            return;
293                        }
294                        let value = signal.with_untracked(JsInputValue::to_js);
295                        untrack(|| input_publish(&js, &value));
296                    }
297                });
298            });
299            *state.observer.borrow_mut() = Some(observer);
300        }) as Box<dyn Fn(bool)>);
301        let javascript = input_create(read.as_ref(), observe.as_ref());
302        Reflect::set(
303            &self.object,
304            &JsValue::from_str(name),
305            &input_api(&javascript),
306        )
307        .expect("fresh input object");
308        *state.javascript.borrow_mut() = Some(javascript);
309        self.guards.push(Box::new(Input {
310            state,
311            _read: read,
312            _observe: observe,
313        }));
314    }
315    fn close(&self) {
316        for guard in &self.guards {
317            guard.close();
318        }
319    }
320}
321
322/// Autoref fallback lets modules without a JsInputs derive receive empty inputs.
323#[doc(hidden)]
324pub struct InputSource<'a, T>(pub &'a T);
325#[doc(hidden)]
326pub trait MaybeInputs {
327    fn inputs(self) -> Inputs;
328}
329impl<T> MaybeInputs for &InputSource<'_, T> {
330    fn inputs(self) -> Inputs {
331        Inputs::default()
332    }
333}
334impl<T: JsInputs> MaybeInputs for InputSource<'_, T> {
335    fn inputs(self) -> Inputs {
336        self.0.js_inputs()
337    }
338}
339
340struct Module {
341    inputs: Inputs,
342    javascript: JsValue,
343    closed: Cell<bool>,
344}
345impl Module {
346    fn close(&self) {
347        if self.closed.replace(true) {
348            return;
349        }
350        self.inputs.close();
351        module_dispose(&self.javascript);
352    }
353}
354impl Drop for Module {
355    fn drop(&mut self) {
356        self.close();
357    }
358}
359
360/// Compiler entry point: install once, after bindings but before activation.
361#[doc(hidden)]
362pub fn mount(scope: &mut Scope, id: &str, inputs: Inputs) -> Result<(), JsValue> {
363    if scope.is_coherent() || scope.is_hydrating() {
364        return Err(JsValue::from_str(
365            "fusor: component JavaScript requires a browser component outside coherent Async and island/server delivery",
366        ));
367    }
368    #[cfg(feature = "islands")]
369    if crate::dom::delivery::enabled() {
370        return Err(JsValue::from_str(
371            "fusor: component JavaScript is unsupported in island delivery",
372        ));
373    }
374    for guard in &inputs.guards {
375        guard.set_owner(scope.owner());
376    }
377    let module = Rc::new(Module {
378        javascript: module_create(scope.root(), &inputs.object, id),
379        inputs,
380        closed: Cell::new(false),
381    });
382    let weak = Rc::downgrade(&module);
383    let cleanup = scope.owner().on_cleanup(move || {
384        if let Some(module) = weak.upgrade() {
385            untrack(|| module.close());
386        }
387    });
388    let weak = Rc::downgrade(&module);
389    let owner: OwnerHandle = scope.owner();
390    let activate = owner.on_activate(move || {
391        if let Some(module) = weak.upgrade() {
392            if let Err(error) = untrack(|| module_activate(&module.javascript)) {
393                module.close();
394                web_sys::console::error_1(&error);
395            }
396        }
397    });
398    scope.retain((module, cleanup, activate));
399    Ok(())
400}