I want to propose a case study to what I coin "the art of complicating:" this website. To quote my test blog post:
This website uses Yew.rs as its frontend framework and warp.rs for its backend. The initial page load is server-side rendered and, if WebAssembly is supported, hydrated on the client to add reactivity.
Using a Rust-based WebAssembly framework for what essentially is a static website with a few reactive components is kind of akin to using a lawnmower to cut one's hair. That's a horrible comparison; what I'm trying to say is it's clunky and overall much harder to accomplish anything.
Let's focus on a "simple" component of this website: the counter to the left or top of this blog post!
Let's state some requirements. We want a:
The backend is quite simple for a volatile counter. Simply declare a global counter:
pub static GLOBAL_COUNTER: AtomicUsize = AtomicUsize::new(0);
and match a rate-limited /counter-increment
to return:
GLOBAL_COUNTER.fetch_add(1, core::sync::atomic::Ordering::Relaxed) + 1
On the frontend, it's quite simple to make a GET request to that endpoint.
// (error handling ommitted) let resp = gloo_net::http::Request::get("/count-increment").send().await.unwrap_throw(); let text = resp.text().await.unwrap_throw(); let count = text.parse::<usize>().unwrap_throw();
Once tied to a button press, we should be golden! Of course, we need to display the count somewhere... so let's set up a state for that.
// (in this example, pretend that the counter doesn't get reset on SPA page loads) let counter = use_state::<usize>(|| 0);
Hm. The counter is initialized to 0 on a fresh page load. The server knows the state of the counter -- and since the server has context for rendering the Yew app -- it should just send it to the counter in some sort of global state.
A really convenient crate, bounce.rs, exists to handle global state management.
With bounce, it's really easy to store the counter in an "atom." All we need is to initialize an Atom
counter element in any component, and it will be available in any other component -- updates and all. For an initial page load, let's just create a "dummy component" whose only purpose is to set the inital page state.
// wrapped in a struct to provide the necessary traits #[derive(Atom, Default, Deserialize, Serialize, PartialEq)] pub struct Counter(usize); impl Deref for Counter { type Target = usize; /* (ommitted) */ } impl Counter { fn new(count: usize) -> Self { /* (ommitted) */ } } #[derive(PartialEq, Properties)] struct PageStateSetterProps { count: usize, } #[function_component(InitialPageStateSetter)] fn initial_page_state_setter_component(props: &PageStateSetterProps) -> Html { // (memoization ommitted) let counter = use_atom::<Counter>(); counter.set(Counter::new(props.count)); // (ommitted) }
The server can then send this state to the root-level component:
#[derive(Properties, PartialEq, Default)] pub struct ServerAppProps { pub count: usize } #[function_component(ServerApp)] #[cfg(not(target_arch = "wasm32"))] pub fn ssr_app_component(props: &ServerAppProps) -> Html { html! { <BounceRoot> <InitialPageStateSetter count={props.count} /> // (page rendering ommitted) </BounceRoot> } }
And now this state is available everywhere! We can go back to our counter and replace our state with our atom:
let counter = use_atom::<Counter>();
...and we are now able to display it!
let counter: &Counter = &*counter; html! { <button>{*counter}</button> }
At least, everywhere in the SSR context.
With this implementation, there's no way to know the value during CSR hydration. Some (undocumented and unintentional) workarounds exist[^1] to preserve SSR state during CSR hydration without hooking into the hydration and rendering cycle exist.
Unfortunately, bounce's only method of passing state from the server to the client, use_prepared_query
, is stateless. There is no way -- bar using static
s in the rendering loop or sending a GET request to ourself -- to pass an external state into a prepared query and have the query cache-able. There also is much boilerplate and overhead for setting up and actually using a query.
How exactly does bounce handle this, though? Under the hood, bounce uses <script type="application/x-yew-comp-state">
s to store a text-serialized state in base64. The inner data of these script tags are then read, decoded, and deserialized upon the initial hydration cycle.
That's a bit of a pain. We can do something similar to store some state for our page, but without reading the innerText of a script tag. It'd be much easier to just store a global object with JavaScript and later retrieve that on the client side.
<!-- index.html --> <!-- ommitted --> <script> // remember: escape backticks window.ssr_state = `--sentinel-ssr-state--`; </script> <!-- ommitted -->
...wherein the first instance of --sentinel-ssr-state--
can be found-and-replaced[^2] and updated to a serialized SSR state object.
Let's create a struct to store some "SSR to CSR" state that would be saved here:
#[derive(Serialize, Deserialize)] pub struct SSRState { pub count: usize, }
We can serialize it for when we find-and-replace the sentinel value:
let resp: String = /* (ommitted) */; let ssr_state = SSRState { count: GLOBAL_COUNTER.fetch(core::sync::atomic::Ordering::Relaxed) }; let ssr_state_str: String = ron::to_string(&ssr_state).unwrap(); let resp = resp.as_str().replace("--sentinel-ssr-state--", &escape_backticks(&ssr_state_str));
During CSR hydration, we can pass the count
prop for the InitialPageSetter
as window.ssr_state.count
. We can fetch that by just looking up window.ssr_state
with web_sys
and deserializing it:
pub fn get_initial_ssr_state() -> SSRState { web_sys::window .unwrap() .get("ssr_state") .and_then(|state_obj| { ron::from_str(&state_obj.as_string().expect("window.ssr_state not string")).ok() }) .unwrap_or_default() }
Viola, we have a root app component with the counter!
#[function_component(App)] #[cfg(target_arch = "wasm32")] pub fn csr_app_component() -> Html { html! { <BounceRoot> <InitialPageStateSette count={get_initial_ssr_state().count} /> // (page rendering ommitted) </BounceRoot> } }
Let's focus back to our counter component. With the code stated above, we can accomplish something like:
async fn count_increment() -> usize { let resp = gloo_net::http::Request::get("/count-increment").send().await.unwrap_throw(); let text = resp.text().await.unwrap_throw(); let count = text.parse::<usize>().unwrap_throw(); count } #[function_component(Counter)] pub fn counter_component() -> Html { let counter = use_atom::<Counter>(); let onclick = { let counter = counter.clone(); // (shadowing) Callback::from(|_: MouseEvent| { counter.set(Counter::new(**counter + 1)); wasm_bindgen_futures::spawn_local(async move { let count = count_increment(); counter.set(Counter::new(count)); }); }) }; let counter: &Counter = &*counter; html! { <button {onclick}>{*counter}</button> } }
This is great! But wouldn't it be cooler if...
A cooldown animation? Yeah, that would be cool!
All this needs to be is an element layed over-top of the counter with a height of 100% and a width of ((time elapsed / cooldown time) * 100)%. The counter can be temporarily disabled in the meantime.
It's quite simple to declare this cooldown child and refer to it in the on click handler:
// (ommitted) let cooldown_node = use_ref(); let counter_node = use_ref(); let onclick = { let cooldown_node = cooldown_node.clone(); // (shadowing) let counter_node = counter_node.clone(); // (shadowing) // (ommitted) Callback::from(|e: MouseEvent| { // (we need this as an async callback for the timer that's coming later) let cooldown_node = cooldown_node.clone(); // (shadowing) let counter_node = counter_node.clone(); // (shadowing) spawn_local(async move { // (read on) }); }) }; // (ommitted) html! { <button {onclick} ref={counter_node}> <div class="counter-cooldown" ref={cooldown_node}></div> {*counter} </button> }
First off, let's "disable"[^3] the counter by setting style="pointer-events: none"
:
let counter_element = counter_node.cast::<HtmlElement>().unwrap(); counter_element.style().set_property("pointer-events", "none").unwrap();
And "enable" the cooldown by setting style="display: block"
let cooldown_element = cooldown_node.cast::<HtmlElement>().unwrap(); counter_element.style().set_property("display", "block").unwrap();
This cooldown necessitates an incremental timer. We can first define some constants for how long we want this to last and how frequently we want it to update:
const DELAY_TIME_MS: u64 = 2000; const DELTATIME_MS: u64 = 10;
We have a couple options for how we want this timer to operate. A JavaScript approach would see creating an interval with setInterval
and later clearing it. Let's investigate how timers work in Yew.
A crate, wasmtimer
, mimics Tokio's async time API as a wrapper for web-sys
's wrapper for JavaScript's setTimeout
, clearTimeout
, setInterval
, and clearInterval
functions.
A simple solution could be to call setTimeout
's wrapper in a range-based loop for however many times we need to, mimicing a short interval.
const STEPS: u64 = DELAY_TIME_MS / DELTA_TIME_MS
...and we can iterate through all the steps to increase the width, sleeping for the deltatime duration:
for step in 1..=STEPS { let percent = (step * 100) / STEPS; cooldown_element.style().set_property("width", &format!("{percent}%")).unwrap(); wasm_timer::tokio::sleep(Duration::from_millis(DELTATIME_MS)).await; }
That wasn't supposed to happen!
wasmtimer
's sleep functionality directly calls the setTimeout
macrotask to set an atomic flag in a sleep handler's state. Its Future::poll
implementation returns Pending
if the flag isn't set and Ready(())
if its set.
From the behavior elicited, it appears as if the async executor isn't polling the timer frequently. Unfortunately, that doesn't make sense. The documentation for spawn_local
-- the driver for the async executor -- states:
The
future
will always be run on the next microtask tick even if it immediately returnsPoll::Ready
.
In other words, there is no task operation budget or "lower priority" given to a future that polls a lot with this executor. All polls, for the lifetime of the spawn_local
future, are to run on every microtask tick.
For what reason would the microtask scheduler take several seconds? As I have limited knowledge of WebAssembly's inner workings, my only hypothesis for the asynchronous context to be broken is that somewhere there's something executing in JavaScript's task scheduler for some reason that takes some long time to finish.
But perhaps it's time to take a step away from reading MDN documentation and wasm_bindgen source code in a desperate plea for answers. After all, workarounds exist.
All I wanted was a counter, and instead I got an overcomplicated mess.
I didn't write this blog post to complain about any crate that's part of this website's stack.
At the beginning of this post, I mentioned an analogy of using lawnmowers for haircutting. This is similarly not the right tool for the job. I've made a few interactive and scalable websites before with Yew.rs + warp.rs that were much more elegant and allowed the tools to thrive. This website would have been better off being a Jekyll template with some custom JavaScript and PHP.
Despite that, it was really fun to churn through this website's development and be left with something functional. It's fun to see how using the wrong tool for a "seemingly" simple task can spiral into a rabbit hole of learning new functionality and gaining more insight on the innerworkings of... vaguely, things in general, I guess.
That's all :^).
[^1]:
Maybe there could be an attr on an element that's an Option<usize>
, since a filled attribute will be skipped during hydration if the hydrating value is None
, and that attribute's value can be read during runtime.
[^2]: It's not actually just found-and-replaced. The SSR renderer breaks the HTML into chunks only once, and all the pieces are concatenated together.
[^3]: This doesn't disable the counter if CSS is ignored. The counter isn't actually a button -- it's an A tag -- and this is "good enough." The on click handler could also be disabled.