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

This is the third 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 we will migrate is presented there; it is a module that creates tabbed panels and lets them be assigned into containers.

On the second part there is a first attempt on migration and discussion of the problems it faces.

Now, we get the final form of that code.

Mutatis Mutandis

The final code is more complex than the original. And granted, it was much harder to write.

That's a normal experience on porting code from high-level languages into Rust. While JavaScript is very easy and fast to write, but extremely difficult to change correctly, Rust is very hard to write, but easy to keep correct even after radical changes.

And this experiment was no exception. It gets even harder to write because of the heavy use of an FFI, but it was changed many times during its creation, and maintaining correctness wasn't a problem at all.

Overall, I am satisfied with the result.

Anyway, let's get to the code.

Cargo.toml

We keep the same dependencies from part 2:

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

[lib]
crate-type = ["cdylib"]

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

[features]
default = ["console_error_panic_hook"]

[dependencies]
wasm-bindgen = "0.2.79"
js-sys = "0.3.56"
web-sys = {version = "0.3.56", features=[
        "Element", "HtmlElement", "Document", "DomStringMap",
        "HtmlCollection", "DomTokenList", "EventListener",
        "MouseEvent", "EventTarget", "console", "HtmlSpanElement",
        ]}

# 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"

src/lib.rs

And remove the "hello world" functionality:

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;

src/tabs.rs

Now, the kernel of the experiment.

We will need stuff:

use std;
use std::rc::Rc;
use std::cell::{RefCell, RefMut};
use std::collections::{HashMap};
use wasm_bindgen::{JsValue, JsCast};
use web_sys::{HtmlElement, Event};
use web_sys::console;
use js_sys::{Array, Function};
use wasm_bindgen::prelude::*;

And will also need our custom JavaScript implementations:

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

We need that global tab registry:

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

/// Uses a tab from the registry
fn with_tab_mut<A, F>(tab_name: &str, f: F) -> Result<A, JsValue>
    where F: FnOnce(RefMut<Tab>) -> Result<A, JsValue> {
    TAB_REGISTRY.with(|all_tabs_ro| {
        let mut all_tabs: RefMut<_> = all_tabs_ro.try_borrow_mut()
            .map_err(|_| concat!(
                "Value is in use ",
                "- this is a bug, not a normal error condition"))?;
        // Get the tab object:
        let tab_ro = all_tabs.get_mut(&tab_name.to_string())
            .ok_or(format!("Tab named {} not found", tab_name))?;
        let tab = tab_ro.try_borrow_mut()
            .map_err(|_| concat!(
                "Value is in use ",
                "- this is a bug, not a normal error condition"))?;
        f(tab)
    })
}

We also nee that static event handler:

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! (
    /// Event handler for user initiated tab selection
    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)>);
);

Also, let's make an empty struct for encapsulating the tab manager functions inside a namespace. It is slightly harder to create real namespaces on the JavaScript interface, and this won't matter at all after the entire front-end is migrated to Rust.

This is not very different from what a global tab manager would look like in JavaScript. There isn't much added complexity on this part of the final code.

/// The class of the selected tabs
const DISP: &str = "display";

/**
Global API for managing tabs
*/
#[wasm_bindgen]
struct Tabs {}

#[wasm_bindgen]
impl Tabs {
    /**
    Creates a new tab, without assigning it.
    */
    pub fn add(tab_name: &str, panel: HtmlElement) -> Result<(), JsValue> {
        TAB_REGISTRY.with(|all_tabs_ro| {
            let mut all_tabs: RefMut<_> = all_tabs_ro.try_borrow_mut()
                .map_err(|_| concat!(
                    "Value is in use ",
                    "- this is a bug, not a normal error condition"))?;
            let t = Tab::new(tab_name, panel)?;
            all_tabs.insert(tab_name.to_string(), Rc::new(RefCell::new(t)));
            Ok(())
        })
    }

