Open-source project
chinedufn/percy avatar
chinedufn/percy

chinedufn/percy: a Rust virtual DOM whose main limitation is a Rust compiler issue

Build frontend browser apps with Rust + WebAssembly. Supports server side rendering.

2,315 stars84 forksRustApache-2.0

At a glance

What is it?
Percy builds browser front ends in Rust compiled to WebAssembly, and its one documented restriction is unusual: text nodes inside its markup macro need quotation marks on stable Rust, because the feature that would remove them depends on span locations not yet stabilised upstream. The example project below that restriction does not follow it, which is the most useful thing in the readme to notice.
Who is it for?
Percy is worth a look if you want a front end whose rendering logic is Rust, whose dependencies are few, and whose virtual DOM you can inspect rather than configure, and you are willing to accept a macro-based authoring model in exchange for it.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 38 days ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 28, 2026, and from our analysis. They are not legal advice.

Editorial analysis

One restriction, and it is tracked in the Rust compiler

Most framework readmes list features. This one leads with a caveat, and the caveat is the most technically interesting sentence in the repository.

Percy compiles on stable Rust with one exception. On the nightly compiler you can write a text node directly inside a tag in its markup macro. On stable, that text has to be wrapped in quotation marks and braces so the macro treats it as an interpolation rather than as bare text:

rust
html! { <div>My text nodes here </div> };
rust
html! { <div>{ "My text nodes here " }</div> };

The reason given is not a design decision. The macro needs to know where each piece of the template sits in the source file, and getting that information requires a compiler feature for span locations that is still in development upstream, with a tracking issue linked. The readme states that the difference will disappear once that feature is stabilised, which is the correct way to describe a limitation you do not own.

It is also worth being clear about what the restriction does and does not cost. The braces form is still a value inside the template, so the macro can still treat text as dynamic. What you lose on stable is the ability to write prose directly in the markup, which in practice means every literal string in a component carries two extra characters. That is a real ergonomic tax on a markup language whose entire argument is that it reads like markup, and it is why a project like this tends to attract the people who already chose Rust for reasons that have nothing to do with the view layer.

The dependency list for that macro is separate from the runtime. A virtual DOM implementation and a markup macro are distinct problems, and the readme links API documentation for each, plus a router. Three pieces, each with its own generated documentation, is a small surface by modern standards.

The quickstart contradicts the rule stated forty lines above it

The example project in this readme is the clearest introduction the project offers, and it is a good one. It starts from a library crate, creates a build script, an HTML entry point and a stylesheet, and shows every file in full. The end result is a page with a heading, an interpolated greeting, a span, and a button that logs to the console when clicked. It is the shortest complete Rust to browser example I would expect to find, and reading it end to end tells you most of what you need about the API.

It also breaks the rule stated earlier in the same document. Inside the button, the template writes its label as bare text with no braces, and a comment in the source cheerfully notes that there is no need to wrap text in quotation marks. That is the nightly form. On stable, following the quickstart as written does not do what the comment says.

There is a charitable reading, which is that some positions in the template tolerate bare text and the span location problem only affects others, but the readme does not say that and the example does not mark the difference. Either the example was written and tested on nightly and never adjusted, or the constraint is narrower than described. For anyone about to commit to this library, that ambiguity is worth resolving by compiling the quickstart on stable, which takes one command, rather than by taking either document at face value.

The same example also demonstrates the two ways to build a virtual node. Most of the template is written with the macro, and one element is constructed through a separate crate that creates virtual nodes programmatically, with a comment noting it can be done without a macro at all. That is a deliberate escape hatch rather than an afterthought, and it is the first sign that this is a library designed to be stepped outside of.

What the markup macro actually accepts

The example is dense with syntax that a reader from another language will not guess, and it is worth taking apart.

