1use 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
34pub 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
126pub 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
135pub 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 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#[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#[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#[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}