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}
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#[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 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#[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#[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}