github tokio-rs/topcoat v0.10.0

2 hours ago

Client side navigation

You can now navigate between server-rendered pages without a full page reload. The new runtime::link component updates the page, title, and browser history while preserving signals shared by both pages. Back and forward navigation restores scroll positions.

use topcoat::{
    Result,
    router::{href, page},
    runtime::link,
    view::{View, view},
};

#[page]
async fn home() -> Result<impl View> {
    Ok(view! { link(href: href!(about::page), "About") })
}

mod about {
    use super::*;

    #[page]
    pub async fn page() -> Result<impl View> {
        Ok(view! { <h1>"About"</h1> })
    }
}

To get started, add .runtime() to your router and topcoat::runtime::script() to the document head, with assets configured as described in runtime setup. Links still work without JavaScript. Modifier clicks, downloads, and external links keep their normal browser behavior.

Custom anchors can get the same behavior through link_attrs. Inside a component or page with cx: &Cx:

use topcoat::runtime::{link_attrs, prefetch_mode};

let attrs = link_attrs(cx, href!(about::page), prefetch_mode(cx));
Ok(view! { <a class="nav-link" (attrs)>"About"</a> })

Prefetching

Runtime links can load pages before you open them. By default, prefetching starts on hover, focus, or touch. Viewport starts loading when a link becomes visible, while Never disables prefetching.

use topcoat::runtime::{PrefetchMode, link};

Ok(view! {
    link(href: href!(about::page), prefetch: PrefetchMode::Viewport, "About")
    link(href: href!(reports), prefetch: PrefetchMode::Never, "Reports")
})

You can set the app-wide default with .prefetch(PrefetchMode::Viewport) on the router builder (RouterBuilderRuntimeExt), or a scoped default with cx.with(PrefetchMode::Never). A link's own setting takes precedence.

Prefetching renders a page even if the user never opens it, so page rendering should not perform mutations. See links and prefetching.

Records

You can now work with your own structs in runtime expressions, signals, and procedure arguments or results. The new #[record] attribute adds support for named fields and struct literals.

use topcoat::{
    Result,
    context::Cx,
    runtime::{record, signal},
    view::{View, component, view},
};

#[record]
#[derive(Clone)]
struct Todo {
    title: String,
    done: bool,
}

#[component]
async fn todo_item(cx: &Cx) -> Result<impl View> {
    let todo = signal(cx, || Todo {
        title: "Write release notes".to_owned(),
        done: false,
    });

    Ok(view! {
        <p>$(todo.read().title.to_owned())</p>
        <button @click=$(|_e| {
            let title = todo.get().title;
            todo.set(Todo { title, done: true });
        })>"Done"</button>
        <p>$(if todo.get().done { "Complete" } else { "Open" })</p>
    })
}

Fields can contain other records or supported containers such as Vec<Todo> and Option<Todo>. Records need named fields and cannot have generics. Individual fields can be rendered, but whole records cannot. Record comparison and struct update syntax (..base) are not supported.

Captured records expose every field to the browser, including private fields. Records sent back by the browser need the same validation as other client input. See records.

Tuple support

You can now capture tuples, construct them inside expr! (or $(...) inside view!), and access their fields in the browser. This includes nested tuples and tuples inside collections.

use topcoat::runtime::expr;

let product = ("Coffee".to_owned(), 4usize);
let products = vec![product.clone()];

let name = expr!(product.0);
let summary = expr!((product.0, product.1 + 1));
let nested = expr!((true, (1u32, "Coffee")));
let first_name = expr!(products.index(0).0.to_owned());
let first_product = expr!(products.index(0).clone());

Tuple literals include () and single-element tuples such as (value,). Borrowed tuple fields stay borrowed, and clone() gives you an owned value. Rendering a tuple renders its elements in order without separators. Tuple comparisons are not supported. See runtime expressions.

Shard connections

Connected pages and shards now share one WebSocket per document. A connected shard can rerender on its own, including inside a connected page or another connected shard. Updating one shard no longer needs to rerender its connected ancestor.

The API stays the same. Calling connected(cx) requests a connection; this shard then sends counter changes to the server over the shared socket:

use topcoat::{
    Result,
    context::Cx,
    runtime::{connected, shard, signal},
    view::{View, view},
};

#[shard]
async fn counter(cx: &Cx) -> Result<impl View> {
    let count = signal(cx, || 0usize);
    let value = count.get();
    let transport = if connected(cx) { "WebSocket" } else { "HTTP" };

    Ok(view! {
        <p>(value) " via " (transport)</p>
        <button @click=$(|_e| count.increment())>"Add one"</button>
    })
}

With .discover() or .route(counter) on the router, counter() can be called from a view. The server-side signal read makes this shard rerender when the counter changes. See shards.

Module parameters

