DEV Community

tengxgfyrz67s
tengxgfyrz67s

Posted on

WebAssembly Deep Dive: How euv Leverages WASM

Project Code:https://github.com/euv-dev/euv

WebAssembly (WASM) is a binary instruction format that runs in modern web browsers at near-native speed. euv is built from the ground up to target WASM, compiling Rust code into efficient browser-executable modules. This article takes a deep dive into how euv uses WebAssembly under the hood, covering the compilation pipeline, memory management, performance characteristics, and the runtime architecture that makes it all work.

Table of Contents

  1. The WASM Compilation Pipeline
  2. Memory Management in WASM
  3. How euv Renders via WASM
  4. Performance Characteristics
  5. The Runtime Architecture
  6. Working with JavaScript from WASM
  7. Conclusion

The WASM Compilation Pipeline

The journey from Rust source code to a running euv application in the browser involves several steps:

Rust Source (.rs)
    ↓ rustc (with wasm32-unknown-unknown target)
WASM Binary (.wasm)
    ↓ wasm-bindgen
JavaScript Glue (.js) + WASM Binary
    ↓ Browser loads via ES module
Running Application
Enter fullscreen mode Exit fullscreen mode

Step 1: Project Configuration

Every euv project starts with the proper Cargo.toml configuration:

[package]
edition = "2024"

[dependencies]
euv = "*"
lombok-macros = "*"

[lib]
crate-type = ["cdylib", "rlib"]
Enter fullscreen mode Exit fullscreen mode

The crate-type = ["cdylib"] entry tells the Rust compiler to produce a dynamic library suitable for WASM. The rlib type allows the crate to also be used as a Rust library (for testing, for example).

Step 2: The Application Entry Point

use euv::*;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn main() {
    console_error_panic_hook::set_once();
    mount("#app", app);
}
Enter fullscreen mode Exit fullscreen mode

The #[wasm_bindgen] attribute generates JavaScript glue code for the main function. The console_error_panic_hook::set_once() call ensures that Rust panics are logged to the browser console — essential for debugging WASM applications.

Step 3: Building with wasm-pack

wasm-pack build --target web --out-dir www/pkg
Enter fullscreen mode Exit fullscreen mode

The --target web flag produces output that can be loaded directly in the browser using ES modules. After a successful build, the pkg/ directory contains:

  • my_app_bg.wasm — the compiled WebAssembly binary
  • my_app.js — the JavaScript glue code
  • package.json — metadata for the package

Step 4: Loading in the Browser

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>euv app</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module">
      import init, { main } from './pkg/euv_example.js';
      await init();
      main();
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The init() function loads and instantiates the WASM module, and main() starts the euv application.

Step 5: Development with euv CLI

For a smoother development experience, euv provides a CLI tool:

cargo install euv-cli
euv run --crate-path ./example --www-dir ./www --port 80
Enter fullscreen mode Exit fullscreen mode

The CLI handles building, file watching, hot reloading, and serving. Build modes are controlled via flags:

# Development build (debug info enabled, optimizations disabled)
euv run --crate-path ./example --www-dir ./www --port 80 --dev

# Release build (optimizations enabled, debug info disabled)
euv run --crate-path ./example --www-dir ./www --port 80 --release

# Profiling build (optimizations enabled, debug info enabled)
euv run --crate-path ./example --www-dir ./www --port 80 --profiling
Enter fullscreen mode Exit fullscreen mode

Memory Management in WASM

WebAssembly has a fundamentally different memory model than JavaScript. Understanding this model is key to writing efficient euv applications.

Linear Memory

WASM operates on a contiguous block of memory called "linear memory." This is a resizable ArrayBuffer that the WASM module can read and write. All data structures — strings, objects, arrays — are serialized into this linear memory as bytes.

┌──────────────────────────────────────────────┐
│              Linear Memory                    │
├──────────┬──────────┬──────────┬─────────────┤
│ Strings  │ Objects  │ Arrays   │ Free Space  │
└──────────┴──────────┴──────────┴─────────────┘
              ↑ Stack    ↑ Heap
Enter fullscreen mode Exit fullscreen mode

Rust's Ownership Model in WASM

Rust's ownership model works the same way in WASM as it does natively. When a Rust value goes out of scope, its memory is automatically freed. This is particularly powerful in the WASM context because:

  1. No garbage collector — Unlike JavaScript, there's no GC pause or overhead
  2. Deterministic deallocation — Memory is freed exactly when the value goes out of scope
  3. No memory leaks — The ownership system prevents leaks at compile time (in safe Rust)