Classes are given as an array rather than a space separated string, so an element carries a class list as a bracketed sequence. Values are interpolated with braces, which is the ordinary case for any dynamic content. Event handlers are closures written inline, taking an event parameter that the example discards, and their bodies are ordinary Rust calling into the browser bindings, so a click handler is a function call into the platform module rather than a string of code to be evaluated at runtime. Comments inside the template are real Rust comments, not a template-specific comment syntax, which means the macro delegates parsing to the Rust tokenizer wherever it can.

The runtime side of the example is equally short and shows the three-step lifecycle. A start view is created with the macro, the document and body are obtained from the browser bindings, and a DOM instance is constructed to append the start view to that body. The instance is stored in a struct annotated for export to JavaScript, with a constructor, which is what makes the whole application instantiable from the entry point. Then an end view is created and the instance is told to update, and that single call is where the diffing happens.

The two-view shape is the part worth pausing on. The start view is minimal and the end view is the real interface, which is the cheapest possible demonstration of a virtual DOM: build something, then change it, and let the library work out what that means for the document. It is a better teaching structure than a library that only ever renders once, because the interesting behaviour is in the update, and it is why the isomorphic example, which does the same thing on the server, is listed as the second thing to read.

A virtual DOM you can leave and come back to

Two of the example directories say more about how this library is meant to be used than anything in the readme does.

One is named for embedding a node that Percy did not create. That is a specific and unusual concession. A virtual DOM that owns the whole document is easier to implement, because it knows every node in the tree is one of its own. The moment a component library, an embedded map, a rich text editor or a third party script inserts a node, that assumption breaks, and the usual outcome is a crash on the next update or a subtree that quietly stops updating. An example dedicated to the interop case is a statement that this library was built for pages that contain things it did not make, which in practice means most real pages.

The other example is unit testing view components. Rendering something and asserting on the result is the minimum you would want from a view layer, and having an example for it implies the API was shaped with testing in mind rather than with rendering speed. Combined with the third example directory, which appears to be a component preview, the pattern is consistent: this is a small set of primitives that assume you will compose them yourself, with a reference implementation for the three problems every composition eventually hits.

That assumption cuts both ways. There is no component library here, no routing conventions you are meant to follow, and no form or state management layer. The readme does not apologise for that, and the contributing section reinforces the tone, saying that a question you could not answer yourself is a documentation failure. The project is candid about being a toolkit rather than a framework, which is the same framing the readme uses when it calls itself a light introduction and points you to a separately hosted book for the full walkthrough.

The build is a shell script, and the first build is a debug build

There is no bundler, no plugin system and no dev server. The entire build is a short script:

sh
#!/bin/bash

cd "$(dirname "$0")"

mkdir -p public

cargo build --target wasm32-unknown-unknown
wasm-bindgen target/wasm32-unknown-unknown/debug/client_side_web_app.wasm --no-typescript --target web --out-dir ./public --debug
cp index.html public/
cp app.css public/

Four steps, and two of them are copying static files. The compilation step targets the browser WebAssembly triple, and the second line runs the binding generator, which is what turns the raw module into something a page can load. Two flags in that command are worth noting. The output is targeted at the web rather than at node, and type definitions are suppressed, which for a project whose consumers are not TypeScript users is a reasonable simplification. The debug flag is the interesting one: the script builds the debug profile, so the example you run first is an unoptimised module. That is a sensible default for an example, since the compilation step is fast, and it means the speed you measure from the quickstart is a floor rather than a representative number.

The manifest carries one line that is easy to miss and fatal to omit. The library crate type is set to a dynamic library target, with a comment attached reminding you not to forget it. Everything else is ordinary: the binding generator, the platform bindings, the virtual DOM crate, and a platform binding with an explicit feature list naming the document, the mouse event, the window and the console.

That feature list is the whole browser API surface the example uses, which is a good illustration of how this style of front end works. You do not get the whole document object model. You ask for exactly the parts you use, at compile time, and the linker gives you those and nothing else. The cost is that every new capability is a trip back to the manifest; the benefit is that the shipped module contains no browser API surface you did not intend to expose.

