v0.8.1
Topcoat 0.8.1 makes a bunch of routing-related improvements, including support for trailing slahes and relative route paths on the module router.
The module router derives a handler's path from its enclosing module: src/app/settings.rs serves /settings. Until now, a handler either used that module path or opted out of it with an absolute path string, which also opted it out of the module router. Serving /settings/export from settings.rs meant creating a settings/export.rs module for it.
A path string starting with ./ is now joined onto the module path. The handler stays in the module router, so module_router!() still discovers it, segment! and path_param! declarations in the module tree still apply to it, and layouts and layers still wrap it by prefix.
// src/app/settings.rs: GET /settings
#[page]
async fn settings() -> Result<impl View> {
Ok(view! { <h1>"Settings"</h1> })
}
// src/app/settings.rs: POST /settings/export
#[page(POST "./export")]
async fn export() -> Result<impl View> {
Ok(view! { <p>"Export started"</p> })
}
The same form works for #[route], #[layout], and #[layer]. A layout declared with #[layout("./admin")] in settings.rs wraps the pages under /settings/admin, and a layer declared with #[layer("./v1")] in api.rs wraps every request under /api/v1. See the module router guide.
Absolute path strings behave as before: they disable module path derivation for that handler, and the handler is registered by name rather than discovered.
A path may now end in a /, and the slash is part of the path. A page at /users/ is served at /users/, and a page at /users is served at /users. In 0.8, a path ending in a slash was an empty segment and rejected outright.
By default, a request for the form a route did not declare is redirected to the declared one with a 308. The status code preserves the method and the body, so a form posted to /signup/ is resubmitted to a page at /signup. The query string is kept. RouterBuilder::trailing_slash selects one of three policies:
TrailingSlash::Redirectredirects to the declared form. This is the default.TrailingSlash::Serveserves the route under both forms. The client keeps the URL it asked for, and the handler can read it throughuri.TrailingSlash::Strictserves the declared form only, and the other form responds 404.
use topcoat::router::{Router, TrailingSlash};
let router = Router::builder()
.trailing_slash(TrailingSlash::Serve)
.build();
The policy never touches the root /, a route ending in a catch-all parameter, or a pair of routes registered at both forms of one path. A route that claims a form for itself is left alone.
In the module router, a bare ./ serves the module path itself with a trailing slash, and a relative path ending in a slash keeps it:
// src/app/settings.rs: GET /settings/
#[page("./")]
async fn settings() -> Result<impl View> {
Ok(view! { <h1>"Settings"</h1> })
}
// src/app/settings.rs: GET /settings/export/
#[page("./export/")]
async fn export() -> Result<impl View> {
Ok(view! { <h1>"Export"</h1> })
}
see_other(uri) builds the 303 response for the Post/Redirect/Get pattern: a completed POST sends the browser on to a new location with a GET, so a reload does not resubmit the form. In 0.8, SeeOther was only a response, returned through Ok. That works for a route, but a page's Ok value is a view, so a page could not answer a submission with a 303.
SeeOther is now an error as well. A route can keep returning it through Ok, and a page returns it through Err. Both produce the same 303 response:
#[page(POST "/signup")]
async fn signup(cx: &Cx, Form(input): Form<Signup>) -> Result<impl View> {
if create_account(cx, &input.email).await? {
return Err(see_other("/welcome").into());
}
Ok(view! { <p>"That email is already taken."</p> })
}
Like redirect, a see_other raised inside a streaming page after the first content was sent degrades to a client-side navigation script, since the status line can no longer change.
A cookie added during a request is written to the response once the handler returns. In 0.8, that only happened when the handler returned a response. A handler that set a cookie and then returned an error, such as a redirect or unauthorized(), lost the cookie, because the error's response is only built after the cookie layer has already returned.
The cookie layer now queues its Set-Cookie headers for the router to apply after the error's response exists. A cookie added before a redirect is set by the redirect, and a session cookie cleared before an unauthorized() is cleared by the 401.
The mechanism behind the fix is public. response_headers(cx) returns a per-request slot of headers the router appends to whatever response the request ends with, success or error. A layer that wants a header on a 404 as much as on a handler's own response can queue it there instead of setting it on the response from Next::run:
impl Layer for RequestId {
fn path(&self) -> Option<&Path> {
None
}
fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
Box::pin(async move {
response_headers(cx).append(
header::HeaderName::from_static("x-request-id"),
header::HeaderValue::from_static("42"),
);
next.run(cx, body).await
})
}
}
The slot is per dispatch, so a rewrite drops whatever the discarded dispatch queued.
- A request for
/users/against a page at/usersresponded 404 in 0.8 and redirects with a 308 now. SetTrailingSlash::Stricton the router builder to keep the old behavior. - Code that walks a
Path's segments can now see an empty static segment at the end. Checkhas_trailing_slashwhere that matters. - A page that answers a form submission by redirecting can return
Err(see_other(uri).into())in place of a route that existed only to send the 303. - Handlers under the module router that used an absolute path to serve a URL below their module can switch to a
./path and be discovered again. - Layers that set a header on the response returned by
Next::runkeep working. Move the header toresponse_headers(cx)if it should also land on error responses.
v0.8.0
Topcoat 0.7 introduced the runtime: signals, $(...) expressions, event handlers, and shards, all written as ordinary Rust and compiled to JavaScript for the browser. Signals were browser-only state, though. The server put an initial value into the page and never heard from it again. Reacting to a signal on the server meant threading its value through a shard argument.
0.8 turns signals into a two-way primitive. A signal is created with a plain Rust function, keeps its identity and value across re-renders, and can be read on the server like any other value. A server-side read is tracked: when the signal changes in the browser, the page or shard that read it runs again with the new value, and the result is morphed into the document.
The signal name = value; statement inside view! is gone. It was a small DSL of its own, with its own scoping and formatting rules, and it forced every signal to live inside a view body. It has been replaced by an ordinary Rust function, signal, which takes the request context and a closure producing the initial value.
Before:
#[component]
async fn faq() -> Result<impl View> {
Ok(view! {
signal open = false;
<button @click=$(|_e| open.toggle())>"What is Topcoat?"</button>
<p :hidden=$(!open.get())>"A full-stack Rust framework."</p>
})
}
After:
use topcoat::{Result, context::Cx, runtime::signal, view::*};
#[component]
async fn faq(cx: &Cx) -> Result<impl View> {
let open = signal(cx, || false);
Ok(view! {
<button @click=$(|_e| open.toggle())>"What is Topcoat?"</button>
<p :hidden=$(!open.get())>"A full-stack Rust framework."</p>
})
}
The body that creates a signal needs a cx: &Cx parameter now, which pages, layouts, components, and shards all accept. In exchange, a signal is a regular value of type Signal<T>. It is cheap to clone, it can be created before the view and used in ordinary Rust between the two, and it can be handed to a component as a &Signal<T> prop. Everything inside $(...) expressions works as before: .get(), .set(...), and the shorthands like toggle, increment, and push_str.
The initial value is still computed once during the server render and serialized into the page, and the browser picks it up as reactive state. Everything the browser does to it afterwards is user input, which matters for the sections below.
A signal now has a stable identity, derived from where in the render it was created. That identity is what lets its value survive a re-render.
In 0.7, a shard's content was rebuilt from scratch on every re-render, so a signal created inside a shard reset to its initial value each time an argument changed. The documentation told you to keep such state outside the shard and pass it in. That restriction is gone. A signal created in a shard body behaves like state: its current value travels with every re-render request, and signal resumes from that value instead of running the initializer again.
#[page]
async fn page(cx: &Cx) -> Result<impl View> {
let label = signal(cx, || String::from("clicks"));
Ok(view! {
<input :value=$(label.get()) @input=$(|e: Event| label.set(e.target.value))>
card(label: $(label.get()))
})
}
#[shard]
async fn card(cx: &Cx, label: String) -> Result<impl View> {
// Re-rendered every time `label` changes, but `count` keeps counting.
let count = signal(cx, || 0.0);
Ok(view! {
<fieldset>
<legend>(label)</legend>
<button @click=$(|_e| count.increment())>"+1"</button>
" "
$(count.get())
</fieldset>
})
}
Typing into the input re-renders the card on the server with the new label. The counter inside the card is untouched: it started at zero on the first render, and after that it holds whatever the user clicked it up to.
Because the value comes back from the browser, treat it exactly like a shard argument: it is user input and must not be trusted.
A signal can be read in plain Rust, outside any $(...) expression, in the body that created it. .get() clones the current value and .read() borrows it.
Both are tracked reads. A tracked read makes the page or shard depend on the signal. When the signal changes in the browser, the body that read it runs again on the server with the signal's current value, and the new HTML is morphed into the document. A whole class of interactions that needed a shard in 0.7 now needs nothing but a signal and a read:
#[page("/search")]
async fn search(cx: &Cx) -> Result<impl View> {
let query = signal(cx, String::new);
let products = search_products(cx, &query.get()).await?;
Ok(view! {
<input :value=$(query.get()) @input=$(|e: Event| query.set(e.target.value))>
for product in products {
<div>(product)</div>
}
})
}
The input keeps working as a client-only binding: :value and @input run in the browser and never wait for the server. The product list follows through the server. Every keystroke changes query, the page runs again with the typed value, and the list updates. On that re-run, signal starts from the value the browser sent rather than computing a fresh one, so the page picks up where the client left off.
Reads inside a $(...) expression never make the page depend on a signal. They are the client-side path and stay in the browser. Only a read in ordinary Rust is tracked.
Every value read on the server is user input and must not be trusted. The client holds the signal and can send anything that fits its type, so validate the value before acting on it, exactly as you would with a shard argument or a procedure parameter.
If a page can re-run itself, why keep shards? Because re-running a page means rendering all of it, including the parts that did not change. A shard is an optimization: it narrows the part of the page that runs again. A signal tracked inside a shard re-renders only that shard, not the page around it.
The shard creates the signal, reads it, and hands the browser the handlers that change it. No arguments are needed:
#[shard]
async fn paginated(cx: &Cx) -> Result<impl View> {
let page = signal(cx, || 1.0);
let items = load_page(cx, page.get()).await?;
Ok(view! {
for item in items {
<div>(item)</div>
}
<button @click=$(|_e| page.decrement())>"previous"</button>
<button @click=$(|_e| page.increment())>"next"</button>
})
}
Clicking a button changes page in the browser. The shard read it on the server, so the shard runs again with the new value and swaps in the next page of items. The rest of the page is never rendered again.
A good way to think about it: start with tracked reads in the page, and introduce a shard once a re-run is doing more work than it should. A shard is a boundary you draw around the part of the page that depends on a signal, and everything outside that boundary stays put. Shards with argument expressions still work as before, and remain the right tool when the shard's input is computed from several signals or from a signal it did not create.
Sometimes a body wants the value a run started with but should not run again when it changes. .get_untracked() and .read_untracked() read a signal without making anything depend on it.
In 0.7, a shard re-render replaced the shard's content wholesale. With pages able to re-run themselves, replacing would have been far more disruptive: focus, scroll position, and half-typed input would be lost on every keystroke.
0.8 morphs the new HTML into the old. Elements that still exist are updated in place, so focus, scroll position, and what the user is typing survive a re-run, and every signal keeps its value. This applies to page re-runs and shard re-renders alike. The search page above is the canonical case: the input stays focused and keeps its cursor while the list below it updates.
Elements are matched by position and tag, and an id pins the match. For a list that can reorder, give each item an id so the morph follows each item to its new position instead of rewriting the items in between:
#[page]
async fn page(cx: &Cx) -> Result<impl View> {
let descending = signal(cx, || false);
let mut fruit = FRUIT;
if descending.get() {
fruit.reverse();
}
Ok(view! {
<button @click=$(|_e| descending.toggle())>"sort"</button>
for item in fruit {
<div id=(item)>(item)</div>
}
})
}
Several signal changes in the same tick coalesce into one request, and starting a request aborts any earlier one still in flight, so the latest values win.
A shard argument is a runtime expression, and the shard re-renders whenever that expression's value changes. That is usually what you want, but not always. Sometimes a shard needs access to a signal without re-rendering on every change to it.
A shard parameter can now be typed Signal<T>, and the caller passes the signal itself with $(signal) rather than its value with $(signal.get()). The argument is the signal handle, which does not change when its value does, so the change alone does not re-render the shard. Whether it does depends on how the shard body reads it:
#[shard]
async fn search_results(cx: &Cx, query: String, limit: Signal<f64>) -> Result<impl View> {
// A new limit takes effect on the next re-render, but does not cause one.
let products = search_products(cx, &query, limit.get_untracked()).await?;
Ok(view! {
for product in products {
<div>(product)</div>
}
})
}
#[component]
async fn search(cx: &Cx) -> Result<impl View> {
let query = signal(cx, String::new);
let limit = signal(cx, || 10.0);
Ok(view! {
search_results(query: $(query.get()), limit: $(limit))
})
}
Here query drives re-renders as before, and limit rides along: the shard reads it untracked, so changing the limit waits until the next query change to show. Reading it with .get() instead would track it, and then the shard re-renders on either change. Passing a signal also lets the shard attach handlers to it, so a shard can render the controls for state its caller owns.
The router needs .runtime(). The browser script talks to routes of its own for page re-runs, and those are mounted by calling .runtime() on the router builder. .discover() still registers your procedures and shards, but no longer covers the runtime itself. topcoat::runtime::script() now takes the request context and panics with a clear message when the router was built without it.
Router::builder()
.runtime()
.discover()
.assets(AssetBundle::load().unwrap())
.build()
Rewrites can change the method and pass context. A RewriteError gained .method(...) to dispatch the rewritten request with a different HTTP method, and .cx(...) to set the request context the rewritten dispatch starts from. Together they let a POST handler re-run the page it was posted from as a GET, handing it a value describing what happened. The request helpers gained original_ counterparts, from original_parts down to original_uri and original_method, returning the request as the client sent it. See the error guide.
Empty form and query values read as None. A browser sends ?page= for a blank input. #[query_params] and form extraction now treat an empty value the same as a missing key for Option<T> fields, instead of failing to parse it.
TowerRoute::any. A shorthand for mounting a tower service at a path that responds to every HTTP method, which is the usual setup when handing a URL subtree to an existing application. TowerRoute::new with an explicit method list remains for restricting a mounted service.
- Replace every
signal name = value;statement withlet name = signal(cx, || value);above theview!, and addcx: &Cxto the enclosing function if it does not have it yet. - Add
.runtime()to the router builder wherevertopcoat::runtime::script()is rendered. - Revisit shards whose only job was to get a signal's value to the server. Many become a tracked read in the page, and some disappear entirely.
- Where a shard's arguments existed to keep state alive across re-renders, move that state back into the shard as a signal.
- Give the items of any list that can reorder an
id, so the morph keeps them in place.
v0.7.0
This release brings streaming server-side rendering to Topcoat. A page no longer has to finish rendering before the browser sees any of it: the parts that are ready go out right away, and the slow parts stream in when they finish, over the same response and without any client-side fetching. The new live! and emit! macros are the general form of this, and the suspense and error_boundary components are the two most common shapes prepackaged.
To make streaming possible, views became lazy. A view! expression is now a value that renders later, much like an async move block, and View is now a trait. This changes the signature of every page, layout, and component, so there is a migration section below.
A page normally renders in full before the browser sees any of it, so a single slow database query or upstream request delays everything, even the parts that are ready. With streaming, the page sends what it has and fills in the rest as it becomes available. The browser needs no client library for this; the response carries everything the swap requires.
live! marks a region of the page whose content can still change while the response streams. Its body is ordinary async Rust, which can be formatted via topcoat fmt. Inside the body, emit! renders markup into the region, and every emission replaces the previous one in the browser.
#[page("/")]
async fn quote() -> Result<impl View> {
Ok(view! {
<h1>"Quote of the day"</h1>
(live! {
emit! { <p>"Loading..."</p> }?;
let quote = fetch_quote().await;
emit! { <blockquote>(quote)</blockquote> }
})
})
}
The heading and the loading message reach the browser immediately. While fetch_quote runs, the rest of the page streams as usual, and once the quote is ready it replaces the loading message in place.
The page waits for a region's first emission and renders it with the rest of the document, so start the body with something that is ready right away, like the loading message above.
emit! accepts everything view! does: elements, text, interpolated expressions, control flow, and components. Between emissions the body is plain async Rust, so it can await work, loop, and branch. Because each emission replaces the previous one, a live region can narrate a long-running task as it happens:
#[page("/progress")]
async fn progress() -> Result<impl View> {
Ok(view! {
<h1>"Progress"</h1>
(live! {
for percent in 0..100 {
emit! { <p>"Working... " (percent) "%"</p> }?;
run_step().await;
}
emit! { <p>"Done!"</p> }
})
})
}
emit! evaluates to a Result carrying an EmitToken, and the body of live! has to returns one. This is a compile-time reminder that a region has to emit at least once so it never leaves a hole in the page. Ending the body with an emission satisfies it naturally; when the control flow does not end with one, return Ok(EmitToken) yourself.
An emission fails when the markup inside it fails to render, for example when a component it calls returns an error. The failure comes back as the Err value of emit! instead of ending the stream, and the body decides what happens next: propagate it with ?, or handle it and emit a fallback in its place.
#[page("/weather")]
async fn weather() -> Result<impl View> {
Ok(view! {
<h1>"Weather"</h1>
(live! {
emit! { <p>"Loading..."</p> }?;
match emit! { forecast() } {
Err(error) => emit! {
<p>"The forecast is unavailable: " (error.to_string())</p>
},
emitted => emitted,
}
})
})
}
The two most common shapes come prepackaged as components, so most pages never need the macros directly.
suspense is a live region that shows a fallback until its child content is ready:
Ok(view! {
suspense(
fallback: view! { <p>"Loading..."</p> },
daily_quote()
)
})
error_boundary renders its child content and swaps in a fallback built from the error when any part of it fails. Returning the error from the fallback rethrows it, so a boundary can pick the errors it handles and let the rest bubble up:
Ok(view! {
error_boundary(
fallback: |error| Ok(view! {
<p>"The stats are unavailable: " (error.to_string())</p>
}),
stats()
)
})
Both compose: wrapping a suspense in an error_boundary streams a widget in behind a fallback and turns its failure into a message in place, while the rest of the page is unaffected.
Error boundaries also replace the old way of catching a page's error in a layout. A layout used to receive the slot as a Result and match on it; it has to wrap the slot in an error boundary (or live! region):
#[layout("/")]
async fn root_layout(slot: Slot<'_>) -> Result<impl View> {
Ok(view! {
<html>
<body>
error_boundary(
fallback: |error| {
if error.downcast_ref::<NotFoundError>().is_none() {
// Any other error type is rethrown.
return Err(error);
}
Ok(view! {
(StatusCode::NOT_FOUND)
<h1>"Page not found"</h1>
})
},
(slot)
)
</body>
</html>
})
}
A live region is a view like any other. A component can return one directly, take one as child content, or interpolate it into a view! body:
#[component]
async fn daily_quote() -> Result<impl View> {
Ok(live! {
emit! { <p>"Loading..."</p> }?;
let quote = fetch_quote().await;
emit! { <blockquote>(quote)</blockquote> }
})
}
#[page("/")]
async fn quote() -> Result<impl View> {
Ok(view! {
<h1>"Quote of the day"</h1>
daily_quote()
})
}
Several regions on one page stream independently, each replacing its own content as it becomes ready, and emitted markup can itself contain components and further live regions.
Once the first content of a page goes out, the response is committed. That has a few consequences worth knowing:
- Status codes and headers declared in a view take effect only if they are part of the first content. Anything a region emits later cannot change them anymore.
- A redirect raised before the response commits is a real HTTP redirect. A redirect raised inside a region after the page started streaming reaches the browser as a client-side navigation to the target instead. Redirect targets are now percent-encoded rather than panicking on characters a header cannot carry.
- An error that escapes after the response committed can no longer turn the page into an error response. Wrap streamed content in an
error_boundary, or handle the error in the region, to show something useful in its place. - Cookies are response headers, so they must be written before the response commits. Writing to the jar from streamed content panics; reading always works.
The new live and suspense examples in examples/ show all of this end to end.
To make streaming SSR work well, we made Views lazy. This means that a view! block is not executed in place, but instead returns a closure to be executed later. the expressions and component calls inside of a view! that is never used are never executed. This change introduces a bunch of trade-offs, but we believe it is the best choice moving forward to more advanced features.
The new signature for every page, layout, component, and shard now has a return type of Result<impl View> and wraps its view in Ok:
#[component]
async fn hello(name: &str) -> Result<impl View> {
Ok(view! { <h1>"Hello, " (name) "!"</h1> })
}
A view value captures every variable the template mentions by moving it into the view, exactly like an async move block or a move closure, which is what the macro expands to:
let title = String::from("Hello");
let header = view! { <h1>(title)</h1> };
// `title` has moved into `header` and cannot be used here anymore.
Ok(view! {
(header)
<p>"Welcome!"</p>
})
When a value is needed both inside the view and after it, interpolate a clone instead.
A view that captures a reference borrows whatever it points at, so it cannot outlive that data. In practice this rarely gets in the way: component props and anything borrowed from the request context stay alive until the render is over, so they are safe to use in a view even when they are references, like a &str prop.
- Streaming. A view that is only described, not rendered, can be driven by the framework piece by piece, which is what
live!andsuspenseneed. - Nothing runs for content that is not shown. A layout now decides where and when (or if!) its slot renders. Child content a component never interpolates never runs either.
- Errors flow through the tree. Because rendering happens inside the view tree, an error raised deep in a page bubbles up through it until a boundary catches it. There is no
?afterview!anymore, and nothing to unwrap by hand. - Recursion without a special mode. Recursive components box their view with
.boxed(), which replaces the old#[component(boxed)]attribute.
- Move semantics. Values a view mentions move into it. Anything used afterwards needs a clone, the same way a
moveclosure does. - Trickier lifetimes. Views that borrow locals from a function without moving them into the view cannot be returned.
- Error handling. Errors have to be caught with
live!+emit!or via the newerror_boundary.view!s no longer return aResultto match on. - Anonymous types. Every
view!has its own type, so a function that returns a view from severalreturnsites has to erase them with.boxed()to give them a common type. The same applies to one component in any recursive cycle. - Lifetimes show up in signatures. Child content is typed
Child<'_>and a layout's slotSlot<'_>, since they borrow from the render they belong to. Give achildprop#[default]so the component can also be called without children. - No rendered output where the view is built. Code that needs the finished markup as a value, such as a route that returns a view inside a tuple with htmx or Datastar headers, resolves it first with
.single().await?, which yields aViewHandle. A layout can no longer inspect the slot'sResult; useerror_boundaryinstead.
The following changes touch most applications. Each is mechanical.
- Return types:
ResultbecomesResult<impl View>, and the body wraps its view inOk(...). Drop the?that used to followview!. ImportViewfromtopcoat::view. - Layouts:
slot: Resultbecomesslot: Slot<'_>, imported fromtopcoat::router, and(slot?)becomes(slot). - Child content:
child: Viewbecomes#[default] child: Child<'_>. - Recursive components: replace
#[component(boxed)]with.boxed()on the returned view, imported throughtopcoat::view::ViewExt. - Catching errors in a layout: replace matching on the slot with an
error_boundaryaround it. The fallback receives the error, can downcast it, and can rethrow it by returning it. - Pages that never render, such as one that always redirects, return
Result<()>. - Routes that return a view inside a tuple resolve it with
.single().await?and type it asViewHandle, which is the name the old concreteViewstruct now carries. - Route handlers now convert their return value through
AsyncIntoResponse, which everyIntoResponsetype implements, so existing routes keep working unchanged. - Cookies must be written before the response commits; move jar writes out of streamed content.
Hrefgainedis_current, and routes and pages gained the same, to tell whether a link points at the page the current request is serving. A link without a query stays current while its page is filtered or paginated.HrefTargetis implemented for&TwhereT: HrefTarget, so trait objects work as link targets.- Redirect targets are percent-encoded instead of panicking on characters a
Locationheader cannot carry.
- The dev server walks up from an occupied port to the first free one and reports the port it picked.
- The dev script no longer reloads the page over an in-flight navigation when it reconnects.
TopcoatCliexposes arunmethod for embedding the CLI.topcoat fmtno longer depends onprettyplease; the pretty printer covers allsyntypes itself.
#[memoize]on an async function with a borrowed argument no longer fails theSendcheck inside handler futures.- Boolean procedure results are preserved. The generated JavaScript no longer exposes a
thenmethod that made the value a thenable and turned it intoundefined.
- Topcoat UI switched its icon set from Feather to Lucide.
- The neutral theme's primary and ring colors were adjusted.
- The
uiexample was revised to avoid misleading showcases.
- The workspace builds on Rust 1.98.
Streaming is the first thing live! and emit! make possible, but a region's body is just async Rust that can keep emitting for as long as the response is open. We want to explore what that allows for more advanced use cases, such as server push, where the server keeps updating a region as things change. That work is for a future release; for now, live regions are about getting pages to the browser sooner.
v0.6.0
[!WARNING] Update your
topcoatCLI before building with this release:cargo install topcoat-cliThe asset bundle is now written to a different location (next to the executable it was scanned from), and
AssetBundle::loadonly looks there — an old CLI will bundle to a path the new runtime never reads, leaving your app unable to resolve its assets. The formatter also learned the new macros in this release. Going forward the CLI warns when its version does not match thetopcoatversion a project depends on.
- New features
- Concurrent component rendering
- The
href!macro for building URLs - Scoped request context with
Cx::with - 17 new topcoat-ui components
- Request rewriting
- Global origin policy on every router
- Request body size limits
- Catch-all 404 pages with
not_found! path_param!replaces#[path_param]- XML sitemap responses
- Promoted string optimization for static markup
#[memoize]now keys on a hash instead ofClone + Eq
- Breaking changes
- The request context:
CxBuilderandCx::detachare gone - Origin verification is on by default
- Request bodies are capped at 2 MiB by default
- Unmatched requests no longer run layers or layouts
#[path_param]is removed- Router handlers become traits
- Request and response helpers moved into dedicated modules
#[memoize]no longer auto-borrowsOption/ResultcontentsView::rendernow consumes the view- View internals reshaped for concurrent rendering
class!'s concrete type changedto_bytesnow returns a proper Topcoat error- Asset bundle location is tied to the executable
- Unused layer sanity check
- Cookie jar seals itself after the response is written
- Multiline Datastar selectors are rejected
- The request context:
Components in a view! block now render concurrently instead of sequentially. Every component in a scope — siblings, the taken branch of an if/match, every iteration of a for loop, and a component together with its own children — starts in the same tick and is polled together, so their await points interleave. Rendered output still comes out in source order.
This matters when components do I/O. Three sibling components that each issue a database query used to run those queries one after another; now they fire at the same time, so page latency is closer to the slowest single query instead of the sum of all of them.
view! {
example_wrapper(
example_component()
if show_extra {
example_component()
}
for item in items {
<li>example_component(label: item)</li>
}
)
}
No opt-in is required — this applies automatically to every view! with two or more component calls, including existing code, and nothing changes in how a #[component] is written. Two things to be aware of:
- Rendering now happens inside a per-request "scope". The router sets this up automatically; only code that builds or renders views outside a router-handled request (standalone tests, scripts) needs to wrap the work in the new
topcoat::view::scope(...)function. - A
forloop whose body renders components joins all iterations' futures at once. If each iteration issues an I/O call, every call fires concurrently — be mindful of this when looping over unbounded or externally-controlled data.
This rework also reshaped some low-level view APIs; see View internals reshaped for concurrent rendering.
Handlers and views can now build the URL of a #[page], #[route], or Path without hand-writing path strings. href! takes the handler function's name and one value per path parameter, using the types generated by path_param!:
use topcoat::router::{href, page, path_param};
path_param!(post_id: u64);
#[page("/posts/{post_id}")]
async fn post(cx: &Cx) -> Result {
view! { "post" }
}
#[page("/posts")]
async fn posts(cx: &Cx) -> Result {
view! {
<a href=(href!(post, PostId(1)))>"The first post"</a>
<a href=(href!(posts))>"All posts"</a>
}
}
Used directly in a view, an href!(...) value renders as the resolved URL string. Where you need an owned String — a redirect, a mail body — call .resolve(cx):
#[route(POST "/todos/{todo_id}/toggle")]
async fn toggle(cx: &Cx) -> Result<SeeOther> {
// ...
Ok(see_other(href!(home).resolve(cx)))
}
The builder also supports:
- Query strings and fragments:
.query(...)takes anySerializevalue (a struct, a slice of pairs) and can be called repeatedly to append;.fragment(...)sets the#fragment. - Absolute URLs:
.absolute()/.relative()override the form per-href, e.g.href!(post, PostId(1)).absolute().resolve(cx)→https://example.com/posts/1. - Catch-all parameters:
href!(document, DocPath(["guides", "getting started"]))fills one percent-encoded segment per element. - Plain paths: the
hreffunction accepts aPathor path literal with parameters as a tuple —href("/posts/{post_id}", (PostId(1),))— for when the target isn't a handler marker in scope.
Values are matched to path parameters by name, not just position, so passing the wrong parameter type panics at resolve time instead of silently building a wrong URL. Parameter types used with href! need a Display impl (checked at compile time). Migrating is optional — literal path strings keep working.
The request context is now immutable and scoped. Instead of writing values into a &mut Cx, registering a value returns a new child Cx whose context also holds that value; the parent is untouched:
fn greet(cx: &Cx) -> String {
let cx = cx.with(Customer { name: "Ada".to_owned() });
let customer: &Customer = request_context(&cx);
format!("Hello, {}", customer.name)
}
Cx::with_many registers several values at once with a tuple: cx.with_many((a, b, c)). Registering a type that's already present shadows it for the child scope only — lookups through the parent still see the original.
Cx is now cheaply cloneable, Send + Sync, and backed by an Arc, so work that outlives the handler — a spawned task, a streaming SSE body, a WebSocket loop — just clones the handle:
#[route(POST "/orders")]
async fn place_order(cx: &Cx) -> Result<&'static str> {
let cx = cx.clone();
tokio::spawn(async move {
let customer: &Customer = request_context(&cx);
record(&customer.name).await;
});
Ok("ok")
}
#[memoize] is now scope-aware: it records every request context value a cached function's body actually reads and only reuses a cached result for a caller whose scope resolves those reads to the same values. A value registered with cx.with can no longer leak a cached result across scopes that shouldn't see it, and dependencies propagate through nested memoized calls.
This replaces the old mutable context; see the migration notes.
The topcoat-ui registry gains 17 new components, installable with topcoat ui add <name> like the existing ones: accordion, alert, alert_dialog, avatar, breadcrumb, dialog, hover_card, kbd, pagination, radio_group, separator, sheet, skeleton, table, tabs, toggle, and tooltip.
Each is copied into your project as ordinary #[component] functions built on view! and styled with Tailwind utilities against the existing theme tokens. All of them lean on native HTML behavior (<details>, <dialog>, checkbox/radio inputs, :hover/:focus-within, CSS @starting-style transitions) rather than JavaScript. A taste:
view! {
accordion(
for (question, answer) in questions {
accordion_item(
attrs: attributes! { name="faq" },
accordion_trigger((question))
accordion_content((answer))
)
}
)
}
view! {
alert(
variant: AlertVariant::Destructive,
icon(data: iconify_icon!("feather:alert-triangle"))
alert_title("Build failed")
alert_description("The last deploy did not finish.")
)
}
dialog is a native <dialog>-based modal whose open parameter is server state, so it survives reloads and can be linked to; alert_dialog and sheet build on it (and pull it in automatically when added, as pagination does with button). tabs and pagination are link-based and server-driven; toggle persists its pressed state through a hidden form input. The change is purely additive — existing commands, components.toml, and previously installed components are unaffected — and the examples/ui app showcases everything.
A handler can now dispatch the request again at a different path, running the whole route stack (layers, layout, page) as if that path had been requested from the start — invisible to the client, unlike a redirect. Build one with rewrite(path, body) and return it as a handler error:
use topcoat::{Result, context::Cx, router::{Body, error::rewrite, page}, view::view};
#[page("/dashboard")]
async fn dashboard(cx: &Cx) -> Result {
if beta_tester(cx).await {
return Err(rewrite("/dashboard-beta", Body::empty()).into());
}
view! { <h1>"Dashboard"</h1> }
}
The rewritten dispatch keeps the original method and headers; everything else — response in progress, request context, memoized values, staged cookies — starts over. A handler reached through a rewrite sees the new path in uri; the new original_uri(cx) returns the URL the client actually requested. The router refuses rewrite cycles and stops any chain after 8 rewrites, responding with a plain 500.
Cross-origin request verification (CSRF and cross-site WebSocket hijacking protection) moved from topcoat-session into the router itself. Every Router now checks the origin of every incoming request as its outermost step, whether or not the app uses sessions.
The default policy rejects state-changing cross-origin browser requests (anything other than GET, HEAD, OPTIONS) and cross-origin WebSocket handshakes with 403 Forbidden. This closes a gap in the old session-only check: a cross-origin WebSocket handshake arrives as a GET, so it previously slipped through as a "safe method" — the router now detects the Upgrade: websocket header and treats the handshake as state-changing.
No setup is needed. To trust a specific cross-origin peer or exempt a route that handles its own protection:
use topcoat::router::{OriginPolicy, Router};
let router = Router::builder()
.origin_policy(
OriginPolicy::new()
.trust_origins(["https://accounts.example.com"])
.exempt_paths(["/webhooks/{*rest}"]),
)
.build();
OriginPolicy::dangerous_disable() opts out entirely. Because the default is on for every router, this is also a breaking change — see the migration notes.
Buffering request-body extractors (Bytes, Json, Form, RawForm, Css<String>, Html<String>, and Multipart) now enforce a maximum body size and reject anything larger with 413 Content Too Large, so a client can no longer exhaust server memory with an oversized body. The limit defaults to 2 MiB and applies automatically.
Register the new BodyLimit layer to raise, lower, or disable it, application-wide or per path prefix:
use topcoat::router::{BodyLimit, Router};
let router = Router::builder()
// Allow up to 32 MiB under /upload, keep the 2 MiB default elsewhere.
.layer(BodyLimit::max(32 * 1024 * 1024).at("/upload"))
.build();
A handler that streams the raw Body by hand isn't covered automatically; read the request's effective limit with the new body_limit(cx) and pass it to to_bytes. Custom FromRequest implementations should delegate buffering to Bytes::from_request, which already enforces the limit. A content_too_large() error constructor joins the existing ones in topcoat::router::error for your own size checks.
Routes that legitimately accept large payloads must now opt in — see the migration notes.
The new not_found! macro registers a catch-all page that resolves every URL under a prefix to a NotFoundError. Because it's a normal page, it dispatches through the router like any other handler, so an outer layout can catch the error and render a branded not-found page:
use topcoat::router::{Router, not_found};
not_found!("/");
let router = Router::builder().page(not_found).build();
It expands to a regular #[page], so .discover() collects it too, and inside a module_router! tree it can be called without arguments to derive its prefix from the enclosing module. not_found!("/admin") covers /admin/{*rest} while more specific routes always win. A layout catches the error the same way it catches any other typed router error:
#[layout("/")]
async fn root_layout(slot: Result) -> Result {
let content = match slot {
Err(error) if error.downcast_ref::<NotFoundError>().is_some() => view! {
(StatusCode::NOT_FOUND)
<h1>"Page not found"</h1>
},
content => content,
}?;
view! {
<html>
<body>(content)</body>
</html>
}
}
This macro exists because unmatched requests no longer run layers or layouts by default — a genuinely unrouted URL is now answered by the router with a bare 404 unless you register a catch-all. See the migration notes. A full walkthrough lives in the new examples/error example.
Path parameters are now declared with a function-like macro instead of an attribute on a hand-written tuple struct. You give it the snake_case parameter name and it generates the Pascal-case type itself:
path_param!(post_id: u64, error = bad_request);
#[page("/posts/{post_id}")]
async fn post(cx: &Cx) -> Result {
let post_id = path_param::<PostId>(cx)?;
view! { "post " (post_id) }
}
The headline addition is native typed catch-all parameters, which previously required a manual segment!(kind = CatchAll) and scanning raw_path_params(cx) by hand. A leading * captures the rest of the path:
path_param!(*doc_path); // path_param::<DocPath>(cx) iterates decoded &str segments
path_param!(*ids: u32, error = bad_request);
#[page("/archive/{*ids}")]
async fn archive(cx: &Cx) -> Result {
let ids: &[u32] = path_param::<Ids>(cx)?;
view! { (format!("{ids:?}")) }
}
Under module_router!, path_param!(*name) inside a module emits the CatchAll segment override automatically. The old attribute macro is removed entirely — migration is mechanical, see the notes.
A new Sitemap response type serves XML sitemaps, behind a new sitemap feature flag (part of full):
use topcoat::{
Result,
router::{
content::sitemap::{ChangeFrequency, Sitemap, SitemapUrl},
route,
},
};
#[route(GET "/sitemap.xml")]
async fn sitemap() -> Result<Sitemap> {
let posts = ["first-post", "second-post"];
Ok(Sitemap::new()
.url("/")
.url(SitemapUrl::new("/about").change_frequency(ChangeFrequency::Monthly))
.urls(posts.map(|slug| format!("/posts/{slug}"))))
}
SitemapUrl carries the optional metadata: last_modified(...) (accepts timestamp types from the common date/time crates), change_frequency(...), and priority(...). Root-relative entries are resolved against the base URL registered on the router with .base_url("https://example.com") — rendering a relative entry without a registered base URL panics, so set it before deploying. This is purely additive.
Using these types is purely an optional optimization — nothing requires them, and existing code renders the same without them. Two new types in topcoat::view, PromotedStr and StaticStr, let a view record a compile-time-constant string without copying it into the buffer. PromotedStr(&"literal") makes Rust promote the string into the binary's read-only data so the view stores only a pointer; StaticStr covers a &'static str only known at run time. Ordinary &str and String values keep working as before — this is an added fast path.
class! uses it automatically: literal class entries now lower to an already-escaped PromotedStr, skipping both the allocation and the escaping pass at render time. A class! built entirely from literals has a stable concrete type, aliased as StaticClass, which you can name in a const or return type:
use topcoat::view::{StaticClass, class};
const BUTTON: StaticClass = class!("btn btn-lg rounded");
fn classes() -> StaticClass {
class!("border bg-primary text-white")
}
The vendored topcoat-ui components were updated to this pattern throughout. Because class!'s concrete type changed, code that spelled out the old type breaks — see the migration notes.
#[memoize] relaxes its trait bounds. The cache used to store an owned copy of each argument and compare with Eq, requiring Clone + Hash + Eq on every argument. It now identifies an entry by a 128-bit SipHash of the arguments and never stores or clones them, so the only bound left is Hash:
// Before
#[derive(Clone, Hash, Eq, PartialEq)]
struct Filter { status: Status }
// After
#[derive(Hash)]
struct Filter { status: Status }
This is a relaxation — existing code compiles unchanged — and borrowed arguments (&str, &[T]) no longer allocate a key clone on a cache miss. One footgun: the hash is now the entire cache key, so a hand-written Hash impl that skips fields its former Eq compared will silently collide. Derived Hash and standard library impls are always safe.
The context API landed in two steps this release, but the migration target is a single new model: an immutable, scoped, cloneable Cx.
-
CxBuilderis removed. Every place that spelledCxBuildernow spellsCx;CxBuilder::new(app_context)becomesCx::new(app_context), andCxBuilder::get/containsare replaced by the existingrequest_context/try_request_contextfree functions. -
Cx::insert/Cx::get_mutare gone; useCx::with/Cx::with_many. Registering a value derives a child context instead of mutating in place. There is no replacement forget_mut— request context values are no longer mutated in place. -
Layers take
&Cx, not&mut Cx. This applies to#[layer]functions, theLayertrait, andNext::run(now also#[must_use]). A layer that inserted a value beforenext.runnow derives a child and passes it down:// Before #[layer("/")] async fn timing(cx: &mut Cx, body: Body, next: Next<'_>) -> Result<Response> { ... } // After #[layer("/")] async fn timing(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> { let cx = cx.with(RequestTimer::start()); next.run(&cx, body).await }
-
Cx::detachis replaced byClone. A clone behaves exactly like the old detached handle — it keeps reading app and request context after the handler returns — with no sealing step or panic risk. As before, a handle that outlives the handler cannot influence the response; cookie changes and other response-directed writes from such work are dropped. -
ContextMapis renamed toAppContext(withRequestContextas the analogous per-request type). Only code constructing aCxdirectly (tests, custom wiring) is affected; theapp_context/try_app_contextfree functions are unchanged.
Every Router now rejects state-changing cross-origin browser requests and cross-origin WebSocket handshakes with 403 Forbidden, not just apps using .sessions(). An app that relied on cross-origin POST/PUT/DELETE/PATCH requests or cross-origin WebSocket handshakes must register an OriginPolicy that trusts or exempts the caller.
The session-crate APIs are removed and migrate to the router:
// Before
let config = SessionConfig::builder()
.trust_origin("https://accounts.example.com")
.build();
// After
let router = Router::builder()
.origin_policy(OriginPolicy::new().trust_origins(["https://accounts.example.com"]))
.build();
topcoat_session::OriginLayerandverify_origin→topcoat::router::OriginPolicy/OriginLayer.SessionConfigBuilder::trust_origin→OriginPolicy::new().trust_origins([...]).SessionConfigBuilder::dangerous_disable_origin_verification→OriginPolicy::dangerous_disable()..sessions(config)no longer registers any origin layer; origin verification is entirely the router's concern.
FromRequest for Bytes, Json, Form, RawForm, Css<String>, Html<String>, and Multipart now rejects bodies over 2 MiB with 413 Content Too Large. Routes that accept large payloads (file uploads, big JSON blobs) must register BodyLimit::max(..) or BodyLimit::disable() for their path, or previously-working requests will start failing.
Two related signature changes:
-
TowerLayer::newno longer takes a path. It wraps every route by default; scope it with the new.at(path):// Before TowerLayer::new(Path::new("/api"), TimeoutLayer::new(Duration::from_secs(5))) // After TowerLayer::new(TimeoutLayer::new(Duration::from_secs(5))).at("/api")
-
Path-taking constructors accept
impl IntoPath(RouteFn::new,PageFn::new,TowerRoute::new, and.at(path)), so a bare string literal works withoutPath::new(...). Existing call sites passingPath::new("...")or aCowcompile unchanged; a malformed string now panics, same asPath::newalready did.
A request that resolves to no route — a 404 (no registered path matches) or a 405 (the path matches, but no route accepts the method) — is now answered by the router directly: no path-scoped layer and no layout runs for it. Previously, layers and layouts whose path prefix matched the URL still ran, which let an outer layout brand 404s for arbitrary unrouted URLs.
If you relied on that, opt in with the new not_found! macro: register a catch-all page and let your layout catch the NotFoundError as before. The only middleware that wraps 404 and 405 responses is a manually-built pathless layer, which wraps every request (see handler traits).
The attribute macro is gone; existing code using it no longer compiles. Migration is mechanical:
// Before
#[path_param(error = bad_request)]
struct PostId(u64);
// After — the macro generates the PostId type itself
path_param!(post_id: u64, error = bad_request);
- Attribute options (
error = not_found, etc.) become trailing arguments topath_param!. - A
str-typed parameter (#[path_param] struct Slug(str);) becomes an untyped declaration:path_param!(slug);. It still reads back as a decoded&str. - Visibility carries over:
path_param!(pub post_id: u64)generates apubtype. - Read sites (
path_param::<PostId>(cx)) are unchanged. - Reading a parameter name the matched route never captured now panics with a clear message rather than a silent or different failure.
The concrete handler structs (PageFn, LayoutFn, RouteFn, LayerFn, Procedure) are replaced by traits (Page, Layout, Route, Layer, Procedure) that the old struct names now implement. Applications using only the macros and .discover()/module_router! see no source change. If you construct handlers manually or implement the traits yourself:
Layer::pathnow returnsOption<&Path>.Some(path)wraps matched routes under that prefix as before;Nonewraps every request, 404s and 405s included — somethingPath::ROOTnever did. A manual layer written withPath::ROOTkeeps working but still never sees a request that resolves to no route unless switched toNone.LayerFn::newtakesOption<impl IntoPath>—LayerFn::new(Some("/"), handle)orLayerFn::new(None::<&Path>, handle)— and, likePageFn::new/LayoutFn::new/RouteFn::new, is no longerconst(theconst_newvariants are removed).- Manual
Route/Page/Layoutimpls need anid()returning aRouteId; callRouteId::new()once per handler and cache it. RouterBuilder::page/::layout/::routetake the traits directly (impl Page, etc.), so custom handler structs register without wrapping.PageWithLayouts::newnow takesBox<dyn Page>andVec<Arc<dyn Layout>>.
Also new here: endpoint(cx)/try_endpoint(cx) and route(cx)/try_route(cx) let a handler inspect the matched route pattern (/users/{id}, not the URL) at request time, and two route groups resolving to the same URL with diverging layer stacks now build correctly instead of panicking.
request and response are now public modules under topcoat::router, and their contents are no longer re-exported at the router root. Imports need updating:
Bytes,BytesMut,FromRequest, and the request accessors (parts,method,uri,version,headers,content_type,extensions) →topcoat::router::request::*IntoResponse,Response→topcoat::router::response::*
// Before
use topcoat::router::{Body, Bytes, FromRequest, IntoResponse, Response, headers, route};
// After
use topcoat::router::{
Body,
request::{Bytes, FromRequest, headers},
response::{IntoResponse, Response},
route,
};
Body, Router, page, route, layer, layout, error, content, body_limit, and to_bytes keep their old paths. Behavior is unchanged — this is purely a path reorganization.
#[memoize] used to special-case Option<T>/Result<T, E> returns, handing back Option<&T>/Result<&T, &E>. It now always returns a plain &T reference to the cached value; the old behavior is an explicit opt-in:
// Before: returned Option<&User>
#[memoize]
async fn current_user(cx: &Cx) -> Option<User> { auth::resolve(cx).await }
// After: add as_ref to keep returning Option<&User>
#[memoize(as_ref)]
async fn current_user(cx: &Cx) -> Option<User> { auth::resolve(cx).await }
Without as_ref, the return type becomes &Option<T>/&Result<T, E>, which surfaces as type errors at call sites expecting the borrowed-contents shape. as_ref works through a new public trait, MemoizeAsRef (in topcoat::context), which you can implement for your own wrapper types. Unrecognized arguments to #[memoize(...)] are now a compile error.
View::render and View::render_response take self by value instead of &self, letting rendering move owned data out of the view instead of cloning it. Mail::formatted changed the same way, since it renders the mail's View internally.
Typical handler code — render once, discard — compiles unchanged. Code that renders the same View (or formats the same Mail) more than once, or only holds a borrow, should clone first:
let html = view.clone().render(&cx);
let html_again = view.render(&cx);
The concurrent rendering rework changed several low-level view APIs. Only code that built views by hand or implemented the *ViewParts traits is affected — view!/#[component] users need no changes:
ViewPartsandView::new(ViewParts)are gone. AViewis now a lightweight handle into the active scope's instruction memory. UseView::empty()for an empty view; build everything else throughview!or thePartsWriterpassed intointo_view_parts.ViewPartis replaced byAttributeValueinAttributes::get/remove/extendand itsIntoIteratorimpls. It no longer exposes rendered content for matching; useis_present()/AttributeValue::absent()and splice values back in as attribute or class entries.PartsWriterpush methods split by ownership:push_string(String)for owned strings,push_str(&str)for borrowed,push_static_str(&'static str)as the cheapest option, each with an_unescapedvariant.PartsWriter::with_contextis replaced byparts.in_context(...).DynViewPartdropped itsclone_boxrequirement — remove the method from manual implementations.- Building or rendering a
View(or inserting intoAttributes) outside an active scope now panics, and aViewcannot cross into a spawned task or another scope. Wrap out-of-request rendering intopcoat::view::scope(...).
A class! built from literals now produces Class<Unescaped<PromotedStr>> (aliased as StaticClass) instead of Class<Cow<'static, str>>. Most call sites infer the type or pass the result straight into a class= attribute and are unaffected; code that spelled out the old type in a variable, const, or function signature must switch to StaticClass:
// Before
fn classes() -> &'static str { "border bg-primary text-white" }
// After
fn classes() -> StaticClass { class!("border bg-primary text-white") }
If you vendored topcoat-ui components before this release, topcoat ui update applies this same shape change to your local copies (e.g. button_variants now returns a Class<...> value instead of a String). The result still works directly in class=(...); only code that used the old String/&'static str directly — say, concatenating with format! outside a view — needs updating.
to_bytes returns topcoat::Result<Bytes> instead of Result<Bytes, BoxError>. It classifies failures itself: ContentTooLargeError (413) when the body exceeds the limit, BadRequestError (400) otherwise — both render themselves as HTTP responses, so just propagate with ?:
// Before
let bytes = to_bytes(body, body_limit(cx))
.await
.map_err(|error| bad_request(format!("failed to read request body: {error}")))?;
// After
let bytes = to_bytes(body, body_limit(cx)).await?;
Hand-written mapping of the old BoxError (including downcasts to http_body_util::LengthLimitError) is now redundant; downcast to topcoat::router::error::ContentTooLargeError if you need to detect the 413 case.
topcoat asset bundle now writes to an assets directory next to the executable it scanned — <cargo-target>/<profile>/assets instead of the shared <cargo-target>/assets — and AssetBundle::load looks only there, no longer searching ancestor directories. This stops a bundle from one profile silently shadowing another's, since asset IDs can differ between dev and --release builds of the same files.
topcoat dev, cargo run, and topcoat asset bundle work without configuration (hence the CLI update warning at the top). When bundling for a specific profile, pass it explicitly (topcoat asset bundle --release) and run a binary built with the same profile. If you relied on the old multi-location search, either place the bundle next to the executable or bundle with --out and load it with AssetBundle::load_dir("/custom/assets/dir"). topcoat asset clean now removes every bundle under the target directory.
RouterBuilder::build() now panics if a registered layer's path wraps no registered page, layout, or route, instead of silently never running the middleware:
layer with path `/admin` did not match any route, this is likely a mistake
Matching is segment-for-segment (/admin does not wrap /administrator; a {id} segment does not wrap {user_id}) and group-aware. Root-path ("/") layers are exempt. If your build starts panicking, the layer was already dead code — add the missing route or fix the path. The older check that rejected diverging layer stacks between route groups sharing a URL is removed. Path::ROOT is a new public constant for "/".
Adding or removing a cookie after the response headers have been sent — from a streaming body, a spawned task, or a WebSocket loop — used to be a silent no-op; the cookie never reached the client. It now panics immediately with a message explaining the situation. Reads keep working, so detached work can still inspect the cookies the request arrived with.
No API changed shape. If a panic appears after upgrading, that cookie write was already being dropped — move it to before the handler returns (before starting the stream or upgrading the WebSocket).
A security fix in topcoat-datastar: selectors are sent as a single line in the SSE stream, so a selector containing a line break could smuggle extra event lines — for example an elements line injecting arbitrary HTML. PatchElements::remove, PatchElements::selector, and DatastarSelector::from now panic on any selector containing \r or \n. Hand-written CSS selectors are unaffected; validate any selector built from untrusted input before passing it in.
- (core) [breaking] add scoped context using
cx.with(...)(#338) - (router) sitemaps (#336)
- (core) seal Cx on detach
- (core) [breaking] make Cx detachable, remove CxBuilder (#322)
- (core) [breaking] specify as_ref manually on memoized functions (#310)
- (router) [breaking] global origin policy (#276)
- (router) [breaking] replace path parameter attribute macro (#242)
- use #[track_caller] where appropriate (#262)
- (view) concurrent rendering (#317)
- (cli) warn on version mismatch between topcoat and cli (#305)
- (cookie) protect cookie jar from being written to after response… (#324)
- (core) use 128-bit hash instead of Clone and Eq for memoization (#337)
- (view) improve new arena rendering system (#319)
- (router) [breaking] add unused layer sanity check (#300)
- (router) use
impl AsRef<str>for route error urls (#351) - (router)
href!macro (#350) - (router) request rewriting (#347)
- (router) add matched endpoint path to request context (#318)
- (router) [breaking] add not_found macro and no longer run layers and layouts by default on unmatched requests (#298)
- (router) add a too-many-requests error with a Retry-After hint (#267)
- (router) add Js and Wasm response wrappers (#268)
- (router) add a service-unavailable error with a Retry-After hint (#266)
- (router) [breaking] request body limits (#233)
- (ui) add 17 new topcoat-ui components (#341)
- (core) stable component identity system (#328)
- implement NodeViewParts and AttributeValueViewParts for Cow<'static, str> (#306)
- fix docs issues
- fix doc tests with default features
- (asset) [breaking] write the bundle next to the executable it was scanned from (#243)
- (asset) ignore false-positive asset scans with empty paths (#280)
- (asset) register one route per bundled file (#249)
- (cli) add '/ws' to exempt OriginPolicy on dev (#348)
- (cli) exclude build script outputs from final output detection (#301)
- (core) detect recursive memoized calls (#278)
- (core) keep macro bodies intact when formatting rust snippets (#273)
- (datastar) reject multiline selectors (#256)
- (font) support unicode ranges ending in E (#307)
- (router) isolate request panics (#257)
- (router) reject invalid route signatures at parse time (#241)
- include docs and visibility in routes, procedures, layers (#232)
- (runtime) support signals outside the body (#334)
- (runtime) let push_str accept an owned string surrogate (#269)
- (runtime) reject non-2xx shard responses instead of rendering them (#247)
- (runtime) render f64 text the way Rust's Display does (#245)
- (runtime) match Rust semantics for string comparison and trim (#244)
- (runtime) reject invalid procedure signatures at parse time (#230)
- (view) allow keyword element names (#274)
- (router) [breaking] replace handler structs with traits in preparation for href (#346)
- sort imports
- make request and response dedicated modules
- decrease logo size
- add readme logo
- (core) [breaking] turn fnv1a hash into a struct and add 128-bit variant (#327)
- (view) add promoted str optimization to avoid allocations (#330)
- fix memoize docs stale example
- (view) consume view when rendering to improve performance (#312)
- (router) rename erased constant for more readable profiler and debugger traces (#329)
- (router) remove PathBuf Arc pointer indirection (#323)
- merge router service and serve into single file
- [breaking] return ContentTooLargeError instead of LengthLimitError from to_bytes (#263)
- (runtime) note that page guards do not cover shard endpoints (#251)
- (view) refactor new rendering system part 2 (#320)
- (view) add docs about concurrent rendering
- (view) add lowering step to high-level intermediate representation (#316)
v0.5.0
Everything that changed since v0.4.0. The release carries breaking changes, so it goes out as 0.5.0. If you are upgrading an existing application, read Breaking changes first; every entry there comes with the before and after.
Three things stand out. Topcoat now speaks the three long-lived transports a full-stack framework needs: WebSockets, server-sent events, and, on top of SSE, a Datastar integration. It sends mail, through a new topcoat-mail crate with SMTP, file, and in-memory transports. And it runs where it could not before: on WebAssembly and other serverless runtimes that hand you a request instead of a listener, and over Unix domain sockets behind a reverse proxy.
Behind the websocket feature. A route becomes a WebSocket endpoint by taking a WebSocketUpgrade parameter and returning the response its on_upgrade builds.
use topcoat::{
Result,
router::{
Response, route,
content::websocket::{Message, WebSocketUpgrade},
},
};
#[route(GET "/echo")]
async fn echo(upgrade: WebSocketUpgrade) -> Result<Response> {
upgrade.on_upgrade(|mut socket| async move {
while let Some(Ok(message)) = socket.recv().await {
if socket.send(message).await.is_err() {
break;
}
}
})
}
The extractor runs inside the handler like any other, so a session check or a cookie read composes with it: reject the request before calling on_upgrade. A malformed handshake is rejected with 400, a non-GET request with 405. Incoming pings are answered for you, subprotocols are negotiated with protocols, and message, frame, and write buffer sizes are bounded through the builder. WebSocket implements Stream and Sink, so the connection can be split into halves that read and write concurrently.
See the WebSocket guide and the websocket example.
Behind the sse feature. A route returns Sse wrapping a stream of Events and the response streams them as text/event-stream.
#[route(GET "/events")]
async fn events() -> Result<Sse<impl Stream<Item = Result<Event>> + use<>>> {
let events = stream::iter(["one", "two"].map(|name| Ok(Event::new().data(name))));
Ok(Sse::new(events).keep_alive(KeepAlive::new()))
}
keep_alive fills idle gaps so proxies do not drop a quiet stream, and last_event_id(cx) reads the Last-Event-ID header a reconnecting EventSource sends, so a stream can resume instead of replaying.
See the SSE guide and the sse example.
Behind the datastar feature, which enables sse. Datastar drives page updates from the backend: data-* attributes bind reactive signals to elements, and the server answers with events that patch HTML and signal values into the page.
The Signals<T> extractor reads the signals an action sends, whether they arrive in the datastar query parameter or as a JSON body. PatchElements, PatchSignals, and ExecuteScript are the events to answer with. Each works as a standalone response and converts into an SSE Event, so the same types serve a one-shot update and a long-lived stream.
#[route(POST "/increment")]
async fn increment(Signals(counter): Signals<Counter>) -> Result<PatchSignals> {
PatchSignals::json(&Counter { count: counter.count + 1 })
}
For plain request/response updates there are responder types (DatastarSelector, DatastarMode, DatastarOnlyIfMissing, and friends) that set the headers Datastar reads, placed before the body in a response tuple.
See the Datastar guide and the datastar example.
Behind the mail feature, plus mail-smtp for the SMTP transport. Declare a mail with the mail! macro and deliver it with send.
let mail = mail! {
from: ("Topcoat", "welcome@example.com"),
to: "ada@example.com",
subject: "Welcome, Ada!",
html: {
<h1>"Welcome!"</h1>
<p>"Your account is ready."</p>
},
}?;
send(cx, mail).await?;
The html field is a view! body, so mail markup is written the same way as page markup, and a plain-text alternative is derived from it by default. Addresses can be strings, (name, address) pairs, or Mailbox values, alone or in collections. Field values can .await and ?, so a recipient list can be fetched inline.
Three transports ship: SmtpTransport with connection pooling and URL-based configuration, FileTransport writing .eml files during development, and MemoryTransport capturing sends for tests to assert on. Register one in a MailConfig on the router and the call sites do not change when you swap it. Implement Transport for anything else, such as a provider's HTTP API.
Alongside it, RouterBuilder::base_url registers the absolute URL the application is reachable at, which mail (and later feeds and sitemaps) needs to write links that leave the site.
See the mail guide, the mail! reference, and the mail example.
topcoat builds for wasm32 now. Serving is the only part of the framework that needs tokio and hyper, and it sits behind the serve feature. Build without default features, leave serve off, and call Router::handle from whatever hands you the request.
Assets work there too. A bundle directory cannot be read at runtime, so embed the manifest instead and register it with AssetConfig::hosted_at, which resolves asset URLs against an external host without mounting any routes:
let manifest = Manifest::parse(include_str!("../dist/assets/manifest.toml"))?;
let router = Router::builder()
.assets(AssetConfig::hosted_at("https://static.example.com/assets", manifest))
.build();
The CLI follows: topcoat asset bundle scans cdylib and dylib outputs, not just executables, so a wasm32 build that produces no binary still bundles.
serve and serve_until now accept any Listener, implemented for TcpListener and, on Unix, UnixListener. That covers running behind nginx or Caddy over a socket path.
let path = "/run/my-app.sock";
let _ = std::fs::remove_file(path);
let listener = tokio::net::UnixListener::bind(path)?;
topcoat::serve(listener, router).await
Binding fails if the socket file exists and dropping the listener does not remove it, so clear a stale file first, as above.
TowerRoute mounts a tower service (an axum router, a hyper service, a reverse proxy) as a route. Registered at a catch-all path with Methods::Any, it hands an entire URL subtree to the service with the original URI intact, which is what you want when migrating an existing application to Topcoat one route at a time.
let router = Router::builder()
.route(TowerRoute::new(Methods::Any, Path::new("/legacy/{*rest}"), legacy_app))
.build();
TowerLayer, for running tower middleware as a layer, was already there and now sits in the same module. Errors from wrapped Topcoat routes pass through unchanged so outer layers can still catch them by type; errors the tower service itself produces surface as TowerServiceError.
See the tower guide.
#[route] accepts a bracketed list or * in place of a single method, and #[page] accepts the same forms to serve something other than GET.
#[route([GET, POST] "/form")]
async fn form() -> Result<&'static str> { Ok("form") }
#[route(* "/webhook")]
async fn webhook() -> Result<&'static str> { Ok("received") }
#[page(POST "/signup")]
async fn signup(Form(input): Form<Signup>) -> Result {
view! { <h1>"Welcome, " (input.email)</h1> }
}
A route declaring a specific method takes precedence over a * route at the same path, so a catch-all can sit under more specific handlers.
A component that calls itself, directly or through another component, used to fail to compile on the resulting future cycle. #[component(boxed)] on one component in the cycle breaks it.
#[component(boxed)]
async fn comment_thread(comment: &Comment) -> Result {
view! {
<li>
(&comment.body)
<ul>
for reply in &comment.replies {
comment_thread(comment: reply)
}
</ul>
</li>
}
}
Writes that depend on the current value have a shorter spelling than set: toggle on a bool signal, increment and decrement on an f64 signal, and push_str on a String signal.
// Before
<button @click=$(|_e| count.set(count.get() + 1.0))>"+"</button>
// After
<button @click=$(|_e| count.increment())>"+"</button>
The dev server used to show only rustc's JSON diagnostics, so a failure cargo reports on stderr instead (a build script exiting non-zero, an unresolvable dependency, a malformed manifest, a --bin naming no target) showed up as a bare "build failed" with nothing to go on. Cargo's stderr is now captured and reported alongside the diagnostics.
A layout used to receive slot: Slot<'_>, a future it awaited wherever the child content belonged. It now receives the already-rendered slot: Result. The Slot type is gone.
// Before
#[layout]
async fn shell(slot: Slot<'_>) -> Result {
view! { <main>(slot.await?)</main> }
}
// After
#[layout]
async fn shell(slot: Result) -> Result {
view! { <main>(slot?)</main> }
}
Migrating is mechanical: change the parameter type to Result, drop the topcoat::router::Slot import, and replace slot.await? with slot?.
The point of the change is that a layout can now inspect the child's error before rendering, which is what makes the branded error page in the router error guide work. The cost is ordering: the page is fully rendered before any layout body runs, where the old future let a layout emit its <head> first. Nothing streamed responses yet, so this is not a behavior regression today, but it does constrain a future streaming SSR design.
The error constructors and types are no longer re-exported from the root of topcoat::router. They live in topcoat::router::error, which now has a guide of its own.
// Before
use topcoat::router::{NotFoundError, RouterErrorExt, SeeOther, not_found, see_other};
// After
use topcoat::router::error::{NotFoundError, RouterErrorExt, SeeOther, not_found, see_other};
This covers not_found, unauthorized, forbidden, bad_request, method_not_allowed, internal_server_error, redirect, redirect_permanent, see_other, their error types, SeeOther, and RouterErrorExt. StatusCode stays at topcoat::router::StatusCode.
The same treatment for the extractors and response wrappers, which also got a guide covering multipart, WebSockets, and SSE.
// Before
use topcoat::router::{Css, Form, Html, Json, Multipart, RawForm};
// After
use topcoat::router::content::{Css, Form, Html, Json, RawForm, multipart::Multipart};
FromRequest, IntoResponse, IntoResponseParts, Body, and Bytes did not move; they are still at topcoat::router.
WebSocket support, added in this same release, lives at topcoat::router::content::websocket rather than topcoat::router::websocket. Only relevant if you tracked main between the two commits.
// Before
use topcoat::router::TowerLayer;
// After
use topcoat::router::tower::{TowerLayer, TowerRoute};
A true boolean attribute value used to render as disabled="true". It now renders as disabled="", matching the HTML convention for boolean attributes. false still omits the attribute entirely, as before.
The rendered markup changes, so update any snapshot test that asserts on it. Nothing changes for the browser: both forms mean the same thing to an HTML parser.
// Before
AssetConfig::hosted_at(AssetBundle::load().unwrap(), "https://cdn.example.com/assets")
// After
AssetConfig::hosted_at("https://cdn.example.com/assets", AssetBundle::load().unwrap())
asset! used to return an Asset, a bare u64 ID. It now returns an Asset handle, and the ID type it used to be is called AssetId. Rendering an Asset in a view! is unchanged; only direct bundle lookups need adjusting.
// Before
let bundled = bundle.get(LOGO).expect("logo was bundled");
// After
let bundled = bundle.get(LOGO.id()).expect("logo was bundled");
The handle exists to keep the embedded declaration alive. The bundler finds assets by scanning the compiled binary, and the declaration is now reachable only through the handle, so an asset is bundled only if some code path uses its handle. A declaration whose handle is never used can be optimized out, and the bundler will not see it. In exchange, the MSVC-specific /OPT:NOREF workaround is gone and asset discovery works the same way on every platform, including WebAssembly.
// Before
let path = bundle.get(LOGO).unwrap().path();
// After
let path = bundle.dir().join(bundle.get(LOGO.id()).unwrap().name());
An entry now names its file relative to the bundle directory rather than carrying an absolute path, because a bundle resolved from an embedded manifest has no directory on disk.
Config and ConfigBuilder were renamed to SessionConfig and SessionConfigBuilder, so they read unambiguously once several features register configuration on the router.
// Before
.sessions(Config::default())
// After
.sessions(SessionConfig::default())
topcoat::serve, serve_until, and start moved behind a new serve feature. It is on by default and implied by full, so an application using default features needs no change. Only a build with default-features = false has to add serve alongside router. This allows for WASM deployments by disabling mio.
Custom Route implementations replace fn method(&self) -> Method with fn methods(&self) -> Methods<'_>, and RouteFn::new now takes anything convertible into OwnedMethods (a Method still works). PageFn::new gained a leading methods argument and is no longer const; the const constructor is PageFn::const_new. This only affects code that builds routes by hand rather than through the macros.
- The browser hung when a text expression rendered an owned string (#201).
topcoat fmtpanicked onsignaldeclarations inside aview!body, which meant any file using client reactivity was unformattable (#172).topcoat fmt --stdinexited with code 0 after a formatting failure, so editors and CI treated a failed format as success (#207).topcoat devhot reload never succeeded on Windows because the running executable held a file lock (#169).- Embedded asset declarations were stripped by the MSVC linker, so on Windows the bundler found no assets and every page panicked (#170). The asset handle change above replaces that workaround with a portable fix (#217).
- Boolean event fields returned raw JavaScript booleans instead of
Boolsurrogates, so expression methods on them failed (#168). - A double type assertion in DOM binding (#171).
- WebSocket handshake keys are now validated rather than accepted as-is (#215).
- New guides for router errors, request and response content, tower, WebSockets, SSE, multipart, Datastar, and mail.
- New examples:
websocket,sse,datastar, andmail. - The
module_router!guide explains what the macro does and does not register, and howdiscoverand value registrations like the asset bundle compose with it. topcoat fmtformatsmail!bodies, and its--macrosflag is documented.- Agent skills for committing, opening pull requests, checking a change, writing prose, writing macros, and code style live in
.agents/skills. - A Nix devshell (
flake.nix) for a reproducible toolchain. - Release automation cuts one git tag and one GitHub release per Topcoat version instead of one per crate.
Thanks to Amein Eskinder, Carl Lerche, Iqbal, Onyeka Obi, RA, Sean Aye, and seth for contributing to this release.