Introduction
Welcome to the Asteracea guide book!
This book is a work in progress!
Until the first minor version stabilisation, there likely will only be this
develop
-branch version published online as rendered version, which usually won't mach a version of the crate published to crates.io. (Respective versions are tagged and can be rendered offline using (from the repository's root directory)cd book
andcargo run
.)In addition to the missing chapters, URLs are subject to change, links have have not been filled in and code blocks without highlighting or rendered HTML output may show unimplemented features.
This framework is a work in progress!
It works alright for very simple examples right now, but certain parts of the grammar related to efficiently defining dynamic content haven't landed yet.
What works
Pretty much any static templating.
Lazy initialisation, via
defer
andbind
expressions.Events and bindings, mostly.
Deferred callback continuations aren't present yet, which prevents direct component instantiation or destruction from event handlers.
Hover documentation! You get tooltips for elements, attributes and events, as far as written.
Some branching.
Dependency injection, should be mostly working.
Though I'm not entirely happy with it yet. There's per-instantiation overhead without using it.
Code-sharing between client and server, as Asteracea targets the modular lignin.
What isn't finished
Thread safety inference is sometimes wonky.
Write
ComponentName()() -> Sync
orComponentName()() -> !Sync
to determine it manually. It's sound either way, but the former may not compile.Repeat arguments for child components. This will lead towards content transclusion. I think.
Attached properties, to configure transclusion.
The grammar. I'll most likely change something in a breaking way before
v0.1
.What's missing
- "Loops"
- A standard library, including a mockable HTTP client (for client and server).
- A router, hopefully nicely possible outside the core library.
- A turnkey app base. This needs to come with SSR and then optionally hydrate.
- Anything I haven't thought of! Suggestions are welcome.
Audience
While using Asteracea effectively requires knowledge of Rust, this book is written also with non-Rustaceans in mind.
If you have experience with more traditional front-end frameworks like Angular or React, quite a few of the presented concepts should be familiar to you. I will also try to highlight these connections where they appear. By the end of the book, you should be able to read Asteracea's component templates and make modifications to them.
If you are already familiar with Rust, you can use the samples from Chapter 6: Integrating Asteracea to create a complete web site or application including static site generation, server-side rendering and/or a (primary or optional) client-side approach.
Background
When I started using Rust privately in 2019, I had worked as a consultant on multiple web projects, mainly as front-end developer using TypeScript, Angular and React. I had grown increasingly frustrated with the failure classes of this weakly typed ecosystem: Aside from (rare but in those cases often destructive) run-ins with outdated or wrong type definitions for external packages, it was too easy to accidentally turn off type checking. It was often easy to forget to handle certain failure cases. React was quick to prototype in, but would often spiral in complexity and unchecked definitions on larger projects. Angular applications were comparatively robust but required manual subscription management to prevent memory leaks and required a significant amount of boilerplate that couldn't be abstracted away due to compiler limitations.
Meanwhile on the server side, Spring Boot was resource-hungry as a microservice platform, requiring powerful development systems to run a local test instance of the platform even without any data added to it. Using the documentation was also frustrating to me, since it was difficult to look up the various implicit behaviours. I wouldn't be able to work efficiently with such a system on my slower home computer that also needed to handle a considerable amount of browser tabs at the same time. To top it off, DTOs couldn't be easily shared through the various layers of the application.
I originally got into Rust to have another go at game development. This didn't go well at the time due to lack of high-level frameworks I could prototype something in quickly, but I liked the language and ended up writing several smaller utility programs. Then I had to switch Android ROMs to still get updates and lost the data stored in the finance tracker app I was using. (Backups were only available by uploading my data to the manufacturer's servers, which I decided against.) I took this as an opportunity to write my own tracker, to be hosted on a Pi Zero W so I could make entries from my phone. In part to learn about technologies I had seen but not used myself at work, I decided to use a network of Docker containers, with Postgres for storage and Nginx to serve static files and act as reverse proxy.
While this tracker project is currently stalled, with help from friends I still managed to create a successful prototype: With Diesel, [Serde] and by targeting WebAssembly, I could reuse a single DTO definition all the way from Diesel to the app's browser frontend. Resource usage was tiny, requiring only about 15MB of private RAM and less than 0.5% CPU for the entire idling prototype server! I was also looking forward to drop JSON from my stack when MsgPack and CBOR inspection was added to Firefox.
However, here is where I hit a snag: I was used to relatively mature web frameworks that make it easy to write reusable components and test them in isolation via dependency injection. I was also looking for CSS scoping and to ideally never touch JavaScript myself (ideally skipping its build ecosystem entirely). I used version 0.1.0
of dodrio
for a while, but as stated on its project page, it's not intended as complete GUI solution. Iced wasn't a good fit due to being more high-level than what I was going for. typed-html
seemed close to what React does, but I was looking for more stateful component tooling. (dodrio
inspired Asteracea's use of a bump allocator.)
(Afterglow did not exist at that point. You will probably want to look at it as an alternative before deciding what to go with. Its design goals seem different from Asteracea's, at a glance.)
I decided to write my own solution to this problem, which is where things started to escalate.
Asteracea's Design Goals
Asteracea is, as of October 2020, still early in development and subject to change. However, there are a few main goals I want to enable with this framework that can be put into writing already:
-
Low boilerplate:
Web components have a certain shape that's shared between each of them. Creating a new component shouldn't require a large amount of text to get started, so that the focus is on what the individual component does.
A completely empty component, minus the outer macro call, can be written as concisely as
E()()[]
. This generates a (zero-size) model, a (practically empty) constructor and a render method that generates an empty element group - a VDOM node that results in no output. More complex components grow naturally from here.Formatting a value into the output can be as simple as
!{value}
. More on all this later. -
Straightforward macros:
While Asteracea relies heavily on procedural macros, these macros aren't magic. By and large, Asteracea does a copy-and-paste source code transformation. (Some dynamic defaults exist. Criticism is welcome.)
Code spans are preserved as much as possible, so if the input is valid to Asteracea but the output is invalid Rust, the relevant errors and warnings will appear in matching locations on the macro input.
-
Inclusive runtime:
At some point during development, Twitter made its new web interface mandatory for all users. As of October 2020, it is still quite heavy (topping
about:perfomance
in Firefox by a wide margin alongside YouTube), loads slowly, is next to impossible to style, occasionally glitchy and does not work whatsoever without JavaScript enabled.Asteracea can't take care of all of these things for you, but I'm proud to announce that serverside-rendering and static site generation are supported without specifically adjusting the application code. The clientside version of the app can then hydrate the existing DOM structure, whether seamlessly or with additional content not included in the static version.
Asteracea has no signature pattern aside from capitalising element names (which saves on some runtime branching). Generated HTML and DOM are structured as if written by hand.
-
Balancing safety, simplicity and generality:
Asteracea inherits its safety and lifetime model from Rust, with the one part not validated by the compiler being the render loop, external to the core framework and main application code. This is due to interaction with the browser DOM at this point, though a different implementation using
FinalizationRegistry
may be possible there.The targeted application model is optionally threaded, which means components and event handlers aren't required to be
Send
orSync
, but can meaningfully be so.Event handlers are only required to be valid for one render cycle (though reusing closures is encouraged and done by the basic event handler syntax). Component instances are required to outlive event handlers, but their lifetime is otherwise unconstrained by default. In particular, you can usually drop component instances before their rendered VDOM iff they don't register event handlers.
Any expression between curly brackets (
{}
) in the templates is plain old Rust: The code is always¹ pasted verbatim and you can use any and all Rust features in those locations.¹ This is technically only effectively true: A small but limited find-and-replace transformation is applied to event handlers to enable using
self
within them. It should match expected Rust behaviour under all circumstances, though.Asteracea is named after the family of Asteraceae, which contains very spectacular as well as very inconspicuous, but generally quite practical flowers. My hope is that this set of libraries will eventually be used for a similarly wide range of applications.
Chapter 0: Sneak Peek
Before I begin to explain in earnest, here is a relatively complex dynamic component using many of Asteracea's features, along with its resulting HTML representation:
#![allow(unused)] fn main() { use lignin::web::Event; use std::cell::Cell; fn schedule_render() { /* ... */ } asteracea::component! { Counter( initial: i32, priv step: i32, pub enabled: bool = true, )( class?: &'bump str, ) -> !Sync let self.value = Cell::<i32>::new(initial); // <div .class? = {class} "The current value is: " !(self.value()) <br> <button .disabled? = {!self.enabled} "+" !(self.step) on bubble click = Self::on_click_plus > > } // impl Counter { pub fn value(&self) -> i32 { self.value.get() } pub fn set_value(&self, value: i32) { self.value.set(value); schedule_render(); } fn on_click_plus(&self, _: Event) { self.set_value(self.value() + self.step); } } asteracea::component! { CounterUser()() -> !Sync <"counter-user" "\n\t" <*Counter *initial = {0} *step = {1} > "\n" > } }
<counter-user>
<DIV>The current value is: 0<BR><BUTTON>+1</BUTTON></DIV>
</counter-user>
This guide assumes you have done some web development before, so some parts of the template should look familiar to you.
Others probably look pretty unfamilar, even with both a web development and Rust background. I removed some redundant grammar and had to invent new syntax for some features that don't appear as such in either ecosystem.
Overall, I like to call this an MVC lite approach: You can see the model, view and controller parts of the component, in this order, without them being separated into different files. I've marked the boundaries between parts with a Rust comment each (//
).
This actually isn't mandatory - Asteracea is quite flexible and lets you mix them when appropriate - but it's a good way to clean up larger components that otherwise wouldn't fit on the screen well.
There's also syntax highlighting without extra tools! The version here in the book is simplified, but if you use rust-analyzer, then it's really quite smart.
The following chapters will teach you how to read and write these components, though becoming fluent may require a little bit of practice.
Chapter 1: Static Components
Asteracea, unlike for example React, does not have multiple ways to define a component depending on whether you'd like to use instance state or not¹. Instead, due to low syntactic overhead and Rust's efficiency, struct
components are generated throughout.
Stateless struct
components have zero runtime overhead compared to functions equivalent fragment!
use. This, along with less boilerplate and for consistency, is why I generally recommend component!
for all reusable GUI elements.
In this chapter, I will introduce the basics of generating various virtual DOM nodes in Asteracea, which can then be translated into (e.g.!) HTML or browser DOM elements.
¹ The distinction has weakened in React recently. Asteracea's approach to stateful components is partially inspired by React's Hooks in terms of UX, but is implemented very differently below the surface.
An Empty Component
As mentioned in the introduction, the simplest Asteracea component is E()()[]
.
In context, and written more like what you'd see in the wild:
#![allow(unused)] fn main() { asteracea::component! { Empty()() [] } }
(All Asteracea component examples are followed by their output as rendered by lignin-html
, but in this case it's an empty string.)
This component expands to the following Rust code, with use
imports extracted by hand to improve readability:
//TODO
As you can see, the component!
macro created a struct
type, with one constructor called new
and one method called render
, as well as a few helper types and functions that enable named arguments, and a Drop
implementation. The output of component!
, as far as you're supposed to touch it, always has this shape. No exceptions.
Identifiers containing __Asteracea__
are considered internal and may change at any point in time. Please don't use them directly, even if technically accessible!
You may find small bits of similar useless syntax like those empty {}
blocks in new
. Some of these pieces of code nudge Rust into giving you a better error message or block off certain edge cases (usually inner attributes) that either would be confusing to read or haven't been properly evaluated yet, while others, like the empty unsafe {}
in drop
are slots where code is placed when generating more complex components, and which should be effectively removed by the compiler if empty. (If you notice such an empty construct that impacts runtime performance or Wasm assembly size, please file a bug report.)
The breakdown
There are five distinct pieces of syntax that are translated into the output here: pub
, Empty
, ()
, ()
and []
.
pub
(visibility)
This is a plain Rust visibility and inserted just before the struct
keyword in the macro output above, controlling where the component can be used directly. Leave it out to for current-module-only visibility.
new
and render
are always declared pub
; They inherit their visibility from the component structure.
Empty
(component name)
This identifier is inserted verbatim into the output as shown.
There aren't any requirements regarding which identifier to use, but I encourage you to avoid generic suffixes like "…Component
".
Consider e.g. "…ListItem
", "…Button
" or, if nothing more specific applies, "…Panel
" as more descriptive alternatives, or leave the suffix off entirefly if there's no confusion regarding which types are components and which are not.
()
(constructor argument list)
This is the first pair of parenthese in the input and also appears before the other in the output. As you can see, it is inserted verbatim after new
here.
You can use any normal argument declaration here, with the exception of self
parameters.
The constructor argument list also supports a shorthand to declare and assign fields on the component instance, but more on that [later].
()
(render argument list)
The second pair of parentheses is used to declare additional render arguments.
This one is never pasted verbatim into the resulting component, despite supporting only plain Rust argument declarations (with the exception of self
parameters and, usually, bump
).
Instead, its items are inserted at the end of render
's argument list above, after the implicit arguments &self
and bump: &'bump Bump
. You can access instance fields through self
in the component body (more on that later) and bump
is a Bump
from bumpalo
, a bump allocation arena that makes the VDOM more efficient.
Do not place anything into bump
that needs to be dropped! Bump allocators are speedy, but this speed is bought by not running any logic before the memory is reused. Some workarounds for common use cases exist, but for the most part Asteracea handles this for you. See bumpalo
's documentation for more information.
[]
(body / empty Multi Node)
The location of []
in this example component is called the body of the component.
[]
itself is an empty Multi Node, which expands to Node::Multi(&*bump.alloc_with(|| []))
.
The contents of this node are placed in the bump allocation arena which, in this case, is effectively no operation. Location and length of this list are stored in the containing [Node
], which here is returned directly from render
.
It's legal to reuse [Node
] instances in multiple places in the VDOM tree. You can also cache long-lived [Node
]s and then refer to them across multiple render cycles, to avoid re-rendering part of the VDOM.
Multi Nodes are a VDOM concept that doesn't translate into DOM: Their contents are replicated without nesting in the surrounding DOM structure. You can use them, for example, to return multiple elements at the top level of a component.
Another use is to represent a variable number of elements, including none. The diffing algorithm in lignin-dom
advances by a single VDOM sibling when processing a multi node. This means that you can avoid shifting any following sibling nodes, which can avoid expensively recreating their DOM representation or confusing the user by moving their selection to an unexpected place.
Rust Comments
You can use three distinct types of comments in Asteracea macros, all serving different purposes:
First, standard Rust comments can be placed anywhere in Asteracea components (or any other place in a Rust program), and are not included in the compiled binary:
#![allow(unused)] fn main() { asteracea::component! { Commented()() [ // This is a one-line comment. /* /* These are *nested* multiline comments. */ */ ] } }
Additionally, Rust documentation is supported in many places:
#![allow(unused)] fn main() { asteracea::component! { /// This is a documented component. /// Running `cargo doc` will pick up on its documentation. pub Documented()() -> Sync [] } }
These ///
(or //!
) annotations are not included in the compiled binary either¹, but can be picked up by standard Rust tooling like rust-analyzer.
¹ Rare exceptions in combination with other macros apply.
HTML comments
The third kind of comment is specific to Asteracea and does affect program output:
#![allow(unused)] fn main() { asteracea::component! { HtmlCommented()() <!-- "This is an HTML comment." --> } }
<!--This is an HTML comment.-->
The double quotes are a Rust limitation: Since Rust tokenises macro input, a string literal is required to extract raw text.
You can use a multiline string to easily write a multiline HTML comment:
#![allow(unused)] fn main() { asteracea::component! { HtmlCommented()() <!-- " This comment spans mul- tiple lines, I hope it is not too annoying. " --> } }
<!--
This comment spans mul-
tiple lines, I hope it is
not too annoying.
-->
Text
To output a static plain Text
element in Asteracea, simply use a text literal in your component's body:
#![allow(unused)] fn main() { asteracea::component! { Text()() "This is text." } }
This is text.
The macro output is largely the same as for the Empty
component, but the render
method has changed:
use lignin::Node;
pub struct Text {}
impl Text {
pub fn new() -> Self {
Self {}
}
// …
// TODO
// …
}
(Click the eye icon to view the rest of the macro output. Note that this also displays a hidden main
method inserted by mdBook which isn't part of component!
's output.)
Multiple Text
elements
Text nodes can be used as children of other nodes, for example a Multi Node:
#![allow(unused)] fn main() { asteracea::component! { TextMulti()() [ "This is text." "This is also text." ] } }
This is text.This is also text.
use asteracea::bumpalo::Bump;
use lignin::{Node, ThreadBound};
pub struct TextMulti {}
impl TextMulti {
pub fn new() -> Self {
Self {}
}
// …
pub fn render<'a, 'bump>(
self: ::std::pin::Pin<&'a Self>,
bump: &'bump asteracea::bumpalo::Bump,
TextMultiRenderArgs {
__Asteracea__phantom: _,
}: TextMultiRenderArgs<'_, 'a, 'bump>,
) -> ::std::result::Result<
impl TextMulti__Asteracea__AutoSafe<
::asteracea::lignin::Node<'bump, ::asteracea::lignin::ThreadBound>,
>,
::asteracea::error::Escalation,
> {
let this = self;
::std::result::Result::Ok(
::asteracea::lignin::Node::Multi::<'bump, _>(&*bump.alloc_try_with(
|| -> ::std::result::Result<_, ::asteracea::error::Escalation> {
::std::result::Result::Ok([
::asteracea::lignin::auto_safety::Align::align(
::asteracea::lignin::Node::Text::<'bump, _> {
text: "This is text.",
dom_binding: None,
}
.prefer_thread_safe(),
),
::asteracea::lignin::auto_safety::Align::align(
::asteracea::lignin::Node::Text::<'bump, _> {
text: "This is also text.",
dom_binding: None,
}
.prefer_thread_safe(),
),
])
},
)?)
.prefer_thread_safe(),
)
}
// …
}
#[allow(non_camel_case_types)]
lignin::auto_safety::AutoSafe_alias!(pub TextMulti__Asteracea__AutoSafe);
pub struct TextMultiRenderArgs<'render, 'a, 'bump> {
__Asteracea__phantom: ::core::marker::PhantomData<(&'render (), &'a (), &'bump ())>,
}
Note that there is no space between the sentences in the generated HTML.
Asteracea gives you fairly precise control over the output, but that also means it won't make changes to the document's whitespace for you. If there's no whitespace in the literal in the input, then there won't be whitespace in the content of the output (when rendering with lignin-html or lignin-dom).
There is one important difference between the HTML and DOM output of adjacent sibling Text nodes: In HTML, it is impossible to distinguish them and browsers will parse them as single DOM node. When manipulating the DOM directly, the distinct text nodes will be preserved.
This is one of the reasons that a client-side renderer must once parse the existing DOM into a VDOM when hydrating an app. Another good reason is that user-supplied browser extensions may have made changes to the DOM tree.
(Please don't render into <body>
directly! Many browser extensions insert their own scripts or overlays as child elements here.
While it's not too likely that these additions will make your app crash, the GUI may glitch and appear for example duplicated. Rendering into, for example, a <div id=app>
instead is more reliable.)
Multi Nodes generated by Asteracea macros always place their contents in the bump allocation arena, even if those contents are theoretically immutable.
Elements
To define elements and their contents, Asteracea provides a syntax similar to HTML:
#![allow(unused)] fn main() { asteracea::component! { Div()() <div> } }
<DIV></DIV>
<name
opens an element and >
is enough to close one. However, you can alternatively close elements with /name>
too, which the compiler will validate:
#![allow(unused)] fn main() { asteracea::component! { Div()() <div // [complex nested template] /div> } }
<DIV></DIV>
Elements can contain any number of valid Asteracea component bodies, which are rendered as the element's children, as long as the specific element supports it:
#![allow(unused)] fn main() { asteracea::component! { Span()() <span "This is text within a <span>." <!-- "This is a comment within a <span>." --> > } }
<SPAN>This is text within a <span>.<!--This is a comment within a <span>.--></SPAN>
This includes other elements:
#![allow(unused)] fn main() { asteracea::component! { DivSpan()() <div <span "This is text within a <span>."> > } }
<DIV><SPAN>This is text within a <span>.</SPAN></DIV>
Elements are statically validated against lignin-schema
.
Empty elements like <br>
are written like any other element, but don't accept children and won't render a closing tag to HTML when using lignin-html:
#![allow(unused)] fn main() { asteracea::component! { Br()() <br> } }
<BR>
To use custom element names without validation, quote them like this:
#![allow(unused)] fn main() { asteracea::component! { Custom()() <"custom-element"> } }
<custom-element></custom-element>
Child Components
Asteracea components can be used inside other templates using asterisk syntax:
#![allow(unused)] fn main() { //TODO: Hide this initially. use std::marker::PhantomData; asteracea::component! { Inner()() "Inner body." } mod module { asteracea::component! { pub(crate) Module()() "Module body." } } asteracea::component! { Generic<T>( //TODO: Hide this initially and show an ellipsis comment. // Generic parameters must be used in an instance field. // We can pretend this is the case using a constructor parameter capture. // `PhantomData` is a type that provides fake storage semantics. priv _phantom: PhantomData<T> = PhantomData::default(), )() "Generic body." } asteracea::component! { Outer()() [ <*Inner> "\n" <*module::Module> "\n" <*Generic::<()>> // Mind the turbofish! ::<> 🐟💨 ] } }
Inner body.
Module body.
Generic body.
Explicit closing is supported:
#![allow(unused)] fn main() { //TODO: Hide repetition. use std::marker::PhantomData; asteracea::component! { Inner()() "Inner body." } mod module { asteracea::component! { pub(crate) Module()() "Module body." } } asteracea::component! { Generic<T>( // Generic parameters must be used in an instance field. // We can pretend this is the case using a constructor parameter capture. // `PhantomData` is a type that provides fake storage semantics. priv _phantom: PhantomData<T> = PhantomData::default(), )() "Generic body." } asteracea::component! { Outer()() [ <*Inner /Inner> "\n" <*module::Module /Module> "\n" <*Generic::<()> /Generic> // 🪣 ] } }
Inner body.
Module body.
Generic body.
Using a component multiple times results in distinct instances:
#![allow(unused)] fn main() { asteracea::component! { Inner()() "Inner body." } asteracea::component! { Outer()() [ <*Inner> <*Inner> ] } }
Inner body.Inner body.
Child Component Instancing
Note: Rust is good at erasing empty instances!
If your reused component is stateless, please restate the component's type name instead of using instancing. This will keep your code clearer and less interdependent.
For more information, see The Rustonomicon on Zero Sized Types (ZSTs).
Instead of instantiating and storing a child component multiple times, you can instance it by giving it a name and referencing it elsewhere through a Rust block:
#![allow(unused)] fn main() { //TODO: Hide this initially. asteracea::component! { Inner()() "Inner body." } asteracea::component! { Outer()() [ <*Inner priv inner> // Alternatively: `pub` or `pub(…)` <*{self.inner_pinned()}> ] } }
Inner body.Inner body.
The component's .render(…)
method is called for each of these appearances, but ::new(…)
is called only once.
Component instancing is especially useful when rendering alternates, since the child instance is available everywhere in the parent component's body (regardless which .render(…)
path is taken).
EX: Stateless Components are Zero-Sized
This is an optional chapter.
EX-chapters don't contain necessary information on how to use Asteracea.
However, they may contain interesting information about performance characteristics or tricks you can use to make your app more maintainable.
Consider the following (grasping at constructor parameter captures and value formatting a bit ahead of time):
#![allow(unused)] fn main() { use std::{fmt::Debug, mem::size_of}; asteracea::component! { MySize<T: Debug>( priv value: T, )() [ !"I contain {:?}!"(self.value) " My size is " <b !(size_of::<Self>())> " bytes." !" I'm located at address {:p}."(self) ] } asteracea::component! { Container()() [ <*MySize::<()> *value = {()}> "\n" <*MySize::<()> *value = {()}> "\n" <*MySize::<u8> *value = {1}> "\n" <*MySize::<usize> *value = {2}> "\n" "The container instance occupies " <b !(size_of::<Self>())> !" bytes at {:p}."(self) ] } }
I contain ()! My size is <B>0</B> bytes. I'm located at address 0x55907f620500.
I contain ()! My size is <B>0</B> bytes. I'm located at address 0x55907f620500.
I contain 1! My size is <B>1</B> bytes. I'm located at address 0x55907f620508.
I contain 2! My size is <B>8</B> bytes. I'm located at address 0x55907f620500.
The container instance occupies <B>16</B> bytes at 0x55907f620500.
The layout here is somewhat implementation-defined, but generally what you should see is that the MySize::<()>
instances take up no space inside the Container
instance and don't displace other children in memory.
This is because Asteracea components contain no hidden instance state, which means they are sized to content (and padding requirements), all the way down to zero. ()
is Rust's unit type, the most convenient zero sized type. The same applies to components without instance fields, of course.
Zero-sizing is transitive and has many interesting implications, but for our purposes the most notable one is that stateless components are almost¹ function-like at runtime. It's for this reason that Asteracea doesn't provide a special "slim" component syntax.
¹ There is likely a small amount of overhead during instantiation due to the dependency extraction system. The compiler is in theory allowed to optimise it away, but this isn't guaranteed.
If you have an idea how to make this process meaningfully conditional without changing Asteracea's macro syntax, I'd like to hear about it!
Escalation
The ::new(…)
and .render(…)
functions of Asteracea-components are fallible, returning a Result<_, Escalation>
.
Escalation
s are panic-like: They are not expected during normal execution of a web frontend, are strictly deprioritised compared to the Ok
path and components that catch them are encouraged to implement a "fail once and stop" approach where child components are disposed of on first failure.
You can escalate any error along the GUI tree as long as it is Any
, Error
and Send
.
#![allow(unused)] fn main() { use asteracea::error::EscalateResult; use std::error::Error; use std::fmt::{Display, Formatter, Result}; #[derive(Debug)] struct AnError; impl Display for AnError { fn fmt(&self, f: &mut Formatter) -> Result { write!(f, "A test error was raised") } } impl Error for AnError {} asteracea::component! { Failing() #[allow(unreachable_code)] () -> Sync { // Raising an `Escalation` means crashing at least part of the app, // so there is a speed bump for this conversion. // Think of `.escalate()?` as a Wasm-unrolling version of `.unwrap()` // and use it sparingly. return Err(AnError).escalate(); } } asteracea::component! { Containing()() <*Failing> } asteracea::component! { pub Outer()() -> Sync <*Containing> } }
A test error was raised
Showing line and column information is planned, but the necessary API is currently not available on stable Rust. Hooking into tracing should be comparatively straightforward, though.
If the
"force-unwind"
feature is enabled,Escalation
instances are erased and the type itself uses the panic infrastructure for propagation instead of being passed up viaErr
variant. This may reduce code size in some cases.However, note that panics cannot be caught on platforms without unwinding, including Wasm (as of Rust 1.49.0).
In the future, panic conversion will be activated automatically on compatible platforms, as long as this can be done without compromising backtraces.
Handling panics
Asteracea's error handling will automatically try to pick up on plain Rust panics, and can prevent them from crashing your app as long you use an [Escalation::catch…
] function to handle errors. However, this only works with unwinding enabled (i.e. not under Wasm!). The currently active panic hook is invoked regardless, too.
The following example should display a backtrace rather than failing the book build:
#![allow(unused)] fn main() { asteracea::component! { Panicking() #[allow(unreachable_code)] () -> Sync { //TODO: Make this conditional on unwinding. panic!("Avoid doing this!"); } } asteracea::component! { pub Outer()() -> Sync <*Panicking> } }
Avoid doing this!
In general, prefer explicit escalation over plain panics whenever possible!
new with { …; }
Arbitrary Rust code can be inserted into a component's constructor using a new with
-block:
#![allow(unused)] fn main() { asteracea::component! { Constructed()() new with { // TODO } [] } }
Code inside the new with
-block has access to constructor parameters, and let
-bindings from this block are in scope for capture initialisers:
#![allow(unused)] fn main() { asteracea::component! { QuoteHolder( text: &str, )() new with { let quoted = format!("‘{}’", text); } let self.quote: String = quoted; !(self.quote) } asteracea::component! { Quoter()() <*QuoteHolder *text = { "This text is quoted." }> } }
‘This text is quoted.’
Predefined Lifetimes
Asteracea implicitly defines certain user-accessible lifetimes:
-
'a
is available in the::new(…)
and.render(…)
contexts and represents a lower bound of the component's lifetime. -
'bump
is the bump allocator lifetime, used when rendering the virtual DOM representation from Asteracea components and only available in the.render(…)
context. This is mostly implicit, but you have to specify it in render arguments where references flow into the VDOM.
Overall, the following are true, if types and values represent their lifetimes:
Self
≥self
self
=='a
'a
≥'bump
TODO: More details, especially regarding event handlers. Remove Self: 'static
constraint.
Conditional Attributes
Asteracea supports conditionally setting optional attributes with the following syntax:
#![allow(unused)] fn main() { asteracea::component! { Classic()( // This will be improved on in the next chapters. class: Option<&'bump str>, ) <div .class? = {class} > } asteracea::component! { Classical()() [ <*Classic .class = {None}> "\n" <*Classic .class = {Some("classicist")}> ] } }
<DIV></DIV>
<DIV class=classicist></DIV>
Instead of &'bump str
, the attribute value type here is Option<&'bump str>
. If None
is provided, the attribute is omitted entirely from the rendered VDOM.
This can be used to conditionally render a boolean attribute like checked
, providing Some("")
to enable the attribute. However, it is usually more convenient to use a bool
directly:
Boolean Attributes
To make dynamic boolean attributes like hidden
more convenient to use, conditional attributes also accept bool
values directly:
#![allow(unused)] fn main() { asteracea::component! { Vis()( visible: bool, ) <div .hidden? = {!visible} "#" > } asteracea::component! { Outer()() [ <*Vis .visible = {true}> "\n" <*Vis .visible = {false}> ] } }
<DIV>#</DIV>
<DIV hidden>#</DIV>
true
is converted to Some("")
and false
to None
in this case, as per specification.
Which types are compatible with conditional attributes is controlled by the
ConditionalAttributeValue
trait.It is by default implemented for
bool
andOption<&'bump str>
, and I recommend not extending this list unless the conversion is very fast.
Argument Defaults
Asteracea provides multiple ways to make working with optional arguments easier.
Like in for example TypeScript, you can specify default parameters for constructor and render arguments:
#![allow(unused)] fn main() { asteracea::component! { Classic()( // This will be improved on in the next chapter. class: Option<&'bump str> = None, ) <div .class? = {class} > } asteracea::component! { Classical()() [ <*Classic> "\n" // Parameter omitted. <*Classic .class = {Some("classicist")}> ] } }
<DIV></DIV>
<DIV class=classicist></DIV>
Default parameter expressions are normal Rust expressions, and are evaluated as needed if the parameter was not specified.
Optional Arguments
When working with values that may or may not be provided, where the default is outside the range of possible provided values, you can improve your component's interface towards consumers by using optional arguments:
#![allow(unused)] fn main() { asteracea::component! { Classic()( class?: &'bump str, ) <div .class? = {class} // `Option<_>`-typed! > } asteracea::component! { Classical()() [ <*Classic> "\n" <*Classic .class = {"classicist"}> // Not `Option<_>`-typed! ] } }
<DIV></DIV>
<DIV class=classicist></DIV>
class
is an Option<&'bump str>
within Classic
s .render(…)
method, but the parameter is provided from outside as &'bump str
.
Conditional Child Component Parameters
Note that providing optional argument values without Some
means that None
can only be specfied by not setting the parameter at all! Fortunately, it's easy to do this conditionally in the same way as for optional attributes on HTML elements:
#![allow(unused)] fn main() { asteracea::component! { Inner()( class?: &'bump str, ) <span .class? = {class}> } asteracea::component! { Middle()( class?: &'bump str, ) <*Inner .class? = {class}> } asteracea::component! { Outer()() [ <*Middle> "\n" <*Middle .class = {"bourgeoisie"}> ] } }
<SPAN></SPAN>
<SPAN class=bourgeoisie></SPAN>
This also applies to any other kind of optional parameter, i.e. arguments with explicit default value.
A note for low-level component implementers
Asteracea, when using child components in templates, constructs parameter bundles using a builder pattern. It assumes that the order of distinctly named parameter assignments does not matter and, in order to reduce code size, moves some assignments of arguments that are assigned to conditionally after those of ones that are not. This is a tradeoff incurred by statically validating parameter list completeness.
Parameter value evaluation order, assignment order among any unconditionally set parameters, assignment order among conditional parameters and the order of assignments to the same name are preserved regardless.
As long as your program compiles, this optimisation is unobservable when handling child component types created using
asteracea::component! { … }
. You may still want to keep it in mind for components with custom argument builders.
Persistence 2: Body Captures
TODO: Renamed this to ⟦pin⟧ |etc.|
TODO
Dependency Extraction
Asteracea natively supports inversion of control via dependency extraction, which is functionally identical to dependency injection but implemented a bit differently.
To define an extractable trait, you can for example write:
TODO
with { …; } <…>
While you can in theory place nearly any Rust code inside {}
-braces as part of Asteracea's grammar, this can be disorderly for more complex calculations or cumbersome where you want to reuse parts of a calculation.
Instead, you can use a with { …; } <…>
-expression to run a number of Rust statements procedurally:
#![allow(unused)] fn main() { asteracea::component! { pub WithExample()() -> Sync with { let tree_type = "oak"; let leaves_state = "fallen"; } <div //TODO: ."class" = !"{} {}"(tree_type, leaves_state) !"The tree in the garden is an {}.\n"(tree_type) !"The {}'s leaves are {}."(tree_type, leaves_state) > } }
<DIV>The tree in the garden is an oak.
The oak's leaves are fallen.</DIV>
with
-expressions can be used anywhere an element expression is expected.
Bindings declared in the with
-expression's are only in scope for the embedded element expression, but with a multi node, you can use them for multiple elements:
#![allow(unused)] fn main() { asteracea::component! { pub WithExample()() -> Sync <div with { let tree_type = "oak"; let leaves_state = "fallen"; } [ !"The tree in the garden is an {}.\n"(tree_type) !"The {}'s leaves are {}."(tree_type, leaves_state) ] > } }
<DIV>The tree in the garden is an oak.
The oak's leaves are fallen.</DIV>
spread if {…} <…>
To conditionally render a node, you can use spread if
-expressions whenever a Node<'bump>
is expected:
#![allow(unused)] fn main() { asteracea::component! { Conditional()( present: bool, ) spread if {present} "I am here." } asteracea::component! { Conditioned()() [ <*Conditional .present = {false}> <*Conditional .present = {true}> ] } }
I am here.
Note the required curly braces ({}
) around the condition and their absence on the body! This is reversed from plain Rust to show that the condition is a Rust expression while the body is not.
To render multiple elements conditionally, use a multi node:
#![allow(unused)] fn main() { asteracea::component! { Conditional()( present: bool, ) [ spread if {present} [ // <-- I recommend formatting this `[]` as you would format `{}` in Rust. "I am here" <span " and "> ] "I like this place." ] } asteracea::component! { Conditioned()() [ <*Conditional .present = {false}> "\n" <*Conditional .present = {true}> ] } }
I like this place.
I am here<SPAN> and </SPAN>I like this place.
Pattern-matching with let
is also available, though this means that Asteracea's if
-{condition}
is not automatically a Rust block. Use {{ statements }}
if you really need one, though wrapping the spread if
in a with { … } <…>
-expression is likely a better idea in terms of code organisation.
#![allow(unused)] fn main() { asteracea::component! { Conditional()( content?: &'bump str, ) [ "[" spread if {let Some(content) = content} <div !(content) > "]" ] } asteracea::component! { Conditioned()() [ <*Conditional> "\n" <*Conditional .content = {"Content!"}> ] } }
[]
[<DIV>Content!</DIV>]
Implicit
else
If a
spread if
-expression's condition is not met, an emptyNode::Multi(…)
([]
) is rendered by default.
A note for React users
Unlike React Hooks, Asteracea's captures (including
<*ChildComponent>
s) are generally fine to use in conditionalspread if
-branches, even if which branch is taken changes during the component's lifetime.The tradeoff for this is that their initialisers always run during component instantiation and that fields are created for any declared captures.
spread if …… else <…>
You can explicitly specify alternative content with an else
branch:
#![allow(unused)] fn main() { asteracea::component! { Alternates()( show_alternative: bool = false, ) spread if {show_alternative} "Default" else "Alternative" } asteracea::component! { pub Alternated()() -> Sync [ <*Alternates> "\n" <*Alternates .show_alternative = {true}> ] } }
Alternative
Default
for
-loops
for
loops in Asteracea components resemble those in plain Rust, but do produce output on each iteration:
#![allow(unused)] fn main() { asteracea::component! { pub Looping()() -> Sync for word in "This is a test.".split(' ') {[ <li !"{:?}"(word) > "\n" ]} } }
<LI>"This"</LI>
<LI>"is"</LI>
<LI>"a"</LI>
<LI>"test."</LI>
You can declare bindings and use child components within the loop's body as normal.
Their state is:
- initialised anew for each new element,
- "stuck" to the respective element in the input sequence when it is reordered and
- dropped if an element has disappeared.
Repeated elements each have their own state, but are considered interchangeable. Each such group's state "list" is auto-edited only at the end.
DOM elements are also reordered for sequence updates, but this isn't entirely reliable if you use keys longer than 32 bits.
This is guaranteed to never cause an inconsistent GUI state (since any output and bindings will still be updated to be in the correct order when diffing), but in very rare cases could result in a selection or focus "jumping elsewhere".
Such glitches are guaranteed to never happen while the sequence is stable, however.
The full explicit syntax for for
-loops is as follows:
#![allow(unused)] fn main() { asteracea::component! { pub Looping()() -> Sync for i: u8 keyed &*i => u8 in 0..255 { "." } } }
...............................................................................................................................................................................................................................................................
where
i
is the item pattern, used both for the loop body (by value) and the selector,: ⦃T⦄
determines the type of items in the sequence,keyed ⦃selector⦄
projects&mut T
to&Q
whereQ: Eq + ToOwned<Owned = K>
,=> ⦃K⦄
whereK: ReprojectionKey
determines the type of state keys cached internally and0..255
is anIntoIterator
to use as item source.
Each of these parts is optional, but currently it may be occasionally necessary to specify a type. Specifying neither T
nor K
also means a loop will use somewhat less efficient dynamically typed stored keys that always incur a heap allocation when an item is added to the list. Both most annotation requirements and relative inefficiency of unannotated loops is expected to disappear with future Rust language improvements.
spread match { … } [ … ]
Rust's match
statements are available in Asteracea contexts, with slightly changed syntax:
#![allow(unused)] fn main() { enum Enum<'a> { Text(&'a str), Other, } asteracea::component! { MatchEnum()( enum_value: Enum<'_>, ) spread match {enum_value} [ Enum::Text(text) => <span !(text)> Enum::Other => <div ."class" = "placeholder"> ] } asteracea::component! { pub Matched()() -> Sync [ <*MatchEnum .enum_value = { Enum::Text("Hello!") }> "\n" <*MatchEnum .enum_value = { Enum::Other }> ] } }
<SPAN>Hello!</SPAN>
<DIV class=placeholder></DIV>
box ⟦priv …⟦: ⟦struct⟧ … ⟦where …;⟧⟧⟧ <…>
A box <…>
expression moves its parameter's backing storage into a new heap object, bound to a field of the current storage context.
The field can be named using box priv …
syntax. If the field is named, its type can be specified using : …
.
If you don't name the type or specify its name using : struct …
, it is generated automatically.
The field can be made public by writing box pub …
. Any other standard Rust visibility can be used in place of pub
, too, to similar effects.
In practical terms:
-
Any captures are first placed in a
Box<…>
before being assigned to a ⟦named⟧ field inside the current component instance.This means that, for example, if you write
box as boxed <*Component as child>
, you'll need to access it asself.boxed.child
.This introduces some level of runtime indirection, also when rendering components.
-
Uninitialised boxed expressions take up very little space.
-
Recursion becomes possible.
Component recursion
Infinite recursive (storage) inlining isn't possible (except theoretically for zero-sized-types, but Rust makes no distinction here).
This means the following requires boxing:
asteracea::component! {
Countdown()(
i: usize,
)
[
!(i)
dyn if {i > 0} [
"\n"
box <*Countdown .i = {i - 1}>
]
]
}
Note:
It's decidedly better to implement the above with a loop!
If you have a better example to demonstrate recursion with, please let me know!
Note the use of dyn if
to prevent infinite eager initialisation.
You can alternatively combine spread if
with lazy ⟦move⟧
to avoid throwing away heap allocations once they exist. This is better in cases where the recursion depth changes quickly or
Memory savings
The container component size reduction isn't very useful in most cases, since Asteracea initialises child components eagerly, but can be used to great effect with dyn
branching if certain arms require especially large storage.
Consider the following:
#![allow(unused)] fn main() { use std::mem::size_of; asteracea::component! { Heavy()() let self.large: [u8; 1_000] = #![allow(dead_code)] [0; 1_000]; // 1 KB "Hello!" } asteracea::component! { Holder()( show: bool = false, ) [ "Holder size: " !(size_of::<Self>()) " bytes" spread if {show} //TODO: Replace `spread` with `dyn`! <*Heavy> ] } }
Holder size: 1000 bytes
As you can see, Holder
requires 1KB of space even though Heavy
is never used.
You can avoid this as follows:
#![allow(unused)] fn main() { use std::mem::size_of; asteracea::component! { Heavy()() let self.large: [u8; 1_000] = #![allow(dead_code)] [0; 1_000]; // 1 KB "Hello!" } asteracea::component! { Holder()( show: bool = false, ) [ "Holder size: " !(size_of::<Self>()) " bytes" spread if {show} //TODO: Replace `spread` with `dyn`! box <*Heavy> ] } }
Holder size: 8 bytes
As dyn if
won't initialise its branches unless necessary, no heap allocation happens in this case either.
defer ⦃storage⦄ <…>
An alternative method of breaking up recursive component initialisation is to defer it for the recursive part of the template until it is rendered.
As such, the recursive example from the box ⟦priv …⟦: ⟦struct⟧ … ⟦where …;⟧⟧⟧ <…>
chapter can be written as:
#![allow(unused)] fn main() { asteracea::component! { Countdown()( i: usize, ) -> Sync // Syncness hint required due to recursion. [ !(i) spread if {i > 0} [ "\n" defer box <*Countdown .i = {i - 1}> ] ] } asteracea::component! { pub HalfADozen()() -> Sync? <*Countdown .i = {6}> } }
6
5
4
3
2
1
0
This has different runtime characteristics:
spread if
doesn't drop branches that aren't active, and defer
only ever runs initialisers once. This means that state persists and heap allocations are cached. Useful for (very) frequently updated content!
Naming the field
As usual, the backing field can be named and then referred to:
#![allow(unused)] fn main() { asteracea::component! { Introspective()() [ "Was I rendered before? " !(self.deferred_pinned().get().is_some()) defer priv deferred: struct Deferred [] ] } asteracea::component! { pub Ruminating()() -> Sync? [ <*Introspective priv introspective> "\n" <*{self.introspective_pinned()}> ] } }
Was I rendered before? false
Was I rendered before? true
The subexpression backing storage is wrapped in a Deferred
instance, which provides a .get()
method returning an Option<Pin<&Deferred>>
depending on whether the subexpression was constructed yet.
You can also call .get_or_poison()
, to evaluate the constructor if pending, which returns a Result<Pin<&Deferred>, Escalation>
.
bind ⦃storage⦄ ⟦move⟧ <…>
Much like defer
expressions, bind
expressions evaluate a sub-expression constructor once when first rendered.
However, they also shift the constructor scope of a sub-expression into its parent's render scope.
This lets you use render parameters as constructor parameters:
#![allow(unused)] fn main() { asteracea::component! { Early( priv early: &'static str, )() ["Constructor parameter: " !"{:?}"(self.early)] } asteracea::component! { Late()( late: &'static str, ) bind <*Early *early = {late}> } asteracea::component! { pub Test()() -> Sync? [ <*Late priv tested .late = {"first"}> "\n" <*{self.tested_pinned()} .late = {"second"}> ] } }
Constructor parameter: "first"
Constructor parameter: "first"
As you can see, early
is only assigned once. late
is discarded during the second call.
By default, the sub-expression constructor acts like (read: is) a plain Rust closure without additional keywords, but you can apply the move
keyword after the optional storage configuration.
Glossary
element expression
Asteracea's most generic template building block. Placeholder: <…>
Chapter 1: Hello Asteracea
#![allow(unused)] fn main() { asteracea::component! { HelloAsteracea()() <span "Hello Asteracea!"> } }
<SPAN>Hello Asteracea!</SPAN>
#![allow(unused)] fn main() { use asteracea::component; use std::cell::Cell; fn schedule_render() { /* ... */ } component! { pub Counter( initial: i32, priv step: i32, )( /// This component's class attribute value. class?: &'bump str, ) -> !Sync // visible across crate-boundaries, so use explicit `Sync`ness let self.value = Cell::<i32>::new(initial); // shorthand capture <div // Attribute usage is validated statically. // (Write its name as `str` literal to sidestep that.) // Anything within curlies is plain Rust. .class? = {class} // Three content nodes in this line, // with a shorthand bump_format call in the middle. "The current value is: " !(self.value()) <br> <button "+" !(self.step) // Correct event usage is validated statically. // (Write its name as `str` literal to sidestep that.) on bubble click = fn (self, _) { self.set_value(self.value() + self.step); } // Inline handler. > > } impl Counter { pub fn value(&self) -> i32 { self.value.get() } pub fn set_value(&self, value: i32) { self.value.set(value); schedule_render(); } } }
<DIV class=counter-class>The current value is: 0<BR><BUTTON>+1</BUTTON></DIV>
🌬️🍃🌄
🏞️🐟🪣