This is a relatively high-level summary post of design decisions I made so far while implementing Asteracea and its related packages like the lignin
group of crates. Iām making this post partially in response to Raph Levienās Xilem: an architecture for UI in Rust, since I noticed we have largely similar approaches to app structure and lifecycle management (although we went for somewhat different solutions that are presented in superficially very different ways to the developer).
Funnily enough, our architectures are both named after plant biology. I previously explained my choice as follows:
Why the name?
Iād started naming my projects after plants shortly before starting this one.
Asteraceae are often plain but wildly varied flowering plants. My wish with this project is to create a āboringā system that is uninteresting by itself and highlights the userās individuality.
The name also reflects that Iād like to support the creation of individual āweb gardensā (small creative pages usually by individuals) as well as sustainable development of large enterprise apps (by encouraging maintainable code style through enterprise features like built-in dependency injection, as well as a green deployment due to (vastly) decreased runtime energy usage and hardware requirements).
(My system uses mutable but largely non-moving data structures, so its supporting crates like lignin
or rhizome
tend to be named after structural components rather than transport tissue, though.)
A Word of Caution
First off, my professional background is largely web frontend development (about 1.5 years of specific experience?), and I donāt exactly have a formal education in this space. Similarly, I picked up English largely while teaching myself how to program. There may be rough patches in terms of vocabulary ahead, in either regard.
Iāll also have to rely on examples and comparisons a bit more, since I lack the theoretical knowledge to name the different existing UI framework API patterns.
As such, please take everything here with a grain of salt, and feel free to point out issues. Iām used to harsh criticism and donāt mind as long as it attacks my ideas rather than something I have no control over.
Motivation
I originally started work on Asteracea in late 2019, as part of a program I could host on my local network that would let me track itemised grocery bills and monitor individual product price changes. At the time, I also needed a break from JavaScript, but still decided to make it a website, so that I could use it easily on mobile without dealing with Android Studio.
Many of the choices I made and features I added are direct responses to frustrations I encountered while working as a consultant on web app projects, as well as as user of a fairly old computer, a phone with low system memory and frequently a slow internet connection.
I like websites that work also without JavaScript and donāt start with a loading screen, wonāt unload other apps as soon as I browse to them, and donāt hog the CPU. As a developer, I prefer to work with very strongly typed languages and to have documentation available directly in the IDE, to reduce the amount of time I spend on a feature and the amount of context switches I have to do.
Additionally, and this is purely personal preference, I love small indie projects and also like crafting my own web presence in detail. (I have not gotten around to doing the latter, for the most part.)
As such, I didnāt want to make a framework aimed only at larger scale deployments, but one that scales down nicely to even the smallest and simplest of pages and isnāt prescriptive in terms of document structure.
Goals
Asteracea is my attempt at making (web) frontend development both more convenient and more accessible, while raising the quality of the compiled applications.
The system is built from modular and often interchangeable parts, so while Iām working towards a turn-key bundle, you donāt have to use my macro DSL to make your components compatible with mine. This is also to enable incremental upgrades of the platform without spilling all application code along with it. (Some glue may be required between distinct paradigms, where there could be a mismatch in the API shape exposed by individual components.)
Personally, both boilerplate and syntactic noise irk me though, so I created a relatively concise macro transform that translates symbols and keywords into structural aspects of the component, while identifiers are mostly used only for application-level constructs.
Problems
The main issue with implementing a classic model-view-controller pattern in Rust is that lifecycle management of stateful hierarchical GUIs is a huge chore, largely due to callbacks that have to reach deep into encapsulated components (while mutability of inert data is much less of a problem).
Component-instantiating GUI frameworks like Angular or WinForms tend to be very boilerplate-heavy, buying a straightforward mental model with dense syntax and/or restated structure. (WinForms gets around this with a designer and generated code, but thatās not a good option for web development, where a well-organised document structure often describes intent more than visual primitives.)
Their strength is maintainable development at scale, as they hide transient local state very well and often allow for better code organisation than other paradigms.
Mainly reactive frameworks like eponymically React, which generate the GUI structure from only a render method, have greatly simplified syntax but often need workarounds for state management. Reactās Hooks for example are very convenient, but they suffer from footguns related to control flow (that you can very reliably lint against, to be fair) and canāt entirely remove initialisation from their hot update path without potentially changing behaviour. They also tend to scale less-than-ideally to more complex applications, mostly hurting code organisation and maintainability.
WPF is overall well-designed and its basic features are very accessible due to great first-party tooling, but full use of its advanced features is not all that well communicated (with a large gap between consuming and providing them) and the framework is non-portable, which is stifling broader adoption.
However, many of its systems rely on externally-visible fairly direct mutability of components, which doesnāt translate all that well into plain Rust. (.NET as a whole is not great at tree-shaking unused code due to its runtime metaprogramming features, but thatās not a problem with WPF in particular, just something that came up while I was thinking about how to make applications tiny enough for the clientside web.)
Immediate-mode GUIs like imgui or Unityās editor GUI system have the advantage of being able to reuse language constructs like loops directly to model aspects of a hierarchical GUI (and save many of the nested parentheses you see in the structurally similar reactive ones, so their syntax tends to be cleaner and more open-ended), but they mostly suffer from very inconvenient state management for child components.
My Approach
Iām remixing existing ideas (including from all of the above).
If you look at the individual pieces that make up Asteracea, youāll likely notice that nearly all of them already exist in some shape or form elsewhere.
This framework aims to be āboringā:
- The macro DSL is mostly contextless and generates unspectacular code you could easily hand-write (if you didnāt mind a lot of verbosity and a bit of straightforward
unsafe
code for pin projections), - the procedural macro lowers syntactic sugar in steps (both by transforming and reparsing in terms of its own syntax, and by delegating to existing general-purpose macros in its output),
- the generated HTML- and DOM-structure is no-frills as-if-handwritten, with no structural restrictions like mandatory custom elements and
- there are no surprises that would suddenly lower app performance because you werenāt explicit enough.
The one original concept that I didnāt see elsewhere before are interlaced local scopes, though I wouldnāt be surprised if that has been done elsewhere, as itās a straightforward source code transformation.
Reference-counting through direct payload borrows is something I came up with on a whim, but later learned already has standard library support in C++ (although in a slightly different way that doesnāt mesh with strict provenance in Rust).
Iāll annotate from where I lifted different concepts below each heading.
My Solutions
Everything explained here is implemented and functional (on some branch in the repository, but mostly indeed develop), unless otherwise noted. Youāre invited to have a go at playing around with it, but keep in mind that there are some usability holes (and no standard library worth mentioning), and that some of the syntax is subject to change as I continue to simplify it.
There is a Zulip Stream for this project, in case you have questions or would like to give feedback on this post.
Apologies for the outdated versions on Crates.io; I started work on larger changes a while back and havenāt finished polishing and documenting everything yet.
Interlaced Local Scopes for Localised Semantics
The smallest, empty, component, which is a ZST, does not have inherent identity, does not incur allocations and does not generate output in either HTML or DOM, is roughly this:
1
2
3
4
5
asteracea::component! {
Empty()()
[]
}
Which likely looks pretty odd. (Yes, thatās two parameter lists.)
I went with a use-what-you-need approach to reduce the boilerplate required for simple components. Stateful and pure reactive components are unified, with no baseline runtime overhead. There are some trade-offs in updating the DOM, as the underlying lignin
is a classic, so far double-buffered, diffed VDOM for easier modularity and platform-independence.
Iāll explain the basic structure in terms of this counter component:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use asteracea::services::Invalidator;
use lignin::web::{Event, Materialize};
use std::sync::atomic::{AtomicUsize, Ordering};
asteracea::component! {
/// A simple counter.
pub(crate) Counter(
priv dyn invalidator: dyn Invalidator,
starting_count: usize,
)(
class?: &'bump str,
button_text: &str = "Click me!",
)
<div
.class?={class}
let self.count = AtomicUsize::new(starting_count);
!"This button was clicked {} times:"(self.count.load(Ordering::Relaxed))
<button
!(button_text)
on bubble click = active Self::on_click
>
/div>
}
impl Counter {
fn on_click(&self, event: Event) {
// This needs work.
let event: web_sys::Event = event.materialize();
event.stop_propagation();
self.count.fetch_add(1, Ordering::Relaxed);
self.invalidator.invalidate_with_context(None);
}
}
(Embedding the code here isnāt ideal, since it loses the semantic highlights it normally has via rust-analyzer. Screenshot)
Components are struct types with some associated functions, so they accept documentation and a visibility, and have a name thatās an item identifier in their containing scope. (First two lines inside the macro.)
Next are the constructor parameters, in the first pair of parentheses:
dyn name: Type
uses the dependency injection mechanism (inspired by Angular) to retrieve a value and bind it toname
. Meanwhile,Type
acts as a token here and not only controls what is retrieved, but also how it is made available, and, if applicable, how it is lazily injected into the dependency tree. (The entry point here is the traitrhizome::sync::Extract
, in my development version as of now.)A
dyn Trait
should usually extract itself as pinning sharing resource handle, which keeps the resource tree node its instance is stored in alive.The resource tree itself is sparse and can āskipā levels in the component hierarchy, so there is no overhead from components that donāt interact with it.
Prefixing a constructor parameter declaration with a visibility declares a field directly in the component and finally assigns the argument (whether extracted or plain) to it. (Lifted verbatim from Typescript.)
starting_count: usize,
declares a plain constructor parameter. All parameters are named, which is inspired by HTML attributes, and to a smaller extent by how properties can be assigned in Angular templates. The implementation is statically validated and should have next-to-no overhead thanks to the typed-builder crate, though there is some room for improvement in the implementation details (i.e. no easy repeatable parameters, and high complexity of optional arguments (which are distinct from optional parameters)).
Next is the render parameter list.
This list is used for transient values that are provided each time the component is rendered, but may flow into the generated VDOM by reference if they are annotated with the special
'bump
lifetime.Placing a
?
after its name makes the parameter optional. The outwards-visible type is unchanged, but internally, it is wrapped in anOption<_>
.An alternative to optional parameters are default arguments, written as
= expr
in their respective parameter definition. These are evaluated for each call where the argument wasnāt specified and have access to preceding parameters. (Inspired byā¦ Python I think? The semantics are different.)
A return type can be optionally specified after the render parameters, but generally this is automatic and a drop handle wrapping a VDOM node that can refer to other nodes in the bump allocator that was given to the render method by the app runtime.
Next is the main original feature of Asteracea, a mixed-style component body. āMixed-styleā has a dual meaning here:
The body is translated into both a constructor (
::new(ā¦)
) and a VDOM-builder (::render(self: Pin<&Self>, ā¦)
).Each piece of plain Rust that appears in this body is quoted into either the one or the other, so distinct sets of local variables are present in each and
self
is only available in.render
.The paradigm is mixed-declarative-imperative:
- You get painless state management like in React, with single statements that take care of both declaration and initialisation of local state.
- You can freely use (certain) flow control statements like
for
-loops without wrapping them in boilerplate.- Some differences apply: For example, loops expected to generate a value from each iteration, but this value can be the empty node
[]
. You can still use the normal versions inside a Rust-block-expression.
- Some differences apply: For example, loops expected to generate a value from each iteration, but this value can be the empty node
- This is a smooth mix: Control flow (generally) translates into stored expression state management, so itās safe to declare more fields wherever, right where you need them. (There is no implicit cross-talk between loop iterations or branches.)
Iāll continue line by line again:
<div
is an opening tag. These can either be standard HTML elements as identifier (which statically validates them to some extent and enables context help (See screenshot below.)), oralternatively the element name can be written as strong literal (
"custom-element"
), which allows more flexibility at the expense of validation.To use an Asteracea-generated component as child, you would write
<*Child
, with an asterisk before the identifier. You can also write<{expr}
to only render or transclude such a child component, without instantiating it..class?={class}
: This is an optional argument (in this case an optional attribute), only set if the value isSome(ā¦)
ortrue
and fully absent if it isNone
orfalse
. This also works on*Child
ren, but only withOption<_>
s.HTML attributes and render arguments are prefixed with a single
.
here. For HTML elements, the identifier can be replaced with a string literal like"data-myData"
to skip validation.Child components may additionally accept constructor parameters prefixed with
*
. For example, the counter above could be placed inside a parent component as follows:1
<*Counter *starting_count={0}>
Constructor argument expressions are placed in the surrounding constructor scope and in most cases run only once when the parent component is instantiated. However, any side-effects should ideally still be idempotent regardless, to make debugging easier.
1
let self.count = AtomicUsize::new(starting_count);
This is Reactās
useState
, except that it can be placed anywhere. The Rust expression on the right is constructor-scoped, so it can seestarting_count
but not, for example,button_text
.The field is statically-typed. The above is actually a shorthand for:
1
let self.count: AtomicUsize = AtomicUsize::new(starting_count);
I used a bit of macro-magic to make that possible, as there is unfortunately no true field type inference.
Fields declared this way can also be published (Their visibility follows
let
but is normally the implicit one.), though in this case thatās not a good idea since setting it directly would not invalidate the rendered GUI state.The next line does string formatting:
!"format string"(args)
is the general pattern, though the implementation specifics are delegated tobumpalo
.You can write
!(arg)
to imply the format string"{}"
.<button
- a nested HTML element, one of the recursion points of this grammar. You can also nest child components, and you can nest HTML elements and child components inside a container component to transclude them. (More on that later: Asteraceaās transclusion is a bit fancier than what e.g. Angular or React can do, closer to WPFās in power.)The next line is just string formatting again, which brings us to:
1
on bubble click = active Self::on_click
Event handlers are currently the most shaky aspect of Asteracea and Iāll likely have to rework some of their details a bit, so Iāll be brief:
Event bindings are on average allocation free (callback registry with drop notifications) and the use of modifiers (
bubble
vs.capture
vs. nothing, and use ofactive
after=
toevent.prevent_default()
) is statically validated if the event is. (As usual, you can use a string literal for the event name to skip validation.)The handler can have a number of different signatures, and can also be written inline as currently
fn (self, event) { ā¦ }
(not a closure).Event bindings on child components are not yet supported, but will use uniform syntax with event subscriptions on HTML elements once available. Support for bubbling event bindings outside the HTML DOM is up in the air; personally I find good direct events to be cleaner, and for skipping layers thereās the DI system already.
>
closes an HTML element or child component without restating its name./div>
closes that HTML element while restating its name. This is validated statically and the elementās context help is available on this closing tag.
Few, Composite Allocations
(inspired by Rustās async blocks)
Asteracea relies very heavily on data inlining to reduce memory use and improve cache behaviour.
Most Asteracea-components donāt allocate directly, and their use elsewhere also does not automatically incur an allocation, as child components are (non-generically) inlined into their parents.
There are a few exceptions to this: box
expressions explicitly heap-allocate the storage for their contents, and e.g. for
loops are storage container expressions that manage storage instances on the heap. Conversely, simple branches like match
(or if
, which in Asteracea is direct syntactic sugar for the former) can use fully inlined storage either as enum
or struct
.
As components must be pinned to be rendered, the asteracea::component!
macro safely implements pin projections for the fields storing child component instances.
(If you create branches with vastly different storage sizes, that does seem to get flagged by Clippy. You can then box
one or more of the branches to dynamically use less memory.)
Rich Tooling Compatibility
Asteracea preserves source code spans and assigns those of synthetic tokens based on fine-grained context. This means that most error messages and warnings are already very precise with just rust-analyzer.
Additionally, there are custom messages for domain-specific errors, like in the following case:
1
2
on bubble error = active fn (self, _) {}
~~~~~~ ~~~~~~
- evaluation of constant value failed
the evaluated program panicked at āKeywordbubble
is not valid for this event; the event does not bubble.ā - evaluation of constant value failed
the evaluated program panicked at āKeywordactive
is not valid for this event; the event is not cancellable.ā
(This isnāt perfect: Rustcās spans are less accurate than rust-analyzerās (but still useful) and Iād love to get rid of the evaluation of constant value failed
, the evaluated program panicked at '
, '
parts entirely. Itās in my eyes at least close to a dedicated editor integration, though, and Error Lens is able to display the message inline with little clutter.)
Span preservation also gives you access to some rust-analyzer quick fixes inside the macro, though not all of them currently work correctly. Iām not sure how much I can improve them with the current proc macro API on stable, so for now Iām biding my time before attempting higher levels of polish in this regard.
Additionally, you can step-debug into components somewhat decently. (There is room for improvement here though, likely by adjusting the Span
s for generated code some more.)
Keyed Repetition
(inspired by loops in Angular templates)
Itās very easy to construct dynamic list displays using loops in the component body:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
asteracea::component! {
ListVisual()(
// Side-note: Supporting this kind of parameter type is pretty tricky.
items: impl IntoIterator<Item = &'_ SomeItem>,
)
<ul
for item in items {
<li
bind <*CaptureItem *item={item}>
>
}
/ul>
}
This list is auto-keyed (which has a bit of overhead vs. specifying the key type manually (also possible), until type ā¦ = impl ā¦;
becomes available), which means the storage contexts for its body are automatically reused, reordered, and reinitialised only as needed.
There is a bit of an oddity here: The constructor parameter *item
on *CaptureItem
receives data originally from the render parameter items
. This is possible because bind
moves the constructor of its argument into the render scope (as Rust closure), running it once when first rendered.
Stored loop body state is dropped when the loop runs again and that item is missing.
(Key repetitions are fine, but itās strictly the last ones for each key that are added or removed.)
Un-keyed Repetition
You can loop in the constructor of a component instead by writing *for
. This is evaluated only once when the surrounding expression (usually the component) is initialised, has exactly the same semantics as a plain Rust loop (aside from generating items), and generates the VDOM a bit more efficiently than the render-scoped loops above.
Transclusion and Parent/Container Parameters
(inspired by transclusion in Angular and attached properties in WPF)
My code calls these āparent parametersā, but Iām not sure which term is better in practice.
Take snippet example for instance:
1
2
3
4
5
<*Router
// I'll redo how the paths are specified.
->path={"/div/*"} <div>
->path={"/span/*"} <span>
>
Here, two child expressions are handed to the Router
instance during rendering. (They are constructed eagerly with the expression that contains this code, but you can easily defer
that in most cases. Here I havenāt done it because purely HTML expressions donāt need storage or construction.)
This functions via closures, so while outer variables are visible, the Router
can decide which branch to actually render here each GUI frame.
The ->path
argument is fully statically typed and validated against Router
ās render method signature, and passed along by value with each child.
Transclusion slots can be named and distinct slots can accept different sets of arguments. The child parameter slot on Router
in this case is repeatable and anonymous. I havenāt yet implemented good syntactic sugar for declaring these, so thatās currently a bit cumbersome.
A single non-repeating anonymous content parameter can currently be declared as ..
, and can also be pasted as ..
in the component body. This is a placeholder and likely to change.
Coloured Async and Named Slots
There isnāt really much to say about these features, as they follow directly from Rustās interpretation of coroutines and child transclusion via (named in general) render parameters.
Asteracea components may be asynchronous and .await
ed in asynchronous expressions, which may be the body of an asynchronous component or created by the async
keyword.
Slots can be named (I used lifetime labels here, since that results in nice syntax highlights.) and a slot can also be set up to accept an asynchronous expression.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async fn future_text() -> String {
"Like a record!".to_string()
}
asteracea::component! {
Spinner()()
"Spinning right 'roundā¦"
}
asteracea::component! {
async Async()()
let self.text: String = future_text().await;
!"{}"(self.text)
}
asteracea::component! {
Instant()()
<*Suspense
'spinner: <*Spinner>
'ready: async <*Async.await>
>
}
(Thatās essentially a clone of Reactās Suspense
, just Rust-y.)
The storage for both the Spinner
and Async
is managed by (and inlined into) Instant
, so Suspense
can be fairlyĀ¹ non-generic, as transclusion is largely type-erased.
However, Suspense
controls how the underlying Future
is driven (holding onto a cancellation token and scheduling a small driver-Future
using an injected ContentRuntime
implementation) and takes care of invalidation (by cloning and passing along an optionally-injected Invalidator
handle).
Dependency Injection
(inspired by Angular, but strictly at runtime)
Itās quite easy to declare resource tokens for injection, here for example the ContentRuntime
declaration in its entirety:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/services/content_runtime.rs
use crate::include::async_::ContentFuture;
use rhizome::sync::derive_dependency;
/// A resource used by [`Suspense`](`crate::components::Suspense`) to schedule [`ContentFuture`]s.
pub trait ContentRuntime {
fn start_content_future(&self, content_future: ContentFuture);
}
derive_dependency!(dyn ContentRuntime);
// Specific implementation:
impl<F: Fn(ContentFuture)> ContentRuntime for F {
fn start_content_future(&self, content_future: ContentFuture) {
self(content_future)
}
}
(Injection of concrete data-holding types is set up a bit differently.)
As ContentRuntime
in particular is blanket-implemented over certain closures, providing an implementation to your app is then as easy as writing:
1
2
3
4
5
6
7
// Given to the root component manually, but otherwise managed implicitly.
let root = Node::new(TypeId::of::<()>());
<dyn ContentRuntime>::inject(root.as_ref(), |content_future| {
// `ContentFuture` is `'static + Unpin + Send + Future<Output = ()> + FusedFuture` and completely type-erased and semantically fire-and-forget,
// and as such trivially compatible with practically every single Rust async runtime out there.
});
A container componentās dependency injection node is parent to that of transcluded children. This allows Invalidator
-interception for memoisation, for example.
As mentioned above, dependency injection into components is done by declaring a constructor parameters like
1
dyn runtime: dyn ContentRuntime,
but itās also possible to not strictly require the runtime by making the parameter optional:
1
dyn runtime?: dyn ContentRuntime,
Memoisation
Between Invalidator injection, transclusion that flows the dependency context, and a drop-guarded VDOM, itās relatively straightforward to implement a memoisation component usable like this:
1
2
3
<*Memo
// ā¦further contentā¦
>
And if you need to externally invalidate the Memo
, you could name the field it is stored in to call methods on it:
1
2
3
4
5
6
7
<*Memo priv memo
// ā¦further contentā¦
>
// ā¦
self.memo.invalidate();
(My current memoisation component has a slightly different API, is on a feature branch, and is not in a clean shape. Iāll likely optimise and clean it only after working on some other features first.)
Sparse VDOM Drop Guards
The VDOM tree itself can be trivially dropped, so double-buffering it with a rotating pair of bump allocators is very efficient.
However, the memoisation component above must know when itās safe to drop a cached version of the VDOM, without direct knowledge of the main allocatorsā unsafe
rotation at the app root.
Relying on a safety contract that prescribes an app lifecycle with repeating elements seemed error-prone, so instead Asteracea uses sparsely aggregated drop guard trees stored in the same bump allocators as the VDOM nodes.
These may contain a type-erased drop notification (as well as the VDOM node they guard), but most importantly can be unsafe
ly split into the notification and VDOM node.
The component macro aggregates the former from child components, only pushing a pair into the allocator where collisions happen, and then composes them with the VDOM node it created to create a safe return value.
If an error or unwind happens while these parts are split, the drop notification is sent and the macro guarantees that the formerly-guarded VDOM nodes are never accessed.
Server-side Rendering and Hydration
As Asteracea is fundamentally platform-agnostic, itās possible to run applications natively on a server without changes to the application code. (If you need different output, you can reconfigure aspects of an app through dependency injection, e.g. by not injecting a user credentials service or by injecting dummy async runtimes that never poll the Future
ās.)
lignin-html
can render lignin
VDOMs into HTML documents, and lignin-dom
can read the DOM tree into a VDOM tree, effectively hydrating it in place on first diff.
Element Bindings
Element bindings function similarly to event subscriptions and give a component running client-side access to the DOM element instance, by notifying it of changes and asking it to clean up when removed from the rendered view.
I havenāt really given this feature much thought yet, but itās there and should be enough to host JavaScript components.
Room for Modifications
The lignin DOM update specification carves out some niches for uncontrolled modifications of the page structure, to make it easier for users to adjust the appearance of pages or add functionality using e.g. web extensions.
With at least my differ, apps wonāt interfere with extra child nodes in an element as long as they follow the app-managed ones, or extra attributes or extra event bindings. (There is no external notification in cases where a DOM element instance happens to be repurposed, though, so extensions must subscribe to DOM changes to stay in sync.)
Additionally, the differ is at the same time resilient enough to detect inconsistencies with the tolerated DOM state and recreate parts of it more aggressively where needed.
A Declarative Compile-Time Schema Library
Asteraceaās knowledge of HTML is provided by the lignin-schema
crate, the source code of which you can find here: lignin-schema/src/lib.rs
The macros encode information about elements, attributes and events into the Rust type system and attach meta data (documentation and deprecations) as attributes, while the asteracea::component!
macro does not have this information and instead just emits validating const expressions and constructs the relevant type paths.
The actual schema validation is then done only by the Rust compiler, which also makes it possible to have automatic in-editor completions for element names and for rust-analyzer to provide documentation on hover. (The macro could be more lenient to allow completions to work in more cases.)
You may notice that one of the macro upper-cases all element names. I somewhat boldly assumed a differ might want to use
Element.tagName
inside an HTML document without doing a case-sensitive comparison here, but maybe lower-casing everything would be better in light of Brotli compression.I donāt think it really matters all that much when using a double-buffered VDOM, at least, since the renderer would only have to adjust casings once during hydration.
Plain Extra Impls
As the entirety of componentās state is stored in ājust fieldsā, itās easy to extend a componentās API.
For example, the following could be added to the counter component aboveās module to give its parent access to the counter state:
1
2
3
4
5
6
7
8
9
impl Counter {
pub fn count(&self) -> usize {
self.count.load(Ordering::Relaxed)
}
pub fn increment(&self) {
self.count.fetch_add(1, Ordering::Relaxed);
}
}
As long as the parent binds the instance to a name, like hereā¦
1
2
3
4
5
6
7
asteracea::component! {
Parent()()
<*Counter priv counter
*starting_count={0}
>
}
It can then call these as e.g. self.counter.count()
.
Some methods may require a Pin<&Self>
. For fields that require pinning, like child components, a matching pin projection method is also available. In this case, self.counter_pinned().count()
would also work that way as long as self
is at least a Pin<&Parent>
.
No-Effort Instrumentation
You only have to enable Asteraceaās "tracing"
feature to automatically instrument all constructors and render methods with tracing spans and log all their arguments. (This uses auto-deref-specialisation internally, so you get meaningful argument logs depending on which traits are available on them, without having to worry about incompatibilities.)
Please wrap any sensitive information you pass around in your program in a debug-opaque newtype to avoid accidental logging. The
lignin-ā¦
crates, once published to Crates.io in their instrumented version, similarly should provide only redacted logs by default, unless content logging is explicitly enabled via a feature.)
You also get nice performance traces in the browser if you use tracing-wasm. Iād like to eventually write a proper debug interface and browser extension to inspect the app state, but for now thatās quite far off.
One caveat here is that async
constructors arenāt quite properly instrumented due to some difficulties with the tracing::instrument
attribute macro. It seems my PR in this regard might land soon though, at which point adjusting Asteracea to use that macro again should be quick and easy.
There is also a potential parameter name collision with this feature that still needs to be solved, though ideally upstream.
Odd Features
Iāll probably scrap or evolve these from their current form, since they have usability drawbacks.
Thread Safety Inference
Asteracea components (by default only in the same crate) can transitively infer the thread-safety tag of their resulting VDOM, entirely at compile time.
At least in theory that, since there seem to be frequent edge cases where this trips up type inference somewhat. (The core hack is deanonymization of opaque (impl ā¦
) return types through the auto trait side channel. You can find more information on that in the lignin::auto_safety
docs.)
Iām considering letting all component-generated VDOM be only !Sync
by default, while keeping the option to safely state otherwise. This would remove those edge cases (mostly dependency loops) where inference suddenly fails, and would probably improve build times a little.
Native GUI Targets
Thereās nothing really stopping you from rendering a lignin
VDOM in terms of a native GUI framework, however this currently maybe isnāt the best fit depending on the framework in question.
As Asteracea doesnāt really take care of event bubbling and assumes all standard elements are present, a fairly thick glue layer may be required to make this work nicely.
I made a quick proof of concept in this space, and it works well with low requirements, but this is not something I plan to pursue immediately.
Future Work
There are a few open points that I still havenāt figured out:
More syntax/syntax revisions
Iād like to add plain let
(and *let
) bindings directly to the body grammar, and in exchange remove the with {}
blocks that paste Rust code into the constructor or render function.
Flow control expressions should consistently use {}
around their branches, while embedded Rust expressions should be more explicit than that.
There are also some flow control expressions or expression variants that arenāt implemented yet.
Attribute/argument syntax is a bit too verbose. I should be able to make the {}
optional (though expressions with .
will have to be parenthesised, still).
An efficient render-context
This would make it easier to propagate transient state along the component tree, with the option to later remove contents from it without re-creating the affected components.
rhizome is not suitable for this, as it unconditionally allocates at least twice for each new populated resource scope and is inflexible regarding removals.
A mockable asynchronous HTTP client
This should be a trait in Asteraceaās standard library, to be used as injected dependency.
(Iāll likely clone hyper
ās API for this on some level, but I havenāt really looked into this.)
Optimisation
Asteracea feels fast as-is, but thereās likely a bunch of potential in giving the crates I proof-of-conceptād as dependencies any optimisation pass at all.
(Iāve constructed the public crate APIs with efficiency in mind already, but I took some shortcuts here and there as far as the internals go. This is my unpaid hobbyist project, after all, so I have to cut corners somewhere ;-) )
A Remnant
API and implementation
Thereās currently only a placeholder VDOM node variant.
āRemnantsā (as I call them) are (to be) lingering pieces of the DOM that are preserved for a while even after they are removed from the VDOM, existing outside the usual app update flow. This makes them useful for animating-out nodes.
Raw HTML Pasting
I havenāt added this to the VDOM since I personally donāt need it, but someone is going to want that feature before long.
Data Binding
(This is a wishlist feature that I havenāt gotten around to seriously working on yet. I only have vague ideas how to implement this.)
Declaring Cell
s and other interior-mutable fields seems okay when using the shorthand syntax, but Iād like to simplify how they are updated in high-level components. Take the following currently not functional syntax sketch for example (inspired by Angular):
1
2
3
4
5
6
7
8
asteracea::component! {
DataBinding()()
let self.data = RefCell::new("".to_string());
<*TextField .(value)={&self.data}><br>
"The text is currently: " !(self.data.borrow())
}
The details of the binding implementation can be provided by a trait, similarly to how optional arguments work already. This way, you could use a plain Cell
for types that are Copy
.
It should be possible to reuse the callback registry mechanism that DOM event subscriptions use here, with the control component (TextField
) storing a CallbackReference
it receives as render parameter. lignin
ās CallbackRef
s are opaque weak handles (meaning they contain only a number and are Copy
), so thereās little overhead to just replacing them on render.
Such data bindings should also automatically invalidate the current component using an implied priv dyn __Asteracea__invalidator? = dyn ::asteracea::services::Invalidator
added to the constructor parameter list.
Planned Features
The following are in theory solved problems, but implementing each solution would either be a fairly large amount of work for me right now or is lower-priority for other reasons.
A component trait
Components are currently duck-typed, with a mix of attached methods and type inference hacks to instantiate the appropriate arguments builders.
It should be possible to at least partially implement a trait for the interface instead, which would make component implementations not using the component!
macro a bit easier.
Repeat parameters
Similarly to optional parameters with ?
, the following declarations should also be valid in the parameter lists:
1
2
3
4
zero_or_more*: Type, // Vec<_>?
one_or_more+: Type, // `Vec1<_>` or similar
optionally_one_or_more+?: Type, // `Option<Vec1<_>>`
optionally_zero_or_more*?: Type, // largely just for completeness, `Option<Vec<_>>`
Then, on the caller side, it should be possible to repeatedly add arguments for these parameters (or transcluded children to the same variadic slot!) and/or to spread an IntoIter<Item = Type>
into them.
I started work on the necessary infrastructure quite a while ago, but so far havenāt found the time and peace of mind to complete this pull request.
Better missing argument errors on child components
Now that const expression panics are available and appear as compile-time errors, it should be fairly straightforward to validate argument presence that way (using the same kind of method chaining as the actual builder, but without argument values). The raised panic would then appear as error on the child componentās identifier or reference-expression, and could clearly list which fields are still missing.
(Ideally this would replace the deprecation message and errors from typed-builder, but Iām not entirely certain how feasible that is. I think it will be possible once inline const { ā¦ }
blocks are available, as then this block could provide the initial builder and, on error, result in a somewhat more lenient vacant dummy type instead.)
Other Issues
For various additional rough edges, please see Asteraceaās issues on GitHub.
How to support this project or me personally
There are two good ways for individuals to contribute to Asteraceaās development right now:
You can send me feedback or questions
If a feature turns out to be unclear, that means I either have to add better documentation for it or revise the feature itself.
(Iām writing a guide book in parallel to development, deployed automatically from and tested against develop. The āIntroductionā page is a bit outdated; Iāll get around to updating it eventually.)
You can contribute code to related repositories
The main Asteracea repository is too in flux right now to efficiently take contributions, but there are a number of dependencies that have a stable API but are internally not optimised well or incomplete.
These crates, for example tiptoe, also mostly have a generic API rather than one designed only for Asteracea, so they may be worth a look for your own projects too.
Iām not currently in a position where I could invest small monetary contributions efficiently to further Asteraceaās development. (The most direct effect would be letting me eat better and showing me that thereās tangible interest in this side project of mine, which may lead to me spending more time on it and coding a bit faster.)
If youād like to tip me regardless, for example for this blog post here, I now have Ko-fi and GitHub Sponsors profiles. If you happen to use RPG Maker MV, you can also have a look at my Itch.io page where I published some tools and plugins.
For anything else, you can reach me at the email address available from the sidebar.
Project repositories
Ā | Ā |
---|---|
Asteracea | Component macro DSL and beginnings of a standard library. |
lignin | VDOM and callbacks. |
lignin-schema | Declarative (superficial) HTML, SVG and MathML schema, encoded in Rustās type system. The name is something of a hold-over; there is no dependency relationship with lignin anymore. |
lignin-html | (Partially-validating) VDOM-to-HTML renderer. |
lignin-dom | DOM hydration and diffing. |
rhizome | A lightweight runtime dependency injection container/reference-counted context tree. |
fruit-salad | Trait object downcasts, among other features. Used by rhizome. |
pinus | Value-pinning B-tree maps that can be added to through shared references. Used by rhizome. |
tiptoe | Intrusively reference-counting smart pointers. Used by rhizome. |
Additional repositories
Ā | Ā |
---|---|
lignin-azul, and asteracea-native-windows-gui and lignin-native-windows-gui | Sketches/proofs of concept for using Asteracea to implement native GUIs. In practice, it might be a good idea to make lignin ās VDOM nodes genericover the element type type, to allow switching the &str s for an enum . |
an-asteracea-app | WIP single page application demo project. Very rough, somewhat outdated, but shows the rotating VDOM buffers. |
Footnotes
Ā¹ Suspense
This is the source code for the suspense component in its entirety:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
use crate::{
include::{
async_::{AsyncContent, ContentSubscription, Synchronized},
render_callback::RenderOnce,
},
services::{ContentRuntime, Invalidator},
__::Built,
};
use lignin::{Guard, ThreadSafety};
use std::cell::UnsafeCell;
use typed_builder::TypedBuilder;
#[derive(TypedBuilder)]
pub struct NoParentParameters {}
impl Built for NoParentParameters {
type Builder = NoParentParametersBuilder<()>;
fn builder() -> Self::Builder {
Self::builder()
}
}
asteracea::component! {
/// Renders `'spinner` unless `'ready` has finished construction.
///
/// `'ready`'s construction is scheduled automatically.
pub Suspense(
priv dyn runtime: dyn ContentRuntime,
priv dyn invalidator?: dyn Invalidator,
)<S: 'bump + ThreadSafety>(
// Clearly missing syntactic sugar for these transclusion slots:
spinner: (NoParentParameters, Box<RenderOnce<'_, 'bump, S>>),
mut ready: (NoParentParameters, AsyncContent<'_, RenderOnce<'_, 'bump, S>>),
) -> Guard::<'bump, S>
// Cancels on drop via strong/weak `Arc` reference counting.
let self.subscription = UnsafeCell::<Option<ContentSubscription>>::new(None);
{
match ready.1.synchronize(unsafe{&mut *self.subscription.get()}) {
Synchronized::Unchanged => (),
Synchronized::Reset(future) => self.runtime.start_content_future(future, self.invalidator.clone()),
}
ready.1.render(bump).unwrap_or_else(|| (spinner.1)(bump))?
}
}
As you can see, itās quite concise already, and only generic over the thread-safety of the resulting VDOM.
The part of the body wrapped in {}
is plain Rust, as there is not much state management to be done here and the transcluded content already generates the finished drop-guarded VDOM handles. I plan to make the syntax for Rust blocks more explicit as I continue to move to brace-bodied flow control expressions.