Serving is handled the same way. The instructions suggest installing a static file server written in Rust, note that any server handling the WebAssembly content type will do, and then point it at the output directory on a chosen port. The entry point is a module script that imports the generated JavaScript glue, awaits initialisation with the path to the module, and constructs the exported application.

Version, edition and licence details that have drifted apart

A few small inconsistencies are worth collecting, because each of them is a place where a reader can lose time.

The root manifest declares a version of zero point zero point one. That is normal for a workspace root, and it is not the version you will depend on; the version you care about is the one the example pins for the virtual DOM crate, which is zero point eleven. So the number that matters is not in the repository root and the number that is in the repository root is meaningless. There are no tagged releases at all, which is consistent with a project published to a package registry rather than distributed as archives, but it does mean the readme gives you no way to check what changed between versions.

The root manifest declares the current Rust edition while the example project declares an older one. Both are correct for what they are, and the difference is only visible if you copy the example and then try to use a current edition feature in it, which fails for a reason that has nothing to do with Percy.

Licensing is dual, with two licence files at the root. The repository metadata reports one of the two, and the readme's licence section names the other. For a library published to a registry that is a real ambiguity rather than a cosmetic one, because it determines what you are required to do when you redistribute it in a binary. A dependency that is dual licensed lets you choose, which is almost certainly the intent, but the readme and the metadata do not say so.

The last small signal is a file whose name ends in a suffix marking it as retired, sitting next to the current continuous integration configuration in the hidden directory. A retired configuration file left in the root is harmless and is exactly the sort of thing that accumulates in a project maintained by one person over several years. It is a reasonable proxy for how the repository is run: no releases, one author, and an ongoing preference for adding rather than pruning.

Editorial conclusion

Percy is worth a look if you want a front end whose rendering logic is Rust, whose dependencies are few, and whose virtual DOM you can inspect rather than configure, and you are willing to accept a macro-based authoring model in exchange for it. It is a poor fit if your team does not already write Rust, since the value is precisely that the same language produces the logic and the view, and it is a poor fit if you need a large component ecosystem, because there is one and the readme points at a separate example directory rather than a catalogue. Before starting, decide whether you are on stable or nightly, because the text node syntax differs and the quickstart example does not match the documented stable rule. If you go further than an experiment, read the book rather than the readme, since the readme describes itself as a light introduction and the book is the maintained documentation.

Frequently asked questions

What is chinedufn/percy?

It is a Rust and WebAssembly toolkit for building interactive browser front ends, providing a virtual DOM implementation, a markup macro and a router, with support for client side rendering, server side rendering or both. It is dual licensed and published as a set of crates rather than as releases.

Why do text nodes need quotation marks in Percy on stable Rust?

The markup macro needs source span locations to know where each part of a template sits, and that compiler feature is not yet stabilised upstream, with a tracking issue linked in the readme. On nightly the bare text form works; on stable the text must be wrapped in braces and treated as an interpolation.

How is a Percy application built and served?

With a short shell script: compile to the browser WebAssembly target, run the binding generator with the web target and no TypeScript output into a public directory, then copy the HTML entry point and stylesheet alongside it. Any static file server that handles the WebAssembly content type can serve the result.

Can Percy be used with components or libraries it did not create?

The example directory includes one dedicated to embedding a node Percy did not create, which suggests interoperability with third party DOM content is a supported scenario rather than an accident. The example also shows that virtual nodes can be created programmatically without the markup macro, through a separate node crate.

Where is the full documentation for Percy?

In a separately hosted book, which the readme describes as the full walkthrough and itself as a light introduction. Generated API documentation is published separately for the virtual DOM crate, the markup macro and the router.

Official sources

  1. chinedufn/percy on GitHub
  2. Issues
  3. License: Apache-2.0
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/chinedufn-percy.svg)](https://hysenlabs.com/projects/chinedufn-percy)