#signal #dynamic #pages #web #ui #node #dom

terrazzo

The Terrazzo library to build dynamic web pages in Rust

3 releases

0.1.3 Dec 28, 2024
0.1.2 Dec 28, 2024
0.1.1 Dec 28, 2024

#443 in Web programming

Download history 190/week @ 2024-12-22 133/week @ 2024-12-29

323 downloads per month
Used in terrazzo-terminal

MIT license

79KB
1.5K SLoC

Terrazzo

Terrazzo is a lightweight, simple and efficient web UI framework based on Rust and WASM.

Tl;DR: See Terrazzo in action in the demo.

Prior art

This project was inspired by frameworks like Dioxus and Leptos.

These frameworks are based on Rust and WASM:

  • Both the server-side and client-side logic is written in Rust
  • Rust↔Javascript interop based on wasm_bindgen allows creating dynamic web pages using the DOM API.

Like many other frameworks, the core is built around a simple concept: reactivity. When a function computes a component (i.e: an HTML node), it records which signals are being used. Then, whenever one of those signals changes, the function is automatically re-evaluated again, and the UI is updated.

The implicit reactive ownership tree derives from the UI components tree (the DOM), and allows making the signals arena-allocated. In Rust terms, it means Signal can be Copy and not just Clone, which greatly improves the ergonomics.

  • We don't have to guess how and when to call .clone(), especially when signals are used in closures.
  • We can always pass signals as values so we don't have to deal with references, lifetimes and the Rust borrow-checker.

In other words, we can leverage the powerful Rust type system, use one language for both the UI and the backend server implementation, and get all the benefits of the rich Rust ecosystem.

Why Terrazzo?

The goal of Terrazzo isn't to replace Dioxus or Leptos. It's a lightweight, bare-bones alternative that aims to achieve one simple task and do it well: a templating system for UI.

Dioxus and Leptos are incredibly feature-rich, but are also prone to bugs.

Arena-allocated signals and use-after-free bugs

I believe that making signals Copy using arena allocation for the sake of ergonomics is an anti-pattern.

I prefer dealing with the Rust borrow-checker and any other kind of static analysis annoyance, even if it means I have to add explicit calls to .clone() and add some extra boilerplate. This is a small price to pay if I can avoid wasting time debugging large classes of bugs.

The promise of Rust is that the compiler has your back: if it compiles, it works. Rust code runs faster than other languages, not because "for-loops" are faster in Rust, but because Rust codebases are easier to refactor and optimize. You can replace a deep copy with a reference, and that promise will hold: if it compiles, it works. Else, the Rust compiler will help you figure out when to call .clone(), when to use use ref-counting pointers, or when to guard mutable state with a mutex or use a cell.

Hydration bugs

Server-side rendering is a hard-to-use feature. It only works if the server-side code generates the same page as the client-side code would. In theory, they should always match since the exact same code runs server- and client-side, it's just an optimization. In practice, it's not necessarily the case, so avoiding these bugs requires careful debugging and testing. Hydration Bugs (and how to avoid them)

Custom tooling

One of the biggest selling points for Rust is strong tooling, including cargo, rustfmt and clippy.

  • The Dioxus CLI is an unnecessary annoyance
  • The rsx! { ... } and view! { ... } macros to write HTML templates look nice at first, but don't work with the standard Rust formatter.

What does Terrazzo look like?

Terrazzo does not need custom tooling. The autoclone!() macro helps with cloning. SSR with hydration is not supported yet but Terrazzo useds a simple diff-merge logic that isn't prone to bugs.

Doing the right thing should be easy, doing the wrong thing should be hard: Terrazzo makes it easier to optimize rendering: reading a signal requires declaring a template, so just make sure to push reading signals down to child DOM nodes. Only read a signal where you need to render something. Use the key special attribute to avoid re-creating DOM nodes when ordering changes but the nodes stay the same.

Terrazzo uses two different macros:

  • The #[template] turns a function into a template. Use #[template(debug = true)] to see what the generated code looks like.
  • The #[html] adds syntactic sugar to replace function calls where the name matches one of the well-known HTML tags into a Rust struct representing an HTML tag. Use #[html(debug = true)] to see what the generated code looks like.
# fn main() {
# #[cfg(feature = "client")] {
# use terrazzo::html;
# use terrazzo::prelude::*;
# use terrazzo::template;
# struct State { value: i32, signal: XSignal<String> }
# impl State { fn click(&self) { println!("Click!"); } }
#[template]
#[html]
pub fn my_main_component() -> XElement {
    let state = State {
        value: 123,
        signal: XSignal::new("signal", "state".to_owned()),
    };
    let state_value = state.value;
    return div(
        class = "main-component",
        style::width = "100%",
        click = move |event| state.click(),
        "text node {state_value}",
        static_component(),
        dynamic_component(state.signal.clone()),
    );
}

#[template(tag = div)]
#[html]
fn static_component() -> XElement {
    tag("static value")
}

#[template(tag = div)]
#[html]
fn dynamic_component(#[signal] value: String) -> XElement {
    tag("Dynamic: ", "{value}")
}
# } // #[cfg(feature = "client")]
# } // fn main()

See demo.rs.

Dependencies

~1–13MB
~168K SLoC