Previously, path_param! both declared a path parameter and changed the module's URL segment. These responsibilities are now separate. path_param! only declares the parameter, while module_param! combines that declaration with a segment! override.

This is a breaking change for module routing. If you used path_param! to make a module dynamic, that declaration now needs to be module_param!. The options and generated parameter type stay the same:

// src/app/posts/post_id.rs serves /posts/{post_id} with module routing.
use topcoat::{
    Result,
    context::Cx,
    router::{module_param, page, path_param},
    view::{View, view},
};

module_param!(pub post_id: u64, error = bad_request);

#[page]
pub async fn post(cx: &Cx) -> Result<impl View> {
    let id = path_param::<PostId>(cx)?;
    Ok(view! { <h1>"Post " (id)</h1> })
}

Catch-all modules follow the same change, with module_param!(*rest). Parameters written explicitly in handler paths still use path_param!, which now allows several declarations in the same module without changing its segment. See module parameters and the upgrade guide below.

Bulk UI installation

You can now install every component from a registry with topcoat ui add --all. Existing component files are skipped and reported.

topcoat ui add --all
topcoat ui add --all --registry my-registry

Adding --overwrite replaces existing components too, including local edits. See UI components.

rustfmt integration

topcoat fmt can now format both Rust code and Topcoat macro bodies in one command. The new --rustfmt flag runs rustfmt first. Both formatters must succeed before a file is written.

topcoat fmt --rustfmt
topcoat fmt --stdin --rustfmt < src/main.rs

To get started, install rustfmt with rustup component add rustfmt and set the edition in your project-root rustfmt.toml:

edition = "2024"

For formatting on save with rust-analyzer, add this to rust-analyzer.toml:

[rustfmt]
overrideCommand = ["topcoat", "fmt", "--stdin", "--rustfmt"]

Formatting checks

You can now check formatting in CI with topcoat fmt --check. It reports differences without changing files and exits with status 1 for differences or errors, or 0 when everything is formatted.

topcoat fmt --check
topcoat fmt --check --rustfmt src
topcoat fmt --stdin --check < src/main.rs

Diagnostics go to stderr. Stdin checks do not emit formatted source. See the formatter guide.

CLI version

The CLI now supports --version and -V:

topcoat --version
cargo topcoat --version

Default development binary

topcoat dev now honors [package] default-run. This helps when an app has a web server and extra binaries such as migration tools. Add the setting to your existing package table:

[package]
name = "my-app"
default-run = "my-app"

topcoat dev selects that binary automatically. An explicit --bin takes precedence.

Documentation and fixes

  • Guides now live under docs/. Examples use module routing as the recommended starting point. The new llms.txt gives coding assistants a compact API guide and links to detailed documentation.
  • cargo topcoat correctly handles Cargo's subcommand arguments. Commands such as cargo topcoat dev and cargo topcoat fmt work again.
  • CLI builds preserve Cargo and Rust environment settings, including RUSTFLAGS, compiler wrappers, and the selected toolchain.
  • Development reloads apply streamed live! updates without waiting for the whole response to finish, including pages without the browser runtime.
  • rust-analyzer completions now work better in some cases with incomplete Rust expressions or bindings inside Topcoat macros.
  • topcoat::Error works again in thiserror variants with #[from] or #[error(transparent)].
  • Macro output is deterministic. Generated procedure and shard URLs use the function's name and source location instead of random values.
  • Runtime WebSockets now limit each connection to 64 simultaneous renders, rejecting extra requests with 429. You can adjust this with .max_runs_per_connection(...) on the router builder.

Upgrade guide

Module routing parameters

path_param! no longer changes the enclosing module's URL segment. Replace it with module_param! wherever you relied on that behavior, including catch-all modules. Otherwise the module uses its normal static segment, and reading a parameter absent from the route will panic.

-use topcoat::router::{page, path_param};
+use topcoat::router::{module_param, page, path_param};

-path_param!(pub post_id: u64, error = bad_request);
+module_param!(pub post_id: u64, error = bad_request);

Keep path_param::<PostId>(cx) and href!(post, PostId(id)) unchanged. Keep path_param! when the handler path already declares the capture, such as #[page("/posts/{post_id}")] or #[page("./{post_id}")]. A module may have one module_param! or one segment!, but not both.

String lengths in expressions

String::len() and str::len() inside runtime expressions now return usize instead of f64. Update floating-point comparisons, arithmetic, and receiving types to use usize. Length still counts UTF-8 bytes.

-let too_long = expr!(name.get().len() > 100.0);
+let too_long = expr!(name.get().len() > 100usize);

Load the browser runtime through topcoat::runtime::script(). Its script tag now supplies data-topcoat-usize-bits, which browser-created lengths need to match the server's integer width. Replace hand-written tags pointing at runtime::SCRIPT with the helper.

Don't miss a new topcoat release

NewReleases is sending notifications on new releases.