Topcoat release notes
Warning
Update your topcoat CLI 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::load only 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 the topcoat version a project depends on.
Table of contents
- 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:
New features
Concurrent component rendering
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:
- 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. - Rendering now happens inside an arena. Views that were rendered in a component cannot be sent to a different thread to be used later.
This rework also reshaped some low-level view APIs; see View internals reshaped for concurrent rendering.
The href! macro for building URLs
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.
Scoped request context with Cx::with
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.
17 new topcoat-ui components
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.
Request rewriting
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.
Global origin policy on every router
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.
Request body size limits
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.
Catch-all 404 pages with not_found!
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_param! replaces #[path_param]
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.
XML sitemap responses
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.
Promoted string optimization for static markup
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] now keys on a hash instead of Clone + Eq
#[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.
Breaking changes
The request context: CxBuilder and Cx::detach are gone
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.
Origin verification is on by default
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.
Request bodies are capped at 2 MiB by default
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.
Unmatched requests no longer run layers or layouts
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).
#[path_param] is removed
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.
Router handlers become traits
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 helpers moved into dedicated modules
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] no longer auto-borrows Option/Result contents
#[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 now consumes the view
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);View internals reshaped for concurrent rendering
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(...).
class!'s concrete type changed
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 now returns a proper Topcoat error
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.
Asset bundle location is tied to the executable
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.
Unused layer sanity check
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 "/".
Cookie jar seals itself after the response is written
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).
Multiline Datastar selectors are rejected
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.
Commit overview
Added
- (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)
Fixed
- 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)
Other
- (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)