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 a String 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 can be used as a child element within a block.


#![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>
}
}

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

  1. Receive a request from the client
  2. Set the initial application state based on the request
  3. Render the application using the initial state
  4. Reply with the initial HTML and the initial state
  5. 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(),
        "<div> User 10 loves Breakfast </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(),
        "<div> User 10 loves Breakfast </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

  1. Rust Nightly.

    rustup default nightly
    rustup target add wasm32-unknown-unknown
    
  2. 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.

  3. 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

  1. Compare the old virtual DOM with the new virtual DOM and generate a Vec<Patch<'a>>

  2. 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) => {
                       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::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)
   }
}

// 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:

  1. Saw the "Hello" virtual text and appended a real Text node into the real DOM <div>
  2. Saw the "World" virtual text and saw that the previous element was also a virtual text node
  3. Appended a <!--ptns> real comment element into the <div>
  4. 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(),
        "<div> User 10 loves Breakfast </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(),
        "<div> User 10 loves Breakfast </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 = ||{} >
   |              ^^^^^^^^^^^^^^^^^