Skip to content

v0.6.0

Latest

Choose a tag to compare

@pikaju pikaju released this 17 Aug 19:38
a2bd596

Topcoat release notes

Warning

Update your topcoat CLI before building with this release:

cargo install topcoat-cli

The 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

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 for loop 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 any Serialize value (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 href function accepts a Path or 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.

  • CxBuilder is removed. Every place that spelled CxBuilder now spells Cx; CxBuilder::new(app_context) becomes Cx::new(app_context), and CxBuilder::get/contains are replaced by the existing request_context/try_request_context free functions.

  • Cx::insert / Cx::get_mut are gone; use Cx::with / Cx::with_many. Registering a value derives a child context instead of mutating in place. There is no replacement for get_mut — request context values are no longer mutated in place.

  • Layers take &Cx, not &mut Cx. This applies to #[layer] functions, the Layer trait, and Next::run (now also #[must_use]). A layer that inserted a value before next.run now 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::detach is replaced by Clone. 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.

  • ContextMap is renamed to AppContext (with RequestContext as the analogous per-request type). Only code constructing a Cx directly (tests, custom wiring) is affected; the app_context/try_app_context free 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::OriginLayer and verify_origintopcoat::router::OriginPolicy / OriginLayer.
  • SessionConfigBuilder::trust_originOriginPolicy::new().trust_origins([...]).
  • SessionConfigBuilder::dangerous_disable_origin_verificationOriginPolicy::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::new no 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 without Path::new(...). Existing call sites passing Path::new("...") or a Cow compile unchanged; a malformed string now panics, same as Path::new already 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 to path_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 a pub type.
  • 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::path now returns Option<&Path>. Some(path) wraps matched routes under that prefix as before; None wraps every request, 404s and 405s included — something Path::ROOT never did. A manual layer written with Path::ROOT keeps working but still never sees a request that resolves to no route unless switched to None.
  • LayerFn::new takes Option<impl IntoPath>LayerFn::new(Some("/"), handle) or LayerFn::new(None::<&Path>, handle) — and, like PageFn::new/LayoutFn::new/RouteFn::new, is no longer const (the const_new variants are removed).
  • Manual Route/Page/Layout impls need an id() returning a RouteId; call RouteId::new() once per handler and cache it.
  • RouterBuilder::page/::layout/::route take the traits directly (impl Page, etc.), so custom handler structs register without wrapping. PageWithLayouts::new now takes Box<dyn Page> and Vec<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, Responsetopcoat::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:

  • ViewParts and View::new(ViewParts) are gone. A View is now a lightweight handle into the active scope's instruction memory. Use View::empty() for an empty view; build everything else through view! or the PartsWriter passed into into_view_parts.
  • ViewPart is replaced by AttributeValue in Attributes::get/remove/extend and its IntoIterator impls. It no longer exposes rendered content for matching; use is_present()/AttributeValue::absent() and splice values back in as attribute or class entries.
  • PartsWriter push 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 _unescaped variant. PartsWriter::with_context is replaced by parts.in_context(...).
  • DynViewPart dropped its clone_box requirement — remove the method from manual implementations.
  • Building or rendering a View (or inserting into Attributes) outside an active scope now panics, and a View cannot cross into a spawned task or another scope. Wrap out-of-request rendering in topcoat::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)