Smithy progress update: how I decreased WebAssembly bundle size by 90%

Introduction

In a previous post, I introduced Smithy, a web development framework written in Rust that compiles to WebAssembly. In the mean time, there has been substantial progress, and Smithy is on the verge of being ready for alpha use!

My goal for Smithy is to enable you to use idiomatic Rust to write front-end code.

Smithy is still in progress, but you can peek at the code at: https://github.com/rbalicki2/smithy, https://github.com/rbalicki2/jsx_compiler and https://github.com/rbalicki2/wasm_website_frontend.
You can see a deployed example of a Smithy app here! https://todolist.robertbalicki.com

In this post, I want to describe the improvements that have been made, and what’s on the Smithy roadmap!

What is Smithy?

Smithy is a framework that allows you to write the following code, and have it render in the browser. For example:

struct AppState {
pub click_count: i32,
};
type Props = ();
impl<'a> Component<'a, Props> for AppState {
fn render(&'a mut self, _props: Props) -> HtmlToken<'a> {
let click_count = self.click_count;
jsx!(
<div
on_click={Box::new(move |_| {
self.click_count = self.click_count + 1
})}
>
I have been clicked {click_count} times.
</div>
)
}
}
let component = AppState { click_count: 0 };
smithy::mount("app", Box::new(component));

Improvements

Decreased bundle size by 90%

As of writing the previous blog post, I had not done any optimization of the wasm binary. Thus, loading the todo list app came in at an embarrassing 1.4mb! Yikes!

After adding some very limited optimization, the total payload of the website went down to 142kb, or a roughly 90% decrease.

“What kind of shitty code were you writing to begin with?”
— actual quote from my girlfriend, upon hearing about the size reduction

It’s not that I did anything special, only that, by default, Rust emits a lot of unnecessary code when compiling to WebAssembly. LLVM settings and wasm-opt can remove a lot of that code.

I’m sure the bundle size can improve dramatically on top of this. I haven’t succeeding in enabling link-time optimization when building. And, the wasm-bindgen guide lists several other pitfalls that can substantially increase bundle size.

What specific code did I use to decrease the wasm bundle size?

The tools I used are binaryen's wasmopt (source code here) and passing some flags to cargo.

First, in my Cargo.toml, I added these lines. (A better version of this would not require updating the Cargo.toml file.)

[profile.dev]
debug = false
opt-level = 'z'
# I could not get LTO to work, but uncommenting the following line
# should also improve things:
# lto = true

In my build.sh script, the prod build did the following:

# Note that binaryen is installed in ../binaryen
PROJECT=wasm_website_frontend
cargo +nightly build --target wasm32-unknown-unknown \
&& wasm-bindgen target/wasm32-unknown-unknown/debug/$PROJECT.wasm \
--out-dir ./dist \
# use wasm-opt to drop unused webassembly code in-place
&& ../binaryen/bin/wasm-opt -Oz -o \
&& dist/$PROJECT.wasm \
dist/$PROJECT_bg.wasm

Note: Setting lto = true or using the profile.release profile in my Cargo.toml both caused my build to fail. Those might work for you, and I would recommend doing the following. First, add this to your Cargo.toml:

[profile.release]
debug = false
opt-level = 'z'
lto = true

And execute this code in your build script:

PROJECT=wasm_website_frontend
cargo +nightly build --release --target wasm32-unknown-unknown \
&& wasm-bindgen target/wasm32-unknown-unknown/release/$PROJECT.wasm \
--out-dir ./dist \
# use wasm-opt to drop unused webassembly code in-place
&& ../binaryen/bin/wasm-opt -Oz -o \
&& dist/$PROJECT.wasm \
dist/$PROJECT_bg.wasm

Good luck!

Moved all Smithy code into Rust

Previously, Smithy had a companion Javascript file that interacted with the DOM. The author of the web app had to separately include this file at a specific location. This is essentially akin to, in a React app, requiring the user to do:

import React from 'react';
// The following can't come from node_modules.
// It must be actually present in your project:
import reactInternals from './react-internals';

However, in the mean time, the wonderful js-sys and web-sys crates have come out. These provide bindings to Javascript and browser APIs, and have allowed me to move all of that code into Rust.

In addition, this allowed me to work directly with Javascript event objects, instead of rolling my own wrappers for MouseEvent, etc. This also lets me avoid serializing everything into JSON and back!

There are still a few browser APIs that Smithy uses wasm-bindgen to manually bind to, but I am confident that my use cases will quickly be handled by web-sys!

Blockers to alpha

There are a few minor things that must be completed before Smithy is ready for alpha use.

Fully featured

Smithy is not fully featured yet. For now, only onclick, oninput and onkeydown event handlers are implemented, because implementing more requires copy-and-pasting large functions.

TODO: write a macro for connecting an event handler.

Reliance on a fork of proc-macro2

jsx_compiler (a dependency of Smithy) currently depends on a fork of proc-macro2. This version is essentially proc-macro2 compiled with the procmacro2_semver_exempt flag set. It does not seem to be possible to pass this flag in the current build process.

This is to enable the span.start() and span.end() methods, which jsx_compiler uses to determine whether the contents of an invocation of the jsx! macro is split across multiple lines, and to smartly add an extra space when necessary.

TODO: figure out how to pass this flag (see this issue) in the current build process, modify the build process to allow passing the flag, accept forking proc-macro2 or wait until this functionality makes its way into proc-macro2 or proc-macro and is not hidden behind a flag.

The browser does not always render what you want. Is that safe?

There are numerous calls to unwrap in Smithy. These would result in a panic if the DOM was previously modified directly, unbeknownst to Smithy. However, they might also result in a panic if the html that we attempt to render is modified by the browser (such as if we placed a td directly inside of a table, the browser will insert intermediate tbody and tr elements).

This could potentially lead to panics at runtime.

TODO: Explore this, ideally without any code inside of Smithy that knows about the specifics of how the DOM works.

set_value vs set_attribute

Currently, there is special handling for the value attribute to enable binding in <input /> elements. Smithy calls element.set_value for updating the value attribute, but element.set_attribute for all other cases.

Smithy should not care about such details! This should be handled at least one level above Smithy.

TODO: Find a way to handle this in a way that doesn’t involve a special case in Smithy.

Improvement roadmap

There are a number of improvements that I want to implement, but which aren’t blocking an alpha release.

Fewer unwraps

Smithy is in an early state. There are many places where we call unwrap.

TODO: handle these explicitly.

Fewer unsafe calls to std::mem::transmute

A DOM object can implement many interfaces. For an example, event.target can implement the EventTarget, HtmlElement, Element and Node APIs (among many more). To express this in Rust, one often has to do code like:

let my_node: Node = get_node();
let parent_opt: Option<Node> = my_node.parent_node();
let my_element: Element = unsafe {
std::mem::transmute::<Node, Element>(my_node)
};
let inner_html = my_element.inner_html();

There is a discussion and an RFC in wasm-bindgenabout how to handle this more ergonomically. I’m certain that once this is merged, a lot of Smithy code will be cleaned up!

TODO: await this to land in wasm-bindgen.

Build process

Currently, the build process is somewhat hacky. I’m sure I will come up with a better solution!

TODO: fix this.

Other notes / wishlist

Tree operations are difficult!

Operating on trees, such as diffing two HtmlToken's, finding the path from one to another, etc. are not simple problems, and are hard to get exactly right.

For example, the jsx_compiler crate has a home-grown, rudimentary diffing algorithm that allows us to not replace the entire DOM tree whenever a subset is modified. A path has the type Vec<usize>. Unfortunately, this means there's no type-level guarantee that the path can actually be followed through the DOM! In addition, even if the path is valid on creation, there is no guarantee that the DOM hasn't been modified in the mean time!

(This article discusses decoupling the form of the function from the mechanism of recursion, and I have a hunch that could simplify many things related to tree manipulation.)

Better closures

Currently, when multiple callbacks both modify the app state, one must wrap self in let cell = Rc::new(RefCell::new(self)), and call .clone() on that repeatedly. Then, in these callbacks, one calls let mut self_cell = cell.borrow_mut();. There are runtime checks that cause .borrow_mut() to panic if self is already borrowed (e.g. if both closures are executed).

But of course, we know that only one of these closures will ever be called!

While we may not be able to get rid of the runtime check, we should be able to find a way to make the developer’s experience much more ergonomic.

Conclusion

Smithy is coming closer and closer to an alpha release!