    /**
    Assigns a tab into a container pannel.
    
    The container must have the property "data-tabsid"
    with the id of its tab panel.
    */
    pub fn assign(tab_name: &str, container: HtmlElement) -> Result<(), JsValue> {
        // Notice that the entire implementation moved here
	    with_tab_mut(tab_name, |tab| {
            // Inserts the element
            let tabsid = get_data(&container, "tabsid");
            let tabs = html_by_id(&tabsid);
            container.append_with_node_1(&tab.panel)?;
            tabs.append_with_node_1(&tab.tab)?;
            
            // 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 {
                tab.tab.class_list().add_1(DISP)?;
                tab.panel.class_list().add_1(DISP)?;
            }
            
            // Selection handling
            SELECT_TAB_HANDLER.with(
                |h| set_onclick(&tab.tab, h.as_ref().unchecked_ref())
            )?;
            
            Ok(())
        })
    }

	/// Selects a tab on its container
    pub fn select(tab_name: &str) -> Result<(), JsValue> {
        with_tab_mut(tab_name, |mut tab| {
            tab.select()?;
            Ok(())
        })
    }
	
	/// Selects the previously selected tab from the container
    pub fn unselect(tab_name: &str) -> Result<(), JsValue> {
        with_tab_mut(tab_name, |mut tab| {
            tab.unselect()?;
            Ok(())
        })
    }
}

And finally, we implement what is left of the Tab class, making sure never to call the tab manager or use the registry from here. Again, there isn't much added complexity here either:

/**
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,
    last_focus: Option<String>,
}

impl Tab{
    pub fn new(tab_name: &str, panel: HtmlElement) -> Result<Tab, JsValue> {
        let tab = create_html("span")?;
        tab.set_text_content(Some(tab_name));
        set_data(&tab, "tabname", tab_name)?;
        panel.dataset().set("tabname", tab_name)?;
        Ok(Tab{
            tab: tab,
            panel: panel,
            last_focus: None
        })
    }
    
    /// Selects this tab as the visible one on the container
    pub fn select(&mut self) -> Result<(), JsValue> {
        let tabparent = self.tab.parent_element()
            .ok_or("Tab element does not have a parent")?;

        for e in Array::from(&tabparent.children()).iter() {
            clear_class(&e, DISP)?;
        }
        let panelparent = self.panel.parent_element()
            .ok_or("Tab panel element does not have a parent")?;
        for e in Array::from(&panelparent.children()).iter() {
            clear_class(&e, DISP)?;
        }
        set_class(&self.tab, DISP)?;
        set_class(&self.panel, DISP)?;
        self.last_focus = None;
        Ok(())
    }

    /// Goes back one step on the focus stack
    pub fn unselect(&mut self) -> Result<(), JsValue> {
        match &self.last_focus {
            None => (),
            Some(last) => {
                let tabparent = self.tab.parent_element()
                    .ok_or("Tab element does not have a parent")?;
                for e in Array::from(&tabparent.children()).iter() {
                    match e.dyn_ref::<HtmlElement>() {
                        None => (),
                        Some(he) =>
                            if he.dataset().get("tabname") == Some(last.to_string()) {
                                he.class_list().add_1(DISP)?;
                            } else {
                                he.class_list().remove_1(DISP)?;
                            }
                    }
                }
                let panelparent = self.panel.parent_element()
                    .ok_or("Tab panel element does not have a parent")?;
                for e in Array::from(&panelparent.children()).iter() {
                    match e.dyn_ref::<HtmlElement>() {
                        None => (),
                        Some(he) =>
                            if he.dataset().get("tabname") == Some(last.to_string()) {
                                he.class_list().add_1(DISP)?;
                            } else {
                                he.class_list().remove_1(DISP)?;
                            }
                    }
                }
            }
        }
        self.last_focus = None;
        Ok(())
    }
}

Using it all

After compiling:

$ wasm-pack build --target web

We can link the objects on the main project:

$ ln -s ../designer/pkg/designer.js
$ ln -s ../designer/pkg/designer_bg.wasm

And serve our front-end locally:

$ python3 -m http.server

On the original JavaScript module, we need some alterations because of the new tabs API. In particular, we need to import and initialize the wasm:

import designer_init, {Tabs} from "./designer.js";

// lots of local code...

window.onload = async () => {
    let designer = await designer_init();
	// ... the rest of the frontend goes here
}

And for each tab created this way:

toolsTab = new Tab('Tools', toolsElement);
toolsTab.assignContainer(document.querySelector("#right.tabspanel"));

We must now do it this way:

Tabs.add('Tools', toolsElement);
Tabs.assign('Tools', document.querySelector("#right.tabspanel"));

Similarly, for tabs selected this way:

detailsTab.select();

We must do it this way:

Tabs.select('Details');

Internationalization will become an issue some day, but it can be corrected on the global tab manager without changing the API.

And issues like those can be fixed at all, now that changing the code it easy.

The interface change did introduce bugs on the JavaScript front-end. I am aware of at least one functionality that should be broken, but wasn't detected by my tests. But the very good news is that I don't care about it anymore!