Architecture Experiment: Moving a front-end to Rust and WASM - Part 2

This is the second part of a multi-page article on an experiment to decide if I can migrate the designer front-end into rust and wasm.

On the first part, there is the motivation and also the creation of a hello world project. Also, the code I will migrate is presented there; it is a module that creates tabbed panels and lets them be assigned into containers.

On the third part there are the conclusion and the final code.

Now, let's migrate that module.

Tabs implementation

Let's create the src/tabs.rs file with the tabs interface only (for now, I will use String as the type of everything that is a DOM element):

struct Tab {
    tab: String, // This will be a DOM element
    panel: String, // Also a DOM element
    tab_name: String,
    last_focus: Option<String> // Focus history
}

impl Tab{
    // Those things will return error as soon as
    // I get what the error should be.
    // For now, let's pretend they can't fail.
    
    fn new(tab_name: String, panel: String) -> Tab {
        Tab{
            tab: "".to_string(), panel: "".to_string(),
            tab_name: tab_name, last_focus: None
        }
    }

    /// Assigns the tab to that panel
    fn assign_container(&self, container: String) {
    }

    /// Selects this tab as the visible one on the container
    fn select(&self) {
    }

    /// Goes back one step on the selection stack
    fn unselect(&self) {
    }
}

The HtmlElement interface must be imported from JavaScript, just as all the functions and objects used within the code, like the global document. Those can be imported manually, as needed, but there exist two crates: js-sys and web-sys that already bring all of common JavaScript API. Let's use them (in Cargo.toml):

[package]
name = "designer"
version = "0.1.0"
edition = "2021"

[lib]
# I won't use the rlib type
crate-type = ["cdylib"]

[build]
target = "wasm32-unknown-unknown"

[features]
default = ["console_error_panic_hook"]

[dependencies]
[dependencies]
# Updated the versions
wasm-bindgen = "0.2.79"
js-sys = "0.3.56"
# One must point all the needed features, they are on the
# library's documentation, and you know you need  one when 
# strange "no method named ... found" errors appear for
# things exported by that crate.
web-sys = {version = "0.3.56", features=[
        "Element", "HtmlElement", "Document", "DomStringMap",
        "HtmlCollection", "DomTokenList", "EventListener",
        "MouseEvent", "EventTarget", "console",
        ]}

# Remove before deployment:
console_error_panic_hook = { version = "0.1.6", optional = true }

# Small but slow memory allocator:
wee_alloc = { version = "0.4.5", optional = true }

[dev-dependencies]
wasm-bindgen-test = "0.3.13"

[profile.release]
# Tell `rustc` to optimize for small code size.
opt-level = "s"

And now I can use the relevant types:

use wasm_bindgen::JsValue;
use web_sys::{HtmlElement, Document};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;

/**
 * A panel with a companion tab that you can assign
 * into tabbed containers, identified by having the
 * class="tabspanel" and data-tabsid="centerTabs"
 * properties.
 */
struct Tab {
    tab: HtmlElement,
    panel: HtmlElement,
    tab_name: String,
    last_focus: Option<String> // Focus history
}

impl Tab{
    fn new(tab_name: String, panel: HtmlElement) -> Result<Tab, JsValue> {
        // Beware the quotation marks, the error messages for
        // missing them are not great.
        let doc = Document::new()?;
        let tab = doc.create_element("span")?;
        // The thing you create with document.createElement
        // on javascript isn't garanteed to be an HtmlElement!
        let htab = tab.dyn_into::<HtmlElement>()?;
        Ok(Tab{
            tab: htab, panel: panel,
            tab_name: tab_name, last_focus: None
        })
    }

    /// Assigns the tab to that panel
    fn assign_container(&self, container: HtmlElement) -> Result<(), JsValue> {
        Ok(())
    }

    /// Selects this tab as the visible one on the container
    fn select(&self) -> Result<(), JsValue> {
        Ok(())
    }

    /// Goes back one step on the focus stack
    fn unselect(&self) -> Result<(), JsValue> {
        Ok(())
    }
}

There was no simple element I could use to stub the tab value on the creator, so I had to implement this part.

Notice that JavaScript is a dynamic language, and the DOM makes use of this. In particular, that document.createElement call can in theory return many different types of element, not only HTML. That's the kind of problem this experiment is intended to catch, and admittedly, that dynamic conversion function wasn't easy to find.

That code is ugly non-idiomatic, but by the documentation, it's the way to actually use that result. But that kind of code can be just encapsulated away.

Anyway, since it's an easy to encapsulate and solved problem, it's not a deal-breaker. Let's move on.

Finally let's export those stubs so they can be used from a web page. For that, there is the #[wasm_bindgen] macro:

