Skip to content

v0.8.0

Latest

Choose a tag to compare

@pikaju pikaju released this 09 Sep 20:15

Topcoat 0.8: signals you can track on the server

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.

signal is a function

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.

Signals keep their identity across re-renders

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.

Reading signals on the server

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.

Where shards fit now

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.

Untracked reads

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.

Re-renders morph instead of replace

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.

Passing a signal to a shard

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.

Other changes

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.

Upgrading

  • Replace every signal name = value; statement with let name = signal(cx, || value); above the view!, and add cx: &Cx to the enclosing function if it does not have it yet.
  • Add .runtime() to the router builder wherever topcoat::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.