fn app() -> VirtualNode {
    // count is allocated when use_signal is called
    let count: Signal<i32> = use_signal(|| 0);
    let count_updater: Signal<i32> = count;

    html! {
        div {
            // count is used here, so it stays alive
            span { count }
            button {
                onclick: move |_event: Event| {
                    let current: i32 = count_updater.get();
                    count_updater.set(current + 1);
                }
                "Increment"
            }
        }
    }
    // When this function returns, local variables are dropped
    // But count lives on because it's captured in the closure
}
Enter fullscreen mode Exit fullscreen mode

Memory and the Virtual DOM

The virtual DOM in euv is a Rust data structure that lives in WASM's linear memory. When the renderer compares the old and new virtual DOM trees, it's doing this comparison entirely within WASM memory — no JavaScript round-trips needed.

// The VirtualNode is a Rust enum stored in WASM memory
enum VirtualNode<T> {
    Element {
        tag: Tag,
        attributes: Vec<AttributeEntry>,
        children: Vec<VirtualNode<T>>,
        key: Option<String>,
        props: Option<T>,
    },
    Text(TextNode),
    Fragment(Vec<VirtualNode<T>>),
    Dynamic(Rc<RefCell<DynamicNode<T>>>),
    Empty,
}
Enter fullscreen mode Exit fullscreen mode

The Dynamic variant is particularly interesting — it uses Rc<RefCell<>> to allow shared mutable access to dynamic nodes that need to re-render when their dependencies change.

Signal Memory Layout

Signals in euv are reference-counted reactive containers:

// Signal<T> internally holds:
// - The current value of type T
// - A set of subscriber callbacks (closures)
// - Reference counting for shared ownership

let count: Signal<i32> = use_signal(|| 0);
// count is a smart pointer (Rc<SignalInner<i32>>)
// SignalInner<i32> { value: 0, subscribers: Vec<...>, ... }
Enter fullscreen mode Exit fullscreen mode

When you clone a signal (let count_updater: Signal<i32> = count;), you're incrementing the reference count, not copying the value. This makes signal cloning essentially free.

How euv Renders via WASM

The Rendering Process

When mount("#app", app) is called, the following happens:

  1. The renderer reads the CSS selector #app (via web-sys)
  2. It calls app() to get the initial VirtualNode tree
  3. It creates real DOM elements matching the virtual tree
  4. It inserts those elements into the #app container
use euv::*;

fn app() -> VirtualNode {
    html! {
        div {
            h1 { "Hello, euv!" }
        }
    }
}

// mount internally:
// 1. Finds #app element via web-sys
// 2. Calls app() to get VirtualNode tree
// 3. Creates DOM elements
// 4. Inserts them into #app
mount("#app", app);
Enter fullscreen mode Exit fullscreen mode

Incremental Rendering

When a signal changes, euv's renderer performs an incremental diff:

let count: Signal<i32> = use_signal(|| 0);

html! {
    div {
        // This becomes a DynamicNode that re-renders when count changes
        span { count }
    }
}
Enter fullscreen mode Exit fullscreen mode

The renderer compares the old and new virtual DOM trees, computing the minimal set of changes needed:

  • Same tag → Patch only changed attributes and children
  • Different tag → Replace the entire DOM node
  • Same text → Skip the update entirely

Keyed Diffing

For list rendering, euv supports keyed diffing to efficiently handle reordering:

struct Item {
    id: String,
    name: String,
}