use std::vec::Vec;
use wasm_bindgen::JsValue;
use web_sys::{HtmlElement, Document};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
struct Tab {
    tab: HtmlElement,
    panel: HtmlElement,
    tab_name: String,
    last_focus: Vec<String> // Focus history
}

#[wasm_bindgen]
impl Tab{
    #[wasm_bindgen(constructor)]
    fn new(tab_name: String, panel: HtmlElement) -> Result<Tab, JsValue> {
        let doc = Document::new()?;
        let tab = doc.create_element("span")?;
        let htab = tab.dyn_into::<HtmlElement>()?;
        Ok(Tab{tab: htab, panel: panel, tab_name: tab_name, last_focus: Vec::new()})
    }

    /// Assigns the tab to that panel
    fn assign_container(&self, container: HtmlElement) -> Result<(), JsValue> {
        Ok(())
    }

    /// Selects this tab as the visible one on the container
    fn select(&self) -> Result<(), JsValue> {
        Ok(())
    }

    /// Goes back one step on the focus stack
    fn unselect(&self) -> Result<(), JsValue> {
        Ok(())
    }
}

That macro has many options you can find on the crate's documentation. Here I used that constructor one.

Also, I must reexport the module as public on src/lib.rs (let's keep all that hello world stuff for a while longer):

mod utils;

use wasm_bindgen::prelude::*;

// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

pub mod tabs;

#[wasm_bindgen]
extern {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, {{project-name}}!");
}

Compiling and packaging (with wasm-pack like done on part 1), when one inspects the loading script (designer.js), there is a new class there, named Tab. We can now use it in the main JavaScript module (runwasm.js):

import init_designer, {Tab} from "./designer.js";

let designer = await init_designer();
let t = new Tab("body", document.body);
designer.greet();

Now it looks like I have every piece needed to implement the entire class in rust. Let's try a direct translation.

The constructor was almost complete already:

    fn new(tab_name: &str, panel: HtmlElement) -> Result<Tab, JsValue> {
        // Beware the quotation marks, the error messages for
        // missing them are not great.
        let doc = Document::new()?;
        let tab = doc.create_element("span")?;
        // The thing you create with document.createElement
        // on javascript isn't garanteed to be an HtmlElement!
        let htab = tab.dyn_into::<HtmlElement>()?;
        htab.set_inner_text(tab_name);
        htab.dataset().set("tabname", tab_name);
        panel.dataset().set("tabname", tab_name);
        Ok(Tab{
            tab: htab, panel: panel,
            tab_name: tab_name.to_string(), last_focus: None
        })
    }

Assigning to a container starts easily enough:

    /// Assigns the tab to that panel
    fn assign_container(&self, container: HtmlElement) -> Result<(), JsValue> {
        // Inserts the element
        let doc = Document::new()?;
        let tabsid = container.dataset().
            get("tabsid").ok_or("data-tabsid not found")?;
        let tabs = doc.get_element_by_id(&tabsid[..]).
            ok_or(format!("Tabs container of id {} not found", tabsid))?;
        container.append_with_node_1(&self.panel)?;

But quite soon I have to iterate over HTML elements, and the lifetime analysis conflicts with the dynamic conversion I did earlier. It's not a large problem, since there are all kinds of conversion functions available.

It also didn't save converting the collection into an array for iterating over it. That is something I will have to write in Rust later. Anyway, the goal is to remove as much code as possible from the JavaScript interpreter, so those cases will get rarer once I refactor the code after the migration.

        // Selects a tab if none is selected
        // This is much worse than the javascript original.
        let selected = Array::from(&tabs.children()).iter().filter(
            |e| {
                match e.dyn_ref::<HtmlElement>() {
                    None => false,
                    Some(he) => he.class_list().contains(DISP),
                }
            }).count();
        if selected == 0 {
            self.tab.class_list().add_1(DISP);
            self.panel.class_list().add_1(DISP);
        }

And now, things get ugly

Next comes the really challenging part: assigning event handlers.

The DOM expects JavaScript functions as event handlers. That means whatever function I use I need to import it into JavaScript first, and then assign it in Rust.

You won't see how to do that on the tutorial, or the wasm book. There is also no direct pointer on the documentation for how to do it. (And to be honest, the Function structure of js-sys is so confusing that at one point I was ready to declare this experiment a failure.)

But the wasm_bindgen crate has a quite complete guide. There is a section there about closures, that has the correct concept to use here.

Well, I just can't explain how to use it better than the example on the guide. Let's just follow on.

The next problem is that, obviously, that function must now live on the JavaScript interpreter until it gets garbage-collected. That is a different lifetime than the Tab object. On JavaScript, this doesn't look like a problem (it can lead to bugs in theory; in practice, it doesn't), but on Rust, it is.

And it gets worse. Currently there is no way to create such JavaScript closures from Rust without leaking some memory. Every detail on wasm_bindgen is designed expecting static closures, what the one from the original script is not.

OK, that is bad. Really bad. That does make this translation not advisable on every case.

But specifically on the designer, there aren't many non-static event handlers. So it's viable to convert every one of them to use only global values.

The problem then becomes that Rust has quite bad support for global values...

I need something like this:

static mut tab_registry: HashMap<String, Tab> = HashMap::new();

Except that this doesn't work. One can not create static mutable values.

The global thread-safe primitives also fail to solve the problem. It's not obvious, but their values can not be accessed by the DOM.

There exist thread local values, that have roughly the necessary format (JavaScript couldn't run in more than one thread at all until recently, and the DOM is still completely thread local). But those values must also be read-only.

The solution is contrived, but there is functionality on the standard library for it.

use std;
use std::rc::Rc;
use std::cell::{RefCell, RefMut};

thread_local! (
    static TAB_REGISTRY: Rc<RefCell<HashMap<String, Rc<RefCell<Tab>>>>>
        = Rc::new(RefCell::new(HashMap::new()));
);

This creates a new modality of run-time errors, that happen if some code that uses the registry calls other code that also tries to use it. So, this solution imposes an specific architecture for the front-end, where there is a global tab manager and only it can access the registry.

That is not a big deal here, because a global tab manager (and global managers for everything) is already on my TODO list. It's way down on my priorities, but it's something I will need.

The handler function, then gets defined this way:

fn select_tab_handler_fn(e: Event) -> Result<(), JsValue> {
    let targ_ev = e.target().ok_or("Tab selection event without a target")?;
    let targ = targ_ev.dyn_into::<HtmlElement>()?;
    let tab_name = targ.dataset().get("tabname")
        .ok_or("Tab element does not have a data-tabname property")?;
    with_tab_mut(&tab_name, |mut tab| {
        tab.select()?;
        tab.last_focus = None;
        Ok(())
    })
}
thread_local! (
    static SELECT_TAB_HANDLER: Closure<dyn Fn(Event)> = Closure::wrap(
        Box::new(move |e: Event| {
            match select_tab_handler_fn(e) {
                Ok(_) => (),
                Err(e) => console::error(&Array::of1(&e)),
            }
        }) as Box<dyn Fn(Event)>);
);

And get uglier

But that isn't the only problem with the translation.

In particular, the web-sys types are mangled in some way and don't represent exactly the JavaScript types they mean to. As a consequence, the dynamic type conversions are simply not reliable enough. That means that part of the web-sys API is not usable.

It looks like Rust generated wasm is very mature and well tested for the use-case of exporting fast functions from Rust into your JavaScript front-end. But it's not as mature for creating an entire front-end.

Anyway, the bindings themselves are quite mature. It's the imported API that is not. And the web-sys API is entirely optional, one can provide one's own instead.

But let's not be that extreme. A lot of web-sys works perfectly. I can replace just the parts that fail. Specifically on this module, the following is enough:

#[wasm_bindgen]
extern {
    #[wasm_bindgen(catch)]
    fn create_html(e: &str) -> Result<HtmlElement, JsValue>;
    #[wasm_bindgen]
    fn html_by_id(i: &str) -> HtmlElement;
    #[wasm_bindgen]
    fn has_class(e: &JsValue, class: &str) -> bool;
    #[wasm_bindgen(catch)]
    fn set_class(e: &JsValue, class: &str) -> Result<(), JsValue>;
    #[wasm_bindgen(catch)]
    fn clear_class(e: &JsValue, class: &str) -> Result<(), JsValue>;
    #[wasm_bindgen(catch)]
    fn set_data(e: &JsValue, key: &str, value: &str) -> Result<(), JsValue>;
    #[wasm_bindgen]
    fn get_data(e: &JsValue, key: &str) -> String;
    #[wasm_bindgen(catch)]
    fn set_onclick(e: &JsValue, on_click: &Function) -> Result<(), JsValue>;
}

The JavaScript implementation of those functions is trivial, and they are well defined enough to never change, so they are not a maintenance burden. They just have to be defined on the main JavaScript namespace (or you can use the namespace option or wasm_bindgen), so they can not be on a module.

Also, the correct mutability, adequate Option return values and specific parameter types all need some encapsulation and private code. As a consequence, they are better done in a separated module.

For the duration of this experiment, I will let those incorrect. I will fix them when I create an architecture for the entire front-end.

And that's it. Really. After fixing those issues, things work. Now you go to the conclusion, on the third part of this article, or you can go read the first part if you want to go back.