dodrio_js_api/
lib.rs

1/*!
2
3Implementing `dodrio` render components with JavaScript.
4
5This crate provides a Rust type `JsRender` that wraps a JavaScript object with a
6`render` method. `JsRender` implements `dodrio::Render` by calling its wrapped
7object's `render` method to get a JavaScript virtual DOM represented as a tree
8of JavaScript values. It then converts this tree of JavaScript values into
9`dodrio`'s normal bump-allocated virtual DOM representation.
10
11This is likely much slower than rendering virtual DOMs directly into the bump
12allocator from the Rust side of things! Additionally, the shape of the
13JavaScript virtual DOM is a bit funky and unidiomatic. Keep in mind that this
14crate exists as a proof of concept for integrating JavaScript components into
15`dodrio` -- which is itself *also* experimental -- and so this crate definitely
16has some rough edges.
17
18# Example
19
20Here is a JavaScript implementation of a rendering component:
21
22```javascript
23class Greeting {
24  constructor(who) {
25    this.who = who;
26  }
27
28  render() {
29    return {
30      tagName: "p",
31      attributes: [
32        {
33          name: "class",
34          value: "greeting",
35        },
36      ],
37      listeners: [
38        {
39          on: "click",
40          callback: this.onClick.bind(this),
41        }
42      ],
43      children: [
44        "Hello, ",
45         {
46           tagName: "strong",
47           children: [this.who],
48         }
49      ],
50    };
51  }
52
53  async onClick(vdom, event) {
54    // Be more excited!
55    this.who += "!";
56
57    // Schedule a re-render.
58    await vdom.render();
59
60    console.log("re-rendering finished!");
61  }
62}
63```
64
65And here is a Rust rendering component that internally uses the JS rendering
66component:
67
68```rust,no_run
69use dodrio::{Node, Render, RenderContext, Vdom};
70use dodrio_js_api::JsRender;
71use js_sys::Object;
72use wasm_bindgen::prelude::*;
73
74#[wasm_bindgen]
75extern {
76    // Import the JS `Greeting` class.
77    #[wasm_bindgen(extends = Object)]
78    #[derive(Clone, Debug)]
79    type Greeting;
80
81    // And the `Greeting` class's constructor.
82    #[wasm_bindgen(constructor)]
83    fn new(who: &str) -> Greeting;
84}
85
86/// This is our Rust rendering component that wraps the JS rendering component.
87pub struct GreetingViaJs {
88    js: JsRender,
89}
90
91impl GreetingViaJs {
92    /// Create a new `GreetingViaJs`, which will internally create a new JS
93    /// `Greeting`.
94    pub fn new(who: &str) -> GreetingViaJs {
95        let js = JsRender::new(Greeting::new(who));
96        GreetingViaJs { js }
97    }
98}
99
100/// And finally the `Render` implementation! This adds a `<p>` element and some
101/// text around whatever the inner JS `Greeting` component renders.
102impl<'a> Render<'a> for GreetingViaJs {
103    fn render(&self, cx: &mut RenderContext<'a>) -> Node<'a> {
104        use dodrio::builder::*;
105        p(&cx)
106            .children([
107                text("JavaScript says: "),
108                self.js.render(cx),
109            ])
110            .finish()
111    }
112}
113```
114
115 */
116#![deny(missing_docs, missing_debug_implementations)]
117
118use dodrio::{builder, bumpalo, Node, Render, RenderContext};
119use js_sys::{Object, Promise, Reflect};
120use wasm_bindgen::prelude::*;
121use wasm_bindgen::JsCast;
122
123#[wasm_bindgen]
124extern "C" {
125    /// A rendering component implemented in JavaScript.
126    ///
127    /// The rendering API is a bit duck-typed: any JS object with a `render`
128    /// method that returns a virtual DOM as JS values with the right shape
129    /// works.
130    ///
131    /// See `JsRender::new` for converting existing JS objects into `JsRender`s.
132    #[derive(Debug, Clone)]
133    pub type JsRender;
134    #[wasm_bindgen(structural, method)]
135    fn render(this: &JsRender) -> JsValue;
136
137    #[wasm_bindgen(extends = Object)]
138    #[derive(Debug, Clone)]
139    type Element;
140    #[wasm_bindgen(structural, getter, method, js_name = tagName)]
141    fn tag_name(this: &Element) -> String;
142    #[wasm_bindgen(structural, getter, method)]
143    fn listeners(this: &Element) -> js_sys::Array;
144    #[wasm_bindgen(structural, getter, method)]
145    fn attributes(this: &Element) -> js_sys::Array;
146    #[wasm_bindgen(structural, getter, method)]
147    fn children(this: &Element) -> js_sys::Array;
148
149    #[wasm_bindgen(extends = Object)]
150    #[derive(Debug, Clone)]
151    type Listener;
152    #[wasm_bindgen(structural, getter, method)]
153    fn on(this: &Listener) -> String;
154    #[wasm_bindgen(structural, getter, method)]
155    fn callback(this: &Listener) -> js_sys::Function;
156
157    #[wasm_bindgen(extends = Object)]
158    #[derive(Debug, Clone)]
159    type Attribute;
160    #[wasm_bindgen(structural, getter, method)]
161    fn name(this: &Attribute) -> String;
162    #[wasm_bindgen(structural, getter, method)]
163    fn value(this: &Attribute) -> String;
164}
165
166/// A weak handle to a virtual DOM.
167///
168/// This is essentially the same as `dodrio::VdomWeak`, but exposed to
169/// JavaScript.
170#[wasm_bindgen]
171#[derive(Clone, Debug)]
172pub struct VdomWeak {
173    inner: dodrio::VdomWeak,
174}
175
176impl VdomWeak {
177    fn new(inner: dodrio::VdomWeak) -> VdomWeak {
178        VdomWeak { inner }
179    }
180}
181
182#[wasm_bindgen]
183impl VdomWeak {
184    /// Schedule re-rendering of the virtual DOM. A promise is returned that is
185    /// resolved after the rendering has happened.
186    pub fn render(&self) -> Promise {
187        let future = self.inner.render();
188
189        wasm_bindgen_futures::future_to_promise(async move {
190            if let Err(e) = future.await {
191                let msg = e.to_string();
192                Err(js_sys::Error::new(&msg).into())
193            } else {
194                Ok(JsValue::null())
195            }
196        })
197    }
198}
199
200impl JsRender {
201    /// Convert a `js_sys::Object` into a `JsRender`.
202    ///
203    /// The given object must have a `render` method that conforms to the
204    /// duck-typed virtual DOM interface which is described in the crate-level
205    /// documentation.
206    pub fn new<O>(object: O) -> JsRender
207    where
208        O: Into<Object>,
209    {
210        let object = object.into();
211        debug_assert!(
212            has_property(&object, "render"),
213            "JS rendering components must have a `render` method"
214        );
215        object.unchecked_into::<JsRender>()
216    }
217}
218
219impl<'a> Render<'a> for JsRender {
220    fn render(&self, cx: &mut RenderContext<'a>) -> Node<'a> {
221        create(cx, self.render())
222    }
223}
224
225fn has_property(obj: &Object, property: &str) -> bool {
226    Reflect::has(obj, &property.into()).unwrap_or_default()
227}
228
229fn create<'a>(cx: &mut RenderContext<'a>, val: JsValue) -> Node<'a> {
230    if let Some(txt) = val.as_string() {
231        let text = bumpalo::collections::String::from_str_in(&txt, cx.bump);
232        return builder::text(text.into_bump_str());
233    }
234
235    let elem = val.unchecked_into::<Element>();
236    debug_assert!(
237        elem.is_instance_of::<Object>(),
238        "JS render methods should only return strings for text nodes or objects for elements"
239    );
240    debug_assert!(
241        has_property(&elem, "tagName"),
242        "element objects returned by JS render methods must have a `tagName` property"
243    );
244
245    let tag_name = elem.tag_name();
246    let tag_name = bumpalo::collections::String::from_str_in(&tag_name, cx.bump);
247
248    builder::ElementBuilder::new(cx.bump, tag_name.into_bump_str())
249        .listeners({
250            let mut listeners =
251                bumpalo::collections::Vec::new_in(cx.bump);
252            if has_property(&elem, "listeners") {
253                let js_listeners = elem.listeners();
254                listeners.reserve(js_listeners.length() as usize);
255                js_listeners.for_each(&mut |listener, _index, _array| {
256                    let listener = listener.unchecked_into::<Listener>();
257                    debug_assert!(
258                        listener.is_instance_of::<Object>(),
259                        "listeners returned by JS render methods must be objects"
260                    );
261                    debug_assert!(
262                        has_property(&listener, "on"),
263                        "listener objects returned by JS render methods must have an `on` property"
264                    );
265                    debug_assert!(
266                        has_property(&listener, "callback"),
267                        "listener objects returned by JS render methods must have an `callback` property"
268                    );
269                    let on = listener.on();
270                    let on = bumpalo::collections::String::from_str_in(&on, cx.bump);
271                    let callback = listener.callback();
272                    let elem = elem.clone();
273                    listeners.push(builder::on(cx.bump, on.into_bump_str(), move |_root, vdom, event| {
274                        let vdom = VdomWeak::new(vdom);
275                        let vdom: JsValue = vdom.into();
276                        if let Err(e) = callback.call2(&elem, &vdom, &event) {
277                            wasm_bindgen::throw_val(e);
278                        }
279                    }));
280                });
281            }
282            listeners
283        })
284        .attributes({
285            let mut attributes = bumpalo::collections::Vec::new_in(cx.bump);
286            if has_property(&elem, "attributes") {
287                let js_attributes = elem.attributes();
288                attributes.reserve(js_attributes.length() as usize);
289                js_attributes.for_each(&mut |attribute, _index, _array| {
290                    let attribute = attribute.unchecked_into::<Attribute>();
291                    debug_assert!(
292                        attribute.is_instance_of::<Object>(),
293                        "attributes returned by JS render methods must be objects"
294                    );
295                    debug_assert!(
296                        has_property(&attribute, "name"),
297                        "attribute objects returned by JS render methods must have a `name` property"
298                    );
299                    debug_assert!(
300                        has_property(&attribute, "value"),
301                        "attribute objects returned by JS render methods must have a `value` property"
302                    );
303                    let name = attribute.name();
304                    let name = bumpalo::collections::String::from_str_in(&name, cx.bump);
305                    let value = attribute.value();
306                    let value = bumpalo::collections::String::from_str_in(&value, cx.bump);
307                    attributes.push(builder::attr(name.into_bump_str(), value.into_bump_str()));
308                });
309            }
310            attributes
311        })
312        .children({
313            let mut children = bumpalo::collections::Vec::new_in(cx.bump);
314            if has_property(&elem, "children") {
315                let js_children = elem.children();
316                children.reserve(js_children.length() as usize);
317                js_children.for_each(&mut |child, _index, _array| {
318                    children.push(create(cx, child));
319                });
320            }
321            children
322        })
323        .finish()
324}