← All news

Components Render Directly Into Page

v0.8.0-rc.2 continues the work started in rc.1: cutting out runtime overhead and unnecessary intermediate allocations from HTML generation. The biggest change is how components hand off their output — they no longer build and return a String, they write straight into the page buffer.

Breaking change: to_render no longer returns StringBreaking

Component::to_render used to build a String and return it, which every parent then had to copy into its own buffer. It now renders directly into the page: &mut Page argument and returns nothing — one buffer for the whole page, not one per component:

1impl Component for Card {
2 fn to_render(&self, page: &mut Page) -> String {
3 view! {
4 <div class="card">{&self.title}</div>
5 }
6 }
7}
1impl Component for Card {
2 fn to_render(&self, page: &mut Page) {
3 view! {
4 <div class="card">{&self.title}</div>
5 }
6 }
7}

Drop the -> String from every Component impl you've written, and remove any explicit return value — view! and head! already push into page for you. Nothing about the view! syntax itself changes.

Attributes now share the text-content syntax

Attribute values used to be piped straight through sanitize!, so an attribute expression had to already produce a string. Attributes now go through the same parser as text content between tags, so they accept a literal, a format string, or any expression:

1// Before: attribute expressions always went through sanitize!,
2// so they had to already be string-like.
3<div class={class_name}></div>
1// After: attributes accept the same three forms as text content.
2<div class={"card"}></div> // literal
3<div class={"card-{}", size}></div> // formatted
4<div data-count={count.to_string()}></div> // any expression

This applies equally to native HTML attributes and to props on your own components.

Slot's lifetimeFix

Slot was defined as Box<dyn Fn(&mut Page)>, which is implicitly 'static — a slot closure could never borrow anything shorter-lived than the whole program. That was never the intent; slots were always meant to be able to borrow from their surrounding scope. Slot now carries an explicit lifetime, fixing that:

1// Before: Slot was implicitly 'static, so slot closures
2// could not borrow anything shorter-lived than 'static.
3pub type Slot = Box<dyn Fn(&mut Page)>;
4
5// After: Slot carries an explicit lifetime.
6pub type Slot<'render> = Box<dyn Fn(&mut Page) + 'render>;
7
8pub struct Card<'a> {
9 pub body: Slot<'a>,
10}

New: unnamed slots

Components that take a single slot and no other props no longer need a named field and a {#slot:name} wrapper. Declare the component as a tuple struct with Slot<'a> as field 0, and the parent passes content directly as children:

1// Unnamed: use this only when a component takes exactly
2// one slot and has no other props.
3pub struct Card<'a>(pub Slot<'a>);
4
5impl Component for Card<'_> {
6 fn to_render(&self, page: &mut Page) {
7 view! {
8 <div class="card">
9 @slot{self.0}
10 </div>
11 }
12 }
13}
14
15// Parent — content passed straight in, no {#slot:name} wrapper:
16view! {
17 <Card>
18 <p>{"Content"}</p>
19 </Card>
20}

Named slots are still there for the cases unnamed slots don't cover — multiple slots on one component, or a single slot alongside other props:

1// Named: use this when a component has more than one slot,
2// or a single slot alongside other props.
3pub struct Card<'a> {
4 pub title: String,
5 pub body: Slot<'a>,
6}
7
8impl Component for Card<'_> {
9 fn to_render(&self, page: &mut Page) {
10 view! {
11 <div class="card">
12 <h2>{&self.title}</h2>
13 @slot{self.body}
14 </div>
15 }
16 }
17}
18
19// Parent:
20view! {
21 <Card title={"My Card".to_string()}>
22 {#slot:body}<p>{"Content"}</p>{/slot}
23 </Card>
24}

The two forms don't mix within a single tag: if a component tag's first child is a {#slot:name} block, the whole tag is treated as named-slot mode, and any other top-level children are silently ignored. Pick one form per component.

Smaller quality-of-life changesQuality of life

page! now imports the Component trait for you, so you no longer need it in scope just to call page!:

1// Before
2use tidos::{page, Component, Page};
3
4// After — page! imports Component for you
5use tidos::{page, Page};

Component structs and their attributes also keep their original source spans through macro expansion, so 'go to definition' and ctrl-click navigation in your IDE now jump to the right place on both a component tag and its individual attributes.

Under the hood

A criterion + allocation-tracking benchmark suite was added (tidos/benches/page_rendering.rs) covering flat lists, static pages, borrowed data, and conditional rendering, so future changes to the code generator can be checked against real numbers instead of guesswork. The IsStatic trait used by the compile-time HTML folding introduced in rc.1 was also unified across all content and attribute kinds — it isn't wired into a new optimization yet, but it's the groundwork for folding fully-static components into a single concat! at compile time.

New documentation site

The site you're reading this on is new this cycle — a Tidos site, written in Tidos, covering the view! and page! syntax, components, slots, scoped CSS, and internationalization.

Upgrading

Update your Cargo.toml to the second release candidate:

1[dependencies]
2tidos = "0.8.0-rc.2"

Then, for every Component you've implemented: remove -> String from to_render's signature, and remove any explicit return of the rendered string — view!/head! already write into page. If you pass attribute values that aren't already string-like, you can now drop the manual .to_string()/format!() wrapping in most cases, since attributes accept expressions directly.