Introduction
Note: The book is a work in progress. Some chapters are empty placeholders that will be filled in over time.
Percy
a collection of libraries for building interactive frontend browser apps with Rust + WebAssembly.
Percy supports server side rendering out of the box.
Percy
is not yet ready for production (unless you're incredibly brave), but if you're
interested in using it for real things you can watch the development progress..
What is Percy?
Percy is a toolkit geared towards building single page web apps entirely in Rust that can also be rendered at the server.
This allows you to build search engine friendly browser applications in Rust.
A snippet
use percy_dom::prelude::*; // Percy supports events, classes, attributes a virtual dom // with diff/patch and everything else that you'd expect from // a frontend toolkit. // // This, however, is just the most basic example of rendering // some HTML on the server side. fn main () { let some_component = html! { <div class="cool-component">Hello World</div> }; let html_string = some_component.to_string(); println!("{}", html_string); }
Roadmap
Percy
is very young and going through the early stages of development. Our roadmap is
is mainly led by Real World Driven Development.
This means that we're using Percy
to build a real, production web app and ironing out
the kinks and fixing the bugs as we go.
Once the tools have stabilized and we've settled into a clean structure for Percy
applications we'll publish a CLI for generating a production-grade starter project with
everything that you need to get up and running.
Check out the Percy issue tracker and maybe open a couple of your own!
Notable Features
Percy
is still young, so the feature set is still growing and maturing. At the moment:
-
An
html!
macro that generates a virtual dom that can can be rendered into a DOM element on the frontend or aString
on the backend. -
CSS in Rust - Optionally writing your CSS styles right next to your
html!
components instead of in separate CSS/Sass/etc files.
Rendering Views
Almost all front-end web applications seek to display some (often times interactive) content to a user.
This section will dive into how to render content with Percy
.
HTML Macro
This chapter discusses rendering HTML using the html!
macro
Writing html!
Static text
Text that will never change can be typed right into your HTML
#![allow(unused)] fn main() { use percy_dom::prelude::*; html!{ <div> Text goes here </div> }; }
Text variables
Text variables must be wrapped in braces.
#![allow(unused)] fn main() { use percy_dom::prelude::*; let text_var = " world"; html! { Hello { <div> { text_var } </div> } } }
Attributes
Attributes work just like regular HTML.
#![allow(unused)] fn main() { let view = html!{ <div id='my-id' class='big wide'></div> }; }
Event Handlers
#![allow(unused)] fn main() { html! { <button onclick=move|_event: web_sys::MouseEvent| { web_sys::console::log_1(&"clicked!".into()); } > Click me! </button> } }
Nested components
html!
calls can be nested.
#![allow(unused)] fn main() { let view1 = html!{ <em> </em> }; let view2 = html{ <span> </span> } let parent_view = html! { <div> { view1 } { view2 } { html! { Nested html! call } } </div> }; let html_string = parent_view.to_string(); // Here's what the String looks like: // <div><em></em><span></span>Nested html! call</div> }
Iterable Children
Any type that implements IntoIter
#![allow(unused)] fn main() { let list = vec!["1", "2", "3"] .map(|item_num| { html! { <li> List item number { item_num } </li> } }); html! { <ul> { list } >/ul> } }
Comments
You can use Rust comments within your HTML
#![allow(unused)] fn main() { html! { /* Main Div */ <div> <br /> // Title <h2>Header</h2> <br /> </div> } }
Classes
let _node = html! {
<div class="some classes here">
<span class=["array", "works", "too"]></span>
<strong class=vec!["vec", "works", "as", "well"]></strong>
<em class=vec!["vec", "works", "as", "well"]></em>
<label class=["as_ref", "str", "works", CssClass::BigButton]></label>
</div>
};
enum CssClass {
BigButton
}
impl AsRef<str> for CssClass {
fn as_ref (&self) -> &str {
match self {
Self::BigButton => "big-button"
}
}
}
Working with Text
One of the most popular types of nodes in the DOM is the Text node, and the `html! macro focuses heavily on making them as easy to create as possible.
You can just type unquoted text into the html!
macro and neighboring text will get combined into a single Text
node, much
like the way that web browsers handle text from html documents.
fn main () { let interpolated_text = "interpolate text variables."; let example = html! { <div> Text can be typed directly into your HTML. <div>Or you can also {interpolated_text}</div> </div> }; }
Preserving Space Between Blocks
You should always get the same spacing (or lack there of) between text and other elements as you would
if you were working in a regular old .html
file.
We'll preserve newline characters so that white-space: pre-wrap
etc will work as expected.
When it comes to interpolated variables, we base spacing on the spacing outside of the braces, not the inside.
Let's illustrate:
fn main () { let text = "hello"; html! { <div>{ hello }</div> }; // <div>hello</div> html! { <div>{hello}</div> }; // <div>hello</div> html! { <div> { hello } </div> }; // <div> hello </div> html! { <div> {hello} </div> }; // <div> hello </div> html! { <div>{hello} </div> }; // <div>hello </div> html! { <div> {hello}</div> }; // <div> hello</div> }
Preserving white-space
Certain CSS styles such as white-space: pre-wrap
will preserve all space and new lines within text.
The html-macro
will treat all sequences of whitespace as a single whitespace, so in cases that you don't want that you'll need to
use a text variable for your text.
Fortunately this should be incredibly uncommon for almost all use cases.
fn main () { let text = r#"This needs it's whitespace perfectly preserved"#; html! { <span style="white-space: pre-wrap">{ text }</span> }; }
Custom Components
Percy's html!
macro supports custom components.
You can create a component by implementing the View
trait.
Here is an example:
#![allow(unused)] fn main() { fn page() -> VirtualNode { html! { <div> <ChildView count={0}/> </div> } } struct ChildView { count: u8, } impl View for ChildView { fn render(&self) -> VirtualNode { html! { <div> Count is {format!("{}", self.count)} </div> } } } }
Setting Inner HTML
You'll sometimes want to use a string of HTML in order to set the child nodes for an element.
For example, if you're creating a tool tip component you might want to be able to support setting tool tips using
arbitrary HTML such as "Hello <strong>World!</strong>"
:
You can use the SpecialAttributes.dangerous_inner_html
attribute to set inner html.
Note that it is called dangerous
because it can potentially expose your application to cross-site scripting attacks if your application
trusts arbitrary un-escaped HTML strings.
#![allow(unused)] fn main() { let tooltip_contents = "<span>hi</span>"; let mut div: VirtualNode = html! { <div></div> }; div.as_velement_mut() .unwrap() .special_attributes .dangerous_inner_html = Some(tooltip_contents.to_string()); let div: Element = div.create_dom_node().node.unchecked_into(); assert_eq!(div.inner_html(), "<span>hi</span>"); }
Conditional Rendering
Sometimes you'll want to conditionally render some html. You can use an Option
.
#![allow(unused)] fn main() { fn conditional_render() { let maybe_render: Option<VirtualNode> = make_view(); html! { <div> <h1>Hello World</h1> { maybe_render } </div> } } }
Real Elements and Nodes
You'll sometimes want to do something to the real DOM [Node] that gets created from your VirtualNode
.
percy-dom
exposes a number of ways to work with real DOM elements.
On Create Element
The on_create_elem
special attribute allows you to register a function that will be called
when the element is first created.
#![allow(unused)] fn main() { let mut div: VirtualNode = html! { <div> <span>This span should get replaced</span> </div> }; div.as_velement_mut() .unwrap() .special_attributes .set_on_create_element( "some-key", move |elem: web_sys::Element| { elem.set_inner_html("Hello world"); }, )); let div: Element = div.create_dom_node().node.unchecked_into(); assert_eq!(div.inner_html(), "Hello world"); }
Macro shorthand
You can also use the html!
macro to set the on_create_element
function.
#![allow(unused)] fn main() { let _ = html! { <div key="some-key" on_create_element = move |element: web_sys::Element| { element.set_inner_html("After"); } > Before </div> } }
On Remove Element
The on_remove_elem
special attribute allows you to register a function that will be called
when the element is removed from the DOM.
#![allow(unused)] fn main() { let mut div: VirtualNode = html! { <div></div> }; div.as_velement_mut() .unwrap() .special_attributes .set_on_remove_element( "some-key", move |_elem: web_sys::Element| { // ... }, )); }
Macro shorthand
You can also use the html!
macro to set the on_remove_element
function.
#![allow(unused)] fn main() { let _ = html! { <div key="some-key" on_remove_element = move |_element: web_sys::Element| { // ... } > Before </div> } }
Boolean Attributes
Boolean attributes such as disabled
and checked
can be added by assigning a bool as their value.
Both variables and expressions can be used.
Here are a few examples:
#![allow(unused)] fn main() { let is_disabled = true; let video_duration = 500; html! { <video autoplay=false></video> <button disabled=is_disabled>Disabled Button</disabled> <video controls={video_duration > 100}></video> } }
Special Virtual DOM Attributes
Some virtual DOM attributes do not merely set or remove the corresponding HTML attribute of the same name.
checked
According to the HTML spec, the checked
HTML attribute only controls the default checkedness.
Changing the checked
HTML attribute may not cause the checkbox's checkedness to change.
By contrast: specifying html! { <input checked={bool} /> }
causes percy
to always render the checkbox with the specified checkedness.
- If the VDOM is updated from
html! { <input checked=true /> }
tohtml { <input checked=false /> }
, the input element's checkedness will definitely change. - If the VDOM is updated from
html! { <input checked=true /> }
tohtml { <input checked=true /> }
, the input element's checkedness will be reverted totrue
even if the user interacted with the checkbox in between.
percy-dom
updates the checked
property (current checkedness, not reflected in HTML).
This behavior is more desirable because percy
developers are accustomed to declaratively controlling the DOM and rendered HTML.
percy-dom
does not affect the presence of the checked
attribute (default checkedness, reflected in HTML).
This means that if you need to configure the checked
attribute (this should almost never be the case if your GUI state is driven by the backend state) then percy-dom
won't get in your way.
value
According to the HTML spec, the value
HTML attribute only controls the default value.
Changing the value
HTML attribute may not cause the input element's value to change.
By contrast: specifying html! { <input value="..." /> }
causes percy
to always render the input element with the specified value.
- If the VDOM is updated from
html! { <input value="hello" /> }
tohtml { <input value="goodbye" /> }
, the input element's value will definitely change. - If the VDOM is updated from
html! { <input value="hello" /> }
tohtml { <input value="hello" /> }
, the input element's value will be reverted to"hello"
even if the user interacted with the input element in between.
percy
updates both
- the
value
attribute (default value, reflected in HTML) and, - the
value
property (current value, not reflected in HTML).
Setting the value
property is desirable because percy
developers are accustomed to declaratively controlling the DOM and rendered HTML.
Lists
Keys
When elements in a list are keyed the diffing and patching functions will use the nodes' keys to know whether an element was removed or simply moved.
This leads to fewer interactions with the real-DOM when modifying lists, as well as being able to preserve child elements when keyed elements are moved around in the list.
This preservation is useful when you have an element that has children that aren't managed by percy-dom.
Using keys in lists is recommended, but not required.
Here's an example of using the key attribute:
#![allow(unused)] fn main() { let items = ["a", "b", "c"]; let items: Vec<VirtualNode> = items .into_iter() .map(|key| { html! { <div key={key}>Div with key {key}</div> } }) .collect(); let node = html! { <div> { items } </div> } }
Virtual DOM
At the heart of the Percy
toolkit is percy-dom
, a crate that provides a virtual dom
implementation that allows you to write functional front-end applications.
This same percy-dom
also works on the backend by rendering to a String instead of a DOM element.
This ability to render on the backend is commonly referred to as server side rendering.
use percy_dom::prelude::*; // The most basic example of rendering to a String fn main () { let component = html! { <div id="my-id"> Hello world </div> }; println!("{}", component); // <div id="my-id">Hello world</div> }
Unit Testing your Views
Percy's testing story is very much a work in progress, so please give feedback as you write tests!
Here's an example of unit testing your views. You can find it in the examples directory at examples/unit-testing-views.
use percy_dom::prelude::*; fn main() { println!("To see this example in action:"); println!("cargo test -p unit-testing-components"); } #[allow(unused)] fn full_water_bottle() -> VirtualNode { html! { <div> <span id="full-water"> I am full of delicious and refreshing H20! </span> </div> } } #[allow(unused)] fn not_full_water_bottle(percent_full: f32) -> VirtualNode { let message = format!( "Please fill me up :( I am only {} percent full :(", percent_full ); let message = VirtualNode::text(&*message); html! { <div id="not-ful-water"> { message } </div> } } #[allow(unused)] fn water_bottle_view(percent_full: f32) -> VirtualNode { if percent_full > 0.5 { full_water_bottle() } else { not_full_water_bottle(percent_full) } } #[cfg(test)] mod tests { use super::*; #[test] fn conditional_water_messaging() { assert_eq!( water_bottle_view(0.7) .children_recursive() .iter() .filter(|v| { if let Some(elem) = v.as_velement_ref() { return elem.attrs.get("id") == Some(&"full-water".into()); } false }) .collect::<Vec<_>>() .len(), 1 ); let water_view = water_bottle_view(0.2587); assert_eq!( water_view .as_velement_ref() .expect("Not an element node") .children[0] .as_vtext_ref() .expect("Not a text node") .text, "Please fill me up :( I am only 0.2587 percent full :(" ) } }
Server Side Rendering
This section outlines Percy's server side rendering support.
Why use Server Side Rendering
In recent years it has become popular for just about all of a web application to be rendered on the client.
Applications will often serve almost nothing but a <script>
tag that loads up some front-end code (JavaScript
and/or WebAssembly
)
and that front-end code is responsible for rendering the application's HTML
and interactions.
Here's an example of what many of today's web application boil down to:
<!DOCTYPE html>
<html lang="en">
<body>
<div id='app'>
<!-- application will render HTML here when it begins -->
</div>
<!--
One this applications loads it will
inject some HTML into the body
-->
<script src="/my-application.js"></script>
</body>
</html>
One downside to this approach is that a user must wait until the script begins rendering before seeing anything.
Let's illustrate:
Client side rendering
without server side rendering:
┌─────────────────────────────────────┐
│ 1) Client requests for web page │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│2) Server responds with <script> tag │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 3) Client downloads script │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│4) Client parses the returned script │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 5) Client executes returned script │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐ User first
│ 6) Script starts rendering content │◀─ sees
└─────────────────────────────────────┘ content
Contrast this with server side rendering, where the initial page load might look something like this:
<!DOCTYPE html>
<html lang="en">
<body>
<div id='app'>
<!--
This content was sent down from the server so
that the user sees something immediately!
-->
</div>
<script src="/my-application.js"></script>
</body>
</html>
And the flow:
Server side rendering then client
takes over rendering:
┌─────────────────────────────────────┐
│ 1) Client requests for web page │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 2) Server responds with server │ User first
│ side rendered content along with a │◀─ sees
│ <script> tag │ content
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 3) Client downloads script │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│4) Client parses the returned script │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 5) Client executes returned script │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 6) Script starts rendering content │
└─────────────────────────────────────┘
Server side rendering allows you to show something to your users more quickly, especially so for users with slower machines and/or bandwidth.
How to SSR
In the most simple case, server side rendering in Percy
boils down to
rendering your virtual DOM to a String
and responding to a client with
that String
.
use percy_dom::prelude::*; use std::cell::Cell; fn main () { let count_cell = Cell::new(5); let app = html! { <div id="app"> <button onclick=|_ev| { *count+= 1; }> Hello world </button> </div> }; let html_to_serve = app.to_string(); // <div id="app"><button>Hello world</button></div> // .. server string to client (http response) ... }
Hydrating initial state
You'll usually want your views to be rendered based on some application state. So, typically, your server will
- Receive a request from the client
- Set the initial application state based on the request
- Render the application using the initial state
- Reply with the initial HTML and the initial state
- Client takes over rendering, starting from the initial state.
To illustrate we'll take a look at an excerpt from a more realistic server side rendering example.
Afterwards you can check out the full example at examples/isormorphic.
A more realistic server side rendering implementation would look like the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/app.css"/>
<link rel="shortcut icon" href="#" />
<title>Rust Web App</title>
</head>
<body style='margin: 0; padding: 0; width: 100%; height: 100%;'>
<div id="isomorphic-rust-web-app" style='width: 100%; height: 100%;'>
#HTML_INSERTED_HERE_BY_SERVER#
</div>
<script>
function downloadJson(path, callback) {
fetch(path)
.then(function(response) {
return response.json();
})
.then(function(json) {
callback(json);
});
}
</script>
<script type=module>
let client
let updateScheduled = false
window.GlobalJS = function () {}
// TODO:
// https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Window.html#method.request_animation_frame
window.GlobalJS.prototype.update = function () {
if (!updateScheduled) {
requestAnimationFrame(() => {
client.render()
updateScheduled = false
})
}
updateScheduled = true
}
window.global_js = new GlobalJS()
import { Client, default as init } from '/static/isomorphic_client.js';
async function run() {
await init('/static/isomorphic_client_bg.wasm');
client = new Client(window.initialState)
}
run();
</script>
<script>
window.initialState = '#INITIAL_STATE_JSON#'
</script>
</body>
</html>
#![allow(unused)] fn main() { // examples/isormorphic/server/src/rocket_server.rs // Check out the full application in /examples/isormorphic directory {{#include ../../../../examples/isomorphic/server/src/rocket_server.rs}} }
And then the client would use serde
to deserialize the initialState
into a State struct and begin rendering using that State.
Type Safe URL Params
percy-router
provides functionality that helps you render different views when your users' visit different routes.
Let's take a look:
#![allow(unused)] fn main() { // Imported from crates/percy-router-macro-test/src/book_example.rs use percy_dom::prelude::*; use percy_router::prelude::*; use std::str::FromStr; mod my_routes { use super::*; #[route(path = "/users/:id/favorite-meal/:meal", on_visit = download_some_data)] pub(super) fn route_data_and_param( id: u16, state: Provided<SomeState>, meal: Meal, ) -> VirtualNode { let id = format!("{}", id); let meal = format!("{:#?}", meal); html! { <div> User { id } loves { meal } </div> } } } fn download_some_data(id: u16, state: Provided<SomeState>, meal: Meal) { // Check state to see if we've already downloaded data ... // If not - download the data that we need } #[test] fn provided_data_and_param() { let mut router = Router::new(create_routes![my_routes::route_data_and_param]); router.provide(SomeState { happy: true }); assert_eq!( &router .view("/users/10/favorite-meal/breakfast") .unwrap() .to_string(), // TODO: Requires proc macro APIs that are currently unstable - https://github.com/rust-lang/rust/issues/54725 // "<div> User 10 loves Breakfast </div>" "<div>User10lovesBreakfast</div>" ); } struct SomeState { happy: bool, } #[derive(Debug)] enum Meal { Breakfast, Lunch, Dinner, } impl FromStr for Meal { type Err = (); fn from_str(s: &str) -> Result<Self, Self::Err> { Ok(match s { "breakfast" => Meal::Breakfast, "lunch" => Meal::Lunch, "dinner" => Meal::Dinner, _ => Err(())?, }) } } }
Type Safe URL Params
percy-router
provides functionality that helps you render different views when your users' visit different routes.
Let's take a look:
#![allow(unused)] fn main() { // Imported from crates/percy-router-macro-test/src/book_example.rs use percy_dom::prelude::*; use percy_router::prelude::*; use std::str::FromStr; mod my_routes { use super::*; #[route(path = "/users/:id/favorite-meal/:meal", on_visit = download_some_data)] pub(super) fn route_data_and_param( id: u16, state: Provided<SomeState>, meal: Meal, ) -> VirtualNode { let id = format!("{}", id); let meal = format!("{:#?}", meal); html! { <div> User { id } loves { meal } </div> } } } fn download_some_data(id: u16, state: Provided<SomeState>, meal: Meal) { // Check state to see if we've already downloaded data ... // If not - download the data that we need } #[test] fn provided_data_and_param() { let mut router = Router::new(create_routes![my_routes::route_data_and_param]); router.provide(SomeState { happy: true }); assert_eq!( &router .view("/users/10/favorite-meal/breakfast") .unwrap() .to_string(), // TODO: Requires proc macro APIs that are currently unstable - https://github.com/rust-lang/rust/issues/54725 // "<div> User 10 loves Breakfast </div>" "<div>User10lovesBreakfast</div>" ); } struct SomeState { happy: bool, } #[derive(Debug)] enum Meal { Breakfast, Lunch, Dinner, } impl FromStr for Meal { type Err = (); fn from_str(s: &str) -> Result<Self, Self::Err> { Ok(match s { "breakfast" => Meal::Breakfast, "lunch" => Meal::Lunch, "dinner" => Meal::Dinner, _ => Err(())?, }) } } }
On Visit Callback
You'll sometimes want to do something whenever you visit a route.
For example, you might want to download some data from an API whenever you visit a route.
You can specify things like this using the on_visit
attribute in the #route(...)
macro.
Doing this in the #route(...)
macro makes it very clear what happens whenever
a route is visited.
#![allow(unused)] fn main() { // snippet: examples/isomorphic/app/src/lib.rs {{#bookimport ../../../../examples/isomorphic/app/src/lib.rs@on-visit-example}} }
CSS in Rust
... TODO ..
Contributing
This section is meant to help you get up to speed with how you can contribute to the Percy
project.
Getting Started
-
Rust Nightly.
rustup default nightly rustup target add wasm32-unknown-unknown
-
Install Geckodriver since some of our tests are meant to run in a browser. Put it somewhere in your path, i.e. you might move it to
/usr/local/bin/geckdriver
. -
Download the project and make sure that you can run the test suite
git clone https://github.com/chinedufn/percy cd percy ./test.sh
Types of Contributions
There are three main types of contributions to Percy
, all of which are equally important.
Documentation / Book Contributions / Examples
Our documentation is the first thing that anyone interested in using Percy
will see.
Before installing the tools, running the examples or starting up their own project they'll
peruse the documentation to get a sense of Percy
's design and how to get started.
Because of this, documentation and book contributions are incredibly useful and important.
While it's common for projects to dismiss typo fixes as unimportant contributions, we feel the complete opposite. We strive for getting as close to "perfect documentation" as possible, and anything that brings us a step closer matters.
Documentation
Scroll around the codebase. If you don't instantly understand something then it is poorly documented. Open up an issue or PR with your ideas on how it can be better communicated.
Book Contributions
If there's a section that you think is missing from the book, PR the title of that section with some placeholder text.
We're totally fine with not having book contributions fully fleshed out right away!
Having a placeholder makes it easy for yourself or someone else to feel motivated one day and start hitting the keyboard.
# To view the book locally as you edit
cd book && mdbook serve --open
Examples
If you can't figure out how exactly to implement something within 5 minutes that might mean
that you were underserved by the examples
directory.
Open up an issue with your question or an idea with how to craft an example that would have answered it!
Building Something with Percy
When you're building a real application you run into problems, trade-offs and considerations that you never could have thought of up front.
We want to uncover those problems and either address them in the main toolkit or point people in the right direction for how to solve them in user land.
The more people that are using Percy
to build things, the more of these problems we can
fix and/or suggest approaches for.
If you have an idea for something that you can build with Percy
then get started. Feel
free to open up an issue with any questions or thoughts that you might have. Also open
issues / PRs as you run into problems / annoyances.
Design of percy
This section is intended to be a deep dive into how percy
's different internal pieces work today.
Diff / Patch Algorithm
This section discusses the design of the diffing and patching algorithms as well as how to troubleshoot and fix issues that might arise.
Diff / Patch Walkthrough
From a user's perspective, rendering on the client side looks roughly like this:
#![allow(unused)] fn main() { // Create a first virtual DOM in application memory then // use this description to render into the real DOM let old_vdom = html! { <div> Old </div> }; pdom.update(old_vdom); // Create a second virtual DOM in application memory then // apply a minimal set of changes to the DOM to get it to look like // this second virtual DOM representation let new_vdom = html! { <div> New </div> } pdom.update(new_vdom); // Create a thid virtual DOM in application memory then // apply a minimal set of changes to the DOM to get it to look like // this second virtual DOM representation let new_vdom = html! { <div> <span>Very New</span> </div> } pdom.update(new_vdom); }
On the code side of things, the process is
-
Compare the old virtual DOM with the new virtual DOM and generate a
Vec<Patch<'a>>
-
Iterate through
Vec<Patch<'a>>
and apply each of those patches in order to update the real DOM that the user sees.
Diffing
Let's say that you have an old virtual dom that you want to update using a new virtual dom.
Old vdom New vdom
┌─────┐ ┌─────┐
│ Div │ │ Div │
└─────┘ └─────┘
│ │
┌────┴─────┐ ┌────┴─────┐
▼ ▼ ▼ ▼
┌────┐ ┌────┐ ┌────┐ ┌────┐
│Span│ │ Br │ │Img │ │ Br │
└────┘ └────┘ └────┘ └────┘
In our example the only thing that has changed is that the Span
has become a Img
.
So, we need to create a vector of patches that describes this.
Our diffing algorithm will recursively iterate through the virtual dom trees and generate a vector of patches that looks like this:
#![allow(unused)] fn main() { // Our patches would look something like this: let patches = vec![ // The real generated patch won't use the `html!` macro, // this is just for illustration. Patch::Replace(1, html! { <span> </span> }), ]; }
This patch says to replace the node with index of 1, which is currently a <br>
with a <span>
.
How does the diffing algorithm determine the index?
As we encounter nodes in our old virtual dom we increment a node index, the root node being index 0. Nodes are traversed breadth.
// Nodes are indexed breadth first.
.─.
( 0 )
`┬'
┌────┴──────┐
│ │
▼ ▼
.─. .─.
( 1 ) ( 2 )
`┬' `─'
┌────┴───┐ │
│ │ ├─────┬─────┐
▼ ▼ │ │ │
.─. .─. ▼ ▼ ▼
( 3 ) ( 4 ) .─. .─. .─.
`─' `─' ( 5 ) ( 6 ) ( 7 )
`─' `─' `─'
Patching
There are several different types of patches that are described in our Patch
enum.
#![allow(unused)] fn main() { use js_sys::Reflect; use std::cell::RefCell; use std::collections::HashSet; use std::collections::{HashMap, VecDeque}; use std::rc::Rc; use virtual_node::event::{insert_non_delegated_event, ElementEventsId, VirtualEventNode}; use virtual_node::VIRTUAL_NODE_MARKER_PROPERTY; use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; use web_sys::{Element, HtmlInputElement, HtmlTextAreaElement, Node, Text}; use crate::event::VirtualEvents; use crate::patch::Patch; use crate::{AttributeValue, PatchSpecialAttribute, VirtualNode}; /// Apply all of the patches to our old root node in order to create the new root node /// that we desire. Also, update the `VirtualEvents` with the new virtual node's event callbacks. /// /// This is usually used after diffing two virtual nodes. // Tested in a browser in `percy-dom/tests` pub fn patch<N: Into<Node>>( root_dom_node: N, new_vnode: &VirtualNode, virtual_events: &mut VirtualEvents, patches: &[Patch], ) -> Result<(), JsValue> { let root_events_node = virtual_events.root(); let mut nodes_to_find = HashSet::new(); for patch in patches { patch.insert_node_indices_to_find(&mut nodes_to_find); } let mut node_queue = VecDeque::new(); node_queue.push_back(NodeToProcess { node: root_dom_node.into(), events_node: root_events_node.clone(), events_node_parent: None, node_idx: 0, }); let mut ctx = PatchContext { next_node_idx: 1, nodes_to_find, found_nodes: HashMap::new(), events_id_for_old_node_idx: HashMap::new(), node_queue, }; while ctx.nodes_to_find.len() >= 1 && ctx.node_queue.len() >= 1 { find_nodes(&mut ctx); } for patch in patches { let patch_node_idx = patch.old_node_idx(); if let Some((_node, elem_or_text, events_elem)) = ctx.found_nodes.get(&patch_node_idx) { match elem_or_text { ElementOrText::Element(element) => { apply_element_patch(&element, events_elem, &patch, virtual_events, &ctx)? } ElementOrText::Text(text_node) => { apply_text_patch(&text_node, &patch, virtual_events, &events_elem.events_node)?; } }; } else { // Right now this can happen if something outside of Percy goes into the DOM and // deletes an element that is managed by Percy. panic!( "We didn't find the element or text node that we were supposed to patch ({}).", patch_node_idx ) } } overwrite_events(new_vnode, root_events_node, virtual_events); Ok(()) } struct PatchContext { next_node_idx: u32, nodes_to_find: HashSet<u32>, found_nodes: HashMap<u32, (Node, ElementOrText, EventsNodeAndParent)>, events_id_for_old_node_idx: HashMap<u32, ElementEventsId>, node_queue: VecDeque<NodeToProcess>, } struct NodeToProcess { node: Node, events_node: Rc<RefCell<VirtualEventNode>>, events_node_parent: Option<Rc<RefCell<VirtualEventNode>>>, node_idx: u32, } enum ElementOrText { Element(Element), Text(Text), } struct EventsNodeAndParent { events_node: Rc<RefCell<VirtualEventNode>>, parent: Option<Rc<RefCell<VirtualEventNode>>>, } impl PatchContext { fn store_found_node(&mut self, node_idx: u32, node: Node, events_node: EventsNodeAndParent) { self.nodes_to_find.remove(&node_idx); match node.node_type() { Node::ELEMENT_NODE => { let elem = ElementOrText::Element(node.clone().unchecked_into()); self.found_nodes.insert(node_idx, (node, elem, events_node)); } Node::TEXT_NODE => { let text = ElementOrText::Text(node.clone().unchecked_into()); self.found_nodes.insert(node_idx, (node, text, events_node)); } other => unimplemented!("Unsupported root node type: {}", other), } } } fn find_nodes(ctx: &mut PatchContext) { if ctx.nodes_to_find.len() == 0 { return; } let next = ctx.node_queue.pop_front(); if next.is_none() { return; } let job = next.unwrap(); let node = job.node; let events_node = job.events_node; let events_node_parent = job.events_node_parent; let cur_node_idx = job.node_idx; if let Some(events_elem) = events_node.borrow().as_element() { let events_id = events_elem.events_id(); ctx.events_id_for_old_node_idx .insert(cur_node_idx, events_id); } if ctx.nodes_to_find.contains(&cur_node_idx) { let events = EventsNodeAndParent { events_node: events_node.clone(), parent: events_node_parent, }; ctx.store_found_node(cur_node_idx, node.clone(), events); } // We use child_nodes() instead of children() because children() ignores text nodes let children = node.child_nodes(); let child_node_count = children.length(); if child_node_count == 0 { return; } let events_node_borrow = events_node.borrow(); let events_node_elem = &events_node_borrow.as_element().unwrap(); let mut next_child = events_node_elem.first_child(); for i in 0..child_node_count { let child_node = children.item(i).unwrap(); if !was_created_by_percy(&child_node) { continue; } let next_node_idx = ctx.next_node_idx; match child_node.node_type() { Node::ELEMENT_NODE | Node::TEXT_NODE => { let events_child_node = next_child.unwrap(); next_child = events_child_node.borrow().next_sibling().cloned(); ctx.node_queue.push_back(NodeToProcess { node: child_node, events_node: events_child_node, events_node_parent: Some(events_node.clone()), node_idx: next_node_idx, }); ctx.next_node_idx += 1; } Node::COMMENT_NODE => { // At this time we do not support user entered comment nodes, so if we see a comment // then it was a delimiter created by percy-dom in order to ensure that two // neighboring text nodes did not get merged into one by the browser. So we skip // over this percy-dom generated comment node. } _other => { // Ignoring unsupported child node type // TODO: What do we do with this situation? } }; } } fn overwrite_events( node: &VirtualNode, events_node: Rc<RefCell<VirtualEventNode>>, virtual_events: &mut VirtualEvents, ) { if let Some(elem) = node.as_velement_ref() { let events_node = events_node.borrow(); let events_node = events_node.as_element().unwrap(); let events_id = events_node.events_id(); for (event_name, event) in elem.events.iter() { virtual_events.overwrite_event_attrib_fn(&events_id, event_name, event.clone()); } let mut events_child = events_node.first_child(); for child in elem.children.iter() { let e = events_child.unwrap(); events_child = e.borrow().next_sibling().cloned(); overwrite_events(child, e, virtual_events); } } } fn apply_element_patch( node: &Element, events_elem_and_parent: &EventsNodeAndParent, patch: &Patch, virtual_events: &mut VirtualEvents, ctx: &PatchContext, ) -> Result<(), JsValue> { match patch { Patch::AddAttributes(_node_idx, attributes) => { for (attrib_name, attrib_val) in attributes.iter() { match attrib_val { AttributeValue::String(val_str) => { node.set_attribute(attrib_name, val_str)?; if attrib_name == &"value" { maybe_set_value_property(node, val_str) } } AttributeValue::Bool(val_bool) => { // Use `set_checked` instead of `{set,remove}_attribute` for the `checked` attribute. // The "checked" attribute only determines default checkedness, // but `percy-dom` takes `checked` to specify the actual checkedness. // See crates/percy-dom/tests/checked_attribute.rs for more info. if *attrib_name == "checked" { maybe_set_checked_property(node, *val_bool); } else if *val_bool { node.set_attribute(attrib_name, "")?; } else { node.remove_attribute(attrib_name)?; } } } } Ok(()) } Patch::RemoveAttributes(_node_idx, attributes) => { for attrib_name in attributes.iter() { node.remove_attribute(attrib_name)?; } Ok(()) } Patch::Replace { old_idx: _, new_node, } => { let (created_node, events) = new_node.create_dom_node(virtual_events); node.replace_with_with_node_1(&created_node)?; let mut events_elem = events_elem_and_parent.events_node.borrow_mut(); events_elem.replace_with_node(events); Ok(()) } Patch::InsertBefore { anchor_old_node_idx: _, new_nodes, } => { let parent = node.parent_node().unwrap(); let parent: Element = parent.dyn_into().unwrap(); let events_parent = events_elem_and_parent.parent.as_ref().unwrap(); for new_node in new_nodes { let (created_node, events) = new_node.create_dom_node(virtual_events); parent.insert_before(&created_node, Some(&node))?; events_parent.borrow_mut().insert_before( Rc::new(RefCell::new(events)), events_elem_and_parent.events_node.clone(), ); } Ok(()) } Patch::MoveNodesBefore { anchor_old_node_idx: _, to_move, } => { let parent = node.parent_node().unwrap(); let parent: Element = parent.dyn_into().unwrap(); let events_parent = events_elem_and_parent.parent.as_ref().unwrap(); let mut events_parent = events_parent.borrow_mut(); for to_move_node in to_move { let (to_move_dom_node, _, to_move_node_events) = ctx.found_nodes.get(to_move_node).unwrap(); parent.insert_before(to_move_dom_node, Some(&node))?; events_parent.remove_node_from_siblings(&to_move_node_events.events_node); events_parent.insert_before( to_move_node_events.events_node.clone(), events_elem_and_parent.events_node.clone(), ); } Ok(()) } Patch::RemoveChildren { parent_old_node_idx: _, to_remove, } => { let parent = node; let events_elem = events_elem_and_parent.events_node.borrow_mut(); let mut events_parent = events_elem; for idx in to_remove { let (node_to_remove, _, events_node_to_remove) = ctx.found_nodes.get(idx).unwrap(); parent.remove_child(&node_to_remove)?; events_parent.remove_node_from_siblings(&events_node_to_remove.events_node); } Ok(()) } Patch::AppendChildren { parent_old_node_idx: _, new_nodes, } => { let parent = &node; let events_elem = events_elem_and_parent.events_node.borrow_mut(); let mut events_parent = events_elem; for new_node in new_nodes { let (created_node, events) = new_node.create_dom_node(virtual_events); parent.append_child(&created_node)?; events_parent .as_element_mut() .unwrap() .append_child(Rc::new(RefCell::new(events))); } Ok(()) } Patch::MoveToEndOfSiblings { parent_old_node_idx: _, siblings_to_move, } => { let parent = node; let events_elem = events_elem_and_parent.events_node.borrow_mut(); let mut events_parent = events_elem; for node in siblings_to_move { let (dom_node_to_move, _, events_node_to_move) = ctx.found_nodes.get(node).unwrap(); parent.append_child(&dom_node_to_move)?; events_parent.remove_node_from_siblings(&events_node_to_move.events_node); events_parent .as_element_mut() .unwrap() .append_child(events_node_to_move.events_node.clone()); } Ok(()) } Patch::ChangeText(_node_idx, _new_node) => { unreachable!("Elements should not receive ChangeText patches.") } Patch::ValueAttributeUnchanged(_node_idx, value) => { node.set_attribute("value", value.as_string().unwrap())?; maybe_set_value_property(node, value.as_string().unwrap()); Ok(()) } Patch::CheckedAttributeUnchanged(_node_idx, value) => { maybe_set_checked_property(node, value.as_bool().unwrap()); Ok(()) } Patch::SpecialAttribute(special) => match special { PatchSpecialAttribute::CallOnCreateElemOnExistingNode(_node_idx, new_node) => { new_node .as_velement_ref() .unwrap() .special_attributes .maybe_call_on_create_element(&node); Ok(()) } PatchSpecialAttribute::CallOnRemoveElem(_, old_node) => { old_node .as_velement_ref() .unwrap() .special_attributes .maybe_call_on_remove_element(node); Ok(()) } PatchSpecialAttribute::SetDangerousInnerHtml(_node_idx, new_node) => { let new_inner_html = new_node .as_velement_ref() .unwrap() .special_attributes .dangerous_inner_html .as_ref() .unwrap(); node.set_inner_html(new_inner_html); Ok(()) } PatchSpecialAttribute::RemoveDangerousInnerHtml(_node_idx) => { node.set_inner_html(""); Ok(()) } }, Patch::AddEvents(node_idx, new_events) => { let events_id = ctx.events_id_for_old_node_idx.get(node_idx).unwrap(); for (event_name, event) in new_events { if event_name.is_delegated() { virtual_events.insert_event( *events_id, (*event_name).clone(), (*event).clone(), None, ); } else { insert_non_delegated_event(node, event_name, event, *events_id, virtual_events); } } Ok(()) } Patch::RemoveEvents(node_idx, events) => { let events_id = ctx.events_id_for_old_node_idx.get(node_idx).unwrap(); for (event_name, _event) in events { if !event_name.is_delegated() { let wrapper = virtual_events.remove_non_delegated_event_wrapper(events_id, event_name); node.remove_event_listener_with_callback( event_name.without_on_prefix(), wrapper.as_ref().as_ref().unchecked_ref(), ) .unwrap(); } virtual_events.remove_event_handler(events_id, event_name); } Ok(()) } Patch::RemoveAllVirtualEventsWithNodeIdx(node_idx) => { let events_id = ctx.events_id_for_old_node_idx.get(node_idx).unwrap(); virtual_events.remove_node(events_id); Ok(()) } } } fn apply_text_patch( node: &Text, patch: &Patch, events: &mut VirtualEvents, events_elem: &Rc<RefCell<VirtualEventNode>>, ) -> Result<(), JsValue> { match patch { Patch::ChangeText(_node_idx, new_node) => { node.set_node_value(Some(&new_node.text)); } Patch::Replace { old_idx: _, new_node, } => { let (elem, enode) = new_node.create_dom_node(events); node.replace_with_with_node_1(&elem)?; events_elem.borrow_mut().replace_with_node(enode); } other => { unreachable!( "Text nodes should only receive ChangeText or Replace patches, not {:?}.", other, ) } }; Ok(()) } // See crates/percy-dom/tests/value_attribute.rs fn maybe_set_value_property(node: &Element, value: &str) { if let Some(input_node) = node.dyn_ref::<HtmlInputElement>() { input_node.set_value(value); } else if let Some(textarea_node) = node.dyn_ref::<HtmlTextAreaElement>() { textarea_node.set_value(value) } } // See crates/percy-dom/tests/checked_attribute.rs fn maybe_set_checked_property(node: &Element, checked: bool) { if let Some(input_node) = node.dyn_ref::<HtmlInputElement>() { input_node.set_checked(checked); } } // Looks for a property on the element. If it's there then this is a Percy element. // // TODO: We need to know not just if the node was created by Percy... but if it was created by // this percy-dom instance.. So give every PercyDom instance a random number and store that at the // virtual node marker property value. fn was_created_by_percy(node: &Node) -> bool { let marker = Reflect::get(&node, &VIRTUAL_NODE_MARKER_PROPERTY.into()).unwrap(); match marker.as_f64() { Some(_marker) => true, None => false, } } }
When patching we iterate over our vector of patches, look at the node index for the patch, then traverse the real DOM in order to find the corresponding DOM element.
So if a patch applies to the node with index 4, we'll start at our root node (node 0) and crawl it's children and it's children's children until we've gone through node 1, 2 and 3.
Fixing diff/patch issues
As our virtual dom implementation ages it will become more and more resilient, but while we're still an experimental library it's possible that the diff/patch algorithm could fail in some scenarios.
If you notice a failure the first step is to open a new issue.
Ideally you include an example start node and end node that isn't working properly.
Let's make up an example here.
# Example things that you'd include in your issue.
start: html! { <div> </div> }
end: html! { <span> </span> }
Observed error: It somehow ends up as <b></b> in my browser!
If you've opened this issue you've already made a big contribution!
If you'd like to go further, here's how to get to the root of the problem.
Debugging Failed Diff
The easiest place to start is by adding a new diff test and seeing what patches you get.
#![allow(unused)] fn main() { use crate::diff::diff; use crate::patch::Patch; use virtual_node::VirtualNode; /// Test that we generate the right Vec<Patch> for some start and end virtual dom. pub struct DiffTestCase<'a> { // ex: html! { <div> </div> } pub old: VirtualNode, // ex: html! { <strong> </strong> } pub new: VirtualNode, // ex: vec![Patch::Replace(0, &html! { <strong></strong> })], pub expected: Vec<Patch<'a>>, } impl<'a> DiffTestCase<'a> { pub fn test(&self) { // ex: vec![Patch::Replace(0, &html! { <strong></strong> })], let patches = diff(&self.old, &self.new); assert_eq!(patches, self.expected); } } }
Diff patch tests get added in diff.rs
. Here's an example:
#![allow(unused)] fn main() { // diff.rs #[test] fn add_children() { DiffTestCase { old: html! { <div> <b></b> </div> }, new: html! { <div> <b></b> <new></new> </div> }, expected: vec![Patch::AppendChildren(0, vec![&html! { <new></new> }])], description: "Added a new node to the root node", }.test(); } }
To run your new test case:
# To run just your new diff test
cargo test -p percy-dom --lib my_new_test_name_here
# To run all diff tests
cargo test -p percy-dom --lib diff::tests
If things are failing then you've found the issue!
Please comment back on your original issue with your findings.
If everything is passing, then it must be a patching issue.
Debugging Failed Patch
If the diff checked out, then the issue must be in the patching process.
Patches are tested in crates/percy-dom/tests/diff_patch.rs
A patch test case looks like this:
#![allow(unused)] fn main() { //! Kept in its own file to more easily import into the book use console_error_panic_hook; use percy_dom::event::VirtualEvents; use percy_dom::prelude::*; use wasm_bindgen::JsCast; use web_sys::{Element, Node}; /// A test case that both diffing and patching are working in a real browser pub struct DiffPatchTest<'a> { /// Description of the test case. /// TODO: Delete the description.. not that useful and easy to forget to update after /// copy/pasting another similar test. pub desc: &'static str, /// The old virtual node. pub old: VirtualNode, /// The new virtual node. pub new: VirtualNode, /// By default we generate the expected based on `new.to_string()`. You can /// use this field to override the expected HTML after patching. pub override_expected: Option<&'a str>, } impl<'a> DiffPatchTest<'a> { pub fn test(&mut self) { console_error_panic_hook::set_once(); let mut events = VirtualEvents::new(); // Create a DOM node of the virtual root node let (root_node, enode) = self.old.create_dom_node(&mut events); events.set_root(enode); // Clone since percy_dom::patch takes ownership of the root node. let patched_root_node: Node = root_node.clone(); // Generate patches let patches = percy_dom::diff(&self.old, &self.new); // Patch our root node. It should now look like `self.new` percy_dom::patch(root_node, &self.new, &mut events, &patches).unwrap(); // Determine the expected outer HTML let expected_outer_html = match self.override_expected { Some(ref expected) => expected.to_string(), None => self.new.to_string(), }; let actual_outer_html = match patched_root_node.node_type() { Node::ELEMENT_NODE => patched_root_node.unchecked_into::<Element>().outer_html(), Node::TEXT_NODE => patched_root_node.text_content().unwrap_or("".into()), _ => panic!("Unhandled node type"), }; assert_eq!(&actual_outer_html, &expected_outer_html, "{}", self.desc); } } }
#![allow(unused)] fn main() { // Example diff patch test case. // Found in `crates/percy-dom/tests/diff_patch.rs` use percy_dom::prelude::*; wasm_bindgen_test_configure!(run_in_browser); mod diff_patch_test_case; use self::diff_patch_test_case::DiffPatchTest; #[wasm_bindgen_test] fn truncate_children() { DiffPatchTest { desc: "Truncates extra children", old: html! { <div> <div> <div> <b></b> <em></em> </div> </div> }
# Run just your new diff patch test
wasm-pack test --chrome --headless crates/percy-dom --test diff_patch -- my_test_name_here
# Run all diff patch tests that contain the word replace
wasm-pack test --chrome --headless crates/percy-dom --test diff_patch -- replace
# Run all diff patch tests
wasm-pack test --chrome --headless crates/percy-dom --test diff_patch
Create your new test case and run it to see if things fail.
If they do, update your original issue with your findings.
Fixing the problem
Look at the documentation for the diff algorithm and the patch algorithm to get a good sense of where and how our diffing and patching is implemented. Fixing the problem will require you to dive into that code.
As you go, if you see opportunities to make the code more understandable, DRY or better commented, seize them!
Look through your errors and try to pinpoint the exact place that the bug is stemming from. If you're stuck, continue to update your issue with your questions and progress and someone will get back to you.
Sibling text nodes
If you render two text nodes next to them the browser will see them as just one text node.
For example, when you have a component that looks like this:
#![allow(unused)] fn main() { use percy_dom::prelude::*; let world = "world"; let sibling_text_nodes = html! { <div> hello {world} </div> }; }
A browser will end up with something like this:
<div>Hello World</div>
The textContent
of the div in the browser is now "Hello World".
If we did not work around this behavior we wouldn't be able to patch the DOM when two text nodes are next to each other. We'd have no way of knowing how to find the original, individual strings that we wanted to render.
To get around this here's what we actually end up rendering:
<div>Hello <!--ptns-->World</div>
Note the new <!--ptns-->
comment node. Here's what percy_dom
's createElement()
method ended up doing:
- Saw the "Hello" virtual text and appended a real Text node into the real DOM
<div>
- Saw the "World" virtual text and saw that the previous element was also a virtual text node
- Appended a
<!--ptns>
real comment element into the<div>
- Appended a real "World" Text node into the
<div>
If we later wanted to patch the DOM with a new component
let different_text = "there";
let sibling_text_nodes = html! { <div> hello {different_text} } </div> };
Our percy_dom
patch function would be able to find the old "World" text node since we've ensured that it
did not get merged in with any other text nodes.
Event Handling
There are two categories of events, delegated and per-node events.
Delegated Events
For delegated events, we attach a single event listener to the application's mount point.
So, say we have a delegated event onclick
. If you create 50 DOM nodes with onclick
handlers,
only one onclick
handler will be in the DOM.
This event listener handles all events and handles bubbling and .stop_propagation()
.
Per-node Events
For per-node events we attach the event to the DOM node.
So, say onfoo
is a per-node event. If you create 50 DOM nodes with 50 onfoo
handlers,
there will be 50 onfoo
callbacks in the DOM (one per node).
Router
Percy provides a route that can be used for deciding which views to render based on, say, the route that the user has visited in their browser.
Here's an example
#![allow(unused)] fn main() { // Imported from crates/percy-router-macro-test/src/book_example.rs use percy_dom::prelude::*; use percy_router::prelude::*; use std::str::FromStr; mod my_routes { use super::*; #[route(path = "/users/:id/favorite-meal/:meal", on_visit = download_some_data)] pub(super) fn route_data_and_param( id: u16, state: Provided<SomeState>, meal: Meal, ) -> VirtualNode { let id = format!("{}", id); let meal = format!("{:#?}", meal); html! { <div> User { id } loves { meal } </div> } } } fn download_some_data(id: u16, state: Provided<SomeState>, meal: Meal) { // Check state to see if we've already downloaded data ... // If not - download the data that we need } #[test] fn provided_data_and_param() { let mut router = Router::new(create_routes![my_routes::route_data_and_param]); router.provide(SomeState { happy: true }); assert_eq!( &router .view("/users/10/favorite-meal/breakfast") .unwrap() .to_string(), // TODO: Requires proc macro APIs that are currently unstable - https://github.com/rust-lang/rust/issues/54725 // "<div> User 10 loves Breakfast </div>" "<div>User10lovesBreakfast</div>" ); } struct SomeState { happy: bool, } #[derive(Debug)] enum Meal { Breakfast, Lunch, Dinner, } impl FromStr for Meal { type Err = (); fn from_str(s: &str) -> Result<Self, Self::Err> { Ok(match s { "breakfast" => Meal::Breakfast, "lunch" => Meal::Lunch, "dinner" => Meal::Dinner, _ => Err(())?, }) } } }
This section will dive into how that works.
route macro
The #[route(...)]
attribute macro is used to annotate functions that we cant to get called
when we visit a certain route.
Before diving into how it works, let's take a look at what code the macro generates for you.
Seeing the end result will make it easier to understand what we're doing and why we do it.
Generated Code
Let's say that you have a file that looks like this:
#![allow(unused)] fn main() { // Imported from crates/percy-router-macro-test/src/book_example.rs use percy_dom::prelude::*; use percy_router::prelude::*; use std::str::FromStr; mod my_routes { use super::*; #[route(path = "/users/:id/favorite-meal/:meal", on_visit = download_some_data)] pub(super) fn route_data_and_param( id: u16, state: Provided<SomeState>, meal: Meal, ) -> VirtualNode { let id = format!("{}", id); let meal = format!("{:#?}", meal); html! { <div> User { id } loves { meal } </div> } } } fn download_some_data(id: u16, state: Provided<SomeState>, meal: Meal) { // Check state to see if we've already downloaded data ... // If not - download the data that we need } #[test] fn provided_data_and_param() { let mut router = Router::new(create_routes![my_routes::route_data_and_param]); router.provide(SomeState { happy: true }); assert_eq!( &router .view("/users/10/favorite-meal/breakfast") .unwrap() .to_string(), // TODO: Requires proc macro APIs that are currently unstable - https://github.com/rust-lang/rust/issues/54725 // "<div> User 10 loves Breakfast </div>" "<div>User10lovesBreakfast</div>" ); } struct SomeState { happy: bool, } #[derive(Debug)] enum Meal { Breakfast, Lunch, Dinner, } impl FromStr for Meal { type Err = (); fn from_str(s: &str) -> Result<Self, Self::Err> { Ok(match s { "breakfast" => Meal::Breakfast, "lunch" => Meal::Lunch, "dinner" => Meal::Dinner, _ => Err(())?, }) } } }
The #[route(...)]
macro above will automatically generate the following code (some unimportant bits have been removed for brevity):
#![allow(unused)] fn main() { // TODO:: This code example isn't imported from a real file so it might go stale over time. fn route_data_and_param(id: u16, state: Provided<SomeState>, meal: Meal) -> VirtualNode { // ... removed ... } fn create_route_data_and_param() -> Route { fn route_param_parser(param_key: &str, param_val: &str) -> Option<Box<dyn RouteParam>> { match param_key { "id" => { return Some(Box::new( u16::from_str_param(param_val).expect("Macro parsed param"), )); } "meal" => { return Some(Box::new( Meal::from_str_param(param_val).expect("Macro parsed param"), )); } _ => panic!("TODO: Handle this case..."), }; None } Route::new( "/users/:id/favorite-meal/:meal", Box::new(route_param_parser), ) } pub mod __route_data_and_param_mod__ { #![deny(warnings)] #![allow(non_camel_case_types)] use super::*; pub struct route_data_and_param_handler { route: Route, provided: Option<ProvidedMap>, } impl route_data_and_param_handler { pub fn new() -> route_data_and_param_handler { route_data_and_param_handler { route: create_route_data_and_param(), provided: None, } } } impl RouteHandler for route_data_and_param_handler { fn route(&self) -> &Route { &self.route } fn set_provided(&mut self, provided: ProvidedMap) { self.provided = Some(provided); } fn provided(&self) -> &ProvidedMap { &self.provided.as_ref().unwrap() } fn view(&self, incoming_route: &str) -> VirtualNode { let id = self .route() .find_route_param(incoming_route, "id") .expect("Finding route param"); let meal = self .route() .find_route_param(incoming_route, "meal") .expect("Finding route param"); let state = self.provided().borrow(); let state = state .get(&std::any::TypeId::of::<Provided<SomeState>>()) .unwrap() .downcast_ref::<Provided<SomeState>>() .expect("Downcast param"); route_data_and_param( u16::from_str_param(id).expect( // ... removed ... )), Provided::clone(state), Meal::from_str_param(meal).expect( // ... removed ... ), ) } } } fn provided_data_and_param() { let mut router = Router::default(); router.provide(SomeState { happy: true }); router.set_route_handlers(<[_]>::into_vec(box [Box::new( self::__route_data_and_param_mod__::route_data_and_param_handler::new(), )])); // ... removed ... } }
html macro
Compile Time Errors
The html-macro
provides compile time errors to help catch mistakes.
Every compile time error is tested in crates/html-macro-ui
using the trybuild
crate.
If you have an idea for an error that you don't see here open an issue!
Here are a few examples:
Wrong closing tag
You've opened with one tag but are attempting to close with another.
extern crate percy_dom; use percy_dom::prelude::*; // Expected a closing div tag, found a closing strong tag fn main() { html! { <div> </strong> }; }
error: Wrong closing tag. Try changing "strong" into "div"
--> $DIR/wrong_closing_tag.rs:7:17
|
7 | <div> </strong>
| ^^^^^^
Should be self closing tag
The tag that you are trying to use is a self closing tagl
extern crate percy_dom; use percy_dom::prelude::*; // We are using open and close tags for a tag that should // actually be a self closing tag fn main() { html! { <br></br> }; }
error: br is a self closing tag. Try "<br>" or "<br />"
--> $DIR/should_be_self_closing_tag.rs:8:15
|
8 | <br></br>
| ^^
Invalid HTML tag
You're trying to use a tag that isn't in the HTML specification. This might happen if you've made a typo.
//! # To Run //! //! cargo test -p html-macro-test --lib ui -- trybuild=invalid_html_tag.rs extern crate percy_dom; use percy_dom::prelude::*; // Used a tag name that does not exist in the HTML spec fn main() { html! { <invalidtagname></invalidtagname> }; }
error: invalidtagname is not a valid HTML tag.
If you are trying to use a valid HTML tag, perhaps there's a typo?
If you are trying to use a custom component, please capitalize the component name.
custom components: https://chinedufn.github.io/percy/html-macro/custom-components/index.html
--> src/tests/ui/invalid_html_tag.rs
|
| <invalidtagname></invalidtagname>
| ^^^^^^^^^^^^^^
on create element without key
You set the on_create_element
but did not set a key.
//! # To Run //! //! cargo test -p html-macro-test --lib ui -- trybuild=on_create_element_without_key.rs extern crate percy_dom; use percy_dom::prelude::*; // Used the `on_create_element` attribute without providing a key attribute. fn main() { html! { <div on_create_element = ||{} > </div> }; }
error: Whenever you use the `on_create_element=...` attribute,
you must also use must use the `key="..."` attribute.
Documentation:
-> https://chinedufn.github.io/percy/html-macro/real-elements-and-nodes/on-create-elem/index.html
--> src/tests/ui/on_create_element_without_key.rs
|
| <div on_create_element = ||{} >
| ^^^^^^^^^^^^^^^^^
on remove element without key
You set the on_remove_element
but did not set a key.
//! # To Run //! //! cargo test -p html-macro-test --lib ui -- trybuild=on_remove_element_without_key.rs extern crate percy_dom; use percy_dom::prelude::*; // Used the `on_remove_element` attribute without providing a key attribute. fn main() { html! { <div on_remove_element = ||{} > </div> }; }
error: Whenever you use the `on_remove_element=...` attribute,
you must also use must use the `key="..."` attribute.
Documentation:
-> https://chinedufn.github.io/percy/html-macro/real-elements-and-nodes/on-remove-elem/index.html
--> src/tests/ui/on_remove_element_without_key.rs
|
| <div on_remove_element = ||{} >
| ^^^^^^^^^^^^^^^^^