html! {
    ul {
        for item in { items.get() } {
            li {
                key: item.id
                item.name
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When all children have a key attribute, the renderer uses a key-based diff algorithm that can reuse existing DOM nodes even when the list order changes. Without keys, it falls back to positional diffing (comparing by index).

Event Delegation

euv's renderer uses global event delegation for bubbling events. Instead of attaching individual event listeners to each element, it registers a single listener on window and dispatches events based on the element's ID:

html! {
    button {
        onclick: move |event: Event| {
            // This handler is stored in a global registry
            // and dispatched by the window-level listener
        }
        "Click me"
    }
}
Enter fullscreen mode Exit fullscreen mode

This dramatically reduces the number of DOM event listeners, improving both memory usage and performance.

Performance Characteristics

Why WASM is Fast

WebAssembly offers several performance advantages over JavaScript:

  1. Near-native speed — WASM instructions map closely to native CPU instructions
  2. No parsing overhead — WASM is a binary format that doesn't need parsing like JavaScript
  3. No GC pauses — Rust's ownership model means no garbage collection
  4. Predictable performance — No JIT warmup or deoptimization

Virtual DOM Diffing Performance

euv's virtual DOM diffing happens entirely in WASM memory, which offers several advantages:

// The diff computation is a pure Rust function
// No JavaScript round-trips during diffing
fn diff(old: &VirtualNode, new: &VirtualNode) -> PatchSet {
    // ... comparison logic ...
}
Enter fullscreen mode Exit fullscreen mode

The only JavaScript interaction happens when patches are applied to the real DOM (via web-sys).

Rendering Optimization

euv includes an important optimization: when a DynamicNode re-renders, the renderer compares the new virtual DOM output with the old one. If they're identical, the DOM patch is skipped entirely:

let count: Signal<i32> = use_signal(|| 0);

html! {
    div {
        // DynamicNode re-renders when count changes
        span { { format!("Count: {}", count.get()) } }
        // But if the formatted string hasn't changed,
        // the DOM update is skipped
    }
}
Enter fullscreen mode Exit fullscreen mode

This is particularly effective for scenarios where signals update frequently but the rendered output doesn't change (e.g., a timer signal that updates every second but the display only shows minutes).

Batch Updates

The batch function groups multiple signal updates into a single DOM update:

use euv::*;

// Multiple set() calls within a batch are coalesced
batch(|| {
    signal1.set(value1);
    signal2.set(value2);
    signal3.set(value3);
});
// Only one DOM update happens after the batch
Enter fullscreen mode Exit fullscreen mode

The watch! macro also uses batching internally:

let celsius: Signal<f64> = use_signal(|| 0.0);
let fahrenheit: Signal<f64> = use_signal(|| 32.0);

watch!(celsius, |celsius_value: f64| {
    fahrenheit.set(celsius_value * 9.0 / 5.0 + 32.0);
});

watch!(fahrenheit, |fahrenheit_value: f64| {
    celsius.set((fahrenheit_value - 32.0) * 5.0 / 9.0);
});
Enter fullscreen mode Exit fullscreen mode

When two signals watch each other, the batch mechanism ensures that cascading set calls within the same frame are merged into a single DOM update, preventing intermediate state flicker.

The Runtime Architecture

Hook System

euv's hook system manages the lifecycle of reactive primitives within dynamic nodes. Each DynamicNode has an associated HookContext that stores:

  • Signal subscriptions
  • Cleanup callbacks
  • Interval handles
  • Window event listeners
fn timer_tab() -> VirtualNode {
    let elapsed: Signal<i32> = use_signal(|| 0);
    let running: Signal<bool> = use_signal(|| false);
    let handle: Signal<Option<IntervalHandle>> = use_signal(|| None);

    // Register a cleanup callback
    use_cleanup(move || {
        if let Some(h) = handle.get() {
            h.clear();
        }
    });

    // Use watch! to react to signal changes
    watch!(running, |is_running: bool| {
        if is_running {
            let elapsed_signal: Signal<i32> = elapsed;
            let handle_signal: Signal<Option<IntervalHandle>> = handle;
            let new_handle: IntervalHandle = use_interval(1000, move || {
                let current: i32 = elapsed_signal.get();
                elapsed_signal.set(current + 1);
            });
            handle_signal.set(Some(new_handle));
        } else {
            let handle_opt: Option<IntervalHandle> = handle.get();
            if let Some(existing_handle) = handle_opt {
                existing_handle.clear();
            }
            handle.set(None);
        }
    });

    html! {
        div {
            span { { format_time(elapsed.get()) } }
            if { !running.get() } {
                button {
                    onclick: move |_event: Event| { running.set(true); }
                    "Start"
                }
            } else {
                button {
                    onclick: move |_event: Event| { running.set(false); }
                    "Pause"
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Component Lifecycle

Components in euv are functions that return VirtualNode. The #[component] attribute macro marks them for the html! macro to recognize:

use euv::*;

#[derive(Clone, Default)]
struct MyCardProps {
    title: &'static str,
}

#[component]
pub fn my_card(node: VirtualNode<MyCardProps>) -> VirtualNode {
    let MyCardProps { title, .. }: MyCardProps = node.try_get_props().unwrap_or_default();
    let children: VirtualNode = node.try_get_child_node();
    html! {
        div {
            class: c_card()
            h3 {
                class: c_card_title()
                title
            }
            children
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The component lifecycle is managed through the virtual DOM:

  1. Creation — When a component tag is encountered in html!, the component function is called
  2. Update — When a parent re-renders, the component function is called again with new props
  3. Cleanup — When a component is removed from the DOM, its associated hooks are cleaned up

The Keep-Alive Pattern

In euv, using match or if/else to switch components destroys the old branch and creates a new one. The Keep-Alive pattern uses CSS display: none/block to preserve component state:

let tab: Signal<String> = use_signal(|| "counter".to_string());

html! {
    div {
        // Tab bar
        div {
            class: c_tab_bar()
            div {
                class: if { tab.get() == "counter" } { c_tab_item_active() } else { c_tab_item_inactive() }
                onclick: move |_event: Event| { tab.set("counter".to_string()); }
                "Counter"
            }
            div {
                class: if { tab.get() == "form" } { c_tab_item_active() } else { c_tab_item_inactive() }
                onclick: move |_event: Event| { tab.set("form".to_string()); }
                "Form"
            }
        }
        // Render all tabs simultaneously, control visibility with display
        div {
            style: { display: if { tab.get() == "counter" } { "block".to_string() } else { "none".to_string() }; }
            { counter_tab() }
        }
        div {
            style: { display: if { tab.get() == "form" } { "block".to_string() } else { "none".to_string() }; }
            { form_tab() }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This keeps all DynamicNode instances and their HookContext alive — signals retain values, setInterval continues running, and form inputs preserve their state.

Working with JavaScript from WASM

The js-sys Bridge

When euv needs to interact with JavaScript (e.g., making HTTP requests), it goes through js-sys:

use euv::*;

// fetch is a JavaScript API accessed through js-sys/web-sys
let window: Window = window().expect("no global window exists");
let promise: Promise = window.fetch_with_str("https://httpbin.org/get");
let future: JsFuture = JsFuture::from(promise);
Enter fullscreen mode Exit fullscreen mode

The web-sys Bridge

For browser APIs, euv uses web-sys:

use euv::*;

let win: Window = window().expect("no global window exists");
let location: Location = win.location();
let hash: String = location.hash().unwrap_or_default();
let _ = location.set_hash("#/about");
Enter fullscreen mode Exit fullscreen mode

Type Conversion

Crossing the WASM boundary requires type conversion. euv handles this automatically through several traits:

// IntoNode trait - converts Rust types to VirtualNode
// String, &str, i32, bool, Signal<T>, etc. all implement IntoNode

let count: Signal<i32> = use_signal(|| 0);

html! {
    div {
        // count (Signal<i32>) is converted to VirtualNode via IntoNode
        count
        // String is converted via IntoNode
        "Hello"
        // i32 is converted via IntoNode
        42
    }
}
Enter fullscreen mode Exit fullscreen mode

The IntoReactiveValue trait handles attribute value conversion:

// All of these can be used as attribute values:
html! {
    div {
        data_role: "container"           // &str → AttributeValue::Text
        data_id: "12345"                 // &str → AttributeValue::Text
        aria_label: "Demo section"       // &str → AttributeValue::Text
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

euv's use of WebAssembly is not just a compilation target — it's a fundamental design choice that shapes every aspect of the framework:

  • Memory safety through Rust's ownership model, with no GC overhead
  • Near-native performance for virtual DOM diffing and signal computations
  • Efficient event handling through global delegation
  • Deterministic resource management through the hook system and cleanup callbacks

The WASM compilation pipeline — from Rust source through wasm-bindgen to browser execution — is streamlined by tools like wasm-pack and the euv CLI. The result is a framework that brings the power of Rust's type system and performance to the browser, while maintaining seamless interoperability with the JavaScript ecosystem through js-sys and web-sys.

Understanding the WASM internals helps you write more efficient euv applications. Key takeaways:

  1. Signal cloning is cheap (reference counting, not deep copy)
  2. Virtual DOM diffing happens entirely in WASM memory
  3. Use batch to group signal updates
  4. Use the Keep-Alive pattern to preserve component state across tab switches
  5. All browser APIs are accessible through use euv::* — no need for direct js-sys or web-sys dependencies

Project Code:https://github.com/euv-dev/euv

Top comments (0)