Rolling your own Router with Smithy v0.0.3
I’m extremely excited to announce the release of Smithy v0.0.3
. This release is a huge usability improvement due to a new feature: smd_no_move!
This procedural macro is like smd!
, except it does not capture its variables. This makes it possible to nest calls to smd!
/smd_no_move!
, which previously precluded many tasks.
In this post, we’ll demonstrate how easy it is to build your own router, largely thanks to our new friend smd_no_move!
What is Smithy?
Smithy is a framework for writing WebAssembly applications entirely in Rust. Its goal is to allow you to do so using idiomatic Rust, without giving up any of the compiler’s safety guarantees.
Currently, Smithy works on the nightly compiler.
You can see the router that we build in this blog post at https://www.smithy.rs/examples/router. You can see the source code here: https://github.com/rbalicki2/smithy_example_router.
How to roll your own router
We want to create a WebAssembly app with a hash router, meaning an app that can reflect changes to its internal state in the browser hash, and can update its internal state in response to changes to the browser hash.
Initial setup
The simplest way to get started is to clone this repo and a helper repo, and install dependencies:
git clone git@github.com:rbalicki2/smithy_router_example.git
cd smithy_router_example
# we're checking out a starter branch.
# the complete code is on the master branch.
git checkout starter
cd ..
git clone git@github.com:rbalicki2/smithy_app_server.git
cd smithy_app_server
yarn
cargo install wasm-bindgen-cli
And in two terminals, in the smithy_app_server
folder, run:
TARGET=../smithy_router_example/ npm run serve
and
TARGET=../smithy_router_example/ npm run watch
Your (empty) app is available on localhost:8080
. Okay, let's get started!
App State
Like with most apps, the first thing we want to do is to define the states that your app can be in. In our case, we want an items
HashMap and a page
enum. (Smithy does not require or encourage these to be combined into a single struct.)
Let’s define the types that we want, and implement a method that returns items
in lib.rs
:
// lib.rs
use std::collections::HashMap;type ItemId = usize;enum Page {
Home,
ItemView(ItemId),
}struct Item {
pub text: String,
pub detail_text: String,
}fn get_items() -> HashMap<ItemId, Item> {
let mut items = HashMap::new();
items.insert(
0,
Item {
text: "Earth".to_string(),
detail_text: "Earth is the third planet from the Sun.".to_string(),
},
);
items.insert(
1,
Item {
text: "Mars".to_string(),
detail_text: "Mars is the fourth planet from the Sun.".to_string(),
},
);
items
}
Getting the hash
Okay, great! Now, we need to be able to create a page. For this, we will impl Default for Page
. But what should the default value of the Page
enum be? That will depend on the current state of the browser hash. Let's make our use of the web_sys
library and create a function that grabs the current hash.
The browser hash, or the “anchor part of a URL”, is what goes after the
#
in the URL.
Let’s create a new file, util.rs
, and add the following:
// util.rs
use web_sys::{
window,
Location,
};pub fn get_location() -> Location {
window().unwrap().location()
}pub fn get_current_hash() -> Option<String> {
get_location()
.hash()
.ok()
.map(|hash_with_hash|
hash_with_hash.chars().skip(1).collect::<String>()
)
}
(The get_location
function can panic if the resulting WebAssembly is run in a non-browser environment, such as in Node. Frankly, I don't give a damn.)
Note the similarities between this and the code we’d be writing in Javascript: window.location.hash
. You'll see this a lot when working with web_sys
! This makes it extremely easy to get proficient with this library :)
Adding methods to Page
Now, we want to implement some things on Page
. First, let's add some setters. We want the browser hash to update whenever we update the page.
// lib.rs
mod util;impl Page {
pub fn go_to_detail_view(&mut self, id: ItemId) {
*self = Page::ItemView(id);
let _ = util::get_location().set_hash(&id.to_string());
} pub fn go_home(&mut self) {
*self = Page::Home;
let _ = util::get_location().set_hash("");
}
}
set_hash
returns a Result<(), JsValue>
. The compiler will complain if we don't do anything with that Result
, so we assign the return value to _
and move on with our merry day. (If we were writing a more robust app, we really should care, though!)
We’re getting very close. Let’s add a method to Page
that will be invoked when we detect a change in the hash.
impl Page {
pub fn handle_hash_change(&mut self) {
// attempt to get an ItemId from the current hash
let id_opt = get_current_hash().and_then(|hash|
hash.parse::<ItemId>().ok()
);
match id_opt {
Some(id) => self.go_to_detail_view(id),
None => self.go_home(),
};
}
}
And now, we’re finally able to impl Default for Page
!
impl Default for Page {
fn default() -> Page {
let mut page = Page::Home;
page.handle_hash_change();
page
}
}
Great — now we have a page object that can respond to hash changes and change the hash as well. Let’s take a quick detour to test this out. Modify your start
function as follows:
#[wasm_bindgen]
pub fn start(root_element: web_sys::Element) {
let page = Page::default();
page.go_to_detail_view(123);
}
Now, if you navigate to localhost:8080
, your browser will redirect to localhost:8080#123
. Magic!
Introducing Smithy, pt. 1
Great. Now, let’s hook up smithy. Modify your start
function as follows:
use smithy::{smd, smd_no_move, mount};
#[wasm_bindgen]
pub fn start(root_element: web_sys::Element) {
let app = smd!(
on_hash_change={|_| {
web_sys::console::log_1(
&wasm_bindgen::JsValue::from_str("hash change detected")
);
}};
<div>hello, smithy!</div>
);
mount(app, root_element);
}
You should see hello, smithy!
displayed in your browser. (What other phrase would be appropriate for such a tutorial?) If you modify the hash in the browser (by navigating to localhost:8080#rustIsCool
), you should see "hash change detected"
logged to the console.
If you see the errors, make sure you’re passing all of the correct features to the smithy package in your
Cargo.toml
. If you followed the instructions in the initial setup section, that should all be done for you!
Introducing Smithy, pt. 2
Now, in our final act, let’s connect everything together. There’s a lot coming in this next code chunk, so we’ll spend some time understanding it afterward.
Modify your start
function as follows:
use smithy::{smd, smd_no_move, mount};
#[wasm_bindgen]
pub fn start(root_element: web_sys::Element) {
let mut page = Page::default();
let items = get_items(); let app = smd!(
on_hash_change={|_| app_state.page.handle_hash_change()};
{
match page {
Page::Home => smd_no_move!(
<h1>Planets</h1>
<ul>
{
let ref mut page = page;
let page = Rc::new(RefCell::new(page));
let items = items.iter().map(
|(id, item)| (id, item, page.clone())
).collect::<Vec<_>>();
&mut items.into_iter().map(|(id, item, page)| {
smd!(<li>
<a
on_click={|e: &MouseEvent| {
page.borrow_mut().go_to_detail_view(*id);
e.prevent_default();
}}
href
>
{ &item.text }
</a>
</li>)
}).collect::<Vec<SmithyComponent>>()
}
</ul>
),
Page::ItemView(id) => {
let ref mut page = page;
let item_opt = items.get(&id);
smd!(
{
match item_opt {
Some(item) => smd!(
<h1>{ &item.text }</h1>
<p>{ &item.detail_text }</p>
),
None => smd!(No item found),
}
}
<a
on_click={|e: &MouseEvent| {
page.go_home();
e.prevent_default();
}}
href
>
Back to list
</a>
)
},
}
}
)
}
That’s a lot! What did we just do?
First, we constructed our items
and page
. Next, we told Smithy that whenever the hash changes, it should execute a function which calls page.handle_hash_change()
.
And then, in the render phase, we match on the page
, and either render the home page or the detail view page.
Try it out
Try changing the hash in the URL, and see how the page responds.
smd!
vs. smd_no_move!
Please feel free to skip this section. It deals with an important concept of Smithy, but is not necessary if you’re only looking for a cursory understanding of how to roll your own router.
Both the smd!
and smd_no_move!
macros create SmithyComponent
s, which are wrappers around FnMut(Phase) -> PhaseResult + 'a>
. In the smd!
case, this closure is a move
closure; in the smd_no_move!
case, it is not. (This naming desperately needs improvement.)
Now, let’s say we have nested smd!
macros, and a variable (page
) defined outside of this. The inner smd!
macro can be called many times, and in each case, the page
variable will be moved into it. This does not work!
Try 2: if the outer macro is an smd_no_move!
macro, and it uses page
, then the closure will have a reference to page
after page
goes out of scope. The compiler will not let this happen!
Finally, if instead, you nest an smd_no_move!
macro within an smd!
macro, page
will be moved into the outer FnMut
, and be available for use in the inner smd_no_move!
macros. Everything is gucci!
TLDR: use an smd!
macro when you need to capture a variable for the first time, and smd_no_move!
macros within.
Conclusion
That’s it! That’s all it takes to roll your own router in Smithy. The final code can be viewed here. What I hope you conclude is that Smithy:
- allows you to build apps in a way that is intuitive and idiomatic,
- allows you to co-locate your mutators with your DOM, and
- allows you to maintain the protections that the compiler gives you in vanilla Rust.
Thank you for reading this article! I’d love to hear from you! Tweet at me @statisticsftw!