Intro
DOMINATOR is a Zero-cost ultra-high-performance declarative DOM library using FRP signals for Rust!
It is based on functional reactive principles, and relies on the futures_signals crate.
This book is accompanied by a few examples, which can be found in the book/tutorials/
directory.
You can serve a locally hosted version of the examples by running the following:
cd book/tutorials/<example>
npm install
npm start
This should open a browser window, with each example in a separate tab. If you modify the code, the browser should automatically reload the page and reflect your changes.
The intro chapters will run through a set of examples showing how to use DOMINATOR. There are references to the tutorial applications, which serve as starting point for your own experimentation! They will not go into extreme detail of every concept used; this will be covered in later sections of the book.
Dependencies
To build the examples, you will need to have rust with the wasm32-unknown-unknown
target installed.
After following the instructions from https://www.rust-lang.org/tools/install, you can install the target with:
rustup target add wasm32-unknown-unknown
Now install node and npm using nvm, use the latest LTS version. Instructions on how to do this can be found in the nvm repository.
Our first DOMINATOR UI
It's time to go through the basics of making a simple gui application using DOMINATOR! Let's briefly go over the project setup of the first example application, and then we'll get started.
Program structure
A dominator project is a rust project that compiles to wasm. This means we can use a fairly recognisable rust project structure.
To build the application for the browser, there are a few options available.
Throughout this tutorial, we will use rollup
with the @wasm-tool/rollup-plugin-rust
.
We will not cover the details of setting up a project with rollup here, but you can take a look at the tutorials/hello-world
project for a simple example.
Feel free to modify the rollup.config.js
file to suit your needs.
In the dist folder, we have the index.html
file.
It is the entry point for our application, and it loads the js bootstrap file that will be generated by rollup.
This js file will set up the memory for and load the wasm module, and take care of any bindings we may need between v8 and the wasm world.
The code
We start out with a very simple, yet obligatory, hello world example:
use dominator::{append_dom, body, html};
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
fn main() {
append_dom(&body(), html!("h1", {
.text("Hello, world!")
}));
}
Although simple, there are a lot of things to unpack here!
The main function is annotated with #[wasm_bindgen(start)]
.
This instructs wasm_bindgen
to generate code such that the function will be called when the wasm module is loaded, much like the main function in a binary crate.
The append_dom
function from dominator lets us attach a dom node to an existing DOM node.
In this case, we use the body
utility function to get a reference to the body
node, and attach the h1
node we create to it.
When executed in the browser, the hello world example above inserts the following DOM structure into the body
element:
<h1>Hello,world!</h1>
Get used to the html! macro
The html!
macro from dominator will be our main tool for constructing dom HTML nodes.
The first parameter is the tag name to construct.
The second parameter is a list of chained method calls that will be applied to the DomBuilder<HtmlElement>
instance used to construct the node.
Don't worry about understanding all the details of this just yet!
Let's just agree on a name for referencing the block containing chained method calls; the method_block
.
For now, be content to know that calling .text()
within the block we pass to the macro will create a text node with the provided static text content!
Adding child elements
To create any useful dom structures, we have to be able to create child elements on our nodes. There are a few methods for doing this on the DomBuilder:
html!("div", {
.child(html!("span", { .text("A child element") }))
.children([
html!("span", { .text("Another child") }),
html!("span", { .text("Another child") }),
html!("span", { .text("Another child") }),
])
})
How it works
Dominator does not hold any internal representation of the DOM tree in memory.
When we create dom nodes using the html!
macro, we simply use standard browser APIs to create and manipulate them.
What we get back from the DomBuilder
is a Dom
instance, which acts as a handle to the real, actual in-the-browser DOM node.
This means we can perform any action on the DOM (and the rest of the browser environment for that matter!) via the generated js_sys and web_sys bindings.
We also need to be aware that there is no VDOM, diffing or other helper in place to alleviate dom updates/style recalculations.
This sounds problematic, but as we will discuss later, using futures-signals
idiomatically actually makes it super easy to make near-optimal DOM updates!
The rest of the tutorials
To avoid having too many tutorial applications (and their corresponding node_modules occupying ludicrous amounts of drive space), the rest of the tutorial code will reside in the tutorials/all_the_others
folder.
Inside of this application there is a sub-folder named tutorials
, which in turn contains one sub folder for each tutorial we will be referencing.
When you run this application, the web page contains a tabbed view of all the tutorials, so you can switch between them on the fly.
The basics of FRP with futures-signals
Now that we have made a simple static html node, we'll pretty soon want to make it a bit more dynamic. But before we dive into the code, we'll very briefly go over the fundamental principle of functional reactive programming (FRP).
The most important principle to understand is that in FRP, we consider the view to be a functional mapping of the state. We typically refer to the result of such a mapping as a derivation.
Secondly, we consider the state to be a stream of values, not just a single value held in memory.
What does this mean?
Imagine that you have a variable x
that holds the value 5
, and we want to turn it into the text "5"
.
One way of doing this, of course, is to simply call x.to_string()
.
This gives us the string representation of x
at the time of the call.
This, however, is not very useful if we want to keep the text up to date with futures values of x
.
If we reassign a new value to x, the string representation will remain the same old "5"
as it was before.
Imagine now that instead of x
holding the single value 5
, it is a stream of i32 values.
We can then map this stream to a stream of strings by calling x.map(|x| x.to_string())
.
This gives us a new stream, which will yield the string representation of the latest value of x
whenever x
yields a new value.
Think of the stringified x
as a view on the numerical value x
holds at any given moment.
Values usually need to be stored however, so modelling them strictly as streams is not very feasible.
futures-signals
handles this by providing a collection of Mutable
data containers.
They are Mutable
, MutableVec<T>
and MutableBTreeMap<K,T>
.
What these have in common is that they store a value, and can give signals for the latest value held by the container.
Think of a signal as a regular async futures-streams Stream
.
They simply provide an async way of getting the next relevant value for a derivation.
In fact, there are utility methods provided to convert signals to and from regular Streams!
The specifics of how signals work vary slightly for the various types of signals.
For now, we will limit ourselves to Mutable
for the introduction to the basic premises.
Don't worry, we will cover signals in more detail later, as they are crucial to understand in order to structure your application efficiently!
Mutable
This is the simplest of the mutable types. It is a simple container, providing get/set methods for accessing the current held value directly.
Note: If your type
T
is Copy, theMutable<T>
type will implement.get()
. IfT
is Clone, there will be.get_cloned()
instead
More importantly, Mutable<T>
gives us a few ways to acquire a signal of the values it will hold.
The simplest signal we can get is when our type is Copy
or Clone
.
In this case, we can create a signal that copies the value forward like so (for cloning, we use the .signal_cloned()
:
let x = Mutable::new(42_u32);
let x_signal_copied = x.signal();
let x_signal_cloned = x.signal_cloned();
The last type of signal we can get from Mutable
is the .signal_ref()
.
This allows us to provide a mapping lambda that will transform a reference to the new value and output that as the signalled value.
In this example, we simply output a copy of the new value:
let x = Mutable::new(42_u32);
let x_signal_ref = x.signal_ref(|new_value: &u32| {
*new_value
});
Now that we have a signal for all future values of x
, we can write a function that should run when we get new values:
async fn log_x(x_signal: impl Signal<Item = u32>) {
x_signal
.for_each(|v| {
info!("Got new x: {}", v);
async {}
})
.await;
}
One very important thing to be aware of regarding Signal
, is that it may skip intermediate values when polled.
The delivery guarantee is that you will always poll the most recent value, but it may drop values if several updates happen in rapid succession.
This may sound strange, but it's important to mentally separate signals from streams.
When you chose to use signals, what you want to achieve is to perform a mapping of the latest state into a derivation.
You should not use Signal
if what you wish to achieve is an element-by-element processing; this is what streams are for!
Let's make a dynamic view
Enough on signals; let's show a practical example.
Let's make a counter, where pressing a button will increment a value shown in a <span>
.
If you recall from our static example, the html!
macro allows us to set properties on the Dom
node we are building by using the .text()
call in the macro invocation.
DOMINATOR usually provides two (or sometimes more) such methods for any property we can set on the builder; one static and one dynamic version.
The dynamic counterpart normally has the suffix _signal
or _signal_vec
to communicate the type of signal it requires.
In our case, we know that we want to make a span with a text that changes according to a counter, so we use the .text_signal()
and a mapping
You can find this example in the tutorials/all_the_rest
application if you wish to see it live!
pub fn counter(counter_value: Mutable<u32>) -> Dom {
let counter_text_signal = counter_value
.signal()
.map(|new_value| format!("The counter value is {}", new_value));
html!("div", {
.child(html!("h1", {
.text_signal(counter_text_signal)
}))
.child(html!("button", {
.text("Increase!")
.event(clone!(counter_value => move |_: events::Click| {
counter_value.set(counter_value.get() + 1);
}))
}))
})
}
If you are used to a more object-oriented way of programming, it may seem strange how we declare our component as a regular rust function. But if you prefer the syntax sugar of using a struct, fear not, it is perfectly fine!
We can simply create a struct to hold our state for us, and have an associated member function to transform it into a DOM node:
#[derive(Default)]
struct Counter {
counter_value: Mutable<u32>,
}
impl Counter {
pub fn render(self) -> Dom {
let counter_text_signal = self
.counter_value
.signal()
.map(|new_value| format!("The counter value is {}", new_value));
html!("div", {
.child(html!("h1", {
.text_signal(counter_text_signal)
}))
.child(html!("button", {
.text("Increase!")
.event(move |_: events::Click| {
self.counter_value.set(self.counter_value.get() + 1);
})
}))
})
}
}
We see that the two implementations are actually very similar, which is unsurprising seeing how they do exactly the same thing!
One should be strict when declaring function arguments (in general, not just with DOMINATOR), so that the signature clearly describes the contract with the caller.
If we do not want to allow the function to mutate our value, we can either accept a ReadOnlyMutable<u32>
or an impl Signal<Item=u32>
.
Working with callbacks
When making shared components, we usually want to customize certain behaviours of the component. The common way to solve this, is by allowing the user of the component to provide callbacks that we trigger under specific circumstances.
With dominator, we can easily do this with regular rust closures:
pub fn my_shared_button(mut on_click: impl (FnMut() -> ()) + 'static) -> Dom {
html!("button", {
.event(move |_: events::Click| {
on_click();
})
})
}
It's important to be familiar with the clone!
macro provided by DOMINATOR.
What it does is to take a list of comma separated values as the first argument, then after the fat arrow (=>
) the code block we wish to move the clones into.
let value_a = Mutable::new(42);
let value_b = Mutable::new(666);
let my_lambda: &dyn Fn() -> () = &clone!(value_a, value_b => move || {
value_a.set(1);
value_b.set(1);
});
value_a.set(2);
We here make a lambda that captures clones of the two values by value, using the clone!
macro.
Here's an example of using our button:
pub fn use_onclick() -> Dom {
let my_local_var = Mutable::new(0);
html!("div", {
.child(html!("span", { . text_signal(my_local_var.signal().map(|v| v.to_string()))}))
.child(my_shared_button(clone!(my_local_var => move || {
my_local_var.set(my_local_var.get() + 1)
})))
})
}
Note that any callback handed over to the JS runtime must have a 'static
lifetime.
This means it must capture everything that isn't a 'static
lifetime reference by value!
Reusing closures
Sometimes, we need to provide a callback function that will be handed over to multiple callers. There are a few ways to do this, depending on what type of closure you wish to use.
Fn
closure
The simplest is to use an Fn
closure, which we can wrap in an Arc
internally, and hand over to our event handlers:
pub fn my_shared_button_factory(
val: Mutable<i32>,
on_click: impl (Fn() -> ()) + 'static,
) -> Dom {
let on_click = Arc::new(on_click);
html!("div", {
.child_signal(val.signal().map(move |v| {
Some(html!("button", {
.event(clone!(on_click => move |_: events::Click| {
on_click();
}))
}))
}))
})
}
FnMut closure
If we need to use an FnMut
closure, it's not sufficient to wrap the closure in an Arc
.
The problem is that to invoke an FnMut
, you need to own a mutable reference to it.
We can use the same approach as for Fn
, but we need to wrap the closure in a mutex as well as an Arc
.
This allows us to acquire a mut borrow for the closure when we need to invoke it!
pub fn my_shared_button_factory(
val: Mutable<i32>,
on_click: impl (FnMut() -> ()) + 'static,
) -> Dom {
let on_click = Arc::new(Mutex::new(on_click));
html!("div", {
.child_signal(val.signal().map(clone!(on_click => move |v| {
let on_click = on_click.clone();
Some(html!("button", {
.event(move |_: events::Click| {
on_click.lock().unwrap()();
})
}))
})))
})
}
Closure factory
And finally, if for some reason our FnMut
closure cannot be Clone
, we can adopt a factory pattern.
This is simply a wrapping lambda, which returns a new closure for each invocation:
pub fn my_shared_button_factory<
TFn: (FnMut() -> ()) + 'static,
TFactory: (FnMut() -> TFn) + 'static,
>(
val: Mutable<i32>,
mut on_click_factory: TFactory,
) -> Dom {
html!("div", {
.child_signal(val.signal().map(move |v| {
let mut on_click = on_click_factory();
Some(html!("button", {
.event(move |_: events::Click| {
on_click();
})
}))
}))
})
}
Dealing with 'static constraints for state management
Since all callbacks we will be dealing with are constrained to capturing by 'static
, we have to make some considerations when designing our application state management.
What this practically means is th at if we want to connect our events to the rest of our application in any meaningful way, we have two options:
- Create static references to the application state by
Box::leak()
and share&'static
references to the relevant parts - Keep state inside cloneable pointer types (usually
Arc
), and capture by move
Both of these are valid approaches, and typically a mix is good. Again, the Patterns chapter will cover more of this.
Async and futures
It's fair to say that when writing a web application, you are going to have to deal with async programming. A lot.
There's a good chance you have to make async calls to your backend. Most likely your users will trigger actions you wish to perform over time without blocking the single threaded runtime of the browser. You may wish to have timer based events happening in your UI.
Any of these requires some form of async
support in your code.
Luckily, rust has a very good async model, and DOMINATOR lets us use it from our components easily!
Futures and components
First of all, let's assume we have an interface to our backend with an async method providing us some item information:
pub trait ItemRepository {
async fn get_item_info(&self, item_id: Uuid) -> Result<ItemInfo>;
}
This provides a nice abstraction over the asynchronous HTTP calls required to retrieve the information from our backend. Now, let's say we wish to call this method when we are rendering our view, so that we can properly display the information when it becomes available.
A good strategy for this is to create a Mutable
for holding our value, initialized to None
.
We then make the request, and whenever the response returns we will populate the mutable with the received value.
This lets us render the view as a simple signal mapping over the optional value:
pub fn item_info_view(info: &Option<ItemInfo>) -> Dom {
if let Some(info) = info {
html!("span", { .text(format!("Item info: {:?}", info).as_str())})
} else {
html!("span", { .text("Loading item info, please wait!") })
}
}
But we need to drive the future returned by our function somehow!
Luckily, dominator lets us associate futures with our elements, and will make sure they are polled.
pub fn item_info(repository: &'static impl ItemRepository, item_id: Uuid) -> Dom {
let item_info = Mutable::new(None);
html!("div", {
.future(clone!(item_info => async move {
let info = repository.get_item_info(item_id).await.unwrap();
item_info.set(Some(info));
}))
.child_signal(item_info.signal_ref(|info| {
Some(item_info_view(info))
}))
})
}
As we can see, this lets us write straight forward async rust code, and allows us to tie it back to our state by capturing the relevant state into our futures.
Be aware that when the element is dropped, the futures associated with it will no longer be polled.
Spawning tasks with spawn_local
Sometimes we have to spawn tasks that doesn't exist at the construction of our elements.
For this, we will have to use the wasm-bindgen-futures
crate, which provides a handy spawn_local
function!
html!("button", {
.event(|_: events::Click| {
spawn_local(async move {
async move {
info!("Yay, I was polled!")
}.await
})
})
})
Advanced Element Construction
Let's take a look at a few slightly more advanced way of setting up elements using the html!
macro.
Accessing the real DOM node
If we wish to gain access to the underlying DOM node, we have to use the with_node!
macro provided by DOMINATOR.
With this, we gain direct access to all methods on the actual DOM node.
fn with_node_example(blur_signal: impl Signal<Item = ()> + 'static) -> Dom {
html!("div", {
.with_node!(element => {
.future(clone!(element => async move {
blur_signal.to_future().await;
// Call some functions on the real DOM node!
element.blur().expect_throw("Failed to blur!");
element.scroll_into_view()
}))
.apply(|builder| {
info!("Inserting a {} node!", element.tag_name());
builder
})
})
})
}
By using the with_node!
macro in the html macros method block, we gain access to the DOM node reference inside the with_node
s apply block.
A bit more concretely; the .future()
and .apply()
methods are applied to our newly constructed div
builder as usual, but inside the async body we have access to the element reference to call blur on it when the signal future resolves!
Note that the apply function allows us to access the DOM node reference before it is inserted into the DOM tree.
Constructing elements with multiple child nodes
Very often, we need to create an element with several child nodes. We may also need to mix text node with html element nodes in the child list. Some children may be dynamically created based on the content of a signal, with static siblings.
Luckily, the html!
macro makes this trivial to do from DOMINATOR!
Take a look at this multinode example:
html!("div", {
.child(html!("span", { .text("first child") }))
.text("A text node")
.child_signal(always(Some(html!("span", { .text("Some dynamic node") }))))
});
As you can see in the method block of the macro, we simply make multiple calls to the various child-inserting methods. This particular example expands into the following DOM structure:
<div>
<span>first child</span>
A text node
<span>Some dynamic node</span>
</div>
This even works with fragments
and children_vec
, which may expand into several dynamically allocated children:
(To read more about fragments and how they work, see the fragments)
let some_vec = MutableVec::new_with_values(vec![1, 2, 3 ]);
let some_other_vec = MutableVec::new_with_values(vec![1, 2, 3 ]);
let some_fragment = fragment!(move {
.text("Hi there")
.children_signal_vec(some_vec.signal_vec()
.map(|v|
html!("span", {
.text(format!("Dynamic child in fragment #{v}").as_str())
})))
});
html!("div", {
.fragment(&some_fragment)
.children_signal_vec(some_other_vec.signal_vec()
.map(|v|
html!("span", {
.text(format!("Dynamic child fragment #{v}").as_str())
})))
})
Designing a SVG slider
It's time to try our hand at designing a reusable encapsulated component. I've chosen an audio slider, the likes of which you see on mixing desks and in fancy DAW software, as it is visually interesting and illustrates key concepts of reusable components!
In this chapter, we will implement our slider using vanilla dominator. We will revisit this component once we cover the dominator macros from DMAT in a later section.
Let's first come up with the properties we need to model in our component:
- It must allow us to control a numeric value linearly between a maximum and minimum value
- We want to allow the user to disable/enable the slider input
- It must look like its physical counterpart
Figuring out the data model
The first thing to do when designing any UI component, is to figure out what data it will operate on, and how we best represent it in our code.
For this slider, there are at least two identifiable data points we can start with.
The most important is the value of the slider. This can be modelled as a simple number, representing the point along the linear axis of the slider the knob is currently at.
We can model this as a simple Mutable<f64>
, with a user configured value range.
There are problems with this choice if we want to make our component library as widely adoptable as possible though.
First of all, choosing a concrete number type forces the user to adopt his data model to our chosen representation.
What if he has a u64
value?
What if he doesn't want to store the value in local state, but rather forward control messages to a physical control board with a motorized slider?
Both the choice of value type and container is problematic when designing reusable code.
We will now first implement the naive Mutable<f64>
solution, and then we will take the effort of generalizing it so that we can analyze the difference.
Defining the component signature
Now that we have settled on our initial data model, let's sketch out a function signature for our slider:
pub fn audio_slider(value: Mutable<f64>, value_range: (f64, f64), disabled: ReadOnlyMutable<bool>) -> Dom {
todo!()
}
It's fairly simple, and as mentioned above also opinionated in how the users have to store their values.
They must keep the value and disabled states in a Mutable
, and the value must be of type f64
.
Let's disregard user choice for the time being, and proceed by implementing the body of our simple slider.
Implementing the slider
The slider needs to have a state representing if it's currently being manipulated or not.
This can be stored in a simple Mutable
, which we can just make at the top of our function:
let button_state = Mutable::new(false);
To render the slider, we need two SVG rectangles:
The first rectangle represents the track in which the physical slider moves, and it can be drawn as a vertical narrow black rectangle.
The second rectangle represents the indicator knob, which is what the user will be moving along the slider to control the value.
For the knob, we can calculate its position as a signal derived from the value. We will define it such that the lowest value of the range corresponds to the knob being at the bottom of the widget:
let y_pos_signal = value.signal().map(move |v| {
let value_scale = value_range.1 - value_range.0;
let value_offset = value_range.0;
let y_pos = 100.0 - 100.0 * (v - value_offset) / value_scale;
y_pos.clamp(0.0, 100.0).to_string()
});
This makes rendering the knob relatively simple:
svg!("rect", {
.event(clone!(button_state, disabled => move |event: events::MouseDown| {
button_state.set(!disabled.get());
}))
.attr_signal("y", y_pos_signal)
.attr("width", "20")
.attr("height", "10")
.attr("fill", "gray")
.attr("cursor", "pointer")
})
The event handler is responsible for starting the move operation when the button receives a MouseDown
event.
Also notice that if the disabled state is true, we simply ignore the drag start.
We delegate releasing the drag state to a global event handler attached to the top level SVG element.
This has to be global so that we don't end up in a situation where the MouseUp
event is received by a different element, causing the slider to be stuck in a move state!
The global handler is configured like his:
.child(svg!("svg", {
.attr("viewBox", "0 0 20 110")
.apply(|builder| {
builder.global_event(clone!(button_state => move |event: events::MouseUp| {
button_state.set(false);
}))
})
// ...
Now we need to handle mouse movement to change the value when the mouse moves inside the slider widgets screen area.
To convert this to values in the correct range, we first define a helper function to do the calculation:
let calculate_value = move |element: &SvgElement, offset_y: i32| -> f64 {
let height = element.get_bounding_client_rect().height();
let value_scale = value_range.1 - value_range.0;
let value_offset = value_range.0;
(value_offset + value_scale * (1.0 - offset_y as f64 / height)).clamp(value_range.0, value_range.1)
};
One interesting property of this function is that it expects a reference to the raw SvgElement
.
The element handle lets us retrieve the bounding rectangle for the element, which is needed to convert the y offset from the drag event into a y percentage, which we need to calculate the correct value for the output.
To access the SvgElement
, we have to use the with_node!
macro discussed in advanced element construction:
.with_node!(element => {
.event(clone!(element, value, button_state => move |event: events::MouseMove| {
if button_state.get() {
value.set(calculate_value(&element, event.offset_y()));
}
}))
// ...
})
The vertical bar is a simple rect, but for convenience, we'll allow clicking the vertical bar to instigate a drag operation as well:
.child(svg!("rect", {
.attr("x", "6")
.attr("y", "5")
.attr("width", "6")
.attr("height", "100")
.attr("cursor", "pointer")
.event(clone!(element, button_state, disabled => move |event: events::MouseDown| {
if !disabled.get() {
button_state.set(true);
value.set(calculate_value(&element, event.offset_y()))
}
}))
}))
The last piece of the puzzle is to have a visual indicator that the component is disabled. We can solve this with a simple rect overlaying the entire component, with a slightly see-through grey tint:
.child_signal(disabled.signal().map(|disabled| {
if disabled {
Some(svg!("rect", {
.attr("width", "40px")
.attr("height", "200px")
.attr("opacity", "0.5")
.attr("fill", "gray")
}))
} else {
None
}
}))
Finally, our naive implementation of the audio slider looks like this:
pub fn audio_slider(value: Mutable<f64>, value_range: (f64, f64), disabled: ReadOnlyMutable<bool>) -> Dom {
let button_state = Mutable::new(false);
let y_pos_signal = value.signal().map(move |v| {
let value_scale = value_range.1 - value_range.0;
let value_offset = value_range.0;
let y_pos = 100.0 - 100.0 * (v - value_offset) / value_scale;
y_pos.clamp(0.0, 100.0).to_string()
});
let calculate_value = move |element: &SvgElement, offset_y: i32| -> f64 {
let height = element.get_bounding_client_rect().height();
let value_scale = value_range.1 - value_range.0;
let value_offset = value_range.0;
(value_offset + value_scale * (1.0 - offset_y as f64 / height)).clamp(value_range.0, value_range.1)
};
html!("div", {
.style("width", "40px")
.style("height", "200px")
.child(svg!("svg", {
.attr("viewBox", "0 0 20 110")
.apply(|builder| {
builder.global_event(clone!(button_state => move |event: events::MouseUp| {
button_state.set(false);
}))
})
.with_node!(element => {
.event(clone!(element, value, button_state => move |event: events::MouseMove| {
if button_state.get() {
value.set(calculate_value(&element, event.offset_y()));
}
}))
.child(svg!("rect", {
.attr("x", "6")
.attr("y", "5")
.attr("width", "6")
.attr("height", "100")
.attr("cursor", "pointer")
.event(clone!(element, button_state, disabled => move |event: events::MouseDown| {
if !disabled.get() {
button_state.set(true);
value.set(calculate_value(&element, event.offset_y()))
}
}))
}))
})
.child(svg!("rect", {
.event(clone!(button_state, disabled => move |event: events::MouseDown| {
button_state.set(!disabled.get());
}))
.attr_signal("y", y_pos_signal)
.attr("width", "20")
.attr("height", "10")
.attr("fill", "gray")
.attr("cursor", "pointer")
}))
.child_signal(disabled.signal().map(|disabled| {
if disabled {
Some(svg!("rect", {
.attr("width", "40px")
.attr("height", "200px")
.attr("opacity", "0.5")
.attr("fill", "gray")
}))
} else {
None
}
}))
}))
})
}
Mixins
Mixins is a pattern that can be used to allow the user access to a components builder. For instance, we may want to give the user a way of setting the ID of our component, or to attach some custom event handler to it.
Here's a very basic example.
The component_with_mixin
component allows its user to attach to its top level elements' apply
function:
fn component_with_mixin(
mixin: impl FnOnce(DomBuilder<HtmlElement>) -> DomBuilder<HtmlElement>,
) -> Dom {
html!("div", {
.apply(mixin)
.text("I support a mixin")
})
}
The user can now customize essentially anything it wants on the component. Here's an example setting a CSS class:
fn using_a_mixin() -> Dom {
html!("div", {
.child(component_with_mixin(|builder| builder.class("added-from-user")))
.text("I applied a mixin to my child!")
})
}
Making generic mixin implementations
Allowing mixins to be applied to components that we write is good, but we can also implement generic functionality as mixin factories.
Let's say we wish to get a signal for the hovered state of any element. We could of course copy and paste the code around, but it seems like a bad practice.
We can instead create a higher order function, which produces a mixin that we can pass to any component that we wish to support this hover functionality for!
#[derive(Copy, Clone, Debug)]
enum HoverState {
Hovered,
NotHovered,
}
fn hover_signal_mixin(
callback: impl FnOnce(DomBuilder<HtmlElement>, MutableSignal<HoverState>) -> DomBuilder<HtmlElement>,
) -> impl FnOnce(DomBuilder<HtmlElement>) -> DomBuilder<HtmlElement> {
let is_hovered = Mutable::new(HoverState::NotHovered);
move |builder| {
builder
.apply(clone!(is_hovered => move |builder|
callback(builder, is_hovered.signal())))
.event(clone!(is_hovered => move |_: MouseEnter|
is_hovered.set(HoverState::Hovered)))
.event(clone!(is_hovered => move |_: MouseLeave|
is_hovered.set(HoverState::NotHovered)
))
}
}
fn using_hover_signal_mixin() -> Dom {
component_with_mixin(hover_signal_mixin(|builder, hover_signal| {
builder.class_signal(
"hovered",
hover_signal.map(|hover_state| match hover_state {
HoverState::Hovered => true,
_ => false,
}),
)
}))
}
Avoiding footgunnyness
As the warning above states, it can be risky to provide full access to your internal builder. This can allow the user to break invariants assumed to be in place for your internal implementation, so use mixins a bit carefully.
Use fragments
If what we wish to do is to let the user inject one or more children into a section of our components DOM tree, we may wish to use fragments instead.
More precise arguments
It's a good idea to consider more targeted callback functions and signals. If you want the user to be allowed to provide a class signal, then simply accept the class signal:
fn component_with_class_signal(active_class_signal: impl Signal<Item = bool> + 'static) -> Dom {
html!("div", {
.class_signal("active", active_class_signal)
})
}
This lets the consumer do a more controlled customization of your component, and doesn't risk the issues associated with mixins.
Builder wrapper
Alternatively, we can wrap the builder in a struct to expose only certain operations:
pub struct MyBuilderWrapper {
inner: DomBuilder<HtmlElement>,
}
impl MyBuilderWrapper {
pub fn class(self, class: impl AsRef<str>) -> Self {
Self {
inner: self.inner.class(class.as_ref()),
}
}
fn new(inner: DomBuilder<HtmlElement>) -> Self {
Self { inner }
}
}
pub fn my_wrapped_mixin(wrapped_mixin: impl FnOnce(MyBuilderWrapper) -> MyBuilderWrapper) -> Dom {
html!("div", {
.apply(|builder| {
wrapped_mixin(MyBuilderWrapper::new(builder)).inner
})
})
}
Fragments
There are many situations where you wish to expose all or parts of your child list to the user of a components, but you may not want to give them full freedom of selecting all the children.
It may also not always be feasible to require a set of children to conform to a certain trait, such as impl SignalVec
etc. to expose only parts of your child list to the caller.
This is what DOMINATOR Fragment
s are for.
They are useful for providing a way of injecting children from outside the implementation of a component, without using a mixin.
On a technical note, the Fragment
trait exposes an apply method, which will apply the fragments method block onto the DomBuilder
for the element the fragment is inserted into.
There is also a handy macro, fragment!
, which allows us to declare fragments essentially how we would use the html!
macro, but without the tag name.
For instance, if we are making a list component, we need to let the user provide the content of our list:
fn list(children: impl Fragment) -> Dom {
html!("ul", {
.fragment(&children)
})
}
We can now use our list like this:
fn use_my_list() -> Dom {
list(fragment!({
.text("Hello, world!")
.child(html!("span", { .text("A static child ")}))
.child_signal(always(true).map(|_| Some(html!("span", { .text("Dynamic child")}))))
.children([ html!("span", {.text("List of children")})])
.children_signal_vec(futures_signals::signal_vec::always(vec![0,1]).map(|idx| html!("span", { .text(format!("Dynamic children #{}", idx).as_str())})))
}))
}
Observe how we are free to implement our fragment in any way we wish, i.e. use both static, dynamic or a combination of these children. However, the implementation of the component gets to chose where and when we insert that group of children into its DOM tree!
Since fragments are essentially implemented as a factory closure, we can even apply them several times. This is handy if we need to redraw the containing element for some reason!
fn redraw_with_children(children: impl Fragment + 'static) -> Dom {
html!("ul", {
.child_signal(always(true).map(move |_| Some(html!("li", {
.fragment(&children)
}))))
})
}
BoxFragment
If you need to store the fragment in a struct, we have to use BoxFragment
and box_fragment!
instead:
struct MyComponent {
fragments: BoxFragment,
}
impl MyComponent {
pub fn render(&self) -> Dom {
html!("div", {
.fragment(&self.fragments)
})
}
}
let my_cmp = MyComponent {
fragments: box_fragment!({ .text("hi there!")}),
};
append_dom(&body(), my_cmp.render());
Moving into fragments
If you are returning a fragment from a function, you will have to capture any state it references with move
:
fn move_fragment() -> impl Fragment {
let value = Mutable::new(42);
fragment!(move { .text_signal(value.signal().map(|v| format!("{}", v)))})
}
The returned fragment will here own the value in its closure, and is as such free to be passed around as a value.
Routing
Any modern web application needs to support routing. DOMINATOR provides some utilities on top of the browsers history APIs to make integrating routing with our futures-signals based application state easier. Let's get started!
url()
The starting point is dominator::routing::url()
, which returns a ReadOnlyMutable<String>
.
This allows us to read the current url directly, but even more importantly, it lets us get a signal for it!
// Print the current value
let url = dominator::routing::url();
info!("You are currently at: {}", url.get_cloned());
// Print every new url
url.signal_cloned().for_each(|new_url| async move {
info!("You are now at: {}", new_url)
}).await;
To change the current URL, we use the goto_url
function
dominator::routing::go_to_url("#/new-url");
We can make a simple application with two routes: #/hello
and a default.
fn hello_world() -> Dom {
let child = url()
.signal_ref(|current_route| {
web_sys::Url::new(current_route.as_str()).expect_throw("Invalid url")
})
.map(
|current_route: web_sys::Url| match current_route.hash().as_str() {
"#/hello" => Some(html!("span", { .text("Hello, world!")})),
_ => Some(html!("span", { .text("Unknown route, sorry!") })),
},
);
html!("div", {
.child(html!("h1", { .text("Simple routing example")}))
.child_signal(child)
})
}
Note how we create a web_sys::Url
from the route string.
This gives some utility, for our case we mostly care about the hash()
method to make matching on relative URLs easier.
The route enum
.. The above example may be all we need for a simple application, but there are also cases were we wish to avoid directly hard coding route strings all over our application code. There are several ways of solving this, one of which is to model the route states as rust enums. We can then write perfectly type-safe routing code, and it also gives us a very structured model of the possible application states!
Let's create an imaginary application. It has a landing page, which is also the default route. There's a "Shop now" view, which additionally can have an item ID as a sub-view for direct linking to items.
We can start by declaring the route enum:
enum ShopRoutes {
LandingPage,
Shop { display_item_id: Option<String> },
}
impl Default for ShopRoutes {
fn default() -> Self {
ShopRoutes::LandingPage
}
}
And our application can accept an instance of this enum to render:
fn shop_application(route: impl Signal<Item = ShopRoutes>) -> impl Signal<Item = Option<Dom>> {
route.map(|new_route| match new_route {
ShopRoutes::Shop { display_item_id } => match display_item_id {
Some(item_id) => Some(
html!("span", { .text(format!("Displaying item with id {}", item_id).as_str()) }),
),
None => Some(html!("span", { .text("Displaying all items") })),
},
_ => Some(html!("Welcome to our shop!")),
})
}
With that out of the way, we need to somehow connect to the routing itself. Again, there are several ways to do this, but one compact and nice-ish way is to create a few static methods on the enum itself.
We need a function to provide us with a signal of the enum value representing the current route. We also need to be able to modify the route.
impl ShopRoutes {
pub fn signal() -> impl Signal<Item = Self> {
let shop_item_regex = Regex::new(r"#/shop/(?<item_id>[0-9]+)").unwrap();
url().signal_ref(move |new_route_path| {
let url = web_sys::Url::new(new_route_path.as_str()).expect_throw("Invalid url");
let hash = url.hash();
if let Some(captured_item_id) = shop_item_regex
.captures(hash.as_str())
.map(|captures| captures["item_id"].to_string())
{
return Self::Shop {
display_item_id: Some(captured_item_id),
};
}
match hash.as_str() {
"#/shop" => ShopRoutes::Shop {
display_item_id: None,
},
_ => ShopRoutes::LandingPage,
}
})
}
pub fn goto(route: Self) {
go_to_url(route.to_url().as_str());
}
pub fn to_url(&self) -> String {
match self {
ShopRoutes::LandingPage => "#/landing".to_string(),
ShopRoutes::Shop { display_item_id } => match display_item_id {
Some(item_id) => format!("#/shop/{}", item_id),
None => "#/shop".to_string(),
},
}
}
}
Now we can put it all together to render a strongly typed, routed application:
fn route_enum_example() -> Dom {
let route_signal = ShopRoutes::signal();
html!("div", {
.child(html!("h1", { .text("Route enum example") }))
.child(html!("div", {
.child(html!("span", {
.text("Landing Page")
.event(|_: dominator::events::Click| {
ShopRoutes::goto(ShopRoutes::LandingPage)
})
}))
.child(html!("span", {
.text("Shop")
.event(|_: dominator::events::Click| {
ShopRoutes::goto(ShopRoutes::Shop { display_item_id: None })
})
}))
.child(link!(
ShopRoutes::Shop { display_item_id: Some("1234".to_string())}.to_url(),
{
.text("Daily offer!")
}))
}))
.child(html!("div", {
.class("main-view")
.child_signal(shop_application(route_signal))
}))
})
}
Notice we use the link!
macro provided by tokio for a shorthand.
This creates an <a>
element with on click set correctly for us!
Generalized router
We can generalize and simplify the route handling a bit.
For instance, we can build a generic router based on the matchit
crate (which is use din the popular backend library axum
).
It is essentially a set of routes, associated with a lambda function to extract the matched parameters and translate them into our desired route value (typically our enum from up-top)
use matchit::{Params, Router};
use wasm_bindgen::UnwrapThrowExt;
use dominator::routing::url;
use futures_signals::signal::Signal;
use futures_signals::signal::SignalExt;
pub struct AppRouter<TValue> {
router: Router<Box<dyn Fn(Params) -> Result<TValue, ()>>>,
}
impl<TValue> AppRouter<TValue> where TValue: Default {
pub fn new(router: Router<Box<dyn Fn(Params) -> Result<TValue, ()>>>) -> Self {
Self { router }
}
#[inline]
fn match_url(&self, url: impl AsRef<str>) -> Result<TValue, ()> {
let matched = self.router.at(url.as_ref()).map_err(|_| ())?;
(matched.value)(matched.params)
}
pub fn signal(self) -> impl Signal<Item=TValue> {
url().signal_ref(|current_route| {
web_sys::Url::new(current_route.as_str()).expect_throw("Invalid url")
}).map(move |new_url| {
if let Ok(route) = self.match_url(new_url.hash().as_str()) {
info!("url: {}", new_url.hash().as_str());
route
} else {
info!("unmatched url: {}", new_url.hash().as_str());
TValue::default()
}
})
}
}
This makes it super easy to wire our application routes using lambdas.
We simply register handler functions for our chosen route patterns on the matchit
router instance.
These lambdas then return the value we wish to receive from the app routers signal.
let mut router = matchit::Router::<Box<dyn Fn(Params) -> Result<ShopRoutes, ()>>>::new();
// Configure a callback on the router for matching a certain route, and convert it to our application enum type
router.insert("#/shop/{id}", Box::new(|params: Params| {
let item_id = params.get("id").ok_or_else(|| ())?;
Ok(ShopRoutes::Shop { display_item_id: Some(item_id.to_string()) })
})).unwrap_throw();
let route_signal = AppRouter::new(router).signal();
Logging and Debugging
By default, you will not be able to view your source code directly in the browser as you would with javascript. However, the browser supports DWARF debugging symbols, which are generated by the rust compiler.
You will also need to use Chrome with the C++ DWARF Chrome extension extension.
Enable debug builds
When using rollup, we need to enable debug builds. This is done via a parameter to the rust plugin, configured in the rollup configuration file.
plugins: [
rust({
serverPath: "js/",
debug: true,
cargoArgs: ["--config", "profile.dev.debug=true"],
wasmBindgenArgs: ["--debug", "--keep-debug"]
})
]
If you are using trunk
, you need the following line in your index.html:
<link data-trunk rel="rust" href="Cargo.toml" data-keep-debug>
With the DWARF plugin installed and debug symbols enabled, we can set breakpoints and step through our code as we are used to from other environments:
When debug builds are enabled, we get a much more meaningful error message from the browser:
As opposed to the (very) unhelpful release build message:
Use unwrap_throw
Another good practice is to use unwrap_throw()
and expect_throw()
as opposed to unwrap()
and expect()
.
The _throw()
counterpart will create a nicer JS exception than the generic runtime error caused by a panic.
This improved exception contains source information that greatly improves the debugging experience:
Logging
The log
crate provides a fairly ubiquitous interface for various logging tasks in the rust ecosystem.
The wasm-logger
crate gives us the option to use this from the browser as well (targeting the console, as one might expect).
Simply add the log
and wasm-logger
crates as dependencies, and initialize logging from your main function:
wasm_logger::init(wasm_logger::Config::default());
You can now use the various logging macros from log
as you are used to!
Testing DOMINATOR UI code
Testing is an essential part of any serious software project, UIs included. Our DOMINATOR UI code cannot execute directly in an x86 unit test environment though, as it relies heavily upon browser APIs being present to function.
Luckily, wasm-bindgen provide a testing tool that allows us to execute tests compiled to wasm inside a headless browser, essentially providing us with a unit test-like environment to verify our UI code in!
To get a better overview of the wasm-bindgen-test crate, head over to their official book to get started.
Requirements
To recap the required configuration, you will need to set up a few things in your project.
First of all, we have to add wasm-bindgen-tests as a dev-dependency to our crate:
[dev-dependencies]
wasm-bindgen-test = "x.y.z" # Please use the latest compatible version you can
Now, to configure the test environment to be a browser, we need to add the following to the root of our crate (usually lib.rs)
#![allow(unused)] fn main() { use wasm_bindgen_test::wasm_bindgen_test_configure; wasm_bindgen_test_configure!(run_in_browser); }
We also need to install the wasm-bindgen-cli
tool, and instruct cargo to use it to execute our tests for the wasm32 target.
Make sure that the x.y-z version of wasm-bindgen-cli matches the version of wasm-bindgen you are using in your projects Cargo.toml.
cargo install wasm-bindgen-cli --vers x.y.z
Then add the following to the .cargo/config.toml
file in your project:
[target.wasm32-unknown-unknown]
runner = "wasm-bindgen-test-runner"
Lastly, you will need to install either chromedriver
or geckodriver
to execute your tests, depending on your browser choice.
Running the tests
To run your headless tests, the following commands should work:
CHROMEDRIVER=/path/to/chromedriver cargo test --target wasm32-unknown-unknown
GECKODRIVER=/path/to/geckodriver cargo test --target wasm32-unknown-unknown
If you wish to run the tests in a headed browser, i.e. in a browser instance opened in a new window, you can set the NO_HEADLESS=1
environment variable:
NO_HEADLESS=1 CHROMEDRIVER=/path/to/chromedriver cargo test --target wasm32-unknown-unknown
An example component with associated in-browser test
Here's a full example of a component with a corresponding unit test. It's not a very complex component, but it serves to illustrate how we can test that some interaction results in the expected dom structure. In this case, we make sure that clicking the button 3 times, results in 3 rows of a specific class being added to the DOM.
use dominator::{clone, Dom, events, html};
use futures_signals::signal::{Mutable, SignalExt};
pub fn my_cmp() -> Dom {
let counter = Mutable::new(0);
html!("div", {
.child(html!("button", {
.attr("id", "click-me")
.event(clone!(counter => move |_: events::Click| {
counter.set(counter.get() + 1);
}))
}))
.children_signal_vec(counter.signal_ref(|count| {
(0..*count).map(|v| {
html!("div", {
.class("count-me")
.text(v.to_string().as_str())
})
}).collect()
}).to_signal_vec())
})
}
#[cfg(test)]
mod in_browser_tests {
use futures_signals::signal::{Mutable, SignalExt};
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
use wasm_bindgen_test::wasm_bindgen_test;
use web_sys::HtmlElement;
use crate::techniques_and_patterns::in_browser_testing::my_cmp;
#[wasm_bindgen_test]
async fn some_dom_test() {
let cmp_to_test = my_cmp();
// Make sure to replace the current body content, to avoid
// multiple tests contaminating each other
dominator::replace_dom(&dominator::body(), &dominator::body().first_child().unwrap(), cmp_to_test);
let button = web_sys::window().unwrap()
.document().unwrap()
.get_element_by_id("click-me").unwrap();
let button_ref = button.dyn_ref::<HtmlElement>().unwrap();
button_ref.click();
button_ref.click();
button_ref.click();
// Yield execution time to the browser!
// This is important; if omitted, the browser will not have time to update the DOM before we do our assertion!
JsFuture::from(js_sys::Promise::resolve(&JsValue::null()))
.await
.unwrap();
// Verify the resulting dom:
let count_mes = web_sys::window().unwrap()
.document().unwrap()
.get_elements_by_class_name("count-me");
assert_eq!(count_mes.length(), 3);
}
}