The Designer is a database designing tool that is currently under development.
It's intended to integrate well with git and a git-based workflow, while also being able to replace the database access applications during development, allowing for the management of the data and schema migration right from the same place.
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.